├── .github
└── workflows
│ └── build-and-test.yml
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── contents.xcworkspacedata
├── LICENSE
├── Makefile
├── Package.resolved
├── Package.swift
├── PackageDependencies.md
├── README.md
├── Sources
├── DependenciesGraph
│ └── DependenciesGraph.swift
└── DependenciesGraphCore
│ ├── Command.swift
│ ├── DependenciesReader.swift
│ ├── Extension
│ └── String+newLine.swift
│ ├── MermaidCreator.swift
│ ├── Module.swift
│ └── ReadmeReader.swift
└── Tests
└── DependenciesGraphCoreTests
├── MermaidCreatorTests.swift
├── ReadmeReaderTests.swift
└── StringNewLineTests.swift
/.github/workflows/build-and-test.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 | workflow_dispatch:
9 |
10 | jobs:
11 | build:
12 | name: Build and Test using any available macOS
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | os: [macos-latest]
17 | xcode: ['15.0']
18 | runs-on: ${{ matrix.os }}
19 | steps:
20 | - name: Checkout
21 | uses: actions/checkout@v3
22 |
23 | - name: Build
24 | env:
25 | scheme: ${{ 'swift-dependencies-graph' }}
26 | platform: ${{ 'macOS' }}
27 | run: |
28 | xcodebuild build-for-testing -scheme "$scheme" -destination "platform=$platform"
29 |
30 | - name: Test
31 | env:
32 | scheme: ${{ 'swift-dependencies-graph' }}
33 | platform: ${{ 'macOS' }}
34 | run: |
35 | xcodebuild test-without-building -scheme "$scheme" -destination "platform=$platform"
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | Packages/
41 | Package.pins
42 | Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 | releases/
90 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Ryu
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 | COMMAND_NAME = dgraph
2 | BINARY_PATH = ./.build/release/$(COMMAND_NAME)
3 | VERSION = 0.1.0
4 |
5 | .PHONY: release
6 | release:
7 | mkdir -p releases
8 | swift build -c release
9 | cp $(BINARY_PATH) dgraph
10 | tar acvf releases/DependenciesGraph_$(VERSION).tar.gz dgraph
11 | cp dgraph releases/dgraph
12 | rm dgraph
13 |
14 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-argument-parser",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-argument-parser",
7 | "state" : {
8 | "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a",
9 | "version" : "1.2.2"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
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: "swift-dependencies-graph",
8 | platforms: [
9 | .macOS(.v11)
10 | ],
11 | products: [
12 | .executable(name: "dgraph", targets: ["DependenciesGraph"])
13 | ],
14 | dependencies: [
15 | // Dependencies declare other packages that this package depends on.
16 | // .package(url: /* package url */, from: "1.0.0"),
17 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.2"),
18 | ],
19 | targets: [
20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
21 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
22 | .executableTarget(
23 | name: "DependenciesGraph",
24 | dependencies: [
25 | "DependenciesGraphCore",
26 | .product(name: "ArgumentParser", package: "swift-argument-parser")
27 | ]
28 | ),
29 | .target(name: "DependenciesGraphCore"),
30 | .testTarget(
31 | name: "DependenciesGraphCoreTests",
32 | dependencies: ["DependenciesGraphCore"]
33 | ),
34 | ]
35 | )
36 |
--------------------------------------------------------------------------------
/PackageDependencies.md:
--------------------------------------------------------------------------------
1 | ```mermaid
2 | graph TD;
3 | DependenciesGraph-->DependenciesGraphCore;
4 | DependenciesGraphTests-->DependenciesGraphCore;
5 | ```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # swift-dependencies-graph
2 | CLI tool to output mermaid diagrams of module dependencies for multi-modularized projects in Swift Package Manager.
3 |
4 | ## Installation
5 | #### Mint
6 | ```
7 | Ryu0118/swift-dependencies-graph@0.1.0
8 | ```
9 |
10 | #### Homebrew
11 | ```
12 | $ brew install Ryu0118/dgraph/dgraph
13 | ```
14 | ##### update
15 | ```
16 | $ brew update
17 | $ brew upgrade Ryu0118/dgraph/dgraph
18 | ```
19 |
20 | ## Usage
21 | ```
22 | USAGE: dgraph [--add-to-readme] [--include-product]
23 |
24 | ARGUMENTS:
25 | Project root directory
26 |
27 | OPTIONS:
28 | --add-to-readme Add Mermaid diagram to README
29 | --include-product Include .product(name:package:)
30 | -h, --help Show help information.
31 | ```
32 |
33 | ## Example
34 | ```mermaid
35 | graph TD;
36 | App-->HogeFeature;
37 | App-->FugaFeature;
38 | App-->LoginFeature;
39 | LoginFeature-->CoreModule;
40 | HogeFeature-->CoreModule;
41 | FugaFeature-->CoreModule;
42 | ```
43 |
44 | ## Package Dependencies
45 | ```mermaid
46 | graph TD;
47 | DependenciesGraph-->DependenciesGraphCore;
48 | DependenciesGraphCoreTests-->DependenciesGraphCore;
49 | ```
50 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraph/DependenciesGraph.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ArgumentParser
3 | import DependenciesGraphCore
4 |
5 | @main
6 | struct DependenciesGraph: ParsableCommand {
7 | @Argument(help: "Project root directory")
8 | var projectPath: String
9 |
10 | @Flag(name: .customLong("add-to-readme"), help: "Add Mermaid diagram to README")
11 | var isAddToReadme: Bool = false
12 |
13 | @Flag(name: .customLong("include-product"), help: "Include .product(name:package:)")
14 | var isIncludeProduct: Bool = false
15 |
16 | static let _commandName: String = "dgraph"
17 |
18 | private var fileManager: FileManager { FileManager.default }
19 |
20 | mutating func run() throws {
21 | #if os(macOS)
22 | if isAddToReadme {
23 | try addToReadme()
24 | } else {
25 | try createPackageDependencies()
26 | }
27 | #endif
28 | }
29 |
30 | mutating func validate() throws {
31 | if !URL(fileURLWithPath: projectPath).hasDirectoryPath {
32 | throw DependenciesGraphError.notDirectory(path: projectPath)
33 | }
34 | else if !fileManager.fileExists(atPath: projectPath) {
35 | throw DependenciesGraphError.projectNotFound(path: projectPath)
36 | }
37 | else if !fileManager.fileExists(atPath: projectPath + "/Package.swift") {
38 | throw DependenciesGraphError.packageSwiftNotFound(path: projectPath)
39 | }
40 | else if !fileManager.fileExists(atPath: projectPath + "/README.md") && isAddToReadme {
41 | throw DependenciesGraphError.readmeNotFound
42 | }
43 | }
44 |
45 | private func addToReadme() throws {
46 | let mermaid = try createMermaid()
47 | let readmeTitle = "## Package Dependencies"
48 | let url = URL(fileURLWithPath: projectPath).appendingPathComponent("README.md")
49 | if !fileManager.fileExists(atPath: url.absoluteString) {
50 | fileManager.createFile(atPath: url.absoluteString, contents: nil)
51 | }
52 | guard var readme = try String(data: Data(contentsOf: url), encoding: .utf8) else {
53 | throw DependenciesGraphError.failedToDecodeReadme
54 | }
55 | let removedReadme = ReadmeReader.removeLines(readme, from: "## Package Dependencies")
56 | readme = removedReadme + readmeTitle + "\n" + mermaid + "\n"
57 | try readme.write(to: url, atomically: true, encoding: .utf8)
58 | print("✅ Updated README.md")
59 | }
60 |
61 | private func createPackageDependencies() throws {
62 | let mermaid = try createMermaid()
63 | let url = URL(fileURLWithPath: projectPath).appendingPathComponent("PackageDependencies.md")
64 | try mermaid.write(to: url, atomically: true, encoding: .utf8)
65 | print("✅ Created PackageDependencies.md")
66 | }
67 |
68 | private func createMermaid() throws -> String {
69 | print("🚀 Reading dependencies...")
70 | let reader = DependenciesReader(packageRootDirectoryPath: projectPath)
71 | let modules = try reader.readDependencies(isIncludeProduct: isIncludeProduct)
72 | print("🧜 Creating Mermaid...")
73 | let mermaid = MermaidCreator.create(from: modules)
74 | return mermaid
75 | }
76 | }
77 |
78 | enum DependenciesGraphError: LocalizedError {
79 | case projectNotFound(path: String)
80 | case packageSwiftNotFound(path: String)
81 | case notDirectory(path: String)
82 | case readmeNotFound
83 | case failedToDecodeReadme
84 |
85 | var errorDescription: String? {
86 | switch self {
87 | case .projectNotFound(let path):
88 | return "\(path) could not be found"
89 |
90 | case .packageSwiftNotFound(let path):
91 | return "\(path)/Package.swift could not be found"
92 |
93 | case .notDirectory(let path):
94 | return "\(path) is not a directory"
95 |
96 | case .readmeNotFound:
97 | return "README.md could not be found"
98 |
99 | case .failedToDecodeReadme:
100 | return "Failed to decode README.md"
101 | }
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraphCore/Command.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import Foundation
3 |
4 | enum Command {
5 | static func run(
6 | launchPath: String,
7 | currentDirectoryPath: String? = nil,
8 | arguments: [String]
9 | ) throws -> String {
10 | let process = Process()
11 | process.launchPath = launchPath
12 | if let currentDirectoryPath {
13 | process.currentDirectoryPath = currentDirectoryPath
14 | }
15 | process.arguments = arguments
16 | let pipe = Pipe()
17 | process.standardOutput = pipe
18 | try process.run()
19 |
20 | guard let data = try pipe.fileHandleForReading.readToEnd() else {
21 | throw CommandError.cannotReadData
22 | }
23 |
24 | return String(data: data, encoding: .utf8) ?? ""
25 | }
26 | }
27 |
28 | enum CommandError: Error {
29 | case cannotReadData
30 | }
31 | #endif
32 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraphCore/DependenciesReader.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import Foundation
3 |
4 | public struct DependenciesReader {
5 | private let packageRootDirectoryPath: String
6 | private let decoder: JSONDecoder
7 |
8 | public init(
9 | packageRootDirectoryPath: String,
10 | decoder: JSONDecoder = .init()
11 | ) {
12 | self.packageRootDirectoryPath = packageRootDirectoryPath
13 | self.decoder = decoder
14 | }
15 |
16 | public func readDependencies(isIncludeProduct: Bool) throws -> [Module] {
17 | let jsonString = try dumpPackage()
18 | let jsonData = jsonString.data(using: .utf8)!
19 | return try decoder
20 | .decode(DumpPackageResponse.self, from: jsonData)
21 | .toModule(isIncludeProduct: isIncludeProduct)
22 | }
23 |
24 | private func dumpPackage() throws -> String {
25 | try Command.run(
26 | launchPath: "/usr/bin/env",
27 | currentDirectoryPath: packageRootDirectoryPath,
28 | arguments: ["swift", "package", "dump-package"]
29 | )
30 | }
31 | }
32 |
33 | private struct DumpPackageResponse: Decodable {
34 | let targets: [Target]
35 |
36 | struct Target: Decodable {
37 | let name: String
38 | let dependencies: [Dependency]
39 |
40 | struct Dependency: Decodable {
41 | let byName: [String?]?
42 | let product: [String?]?
43 | }
44 | }
45 | }
46 |
47 | extension DumpPackageResponse {
48 | func toModule(isIncludeProduct: Bool) -> [Module] {
49 | targets.map { target in
50 | let byNameDependencies = target.dependencies.compactMap { $0.byName?.compactMap { $0 }.first }
51 | let productDependencies = target.dependencies.compactMap { $0.product?.compactMap { $0 }.first }
52 | let dependencies = isIncludeProduct ? byNameDependencies + productDependencies : byNameDependencies
53 | return Module(name: target.name, dependencies: dependencies)
54 | }
55 | }
56 | }
57 | #endif
58 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraphCore/Extension/String+newLine.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension String {
4 | mutating func newLine(_ new: String, indent: Int = 0) {
5 | self = self + "\n" + String(repeating: " ", count: indent) + new
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraphCore/MermaidCreator.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public enum MermaidCreator {
4 | public static func create(from modules: [Module]) -> String {
5 | var mermaid = "```mermaid"
6 | mermaid.newLine("graph TD;")
7 | for module in modules {
8 | for dependency in module.dependencies {
9 | mermaid.newLine("\(module.name)-->\(dependency);", indent: 1)
10 | }
11 | }
12 | mermaid.newLine("```")
13 | return mermaid
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraphCore/Module.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct Module {
4 | let name: String
5 | let dependencies: [String]
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/DependenciesGraphCore/ReadmeReader.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public enum ReadmeReader {
4 | public static func removeLines(_ readme: String, from: String) -> String {
5 | var removedReadme = ""
6 | let lines = readme.components(separatedBy: "\n")
7 | for (index, line) in lines.enumerated() {
8 | if line.contains(from) {
9 | break
10 | }
11 | removedReadme += line
12 | if index < lines.count {
13 | removedReadme += "\n"
14 | }
15 | }
16 | return removedReadme
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Tests/DependenciesGraphCoreTests/MermaidCreatorTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DependenciesGraphCore
3 |
4 | class MermaidCreatorTests: XCTestCase {
5 | func testMermaidCreator() {
6 | XCTAssertEqual(
7 | MermaidCreator.create(
8 | from: [
9 | Module(name: "module1", dependencies: ["module2", "module3"]),
10 | Module(name: "module2", dependencies: ["module3"]),
11 | Module(name: "module3", dependencies: [])
12 | ]
13 | ),
14 | """
15 | ```mermaid
16 | graph TD;
17 | module1-->module2;
18 | module1-->module3;
19 | module2-->module3;
20 | ```
21 | """
22 | )
23 | }
24 |
25 | func testMermaidCreatorWithNoDependencies() {
26 | XCTAssertEqual(
27 | MermaidCreator.create(
28 | from: [
29 | Module(name: "module1", dependencies: []),
30 | Module(name: "module2", dependencies: []),
31 | Module(name: "module3", dependencies: [])
32 | ]
33 | ),
34 | """
35 | ```mermaid
36 | graph TD;
37 | ```
38 | """
39 | )
40 | }
41 |
42 | func testMermaidCreatorWithNoModules() {
43 | XCTAssertEqual(
44 | MermaidCreator.create(from: []),
45 | """
46 | ```mermaid
47 | graph TD;
48 | ```
49 | """
50 | )
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Tests/DependenciesGraphCoreTests/ReadmeReaderTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DependenciesGraphCore
3 |
4 | final class ReadmeReaderTests: XCTestCase {
5 | func testRemoveLinesWithTargetString() {
6 | XCTAssertEqual(
7 | ReadmeReader.removeLines(
8 | "This is a readme text.",
9 | from: "readme"
10 | ),
11 | ""
12 | )
13 | }
14 |
15 | func testRemoveLinesWithoutTargetString() {
16 | XCTAssertEqual(
17 | ReadmeReader.removeLines(
18 | "This is a readme text.",
19 | from: "Non-existent string"
20 | ),
21 | "This is a readme text.\n"
22 | )
23 | }
24 |
25 | func testRemoveLinesWithEmptyString() {
26 | XCTAssertEqual(
27 | ReadmeReader.removeLines(
28 | "",
29 | from: "Non-existent string"
30 | ),
31 | "\n"
32 | )
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Tests/DependenciesGraphCoreTests/StringNewLineTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DependenciesGraphCore
3 |
4 | final class StringNewLineTests: XCTestCase {
5 | func testNewLineWithoutIndent() {
6 | var str = "Line1"
7 | str.newLine("Line2")
8 | XCTAssertEqual(str, "Line1\nLine2")
9 | }
10 |
11 | func testNewLineWithIndent1() {
12 | var str = "Line1"
13 | str.newLine("Line2", indent: 1)
14 | XCTAssertEqual(str, "Line1\n Line2")
15 | }
16 |
17 | func testNewLineWithIndent2() {
18 | var str = "Line1"
19 | str.newLine("Line2", indent: 2)
20 | XCTAssertEqual(str, "Line1\n Line2")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------