├── .github └── workflows │ ├── release.yml │ └── swift.yml ├── .ibgraph.yml ├── .swift-version ├── .swiftlint.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── contents.xcworkspacedata │ └── xcshareddata │ └── xcschemes │ └── ibgraph.xcscheme ├── .travis.yml ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── IBGraph │ └── main.swift ├── IBGraphFrontend │ ├── Commands │ │ ├── GenerateCommand.swift │ │ └── VersionCommand.swift │ ├── IBGraphFrontend.swift │ └── Version.swift └── IBGraphKit │ ├── Config │ └── Config.swift │ ├── GraphBuilder.swift │ ├── IBGraph.swift │ ├── Reporters │ ├── DOTReporter.swift │ ├── DefaultReporter.swift │ ├── GMLReporter.swift │ ├── GraphMLReporter.swift │ ├── JSONReporter.swift │ ├── OnlineReporter.swift │ └── Reporter.swift │ ├── Utils │ └── Glob.swift │ └── Vertexable.swift └── Tests ├── First.storyboard ├── First.xml ├── Second.storyboard ├── Second.xml └── test.swift /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | update: 8 | name: build 9 | runs-on: macOS-latest 10 | steps: 11 | - name: ⬇️ Checkout 12 | uses: actions/checkout@master 13 | with: 14 | fetch-depth: 1 15 | - name: 🔢 Edit version file 16 | run: | 17 | RELEASE_NAME=$(jq --raw-output '.release.tag_name' $GITHUB_EVENT_PATH) 18 | sed -i '' "s/0.0.1/$RELEASE_NAME/g" Sources/IBGraphFrontend/Version.swift 19 | - name: 🏗 swiftbuild 20 | run: | 21 | swift build -c release 22 | - name: 📦 Build archive 23 | run: | 24 | REPOSITORY_NAME=$(jq --raw-output '.repository.name' $GITHUB_EVENT_PATH) 25 | zip -r $REPOSITORY_NAME.zip .build/release/$REPOSITORY_NAME 26 | - name: ⬆️ Upload to Release 27 | run: | 28 | REPOSITORY_NAME=$(jq --raw-output '.repository.name' $GITHUB_EVENT_PATH) 29 | ARTIFACT=./$REPOSITORY_NAME.zip 30 | AUTH_HEADER="Authorization: token $GITHUB_TOKEN" 31 | CONTENT_LENGTH_HEADER="Content-Length: $(stat -f%z "$ARTIFACT")" 32 | CONTENT_TYPE_HEADER="Content-Type: application/zip" 33 | RELEASE_ID=$(jq --raw-output '.release.id' $GITHUB_EVENT_PATH) 34 | FILENAME=$(basename $ARTIFACT) 35 | UPLOAD_URL="https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$FILENAME" 36 | echo "$UPLOAD_URL" 37 | curl -sSL -XPOST \ 38 | -H "$AUTH_HEADER" -H "$CONTENT_LENGTH_HEADER" -H "$CONTENT_TYPE_HEADER" \ 39 | --upload-file "$ARTIFACT" "$UPLOAD_URL" 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | -------------------------------------------------------------------------------- /.ibgraph.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - ".build/checkouts/IBDecodable" 3 | - "Tests" 4 | reporter: "dot" 5 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.0 2 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | line_length: 4 | warning: 165 5 | ignores_comments: true 6 | large_tuple: 7 | warning: 3 8 | disabled_rules: 9 | - colon 10 | - identifier_name 11 | - type_name 12 | - cyclomatic_complexity 13 | - pattern_matching_keywords 14 | - nesting 15 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/ibgraph.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 68 | 70 | 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | 91 | 93 | 99 | 100 | 101 | 102 | 104 | 105 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: objective-c 2 | osx_image: xcode10.2 3 | before_script: 4 | - swiftlint --strict 5 | script: 6 | - swift test 7 | before_deploy: 8 | - make portable_zip 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 phimage(Eric Marchand) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX?=/usr/local 2 | 3 | build: 4 | swift build --disable-sandbox -c release --static-swift-stdlib 5 | 6 | clean_build: 7 | rm -rf .build 8 | make build 9 | 10 | portable_zip: build 11 | rm -rf portable_ibgraph 12 | mkdir portable_ibgraph 13 | mkdir portable_ibgraph/bin 14 | cp -f .build/release/ibgraph portable_ibgraph/bin/ibgraph 15 | cp -f LICENSE portable_ibgraph 16 | cd portable_ibgraph 17 | (cd portable_ibgraph; zip -yr - "bin" "LICENSE") > "./portable_ibgraph.zip" 18 | rm -rf portable_ibgraph 19 | 20 | install: build 21 | mkdir -p "$(PREFIX)/bin" 22 | cp -f ".build/release/ibgraph" "$(PREFIX)/bin/ibgraph" 23 | 24 | current_version: 25 | @cat .version 26 | 27 | bump_version: 28 | $(eval NEW_VERSION := $(filter-out $@,$(MAKECMDGOALS))) 29 | @echo $(NEW_VERSION) > .version 30 | @sed 's/__VERSION__/$(NEW_VERSION)/g' script/Version.swift.template > Sources/IBGraphFrontend/Version.swift 31 | git commit -am"Bump version to $(NEW_VERSION)" 32 | 33 | publish: 34 | brew update && brew bump-formula-pr --tag=$(shell git describe --tags) --revision=$(shell git rev-parse HEAD) ibgraph 35 | COCOAPODS_VALIDATOR_SKIP_XCODEBUILD=1 pod trunk push IBGraph.podspec 36 | 37 | %: 38 | @: 39 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Commandant", 6 | "repositoryURL": "https://github.com/Carthage/Commandant.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "ab68611013dec67413628ac87c1f29e8427bc8e4", 10 | "version": "0.17.0" 11 | } 12 | }, 13 | { 14 | "package": "DotSwift", 15 | "repositoryURL": "https://github.com/ferranpujolcamins/DotSwift.git", 16 | "state": { 17 | "branch": "HEAD", 18 | "revision": "e1749ec3206dc09e57bd420a355f8fd63d54af2c", 19 | "version": null 20 | } 21 | }, 22 | { 23 | "package": "IBDecodable", 24 | "repositoryURL": "https://github.com/IBDecodable/IBDecodable.git", 25 | "state": { 26 | "branch": "HEAD", 27 | "revision": "f6e0a7259dde662b0f9caafcc3558c09c024fffe", 28 | "version": null 29 | } 30 | }, 31 | { 32 | "package": "Nimble", 33 | "repositoryURL": "https://github.com/Quick/Nimble.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "f8657642dfdec9973efc79cc68bcef43a653a2bc", 37 | "version": "8.0.2" 38 | } 39 | }, 40 | { 41 | "package": "PathKit", 42 | "repositoryURL": "https://github.com/kylef/PathKit.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "73f8e9dca9b7a3078cb79128217dc8f2e585a511", 46 | "version": "1.0.0" 47 | } 48 | }, 49 | { 50 | "package": "Quick", 51 | "repositoryURL": "https://github.com/Quick/Quick.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "94df9b449508344667e5afc7e80f8bcbff1e4c37", 55 | "version": "2.1.0" 56 | } 57 | }, 58 | { 59 | "package": "Spectre", 60 | "repositoryURL": "https://github.com/kylef/Spectre.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "f14ff47f45642aa5703900980b014c2e9394b6e5", 64 | "version": "0.9.0" 65 | } 66 | }, 67 | { 68 | "package": "SwiftGraph", 69 | "repositoryURL": "https://github.com/davecom/SwiftGraph", 70 | "state": { 71 | "branch": null, 72 | "revision": "3e9ed26e380b40fcadf80ef5d588829ced251673", 73 | "version": "3.0.0" 74 | } 75 | }, 76 | { 77 | "package": "SWXMLHash", 78 | "repositoryURL": "https://github.com/drmohundro/SWXMLHash.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "f43166a8e18fdd0857f29e303b1bb79a5428bca0", 82 | "version": "4.9.0" 83 | } 84 | }, 85 | { 86 | "package": "Yams", 87 | "repositoryURL": "https://github.com/jpsim/Yams.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "c947a306d2e80ecb2c0859047b35c73b8e1ca27f", 91 | "version": "2.0.0" 92 | } 93 | } 94 | ] 95 | }, 96 | "version": 1 97 | } 98 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.2 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: "IBGraph", 8 | products: [ 9 | .executable( 10 | name: "ibgraph", targets: ["IBGraph"] 11 | ), 12 | .library( 13 | name: "IBGraphKit", targets: ["IBGraphKit"] 14 | ), 15 | .library( 16 | name: "IBGraphFrontend", targets: ["IBGraphFrontend"] 17 | ) 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/ferranpujolcamins/DotSwift.git", .revision("HEAD")), 21 | .package(url: "https://github.com/IBDecodable/IBDecodable.git", .revision("HEAD")), 22 | .package(url: "https://github.com/Carthage/Commandant.git", .upToNextMinor(from: "0.17.0")), 23 | .package(url: "https://github.com/jpsim/Yams.git", .upToNextMinor(from: "2.0.0")), 24 | .package(url:"https://github.com/kylef/PathKit.git", .upToNextMinor(from:"1.0.0")) 25 | ], 26 | targets: [ 27 | .target( 28 | name: "IBGraph", 29 | dependencies: ["IBGraphKit", "IBGraphFrontend"], 30 | path: "Sources/IBGraph" 31 | ), 32 | .target( 33 | name: "IBGraphKit", 34 | dependencies: ["Commandant", "DotSwiftAttributes", "DotSwiftEncoder", "SwiftGraphBindings", "IBDecodable", "Yams"], 35 | path: "Sources/IBGraphKit" 36 | ), 37 | .target( 38 | name: "IBGraphFrontend", 39 | dependencies: [ "IBGraphKit", "PathKit"], 40 | path: "Sources/IBGraphFrontend" 41 | ), 42 | .testTarget( 43 | name: "IBGraphTests", 44 | dependencies: ["IBGraph", "IBGraphKit"], 45 | path: "Tests" 46 | ) 47 | ] 48 | ) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IBGraph 2 | [![Build Status](https://travis-ci.org/IBDecodable/IBGraph.svg?branch=master)](https://travis-ci.org/IBDecodable/IBGraph) 3 | [![Swift 5.0](https://img.shields.io/badge/Swift-5.0-orange.svg?style=flat)](https://developer.apple.com/swift/) 4 | 5 | A tool to create a graph representaton of your `.storyboard` files. 6 | 7 | ## Install 8 | 9 | ### Using sources 10 | 11 | ``` 12 | git clone https://github.com/IBDecodable/IBGraph.git 13 | cd IBGraph 14 | make install 15 | ``` 16 | 17 | ### Using Homebrew (swiftbrew) 18 | 19 | If not already installed yet, install [Swiftbrew](https://github.com/swiftbrew/Swiftbrew) with [Homebrew](https://brew.sh/index_fr) 20 | 21 | ``` 22 | brew install swiftbrew/tap/swiftbrew 23 | ``` 24 | 25 | then type 26 | ``` 27 | swift brew install IBDecodable/IBGraph 28 | ``` 29 | 30 | ## Usage 31 | 32 | You can see all description by `ibgraph help` 33 | 34 | ``` 35 | $ ibgraph help 36 | Available commands: 37 | 38 | help Display general or command-specific help 39 | generate Show graph (default command) 40 | version Display the current version of ibgraph 41 | ``` 42 | 43 | ### Generate command 44 | 45 | #### Using `default` reporter 46 | 47 | ``` 48 | $ ibgraph 49 | FirstViewController -> ["SecondViewController"] 50 | ``` 51 | 52 | #### Using `dot` reporter 53 | ``` 54 | $ ibgraph --reporter dot 55 | digraph { 56 | 0 [label = "FirstViewController"]; 57 | 1 [label = "SecondViewController"]; 58 | 59 | 0 -> 1; 60 | } 61 | # or ibgraph if reporter defined in configuration file 62 | ``` 63 | 64 | _[Visualize this graph online](http://bit.ly/2YtkuY5)_ 65 | 66 | _[Example on IBAnimatable demo app](http://bit.ly/2STM1wW)_ 67 | 68 | or if you ave `graphviz` installed you can open preview 69 | 70 | ```bash 71 | ibgraph --reporter dot | dot -Tpng | open -Wfa preview 72 | ``` 73 | 74 | #### Using `online` reporter 75 | 76 | This reporter open the graph on https://dreampuf.github.io/GraphvizOnline/ 77 | 78 | ``` 79 | $ ibgraph --reporter online 80 | Open url https://dreampuf.github.io/GraphvizOnline/#digraph.... 81 | ``` 82 | 83 | ### Convert graph to png 84 | 85 | First use `dot` reporter. 86 | 87 | 88 | Then you can install `graphviz` using Homebrew 89 | 90 | ``` 91 | brew install graphviz 92 | ``` 93 | 94 | And finally launch the convertion using `dot` command on your result file 95 | 96 | ``` 97 | dot -Tpng MyStoryboards.dot -o MyStoryboards.png 98 | ``` 99 | 100 | or directly after `ibgraph` launch 101 | 102 | ``` 103 | ibgraph --reporter dot | dot -Tpng -o MyStoryboards.png 104 | ``` 105 | 106 | #### Xcode 107 | 108 | Add a `Run Script Phase` to integrate IBGraph with Xcode 109 | 110 | ```sh 111 | if which ibgraph >/dev/null; then 112 | if which dot >/dev/null; then 113 | ibgraph generate --reporter dot | dot -Tpng -o storyboards.png 114 | else 115 | echo "warning: dot from graphviz is not installed, check how to install here https://github.com/IBDecodable/IBGraph#convert-graph-to-png" 116 | fi 117 | fi 118 | else 119 | echo "warning: IBGraph not installed, download from https://github.com/IBDecodable/IBGraph" 120 | fi 121 | ``` 122 | 123 | ## Requirements 124 | 125 | IBGraph requires Swift5.0 runtime. Please satisfy at least one of following requirements. 126 | 127 | - macOS 10.14.4 or later 128 | - Install `Swift 5 Runtime Support for Command Line Tools` from [More Downloads for Apple Developers](https://developer.apple.com/download/more/) 129 | 130 | ## Configuration 131 | 132 | You can configure IBGraph by adding a `.ibgraph.yml` file from project root directory. 133 | 134 | 135 | | key | description | 136 | |:---------------------|:--------------------------- | 137 | | `excluded` | Path to ignore. | 138 | | `included` | Path to include. | 139 | | `reporter` | Choose the output format between `default`, `dot`, `json`, `gml` and `graphml`. Or `online` to open graph on default browser.| 140 | 141 | ```yaml 142 | included: 143 | - App/Views 144 | reporter: "dot" 145 | ``` 146 | -------------------------------------------------------------------------------- /Sources/IBGraph/main.swift: -------------------------------------------------------------------------------- 1 | import IBGraphFrontend 2 | 3 | let app = IBGraphFrontend() 4 | app.run() 5 | -------------------------------------------------------------------------------- /Sources/IBGraphFrontend/Commands/GenerateCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenerateCommand.swift 3 | // IBGraphFrontend 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | import IBDecodable 10 | import IBGraphKit 11 | import Commandant 12 | import PathKit 13 | 14 | struct GenerateCommand: CommandProtocol { 15 | typealias Options = GenerateOptions 16 | typealias ClientError = Options.ClientError 17 | 18 | let verb: String = "generate" 19 | var function: String = "Show graph (default command)" 20 | 21 | func run(_ options: GenerateCommand.Options) -> Result<(), GenerateCommand.ClientError> { 22 | let workDirectoryString = options.path ?? FileManager.default.currentDirectoryPath 23 | let workDirectory = URL(fileURLWithPath: workDirectoryString) 24 | guard FileManager.default.isDirectory(workDirectory.path) else { 25 | fatalError("\(workDirectoryString) is not directory.") 26 | } 27 | 28 | let config = Config(options: options) ?? Config.default 29 | let graphBuilder = GraphBuilder() 30 | let graph = graphBuilder.graph(workDirectory: workDirectory, config: config) 31 | 32 | let reporter = Reporters.reporter(from: options.reporter ?? config.reporter) 33 | let report = reporter.generateReport(graph: graph) 34 | print(report) 35 | 36 | if graph.isEmpty { 37 | exit(2) 38 | } else { 39 | return .success(()) 40 | } 41 | } 42 | } 43 | 44 | struct GenerateOptions: OptionsProtocol { 45 | typealias ClientError = CommandantError<()> 46 | 47 | let path: String? 48 | let reporter: String? 49 | let configurationFile: String? 50 | 51 | static func create(_ path: String?) -> (_ reporter: String?) -> (_ config: String?) -> GenerateOptions { 52 | return { reporter in 53 | return { config in 54 | self.init(path: path, reporter: reporter, configurationFile: config) 55 | } 56 | } 57 | } 58 | 59 | static func evaluate(_ mode: CommandMode) -> Result> { 60 | return create 61 | <*> mode <| Option(key: "path", defaultValue: nil, usage: "validate project root directory") 62 | <*> mode <| Option(key: "reporter", defaultValue: nil, usage: "the reporter used to show graph") 63 | <*> mode <| Option(key: "config", defaultValue: nil, usage: "the path to IBGraph's configuration file") 64 | } 65 | } 66 | 67 | extension Config { 68 | init?(options: GenerateOptions) { 69 | if let configurationFile = options.configurationFile { 70 | let configurationURL = URL(fileURLWithPath: configurationFile) 71 | try? self.init(url: configurationURL) 72 | } else { 73 | let workDirectoryString = options.path ?? FileManager.default.currentDirectoryPath 74 | let workDirectory = URL(fileURLWithPath: workDirectoryString) 75 | try? self.init(directoryURL: workDirectory) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/IBGraphFrontend/Commands/VersionCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionCommand.swift 3 | // IBGraphFrontend 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Commandant 9 | 10 | struct VersionCommand: CommandProtocol { 11 | let verb = "version" 12 | let function = "Display the current version of IBGraph" 13 | 14 | func run(_ options: NoOptions>) -> Result<(), CommandantError<()>> { 15 | print(Version.current.value) 16 | return .success(()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/IBGraphFrontend/IBGraphFrontend.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IBGraphFrontend.swift 3 | // IBGraphFrontend 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Commandant 9 | 10 | public struct IBGraphFrontend { 11 | 12 | public init() {} 13 | 14 | public func run() { 15 | let registry = CommandRegistry>() 16 | registry.register(GenerateCommand()) 17 | registry.register(HelpCommand(registry: registry)) 18 | registry.register(VersionCommand()) 19 | 20 | registry.main(defaultVerb: GenerateCommand().verb) { (error) in 21 | print(String.init(describing: error)) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/IBGraphFrontend/Version.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Version.swift 3 | // IBGraphFrontend 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | public struct Version { 9 | public let value: String 10 | 11 | public static let current = Version(value: "0.0.1") 12 | } 13 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Config/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Config.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | import Yams 10 | 11 | public struct Config: Codable { 12 | public let excluded: [String] 13 | public let included: [String] 14 | public let reporter: String 15 | public let type: IBGraphType 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case excluded 19 | case included 20 | case reporter 21 | case type 22 | } 23 | 24 | public static let fileName = ".ibgraph.yml" 25 | public static let `default` = Config.init() 26 | public static let defaultReporter = "default" 27 | 28 | private init() { 29 | excluded = [] 30 | included = [] 31 | reporter = Config.defaultReporter 32 | type = .default 33 | } 34 | 35 | init(excluded: [String] = [], included: [String] = [], reporter: String = Config.defaultReporter) { 36 | self.excluded = excluded 37 | self.included = included 38 | self.reporter = reporter 39 | self.type = .default 40 | } 41 | 42 | public init(from decoder: Decoder) throws { 43 | let container = try decoder.container(keyedBy: CodingKeys.self) 44 | excluded = try container.decodeIfPresent(Optional<[String]>.self, forKey: .excluded).flatMap { $0 } ?? [] 45 | included = try container.decodeIfPresent(Optional<[String]>.self, forKey: .included).flatMap { $0 } ?? [] 46 | reporter = try container.decodeIfPresent(String.self, forKey: .reporter) ?? Config.defaultReporter 47 | type = try container.decodeIfPresent(IBGraphType.self, forKey: .type) ?? .default 48 | } 49 | 50 | public init(url: URL) throws { 51 | self = try YAMLDecoder().decode(from: String.init(contentsOf: url)) 52 | } 53 | 54 | public init(directoryURL: URL, fileName: String = fileName) throws { 55 | let url = directoryURL.appendingPathComponent(fileName) 56 | try self.init(url: url) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/GraphBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validator.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | import IBDecodable 10 | 11 | public class GraphBuilder { 12 | 13 | public init() {} 14 | 15 | public func graph(workDirectory: URL, config: Config) -> IBGraph { 16 | let storyboards = lintablePaths(workDirectory: workDirectory, config: config) 17 | return graph(files: storyboards, config: config) 18 | } 19 | 20 | public func graph(files: Set, config: Config) -> IBGraph { 21 | return files.compactMap { path in 22 | return try? StoryboardFile(path: path.relativePath) 23 | }.graph(type: config.type) 24 | } 25 | 26 | private struct InterfaceBuilderFiles { 27 | var storyboardPaths: Set = [] 28 | } 29 | 30 | private final class LintableFileMatcher { 31 | let fileManager: FileManager 32 | let globber: Glob 33 | init(fileManager: FileManager = .default) { 34 | self.fileManager = fileManager 35 | self.globber = Glob(fileManager: fileManager) 36 | } 37 | 38 | func interfaceBuilderFiles(withPatterns patterns: [URL]) -> InterfaceBuilderFiles { 39 | return patterns.flatMap { globber.expandRecursiveStars(pattern: $0.path) } 40 | .reduce(into: InterfaceBuilderFiles()) { result, path in 41 | let files = self.interfaceBuilderFiles(atPath: URL(fileURLWithPath: path)) 42 | result.storyboardPaths.formUnion(files.storyboardPaths) 43 | } 44 | } 45 | 46 | func interfaceBuilderFiles(atPath path: URL) -> InterfaceBuilderFiles { 47 | var storyboards: Set = [] 48 | guard let enumerator = fileManager.enumerator(at: path, includingPropertiesForKeys: [.isRegularFileKey]) else { 49 | return InterfaceBuilderFiles(storyboardPaths: storyboards) 50 | } 51 | 52 | for element in enumerator { 53 | guard let absolute = element as? URL else { continue } 54 | switch absolute.pathExtension { 55 | case "storyboard": storyboards.insert(absolute) 56 | default: continue 57 | } 58 | } 59 | return InterfaceBuilderFiles(storyboardPaths: storyboards) 60 | } 61 | } 62 | 63 | public func lintablePaths(workDirectory: URL, config: Config, fileManager: FileManager = .default) -> Set { 64 | let matcher = LintableFileMatcher(fileManager: fileManager) 65 | let files: InterfaceBuilderFiles 66 | if config.included.isEmpty { 67 | files = matcher.interfaceBuilderFiles(atPath: workDirectory) 68 | } else { 69 | files = matcher.interfaceBuilderFiles( 70 | withPatterns: config.included.map { workDirectory.appendingPathComponent($0) } 71 | ) 72 | } 73 | 74 | let excluded = matcher.interfaceBuilderFiles( 75 | withPatterns: config.excluded.map { workDirectory.appendingPathComponent($0) } 76 | ) 77 | let storyboardLintablePaths = files.storyboardPaths.subtracting(excluded.storyboardPaths) 78 | return storyboardLintablePaths 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/IBGraph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IBGraph.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | import Foundation 8 | 9 | import IBDecodable 10 | 11 | import SwiftGraph 12 | import SwiftGraphBindings 13 | 14 | public typealias Vertex = String 15 | public typealias IBGraph = UnweightedGraph 16 | 17 | public enum IBGraphType: String, Codable { 18 | case storyboard 19 | case viewController 20 | 21 | static let `default`: IBGraphType = .storyboard 22 | } 23 | 24 | public extension Sequence where Element == StoryboardFile { 25 | 26 | func graph(type: IBGraphType = .storyboard) -> UnweightedGraph { 27 | let graph = UnweightedGraph() 28 | 29 | var vertexes: [String: Int] = [:] 30 | // Add vertex 31 | for file in self { 32 | switch type { 33 | case .storyboard: 34 | let vertex = file.vertex 35 | let index = graph.addVertex(vertex) 36 | vertexes[vertex] = index 37 | case .viewController: 38 | if let scenes = file.document.scenes { 39 | for scene in scenes { 40 | let vertex = scene.vertex 41 | let index = graph.addVertex(vertex) 42 | vertexes[vertex] = index 43 | } 44 | } 45 | } 46 | } 47 | 48 | // Add link in second phrase 49 | for file in self { 50 | let vertex = file.vertex 51 | if let scenes = file.document.scenes { 52 | for scene in scenes { 53 | switch type { 54 | case .storyboard: 55 | // Link to other storyboard 56 | if let placeHolder = scene.viewControllerPlaceholder { 57 | let reference = placeHolder.storyboardName 58 | // print("\(vertex) -> \(reference)") 59 | graph.addEdge(from: vertex, to: reference, directed: true) 60 | // graph.addEdge(fromIndex: vertexes[vertex]!, toIndex: vertexes[reference]!) 61 | } 62 | case .viewController: 63 | let vertex = scene.vertex 64 | let segues: [Segue] = scene.children(of: Segue.self, recursive: true) 65 | for segue in segues { 66 | let reference = segue.destination 67 | // print("\(vertex) -> \(reference)")s 68 | graph.addEdge(from: vertex, to: reference, directed: true) 69 | } 70 | } 71 | } 72 | } 73 | } 74 | return graph 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/DOTReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DOTReporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | import SwiftGraph 11 | import SwiftGraphBindings 12 | import DotSwiftEncoder 13 | 14 | struct DOTReporter: Reporter { 15 | 16 | static let identifier = "dot" 17 | 18 | static func generateReport(graph: IBGraph) -> String { 19 | return DOTEncoder(type: .digraph).encode(graph) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/DefaultReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultReporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DefaultReporter: Reporter { 11 | 12 | static let identifier = "default" 13 | 14 | static func generateReport(graph: IBGraph) -> String { 15 | return graph.description 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/GMLReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GMLReporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | import DotSwiftEncoder 10 | 11 | struct GMLReporter: Reporter { 12 | 13 | static let identifier = "gml" 14 | 15 | static func generateReport(graph: IBGraph) -> String { 16 | var string = """ 17 | graph [ 18 | directed 1 19 | """ 20 | let nodes = graph.enumerated().map { arg -> Node in 21 | let (i, v) = arg 22 | return Node(id: i, label: v.description) 23 | } 24 | 25 | for node in nodes { 26 | string += """ 27 | \n node [ 28 | id \(node.id) 29 | label "\(node.label)" 30 | ] 31 | """ 32 | } 33 | for edge in graph.edgeList() { 34 | string += """ 35 | \n edge [ 36 | source \(edge.u) 37 | target \(edge.v) 38 | ] 39 | """ 40 | } 41 | string += "\n]" 42 | return string 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/GraphMLReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GraphMLReporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | import DotSwiftEncoder 10 | 11 | struct GraphMLReporter: Reporter { 12 | 13 | static let identifier = "graphml" 14 | 15 | static func generateReport(graph: IBGraph) -> String { 16 | var string = """ 17 | 18 | 19 | 20 | 21 | """ 22 | let nodes = graph.enumerated().map { arg -> Node in 23 | let (i, v) = arg 24 | return Node(id: i, label: v.description) 25 | } 26 | 27 | for node in nodes { 28 | string += """ 29 | 30 | 31 | 32 | 33 | \(node.label) 34 | 35 | 36 | 37 | 38 | """ 39 | } 40 | for (index, edge) in graph.edgeList().enumerated() { 41 | string += "\n " 42 | } 43 | string += "\n " 44 | string += "\n" 45 | return string 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/JSONReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONReporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | struct JSONReporter: Reporter { 11 | 12 | static let identifier = "json" 13 | 14 | static func generateReport(graph: IBGraph) -> String { 15 | guard let data = try? JSONEncoder().encode(graph) else { 16 | return "" 17 | } 18 | return String(data: data, encoding: .utf8)! 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/OnlineReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnlineReporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 30/07/2019. 6 | // 7 | 8 | import Foundation 9 | 10 | import SwiftGraph 11 | import SwiftGraphBindings 12 | import Cocoa 13 | 14 | struct OnlineReporter: Reporter { 15 | 16 | static let identifier = "online" 17 | static let baseURL = "https://dreampuf.github.io/GraphvizOnline/#" 18 | static func generateReport(graph: IBGraph) -> String { 19 | let report = DOTReporter.generateReport(graph: graph) 20 | 21 | guard var component = URLComponents(string: baseURL) else { 22 | return "Failed to parse url \(baseURL)" 23 | } 24 | component.fragment = report 25 | guard let url = component.url else { 26 | return "Failed to create url for graph" 27 | } 28 | NSWorkspace.shared.open(url) 29 | 30 | return "Open url \(url)" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Reporters/Reporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Reporter.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | public protocol Reporter { 9 | static var identifier: String { get } 10 | 11 | static func generateReport(graph: IBGraph) -> String 12 | } 13 | 14 | public struct Reporters { 15 | 16 | public static func reporter(from reporter: String) -> Reporter.Type { 17 | switch reporter { 18 | case DefaultReporter.identifier: 19 | return DefaultReporter.self 20 | case DOTReporter.identifier: 21 | return DOTReporter.self 22 | case JSONReporter.identifier: 23 | return JSONReporter.self 24 | case GraphMLReporter.identifier: 25 | return GraphMLReporter.self 26 | case GMLReporter.identifier: 27 | return GMLReporter.self 28 | case OnlineReporter.identifier: 29 | return OnlineReporter.self 30 | // https://en.wikipedia.org/wiki/DGML 31 | // https://en.wikipedia.org/wiki/Trivial_Graph_Format 32 | default: 33 | fatalError("no reporter with identifier '\(reporter) available'") 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Utils/Glob.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol GlobFileManager { 4 | func subpathsOfDirectory(atPath path: String) throws -> [String] 5 | func isDirectory(_ url: String) -> Bool 6 | } 7 | 8 | extension FileManager: GlobFileManager {} 9 | 10 | extension FileManager { 11 | public func isDirectory(_ url: String) -> Bool { 12 | var isDirectory: ObjCBool = false 13 | if fileExists(atPath: url, isDirectory: &isDirectory) { 14 | return isDirectory.boolValue 15 | } else { 16 | return false 17 | } 18 | } 19 | } 20 | 21 | #if os(Linux) 22 | import Glibc 23 | 24 | let system_glob = Glibc.glob 25 | #else 26 | import Darwin 27 | 28 | let system_glob = Darwin.glob 29 | #endif 30 | 31 | public final class Glob { 32 | let fileManager: GlobFileManager 33 | init(fileManager: GlobFileManager) { 34 | self.fileManager = fileManager 35 | } 36 | 37 | private let globFlags = GLOB_TILDE | GLOB_BRACE | GLOB_MARK 38 | public func glob(pattern: String) -> Set { 39 | var gt = glob_t.init() 40 | defer { globfree(>) } 41 | 42 | let patterns = expandRecursiveStars(pattern: pattern) 43 | var results: [String] = [] 44 | 45 | for pattern in patterns { 46 | if executeGlob(pattern: pattern, gt: >) { 47 | #if os(Linux) 48 | let matchCount = Int(gt.gl_pathc) 49 | #else 50 | let matchCount = Int(gt.gl_matchc) 51 | #endif 52 | for i in 0.., gt: UnsafeMutablePointer) -> Bool { 62 | return 0 == system_glob(pattern, globFlags, nil, gt) 63 | } 64 | 65 | func expandRecursiveStars(pattern: String) -> Set { 66 | func splitFirstStar(pattern: String) -> (head: String, tail: String?)? { 67 | let components = pattern.components(separatedBy: "**") 68 | guard let head = components.first, !head.isEmpty else { return nil } 69 | let tailComponents = components.dropFirst() 70 | guard !tailComponents.isEmpty else { return (head, nil) } 71 | var tail = tailComponents.joined(separator: "**") 72 | tail = tail.first == "/" ? String(tail.dropFirst()) : tail 73 | return (head, tail) 74 | 75 | } 76 | guard let (head, tail) = splitFirstStar(pattern: pattern) else { return [] } 77 | func childDirectories(current: String) -> [String] { 78 | do { 79 | return try fileManager.subpathsOfDirectory(atPath: current) 80 | .compactMap { 81 | let path = URL(fileURLWithPath: current).appendingPathComponent($0).path 82 | guard fileManager.isDirectory(path) else { return nil } 83 | return path 84 | } + [current] 85 | } catch { 86 | return [] 87 | } 88 | } 89 | guard let tailComponent = tail else { return [head] } 90 | guard !tailComponent.isEmpty else { 91 | if fileManager.isDirectory(head) { 92 | return [URL(fileURLWithPath: head).appendingPathComponent("*").path] 93 | } else { 94 | return [] 95 | } 96 | } 97 | let children = childDirectories(current: head) 98 | let result = children.map { URL(fileURLWithPath: $0).appendingPathComponent(tailComponent).path } 99 | .flatMap { expandRecursiveStars(pattern: $0) } 100 | return Set(result) 101 | } 102 | } 103 | 104 | public func glob(pattern: String, fileManager: GlobFileManager = FileManager.default) -> Set { 105 | return Glob(fileManager: fileManager).glob(pattern: pattern) 106 | } 107 | -------------------------------------------------------------------------------- /Sources/IBGraphKit/Vertexable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Vertexable.swift 3 | // IBGraphKit 4 | // 5 | // Created by phimage on 28/07/2019. 6 | // 7 | 8 | import Foundation 9 | import IBDecodable 10 | 11 | protocol Vertexable { 12 | /// Return the vertex node. 13 | var vertex: Vertex { get } 14 | } 15 | 16 | extension StoryboardFile: Vertexable { 17 | var vertex: Vertex { 18 | return URL(fileURLWithPath: self.pathString).deletingPathExtension().lastPathComponent 19 | } 20 | } 21 | 22 | extension Scene: Vertexable { 23 | var vertex: Vertex { // XXX Find a better representation for scene 24 | return self.viewController?.viewController.id ?? self.viewControllerPlaceholder?.id ?? id 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/First.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /Tests/First.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /Tests/Second.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Tests/Second.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Tests/test.swift: -------------------------------------------------------------------------------- 1 | 2 | 3 | import XCTest 4 | 5 | import IBDecodable 6 | 7 | import SwiftGraph 8 | import SwiftGraphBindings 9 | import DotSwiftEncoder 10 | 11 | @testable import IBGraphKit 12 | 13 | class Tests: XCTestCase { 14 | let urls = [url("First", "xml"), url("Second", "xml")] 15 | 16 | func testGraphStoryboard() { 17 | let files: [StoryboardFile] = urls.compactMap { try? StoryboardFile(url: $0) } 18 | 19 | let graph = files.graph() 20 | XCTAssertFalse(graph.isEmpty) 21 | 22 | print(DOTEncoder(type: .digraph).encode(graph)) 23 | } 24 | 25 | func testGraphViewController() { 26 | let files: [StoryboardFile] = urls.compactMap { try? StoryboardFile(url: $0) } 27 | 28 | let graph = files.graph(type: .viewController) 29 | XCTAssertFalse(graph.isEmpty) 30 | 31 | print(DOTEncoder(type: .digraph).encode(graph)) 32 | } 33 | } 34 | 35 | func url(_ name: String, _ ext: String) -> URL { 36 | return Bundle(for: Tests.self).url(forResource: name, withExtension: ext) ?? URL(fileURLWithPath: "Tests/\(name).\(ext)") 37 | } 38 | --------------------------------------------------------------------------------