├── .gitignore ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── SwiftOutline │ └── main.swift └── SwiftOutlineKit │ ├── Analyzer.swift │ ├── Emitter.swift │ └── GraphvizClient.swift ├── Tests ├── LinuxMain.swift ├── SwiftOutlineKitTests │ ├── GraphvizClientTests.swift │ ├── SwiftOutlineTests.swift │ └── XCTestManifests.swift └── SwiftOutlineTests │ ├── SwiftOutlineTests.swift │ └── XCTestManifests.swift └── doc ├── kickstarter_ios-oss.png ├── sample.dot └── sample.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX?=/usr/local 2 | 3 | build: 4 | swift build --disable-sandbox -c release 5 | 6 | install: build 7 | mkdir -p "$(PREFIX)/bin" 8 | cp -f ".build/release/SwiftOutline" "$(PREFIX)/bin/swiftoutline" 9 | 10 | xcode: 11 | swift package generate-xcodeproj 12 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Commander", 6 | "repositoryURL": "https://github.com/kylef/Commander.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "dc97e80a1cf5df6c5768528f039c65ad8c564712", 10 | "version": "0.9.0" 11 | } 12 | }, 13 | { 14 | "package": "PathKit", 15 | "repositoryURL": "https://github.com/kylef/PathKit", 16 | "state": { 17 | "branch": null, 18 | "revision": "73f8e9dca9b7a3078cb79128217dc8f2e585a511", 19 | "version": "1.0.0" 20 | } 21 | }, 22 | { 23 | "package": "Spectre", 24 | "repositoryURL": "https://github.com/kylef/Spectre.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "f14ff47f45642aa5703900980b014c2e9394b6e5", 28 | "version": "0.9.0" 29 | } 30 | }, 31 | { 32 | "package": "SwiftSyntax", 33 | "repositoryURL": "https://github.com/apple/swift-syntax.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "43aa4a19b8105a803d8149ad2a86aa53a77efef3", 37 | "version": "0.50000.0" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftOutline", 8 | products: [ 9 | .executable(name: "SwiftOutline", targets: ["SwiftOutline"]), 10 | .library(name: "SwiftOutlineKit", type: .static, targets: ["SwiftOutlineKit"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/apple/swift-syntax.git", .exact("0.50000.0")), 14 | .package(url: "https://github.com/kylef/PathKit", from: "1.0.0"), 15 | .package(url: "https://github.com/kylef/Commander.git", from: "0.9.0") 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 19 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 20 | .target( 21 | name: "SwiftOutline", 22 | dependencies: [ 23 | "SwiftOutlineKit", 24 | "Commander", 25 | ]), 26 | .target( 27 | name: "SwiftOutlineKit", 28 | dependencies: [ 29 | "SwiftSyntax", 30 | "PathKit", 31 | ]), 32 | .testTarget( 33 | name: "SwiftOutlineTests", 34 | dependencies: ["SwiftOutline"]), 35 | .testTarget( 36 | name: "SwiftOutlineKitTests", 37 | dependencies: [ 38 | "SwiftOutlineKit", 39 | ]), 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftOutline 2 | 3 | SwiftOutline is a tool to generate relationship graph of iOS ViewControllers. 4 | 5 | ## Install 6 | ``` 7 | $ brew install kenmaz/taps/swiftoutline 8 | ``` 9 | 10 | ## Usage 11 | ``` 12 | $ swiftoutline --dir 13 | ``` 14 | 15 | ## Graph Examples 16 | You can get a relationship graph of iOS ViewControllers like these: 17 | 18 | ### kickstarter/ios-oss 19 | ![kickstarter/ios-oss](https://github.com/kenmaz/SwiftOutline/blob/master/doc/kickstarter_ios-oss.png?raw=true) 20 | https://github.com/kickstarter/ios-oss 21 | ``` 22 | swiftoutline --dir Kickstarter-iOS/Views/Controllers/ --exclude Tests.swift 23 | ``` 24 | 25 | ### AnimeMaker (My Side Project 😁) 26 | https://apps.apple.com/jp/app/animemaker/id405622194 27 | ![Sample Graph](https://github.com/kenmaz/SwiftOutline/blob/master/doc/sample.png?raw=true) 28 | 29 | 30 | ## Options 31 | ``` 32 | $ swiftoutline --help 33 | Usage: 34 | 35 | $ swiftoutline 36 | 37 | Options: 38 | --dir [default: ["."]] - Target sources directory 39 | --exclude [default: ["Debug"]] - Keyword to exclude target source path 40 | --output [default: /tmp/graph.png] - Graph image file output path 41 | --dotfile [default: /tmp/graph.dot] - Intermediate .dot file output path 42 | ``` 43 | -------------------------------------------------------------------------------- /Sources/SwiftOutline/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Commander 3 | import SwiftOutlineKit 4 | 5 | let main = command( 6 | VariadicOption("dir", default: ["."], description: "Target sources directory"), 7 | VariadicOption("exclude", default: ["Debug"], description: "Keyword to exclude target source path"), 8 | Option("output", default: "/tmp/graph.png", description: "Graph image file output path"), 9 | Option("dotfile", default: "/tmp/graph.dot", description: "Intermediate .dot file output path") 10 | ) { (dirs, excludes, outputPath, dotfilePath) in 11 | print("dirs: \(dirs)") 12 | print("excludes: \(excludes)") 13 | print("dotfile: \(dotfilePath)") 14 | print("output: \(outputPath)") 15 | 16 | Emitter().execute(rootDirs: dirs, excludes: excludes, dotfilePath: dotfilePath, outputPath: outputPath) 17 | } 18 | main.run("0.0.3") 19 | 20 | 21 | -------------------------------------------------------------------------------- /Sources/SwiftOutlineKit/Analyzer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Analyzer.swift 3 | // SwiftOutline 4 | // 5 | // Created by kenmaz on 2019/05/27. 6 | // 7 | 8 | import SwiftSyntax 9 | import Foundation 10 | 11 | class Analyzer: SyntaxRewriter { 12 | 13 | var classNameStack: [String] = [] 14 | 15 | struct Call: Equatable { 16 | let caller: String 17 | let callee: String 18 | } 19 | private(set) var results: [Call] = [] 20 | 21 | func run(source: SourceFileSyntax) { 22 | let _ = visit(source) 23 | } 24 | 25 | func reset() { 26 | results = [] 27 | } 28 | 29 | override func visit(_ node: ClassDeclSyntax) -> DeclSyntax { 30 | let className = node.identifier.text 31 | classNameStack.append(className) 32 | return super.visit(node) 33 | } 34 | 35 | 36 | 37 | override func visit(_ node: MemberAccessExprSyntax) -> ExprSyntax { 38 | if let baseName = (node.base as? IdentifierExprSyntax)?.identifier.text, isViewController(callee: baseName) { 39 | addResult(callee: baseName) 40 | } 41 | return super.visit(node) 42 | } 43 | 44 | override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax { 45 | if let callee = (node.calledExpression as? IdentifierExprSyntax)?.identifier.text, isViewController(callee: callee) { 46 | addResult(callee: callee) 47 | } 48 | return super.visit(node) 49 | } 50 | 51 | override func visitPost(_ node: Syntax) { 52 | if node is ClassDeclSyntax { 53 | _ = classNameStack.dropLast() 54 | } 55 | } 56 | 57 | private func isViewController(callee: String) -> Bool { 58 | let prefix = callee.prefix(1) 59 | return prefix.uppercased() == prefix 60 | && (callee.hasSuffix("ViewController") 61 | || (callee != "NavigationController" && callee.hasSuffix("NavigationController"))) 62 | } 63 | 64 | private func addResult(callee: String) { 65 | let caller = classNameStack.last ?? "unknown" 66 | results.append(Call(caller: caller, callee: callee)) 67 | } 68 | 69 | } 70 | 71 | -------------------------------------------------------------------------------- /Sources/SwiftOutlineKit/Emitter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Emitter.swift 3 | // SwiftOutline 4 | // 5 | // Created by kenmaz on 2019/06/24. 6 | // 7 | 8 | import Foundation 9 | import SwiftSyntax 10 | import PathKit 11 | 12 | public final class Emitter { 13 | 14 | public init() {} 15 | 16 | public func execute(rootDirs: [String], excludes: [String], dotfilePath: String, outputPath: String) { 17 | var subgraphs:[[Analyzer.Call]] = [] 18 | let analyzer = Analyzer() 19 | for rootDir in rootDirs { 20 | let files = allSourcePaths(directoryPath: rootDir) 21 | for file in files { 22 | if !excludes.filter({ file.contains($0) }).isEmpty { 23 | continue 24 | } 25 | print("Process.. \(file)") 26 | let url = URL(fileURLWithPath: file) 27 | let sourceFile = try! SyntaxTreeParser.parse(url) 28 | analyzer.run(source: sourceFile) 29 | } 30 | subgraphs.append(analyzer.results) 31 | analyzer.reset() 32 | } 33 | var names:Set = Set() 34 | var buffer: [String] = [] 35 | buffer.append("digraph SwiftOutline {") 36 | buffer.append("\n") 37 | buffer.append("graph [rankdir=LR]") 38 | buffer.append("\n") 39 | for (i, subgraph) in subgraphs.enumerated() { 40 | buffer.append("\t") 41 | buffer.append("subgraph cluster_\(i) {") 42 | for call in subgraph { 43 | buffer.append("\t") 44 | let left = call.caller 45 | let right = call.callee 46 | buffer.append("\"\(left)\" -> \"\(right)\"") 47 | buffer.append("\n") 48 | names.insert(call.caller) 49 | names.insert(call.callee) 50 | } 51 | buffer.append("}") 52 | buffer.append("\n") 53 | } 54 | for name in names { 55 | if !name.hasSuffix("Controller") { 56 | buffer.append("\"\(name)\" [style=\"filled\",fillcolor=black, fontcolor=white];") 57 | buffer.append("\n") 58 | } 59 | } 60 | 61 | buffer.append("}") 62 | buffer.append("\n") 63 | let text = buffer.joined() 64 | 65 | let outpath = Path(dotfilePath) 66 | try! outpath.write(text.data(using: .utf8)!) 67 | 68 | let graphviz = GraphvizClient() 69 | graphviz.generateDotPNG(dotPath: dotfilePath, outputPath: outputPath) 70 | } 71 | 72 | private func allSourcePaths(directoryPath: String) -> [String] { 73 | let absolutePath = Path(directoryPath).absolute() 74 | do { 75 | return try absolutePath 76 | .recursiveChildren() 77 | .filter({ $0.extension == "swift" }) 78 | .map({ $0.string }) 79 | } catch { 80 | return [] 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/SwiftOutlineKit/GraphvizClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GraphvizClient.swift 3 | // SwiftOutlineKit 4 | // 5 | // Created by kenmaz on 2019/06/26. 6 | // 7 | 8 | import Foundation 9 | 10 | struct GraphvizClient { 11 | 12 | @discardableResult 13 | private func shell(_ args: String...) -> Int32 { 14 | let task = Process() 15 | 16 | var env = task.environment ?? [:] 17 | let path = env["PATH"] ?? "" 18 | env["PATH"] = "/usr/local/bin:/usr/bin:/bin:\(path)" 19 | task.environment = env 20 | 21 | task.launchPath = "/usr/bin/env" 22 | task.arguments = args 23 | task.launch() 24 | task.waitUntilExit() 25 | return task.terminationStatus 26 | } 27 | 28 | func generateDotPNG(dotPath: String, outputPath: String) { 29 | guard shell("which", "dot") == 0 else { 30 | print("dot command is not found. Please install graphviz.") 31 | print(" brew install graphviz") 32 | return 33 | } 34 | guard shell("dot", "-Tpng", "-o", outputPath, dotPath) == 0 else { 35 | print("🔥 Failed to generate PNG file") 36 | return 37 | } 38 | shell("open", outputPath) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftOutlineTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SwiftOutlineTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/SwiftOutlineKitTests/GraphvizClientTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GraphvizClientTests.swift 3 | // SwiftOutlineKitTests 4 | // 5 | // Created by kenmaz on 2019/06/26. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftOutlineKit 10 | import PathKit 11 | 12 | class GraphvizClientTests: XCTestCase { 13 | 14 | func test() { 15 | let code = """ 16 | digraph SwiftOutline { 17 | graph [rankdir=LR] 18 | subgraph cluster_0 { 19 | "FooViewController" -> "BarViewController" 20 | "FooViewController" -> "ZooViewController" 21 | } 22 | } 23 | """ 24 | let dotFile = Path("/tmp/out.dot") 25 | try! dotFile.write(code.data(using: .utf8)!) 26 | let outputPath = "/tmp/out.png" 27 | 28 | let client = GraphvizClient() 29 | client.generateDotPNG(dotPath: dotFile.string, outputPath: outputPath) 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SwiftOutlineKitTests/SwiftOutlineTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | @testable import SwiftOutlineKit 4 | import SwiftSyntax 5 | import PathKit 6 | 7 | final class SwiftOutlineKitTests: XCTestCase { 8 | func testInitCall() throws { 9 | let code = """ 10 | class AAAViewController { 11 | func openBBB() { 12 | let vc = BBBViewController() 13 | print(v) 14 | } 15 | } 16 | class BBBViewController { 17 | } 18 | """ 19 | let path = Path("/tmp/test.swift") 20 | try! path.write(code.data(using: .utf8)!) 21 | 22 | let source = try! SyntaxTreeParser.parse(path.url) 23 | let analyze = Analyzer() 24 | analyze.run(source: source) 25 | let result = analyze.results 26 | 27 | XCTAssertEqual(result.count, 1) 28 | XCTAssertEqual(result[0], Analyzer.Call(caller: "AAAViewController", callee: "BBBViewController")) 29 | } 30 | func testFactoryCall() throws { 31 | let code = """ 32 | class AAAViewController { 33 | func openBBB() { 34 | BBBViewController.open() 35 | } 36 | } 37 | class BBBViewController { 38 | func open() { 39 | let vc = BBBViewController() 40 | present(vc) 41 | } 42 | } 43 | """ 44 | let path = Path("/tmp/test.swift") 45 | try! path.write(code.data(using: .utf8)!) 46 | 47 | let source = try! SyntaxTreeParser.parse(path.url) 48 | let analyze = Analyzer() 49 | analyze.run(source: source) 50 | let result = analyze.results 51 | 52 | XCTAssertEqual(result.count, 2) 53 | XCTAssertEqual(result[0], Analyzer.Call(caller: "AAAViewController", callee: "BBBViewController")) 54 | XCTAssertEqual(result[1], Analyzer.Call(caller: "BBBViewController", callee: "BBBViewController")) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Tests/SwiftOutlineKitTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SwiftOutlineTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/SwiftOutlineTests/SwiftOutlineTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import class Foundation.Bundle 3 | 4 | final class SwiftOutlineTests: XCTestCase { 5 | func testExample() throws { 6 | // This is an example of a functional test case. 7 | // Use XCTAssert and related functions to verify your tests produce the correct 8 | // results. 9 | 10 | // Some of the APIs that we use below are available in macOS 10.13 and above. 11 | guard #available(macOS 10.13, *) else { 12 | return 13 | } 14 | 15 | let fooBinary = productsDirectory.appendingPathComponent("SwiftOutline") 16 | 17 | let process = Process() 18 | process.executableURL = fooBinary 19 | 20 | let pipe = Pipe() 21 | process.standardOutput = pipe 22 | 23 | try process.run() 24 | process.waitUntilExit() 25 | 26 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 27 | let output = String(data: data, encoding: .utf8) 28 | 29 | XCTAssertEqual(output, "Hello, world!\n") 30 | } 31 | 32 | /// Returns path to the built products directory. 33 | var productsDirectory: URL { 34 | #if os(macOS) 35 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { 36 | return bundle.bundleURL.deletingLastPathComponent() 37 | } 38 | fatalError("couldn't find the products directory") 39 | #else 40 | return Bundle.main.bundleURL 41 | #endif 42 | } 43 | 44 | static var allTests = [ 45 | ("testExample", testExample), 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /Tests/SwiftOutlineTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SwiftOutlineTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /doc/kickstarter_ios-oss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenmaz/SwiftOutline/dcec496d670ee107e784c8b0ee8fab8e31c93b96/doc/kickstarter_ios-oss.png -------------------------------------------------------------------------------- /doc/sample.dot: -------------------------------------------------------------------------------- 1 | digraph SwiftOutline { 2 | graph [rankdir=LR] 3 | subgraph cluster_0 { "AddOnViewController" -> "AddOnViewController" 4 | "AddOnViewController" -> "UINavigationController" 5 | "AddOnUtil" -> "AddOnViewController" 6 | "AddOnUtil" -> "AddOnViewController" 7 | "SupportMail" -> "MFMailComposeViewController" 8 | "SupportMail" -> "MFMailComposeViewController" 9 | "HomeViewController" -> "HomeViewController" 10 | "HomeViewController" -> "HomeViewController" 11 | "HomeViewController" -> "HomeViewController" 12 | "HomeViewController" -> "CanvasViewController" 13 | "HomeViewController" -> "UIActivityViewController" 14 | "HomeViewController" -> "MenuViewController" 15 | "HomeViewController" -> "HomeViewController" 16 | "FileViewController" -> "CanvasViewController" 17 | "FileViewController" -> "CanvasViewController" 18 | "MainTabBarController" -> "HomeViewController" 19 | "MainTabBarController" -> "UINavigationController" 20 | "MainTabBarController" -> "FileViewController" 21 | "MainTabBarController" -> "UINavigationController" 22 | "MainTabBarController" -> "HomeViewController" 23 | "MainTabBarController" -> "UINavigationController" 24 | "MainTabBarController" -> "HomeViewController" 25 | "MainTabBarController" -> "UINavigationController" 26 | "MenuViewController" -> "MenuViewController" 27 | "MenuViewController" -> "MenuViewController" 28 | "MenuViewController" -> "AddOnViewController" 29 | "CanvasViewController" -> "CanvasViewController" 30 | "CanvasViewController" -> "CanvasViewController" 31 | "CanvasViewController" -> "UploadWebViewController" 32 | } 33 | "SupportMail" [style="filled",fillcolor=black, fontcolor=white]; 34 | "AddOnUtil" [style="filled",fillcolor=black, fontcolor=white]; 35 | } 36 | -------------------------------------------------------------------------------- /doc/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kenmaz/SwiftOutline/dcec496d670ee107e784c8b0ee8fab8e31c93b96/doc/sample.png --------------------------------------------------------------------------------