├── .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 | [](https://travis-ci.org/IBDecodable/IBGraph)
3 | [](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 |
--------------------------------------------------------------------------------