diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cd93a8f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphKit.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphKit.xcscheme
new file mode 100644
index 0000000..fd73e59
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphKit.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphLint.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphLint.xcscheme
new file mode 100644
index 0000000..4a02a31
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphLint.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphMap.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphMap.xcscheme
new file mode 100644
index 0000000..bed66f8
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphMap.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphVisualize.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphVisualize.xcscheme
new file mode 100644
index 0000000..73bf5fd
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/SPMGraphVisualize.xcscheme
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph-Package.xcscheme
new file mode 100644
index 0000000..276bea5
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph-Package.xcscheme
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme
new file mode 100644
index 0000000..421afe1
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..864fdd4
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,132 @@
+{
+ "originHash" : "28b0823a1db28dd4f63c34e7404bf168ddefb7dc89c4bec8fd541c1de6acac53",
+ "pins" : [
+ {
+ "identity" : "filemonitor",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/aus-der-Technik/FileMonitor",
+ "state" : {
+ "branch" : "1.2.0",
+ "revision" : "ef22f1487d07fbff0a7a5f743d42611bfd03b5e8"
+ }
+ },
+ {
+ "identity" : "graphviz",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/tuist/GraphViz.git",
+ "state" : {
+ "branch" : "main",
+ "revision" : "083bccf9e492fd5731dd288a46741ea80148f508"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser.git",
+ "state" : {
+ "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531",
+ "version" : "1.2.3"
+ }
+ },
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "ae33e5941bb88d88538d0a6b19ca0b01e6c76dcf",
+ "version" : "1.3.1"
+ }
+ },
+ {
+ "identity" : "swift-certificates",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-certificates.git",
+ "state" : {
+ "revision" : "83640c8097acaec17c9835a083e89678cb0f2b66",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
+ "version" : "1.1.4"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "629f0b679d0fd0a6ae823d7f750b9ab032c00b80",
+ "version" : "3.0.0"
+ }
+ },
+ {
+ "identity" : "swift-driver",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-driver.git",
+ "state" : {
+ "branch" : "release/6.0",
+ "revision" : "2f500f2dd236034744dbf7051848f8780d6f5180"
+ }
+ },
+ {
+ "identity" : "swift-llbuild",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-llbuild.git",
+ "state" : {
+ "branch" : "release/6.0",
+ "revision" : "f56e1f27b3aecb067b1af56106d7fbe742be0e66"
+ }
+ },
+ {
+ "identity" : "swift-package-manager",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-package-manager",
+ "state" : {
+ "branch" : "swift-6.0-RELEASE",
+ "revision" : "5bd155f053b23664a8bb586f625aa9f8fa83ed86"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
+ "state" : {
+ "branch" : "release/6.0",
+ "revision" : "0687f71944021d616d34d922343dcef086855920"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-tools-support-core",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-tools-support-core.git",
+ "state" : {
+ "branch" : "release/6.0",
+ "revision" : "3e5d9c81a087832dffba0e4914ceb98caca594ea"
+ }
+ },
+ {
+ "identity" : "yams",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/jpsim/Yams.git",
+ "state" : {
+ "revision" : "0d9ee7ea8c4ebd4a489ad7a73d5c6cad55d6fed3",
+ "version" : "5.0.6"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..68860e0
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,144 @@
+// swift-tools-version: 6.0
+
+import PackageDescription
+
+let package = Package(
+ name: "spmgraph",
+ platforms: [
+ .macOS(.v13)
+ ],
+ products: [
+ .executable(
+ name: "spmgraph",
+ targets: ["SPMGraphExecutable"]
+ ),
+ .library(
+ name: "SPMGraphKit",
+ targets: [
+ "SPMGraphVisualize",
+ "SPMGraphLint",
+ "SPMGraphTests",
+ ]
+ ),
+ .library(
+ name: "SPMGraphDescriptionInterface",
+ type: .dynamic,
+ targets: [
+ "SPMGraphDescriptionInterface"
+ ]
+ ),
+ ],
+ dependencies: [
+ .package(
+ url: "https://github.com/tuist/GraphViz.git",
+ branch: "main" // a few commits ahead of the deprecated GraphViz original repo. It also includes Xcode 16 fixes.
+ ),
+ .package(
+ url: "https://github.com/apple/swift-argument-parser.git",
+ .upToNextMinor(from: "1.2.2")
+ ),
+
+ // TODO: Review which tag / Swift release to use
+ // - Initially it may be strict and sometimes "enforce" specific Xcode/Swift toolchains
+ // - For now pinned to the 6.0 release / Xcode 16.0
+ //
+ // It auto exports SwiftToolsSupport, so no need to directly depend on the former ๐
+ .package(
+ url: "https://github.com/apple/swift-package-manager",
+ revision: "swift-6.0-RELEASE"
+ ),
+ .package(
+ url: "https://github.com/aus-der-Technik/FileMonitor",
+ revision: "1.2.0"
+ ),
+ ],
+ targets: [
+ // MARK: - Functionality
+
+ .target(
+ name: "SPMGraphVisualize",
+ dependencies: [
+ .target(name: "Core"),
+ .product(
+ name: "GraphViz",
+ package: "GraphViz"
+ ),
+ .product(
+ name: "SwiftPM",
+ package: "swift-package-manager"
+ ),
+ ]
+ ),
+ .target(
+ name: "SPMGraphLint",
+ dependencies: [
+ .target(name: "Core"),
+ .target(name: "SPMGraphDescriptionInterface"),
+ ]
+ ),
+ .target(
+ name: "SPMGraphTests",
+ dependencies: [
+ .target(name: "Core"),
+ .target(name: "SPMGraphDescriptionInterface"),
+ ]
+ ),
+ .target(
+ name: "SPMGraphConfigSetup",
+ dependencies: [
+ .target(name: "Core"),
+ .product(
+ name: "FileMonitor",
+ package: "FileMonitor"
+ ),
+ ],
+ resources: [
+ .copy("Resources")
+ ]
+ ),
+
+ // MARK: - Interface for dynamically loaded lint rules
+
+ .target(
+ name: "SPMGraphDescriptionInterface",
+ dependencies: [
+ .product(
+ name: "SwiftPM",
+ package: "swift-package-manager"
+ )
+ ]
+ ),
+
+ // MARK: - Core
+
+ .target(
+ name: "Core",
+ dependencies: [
+ .product(
+ name: "SwiftPM",
+ package: "swift-package-manager"
+ ),
+ .product(
+ name: "PackagePlugin",
+ package: "swift-package-manager"
+ ),
+ ]
+ ),
+
+ // MARK: - Argument parser / CLI
+
+ .executableTarget(
+ name: "SPMGraphExecutable",
+ dependencies: [
+ .target(name: "SPMGraphVisualize"),
+ .target(name: "SPMGraphLint"),
+ .target(name: "SPMGraphTests"),
+ .target(name: "SPMGraphConfigSetup"),
+ .product(
+ name: "ArgumentParser",
+ package: "swift-argument-parser"
+ ),
+ ]
+ ),
+ ]
+)
diff --git a/Sources/Core/Extensions/AbsolutePath+Core.swift b/Sources/Core/Extensions/AbsolutePath+Core.swift
new file mode 100644
index 0000000..1771138
--- /dev/null
+++ b/Sources/Core/Extensions/AbsolutePath+Core.swift
@@ -0,0 +1,32 @@
+import Basics
+import Foundation
+
+public extension AbsolutePath {
+ /// The path to the programโs current directory.
+ static var currentDir: AbsolutePath {
+ guard let currentDir = localFileSystem.currentWorkingDirectory else {
+ preconditionFailure("Unable to resolve the current directory")
+ }
+ return currentDir
+ }
+}
+
+public extension AbsolutePath {
+ static func packagePath(_ spmPackageDirectory: String) throws -> AbsolutePath {
+ try AbsolutePath(
+ validating: spmPackageDirectory,
+ relativeTo: .currentDir
+ )
+ }
+
+ static func buildDirectory(_ path: String?) throws -> AbsolutePath {
+ if let path {
+ try AbsolutePath(
+ validating: path,
+ relativeTo: .currentDir
+ )
+ } else {
+ try localFileSystem.tempDirectory
+ }
+ }
+}
diff --git a/Sources/Core/Extensions/KeyPath+Core.swift b/Sources/Core/Extensions/KeyPath+Core.swift
new file mode 100644
index 0000000..941a308
--- /dev/null
+++ b/Sources/Core/Extensions/KeyPath+Core.swift
@@ -0,0 +1,13 @@
+public func == (
+ lhs: KeyPath,
+ rhs: Value
+) -> (Root) -> Bool {
+ { $0[keyPath: lhs] == rhs }
+}
+
+public func != (
+ lhs: KeyPath,
+ rhs: Value
+) -> (Root) -> Bool {
+ { $0[keyPath: lhs] != rhs }
+}
diff --git a/Sources/Core/GitClient.swift b/Sources/Core/GitClient.swift
new file mode 100644
index 0000000..03b3720
--- /dev/null
+++ b/Sources/Core/GitClient.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+/// A helper for common git operations
+public struct GitClient {
+ public typealias BaseBranch = String
+
+ /// List all files that changed on git when compared to a given `base branch`
+ public var listChangedFiles: (BaseBranch) throws -> [String]
+}
+
+extension GitClient {
+ /// Makes a **Live** ``GitClient`` instance
+ public static func makeLive(
+ system: SystemProtocol = System.shared
+ ) -> Self {
+ .init { baseBranch in
+ // Get the root directory of git repository
+ let rootDirectory =
+ try system
+ .runAndCapture("git", "rev-parse", "--show-toplevel")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Get changed files
+ let output = try system.runAndCapture(
+ "git",
+ "diff",
+ "origin/\(baseBranch)...HEAD",
+ "--name-only"
+ )
+
+ // Convert changed files relative paths to absolute paths
+ let changedFiles =
+ output
+ .components(separatedBy: "\n")
+ .filter { !$0.isEmpty }
+ .map { "\(rootDirectory)/\($0)" }
+
+ return changedFiles
+ }
+ }
+}
diff --git a/Sources/Core/PackageLoader.swift b/Sources/Core/PackageLoader.swift
new file mode 100644
index 0000000..03b6333
--- /dev/null
+++ b/Sources/Core/PackageLoader.swift
@@ -0,0 +1,34 @@
+import Basics
+import PackageModel
+import Workspace
+
+/// Loads the content of a Package.swift, the dependency graph included
+///
+/// The ``PackageLoader`` uses the SPM library to load the package representation
+public struct PackageLoader: Sendable {
+ /// Asynchronously loads the Package.swift file located at the provided `packagePath`.
+ /// - Parameters:
+ /// - packagePath: The path to the Package.swift file to load.
+ /// - verbose: A Boolean indicating whether to log detailed debug information during the loading process.
+ /// - Returns: The `Package` object constructed from the Package.swift file.
+ /// - Throws: If there is an error loading the Package.swift file.
+ public var load: @Sendable (AbsolutePath, _ verbose: Bool) async throws -> Package
+}
+
+extension PackageLoader {
+ /// Makes a **Live** ``PackageLoader`` instance
+ public static let live: Self = {
+ .init(
+ load: { packagePath, verbose in
+ let observability = ObservabilitySystem { if verbose { print("\($0): \($1)") } }
+
+ let workspace = try Workspace(forRootPackage: packagePath)
+
+ return try await workspace.loadRootPackage(
+ at: packagePath,
+ observabilityScope: observability.topScope
+ )
+ }
+ )
+ }()
+}
diff --git a/Sources/Core/System.swift b/Sources/Core/System.swift
new file mode 100644
index 0000000..f4c591a
--- /dev/null
+++ b/Sources/Core/System.swift
@@ -0,0 +1,234 @@
+// Inspired by and mainly copied from https://github.com/tuist/tuist/blob/cf57cbcc8ef5574b0be717ce620c35e1a8f3fb5a/Sources/TuistSupport/System/System.swift
+
+import Foundation
+import TSCBasic
+
+/// Defines the API to interact with the system shell
+///
+/// - note: It heavily relies on variadic parameters, thus the decision to use a gold old protocol based API,
+/// as of now variadic args are still quite limited and not allowed in tuples
+public protocol SystemProtocol: AnyObject {
+ /// Run a shell command and capture the output
+ /// - Returns: `utf8` formatted `String` output
+ @discardableResult
+ func runAndCapture(_ arguments: String...) throws -> String
+
+ /// Run a shell command in a specific directory and capture the output
+ /// - Returns: `utf8` formatted `String` output
+ @discardableResult
+ func runAndCapture(_ arguments: String..., workingDirectory: AbsolutePath) throws -> String
+
+ /// Runs a shell command and prints its output
+ func run(
+ _ arguments: String...,
+ verbose: Bool
+ ) throws
+
+ /// Runs a shell command in a specific directory and prints its output
+ func run(
+ _ arguments: String...,
+ workingDirectory: AbsolutePath,
+ verbose: Bool
+ ) throws
+
+ /// Run an echo command
+ func echo(
+ _ command: String
+ ) throws
+}
+
+/// Implements the API to interact with the system shell
+// N.B.: Unchecked Sendable should be fine because System is a fire and forget shell client
+// that doesn't have any shared mutable state
+public final class System: SystemProtocol, @unchecked Sendable {
+ public static let shared = System()
+
+ /// Helper to access the current env
+ public static let env = ProcessInfo.processInfo.environment
+
+ /// Run a shell command and capture the output
+ /// - Returns: `utf8` formatted `String` output
+ @discardableResult
+ public func runAndCapture(_ arguments: String...) throws -> String {
+ let process = Process(
+ arguments: arguments,
+ environmentBlock: ProcessEnvironmentBlock(System.env),
+ outputRedirection: .collect,
+ startNewProcessGroup: false
+ )
+
+ try process.launch()
+ let result = try process.waitUntilExit()
+ try result.throwIfErrored()
+
+ return try result.utf8Output()
+ }
+
+ /// Run a shell command in a specific directory and capture the output
+ /// - Returns: `utf8` formatted `String` output
+ @discardableResult
+ public func runAndCapture(
+ _ arguments: String...,
+ workingDirectory: AbsolutePath
+ ) throws -> String {
+ let process = Process(
+ arguments: arguments,
+ environmentBlock: ProcessEnvironmentBlock(System.env),
+ workingDirectory: workingDirectory,
+ outputRedirection: .collect,
+ startNewProcessGroup: false
+ )
+
+ try process.launch()
+ let result = try process.waitUntilExit()
+ try result.throwIfErrored()
+
+ return try result.utf8Output()
+ }
+
+ /// Run an echo command
+ public func echo(
+ _ command: String
+ ) throws {
+ try run(
+ "echo",
+ command,
+ verbose: true
+ )
+ }
+
+ /// Runs a shell command and prints its output
+ public func run(
+ _ arguments: String...,
+ verbose: Bool = true
+ ) throws {
+ let redirection: TSCBasic.Process.OutputRedirection = .none
+ let process = Process(
+ arguments: arguments,
+ outputRedirection: .stream(
+ stdout: { bytes in
+ FileHandle.standardOutput.write(Data(bytes))
+ redirection.outputClosures?.stdoutClosure(bytes)
+ },
+ stderr: { bytes in
+ FileHandle.standardError.write(Data(bytes))
+ redirection.outputClosures?.stderrClosure(bytes)
+ }
+ ),
+ startNewProcessGroup: false
+ )
+
+ try launchAndRunProcess(process, verbose: verbose)
+ }
+
+ /// Runs a shell command in a specific directory and prints its output
+ public func run(
+ _ arguments: String...,
+ workingDirectory: AbsolutePath,
+ verbose: Bool = true
+ ) throws {
+ let redirection: TSCBasic.Process.OutputRedirection = .none
+ let process = Process(
+ arguments: arguments,
+ workingDirectory: workingDirectory,
+ outputRedirection: .stream(
+ stdout: { bytes in
+ FileHandle.standardOutput.write(Data(bytes))
+ redirection.outputClosures?.stdoutClosure(bytes)
+ },
+ stderr: { bytes in
+ FileHandle.standardError.write(Data(bytes))
+ redirection.outputClosures?.stderrClosure(bytes)
+ }
+ ),
+ startNewProcessGroup: false
+ )
+
+ try launchAndRunProcess(process, verbose: verbose)
+ }
+}
+
+private extension System {
+ func launchAndRunProcess(
+ _ process: TSCBasic.Process,
+ verbose: Bool
+ ) throws {
+ try process.launch()
+ let result = try process.waitUntilExit()
+ let output = try result.utf8Output()
+ if verbose {
+ print(output)
+ }
+
+ try result.throwIfErrored()
+ }
+}
+
+extension ProcessResult {
+ /// Throws a SystemError if the result is unsuccessful.
+ ///
+ /// - Throws: A SystemError.
+ func throwIfErrored() throws {
+ switch exitStatus {
+ case let .signalled(code):
+ let data = Data(try stderrOutput.get())
+ throw SystemError.signalled(command: command(), code: code, standardError: data)
+ case let .terminated(code):
+ if code != 0 {
+ let data = Data(try stderrOutput.get())
+ throw SystemError.terminated(command: command(), code: code, standardError: data)
+ }
+ }
+ }
+
+ /// It returns the command that the process executed.
+ /// If the command is executed through xcrun, then the name of the tool is returned instead.
+ /// - Returns: Returns the command that the process executed.
+ func command() -> String {
+ let command = arguments.first!
+ if command == "/usr/bin/xcrun" {
+ return arguments[1]
+ }
+ return command
+ }
+}
+
+enum SystemError: Error, Equatable {
+ case terminated(command: String, code: Int32, standardError: Data)
+ case signalled(command: String, code: Int32, standardError: Data)
+
+ var description: String {
+ switch self {
+ case let .signalled(command, code, data):
+ if data.count > 0, let string = String(data: data, encoding: .utf8) {
+ return "The '\(command)' was interrupted with a signal \(code) and message:\n\(string)"
+ } else {
+ return "The '\(command)' was interrupted with a signal \(code)"
+ }
+ case let .terminated(command, code, data):
+ if data.count > 0, let string = String(data: data, encoding: .utf8) {
+ return "The '\(command)' command exited with error code \(code) and message:\n\(string)"
+ } else {
+ return "The '\(command)' command exited with error code \(code)"
+ }
+ }
+ }
+}
+
+public enum ANSIColor: String, CaseIterable {
+ case black = "\u{001B}[30m"
+ case red = "\u{001B}[31m"
+ case green = "\u{001B}[32m"
+ case yellow = "\u{001B}[33m"
+ case blue = "\u{001B}[34m"
+ case magenta = "\u{001B}[35m"
+ case cyan = "\u{001B}[36m"
+ case white = "\u{001B}[37m"
+ case `default` = "\u{001B}[38m"
+ case reset = "\u{001B}[0m"
+}
+
+public enum FontStyle: String, CaseIterable {
+ case `default` = ""
+ case bold = "\u{001B}[1m"
+}
diff --git a/Sources/SPMGraphConfigSetup/Resources/DoNotEdit_DynamicLoading.txt b/Sources/SPMGraphConfigSetup/Resources/DoNotEdit_DynamicLoading.txt
new file mode 100644
index 0000000..b119282
--- /dev/null
+++ b/Sources/SPMGraphConfigSetup/Resources/DoNotEdit_DynamicLoading.txt
@@ -0,0 +1,14 @@
+import SPMGraphDescriptionInterface
+
+// MARK: - Dynamic Loading - DO NOT EDIT!
+
+final class ClientSPMGraphConfigBuilder: SPMGraphConfigBuilder {
+ override func build() -> SPMGraphConfig {
+ spmGraphConfig
+ }
+}
+
+@_cdecl("loadSPMGraphConfig")
+public func loadSPMGraphConfig() -> UnsafeMutableRawPointer {
+ return Unmanaged.passRetained(ClientSPMGraphConfigBuilder()).toOpaque()
+}
diff --git a/Sources/SPMGraphConfigSetup/Resources/Package.txt b/Sources/SPMGraphConfigSetup/Resources/Package.txt
new file mode 100644
index 0000000..6b2619f
--- /dev/null
+++ b/Sources/SPMGraphConfigSetup/Resources/Package.txt
@@ -0,0 +1,47 @@
+// swift-tools-version: 6.0
+
+import PackageDescription
+
+let package = Package(
+ name: "SPMGraphConfig",
+ platforms: [
+ .macOS(.v13)
+ ],
+ products: [
+ .library(
+ name: "SPMGraphDescription",
+ type: .dynamic,
+ targets: [
+ "SPMGraphConfig"
+ ]
+ ),
+ ],
+ dependencies: [
+ // TODO: Replace by remote when made public
+ .package(path: "/Users/marino.felipe/Documents/projects/iOS/spmgraph"),
+ // TODO: Review which tag / Swift release to use
+ // - Initially it may be strict and sometimes "enforce" specific Xcode/Swift toolchains
+ // - For now pinned to the 6.0 release / Xcode 16.0
+ //
+ // It auto exports SwiftToolsSupport, so no need to directly depend on the former ๐
+ .package(
+ url: "https://github.com/apple/swift-package-manager",
+ revision: "swift-6.0-RELEASE"
+ ),
+ ],
+ targets: [
+ .target(
+ name: "SPMGraphConfig",
+ dependencies: [
+ .product(
+ name: "SPMGraphDescriptionInterface",
+ package: "spmgraph"
+ ),
+ .product(
+ name: "SwiftPM",
+ package: "swift-package-manager"
+ )
+ ]
+ ),
+ ]
+)
diff --git a/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt b/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt
new file mode 100644
index 0000000..e4eb8ec
--- /dev/null
+++ b/Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt
@@ -0,0 +1,166 @@
+import Foundation
+import PackageModel
+import SPMGraphDescriptionInterface
+
+// MARK: - Instructions
+
+// You can leverage the minimal and advanced configurations below.
+//
+// By default, when using the minimal config, the lint rules are the `.default` ones included
+// in SPMGraphConfig.
+//
+// The advanced configuration contains examples of creating our own dependency graph rules,
+// including how to extend the PackageModel types with helpers that, for example, categorize types
+// of modules.
+//
+// Once you finish editing your final `spmGraphConfig`, build the `SPMGraphConfig` target
+// and see if everything is working fine.
+//
+// Have fun! Ah, and don't forget to select `macOS` as the build target ;)!
+
+// MARK: - Minimal
+
+//let spmGraphConfig = SPMGraphConfig(
+// lint: SPMGraphConfig.Lint(isStrict: true)
+//)
+
+// MARK: - Advanced
+
+let spmGraphConfig = SPMGraphConfig(
+ lint: SPMGraphConfig.Lint(
+ rules: .custom + .default, // The default rules + your custom ones
+ isStrict: true,
+ expectedWarningsCount: 2
+ ),
+ tests: SPMGraphConfig.Tests(
+ baseBranch: "develop"
+ ),
+ excludedSuffixes: ["Tests"],
+ verbose: ProcessInfo.processInfo.environment["CI"] != nil
+)
+
+// MARK: Write your own rules
+
+enum CustomLintError: LocalizedError {
+ case abcModuleThirdPartyDependencies(module: String, thirdParty: String)
+ case ruleThatChecksTheSourceFilesContent(module: String, dependency: String)
+
+ var errorDescription: String? {
+ switch self {
+ case let .abcModuleThirdPartyDependencies(module, thirdParty):
+ "\(module) must not depend on the third party \(thirdParty)"
+ case let .ruleThatChecksTheSourceFilesContent(module, dependency):
+ "Lint error message for module: \(module) and dependency: \(dependency)"
+ }
+ }
+}
+
+public extension Array where Element == SPMGraphConfig.Lint.Rule {
+ static let custom: [SPMGraphConfig.Lint.Rule] = [
+ .abcFeatureModuleShouldNotDependOnThirdParties,
+ .ruleThatChecksTheSourceFilesContent,
+ // Example of how to use built in rules with your own conditions.
+ // Note that `.liveModuleLiveDependency()` and `.baseOrInterfaceModuleLiveDependency()` are
+ // enabled by default as part of the `.default` ones, so consider removing `.default` and picking the
+ // ones you wish to use!
+ .liveModuleLiveDependency(
+ isLiveModule: {
+ $0.name.hasSuffix("Implementation")
+ }
+ )
+ ]
+}
+
+extension SPMGraphConfig.Lint.Rule {
+ static let abcFeatureModuleShouldNotDependOnThirdParties = Self(
+ id: "moduleABCShouldNotDependOnThirdParties",
+ name: "The feature module ABC should not depend on third parties",
+ abstract: "A specific category of modules ABC should not have third party dependencies.",
+ validate: { package, excludedSuffixes in
+ let abcModules = package.modules
+ .filter { !$0.containsOneOf(suffixes: excludedSuffixes) }
+ .filter { $0.name.contains("ABC") && $0.isFeature }
+ .sorted()
+
+ let errors: [CustomLintError] =
+ abcModules
+ .map { abcModule in
+ abcModule.dependencies
+ .compactMap(\.product)
+ .map { thirdParty in
+ CustomLintError.abcModuleThirdPartyDependencies(
+ module: abcModule.name,
+ thirdParty: thirdParty.name
+ )
+ }
+ }
+ .reduce([], +)
+
+ return errors
+ }
+ )
+
+ static let ruleThatChecksTheSourceFilesContent = Self(
+ id: "ruleThatCheckTheSourceFilesContent",
+ name: "Unused linked dependencies",
+ abstract: """
+ To keep the project clean and avoid long compile times, a Module should not have any unused dependencies.
+ Note that for `@_exported` usages, there will be an error in case the only the exported module is used.
+ For example, module Networking exports module NetworkingHelpers, if only NetworkingHelpers is used by a target
+ there will be a lint error, while if both Networking and NetworkingHelpers are used there will be no error.
+ """,
+ validate: { package, excludedSuffixes in
+ let errors: [CustomLintError] = package.modules
+ // Filters out excluded suffixes and App modules
+ .filter { !$0.containsOneOf(suffixes: excludedSuffixes) && !$0.isApp }
+ .sorted()
+ .compactMap { module in
+ let dependencies = module
+ .dependencies
+ .filter { dependency in
+ let isExcluded = dependency.containsOneOf(suffixes: excludedSuffixes)
+ return !isExcluded && dependency.shouldBeImported
+ }
+ let swiftFiles = try? findSwiftFiles(in: module.path.pathString)
+
+ // Implement your logic and return lint errors when it applies
+ // For example, the default `unusedDependencies` rule checks if linked dependencies are
+ // actually imported, otherwise it errors out!
+
+ return nil // no error
+ }
+
+ return errors
+ }
+ )
+}
+
+// MARK: Examples of extending the PackageModel API
+
+private extension Module {
+ // Defines whether a module is an App module
+ var isApp: Bool {
+ name.contains("App") || name.hasSuffix("UI")
+ }
+
+ // Defines whether a module is a feature module
+ var isFeature: Bool {
+ name.contains("Feature")
+ }
+
+ // Whether it's an implementation and not an interface module
+ var isImplementation: Bool {
+ name.hasSuffix("Implementation")
+ }
+}
+
+private extension Module.Dependency {
+ // Macros and Plugins are dependencies that don't require an import clause,
+ // this helper allows excluding them from a rule that checks whether
+ // linked dependencies are used or not
+ var shouldBeImported: Bool {
+ guard let module else { return true }
+
+ return module.type != .macro && module.type != .plugin
+ }
+}
diff --git a/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift b/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift
new file mode 100644
index 0000000..68b8601
--- /dev/null
+++ b/Sources/SPMGraphConfigSetup/SPMGraphEdit.swift
@@ -0,0 +1,248 @@
+import Basics
+import Core
+import FileMonitor
+import Foundation
+
+// MARK: - Input & Error
+
+public struct SPMGraphEditInput {
+ /// Directory path of the Package.swift file
+ let spmPackageDirectory: AbsolutePath
+ /// A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ let buildDirectory: AbsolutePath
+ /// Show extra logging for troubleshooting purposes
+ let verbose: Bool
+
+ /// Makes an instance of ``SPMGraphLintInput``
+ public init(
+ spmPackageDirectory: String,
+ buildDirectory: String?,
+ verbose: Bool
+ ) throws {
+ self.spmPackageDirectory = try AbsolutePath.packagePath(spmPackageDirectory)
+ self.buildDirectory = try AbsolutePath.buildDirectory(buildDirectory)
+ self.verbose = verbose
+ }
+}
+
+public enum SPMGraphEditError: Error {
+ case unableToLoadTemplates(bundle: Bundle)
+ case failedToCreateOrLoadConfigFile(underlying: Error)
+ case failedToCreateEditPackage(underlying: Error)
+ case failedToCopyTemplateFile(underlying: Error)
+ case failedToOpenEditPackageForEditing(underlying: Error)
+ case failedToObserveConfigFileChanges(underlying: Error)
+}
+
+// MARK: - Abstraction and Implementation
+
+/// Represents a type that edits a spmgraph configuration
+public protocol SPMGraphEditProtocol {
+ func run() async throws(SPMGraphEditError)
+}
+
+/// A type that edits a spmgraph configuration
+public final class SPMGraphEdit: SPMGraphEditProtocol {
+ private let input: SPMGraphEditInput
+ private let system: SystemProtocol
+ private let buildDirectory: AbsolutePath
+
+ private var verbose: Bool {
+ input.verbose
+ }
+
+ private lazy var editPackageDirectory: AbsolutePath = buildDirectory.appending("spmgraph-config")
+ private lazy var editPackageSourcesDirectory =
+ editPackageDirectory
+ .appending("Sources")
+ .appending("SPMGraphConfig")
+
+ public init(
+ input: SPMGraphEditInput,
+ system: SystemProtocol = System.shared
+ ) throws {
+ self.input = input
+ self.system = system
+ self.buildDirectory = input.buildDirectory
+ }
+
+ public func run() async throws(SPMGraphEditError) {
+ try createEditPackage()
+
+ // Load template files
+ let bundle = Bundle.module
+ guard
+ let templatePackageDotSwiftFileURL = bundle.url(
+ forResource: "Resources/Package",
+ withExtension: "txt"
+ ),
+ let templateConfigFileURL = bundle.url(
+ forResource: "Resources/SPMGraphConfig",
+ withExtension: "txt"
+ )
+ else {
+ throw .unableToLoadTemplates(bundle: bundle)
+ }
+
+ // Copy the template Package.swift into the edit package
+ try copyTemplatePackageDotSwift(templatePackageDotSwiftFileURL: templatePackageDotSwiftFileURL)
+
+ // The config file should be in the same dir as the root of their package
+ let userConfigFile = input.spmPackageDirectory.appending("SPMGraphConfig.swift")
+
+ // Create or load the user's `SPMGraphConfig.swift` file
+ try createOrLoadTheUserConfigFile(
+ templateConfigFileURL: templateConfigFileURL,
+ userConfigFile: userConfigFile,
+ spmPackageDirectory: input.spmPackageDirectory
+ )
+
+ // Open the config edit package for editing
+ try openEditPackage()
+
+ // Observe changes on the editing config file
+ try await observeEditingConfigFile(updating: userConfigFile)
+ }
+}
+
+private extension SPMGraphEdit {
+ func openEditPackage() throws(SPMGraphEditError) {
+ if verbose {
+ print("Opening the edit package...")
+ }
+
+ do {
+ try system.run(
+ "xed",
+ ".",
+ workingDirectory: TSCAbsolutePath(editPackageDirectory),
+ verbose: verbose
+ )
+ } catch {
+ throw .failedToOpenEditPackageForEditing(underlying: error)
+ }
+ }
+
+ func createOrLoadTheUserConfigFile(
+ templateConfigFileURL: URL,
+ userConfigFile: AbsolutePath,
+ spmPackageDirectory: AbsolutePath
+ ) throws(SPMGraphEditError) {
+ do {
+ let templateConfigFile = try AbsolutePath(validating: templateConfigFileURL.path())
+ let configFileDestination = editPackageSourcesDirectory.appending("SPMGraphConfig.swift")
+
+ // Check if the user already has a `SPMGraphConfig.swift` in the same directory as their Package.swift
+ let hasConfigFile = try localFileSystem.getDirectoryContents(spmPackageDirectory)
+ .contains("SPMGraphConfig.swift")
+
+ if !hasConfigFile {
+ // Create a user config file from the template file
+ try localFileSystem.copy(
+ from: templateConfigFile,
+ to: userConfigFile
+ )
+
+ if verbose {
+ print("Created a SPMGraphConfig.swift for the user")
+ }
+ }
+
+ // Copy the user SPMGraphConfig.swift into the edit package
+ try localFileSystem.copy(
+ from: userConfigFile,
+ to: configFileDestination
+ )
+
+ if verbose {
+ print("Loaded the user SPMGraphConfig.swift into the edit package")
+ }
+ } catch {
+ throw .failedToCreateOrLoadConfigFile(underlying: error)
+ }
+ }
+
+ func copyTemplatePackageDotSwift(templatePackageDotSwiftFileURL: URL) throws(SPMGraphEditError) {
+ do {
+ let templatePackageDotSwiftFile = try AbsolutePath(
+ validating: templatePackageDotSwiftFileURL.path()
+ )
+ let packageDotSwiftDestinationPath = editPackageDirectory.appending(
+ component: "Package.swift"
+ )
+
+ // Copy the template Package.swift file into the edit package
+ try localFileSystem.copy(
+ from: templatePackageDotSwiftFile,
+ to: packageDotSwiftDestinationPath
+ )
+ } catch {
+ throw .failedToCopyTemplateFile(underlying: error)
+ }
+ }
+
+ func createEditPackage() throws(SPMGraphEditError) {
+ print(
+ """
+ Generating a package for editing your SPMGraphConfig.swift.
+ Inspect the symbols and look at the examples to build a configuration that works for you. Build to make ensure it compiles.
+ Press CTRL+C once you finish editing it...
+ """
+ )
+
+ if verbose {
+ print("Package generated at \(buildDirectory.pathString)")
+ }
+
+ do {
+ try localFileSystem.removeFileTree(editPackageDirectory)
+ try localFileSystem.createDirectory(editPackageSourcesDirectory, recursive: true)
+
+ if verbose {
+ print("Edit package directory created at \(editPackageDirectory.pathString)")
+ }
+ } catch {
+ throw .failedToCreateEditPackage(underlying: error)
+ }
+ }
+
+ func observeEditingConfigFile(
+ updating userConfigFile: AbsolutePath
+ ) async throws(SPMGraphEditError) {
+ do {
+ let monitor = try FileMonitor(directory: editPackageSourcesDirectory.asURL)
+ try monitor.start()
+ for await event in monitor.stream {
+ switch event {
+ case .changed(let file):
+ // Skip if the file is under an editing state
+ guard !file.path().contains("~") else {
+ break
+ }
+
+ let fileContents =
+ try localFileSystem
+ .readFileContents(try AbsolutePath(validating: file.absoluteString))
+ try fileContents.withData { data in
+ try localFileSystem.withLock(on: TSCAbsolutePath(userConfigFile)) {
+ try localFileSystem.writeIfChanged(
+ path: userConfigFile,
+ data: data
+ )
+ }
+ }
+
+ if verbose {
+ print("Detected an update on the editing SPMGraphConfig.swift file at \(file.path)")
+ }
+ case .added, .deleted:
+ break
+ }
+ }
+ } catch {
+ throw .failedToObserveConfigFileChanges(underlying: error)
+ }
+ }
+}
+
+extension FileChange: @retroactive @unchecked Sendable {}
diff --git a/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift b/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift
new file mode 100644
index 0000000..9c76eb2
--- /dev/null
+++ b/Sources/SPMGraphConfigSetup/SPMGraphLoad.swift
@@ -0,0 +1,138 @@
+import Basics
+import Core
+import FileMonitor
+import Foundation
+
+// MARK: - Input & Error
+
+public struct SPMGraphLoadInput {
+ /// Directory path of the `SPMGraphConfig.swift` file, which is the same as the `Package.swift` file
+ let directory: AbsolutePath
+ /// A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ let buildDirectory: AbsolutePath
+ /// Show extra logging for troubleshooting purposes
+ let verbose: Bool
+
+ /// Makes an instance of ``SPMGraphConfigInput``
+ public init(
+ directory: String,
+ buildDirectory: String?,
+ verbose: Bool
+ ) throws {
+ self.directory = try AbsolutePath.packagePath(directory)
+ self.buildDirectory = try AbsolutePath.buildDirectory(buildDirectory)
+ self.verbose = verbose
+ }
+}
+
+public enum SPMGraphLoadError: Error {
+ case failedToReadTheConfig(underlying: Error)
+ case failedToLoadTheConfigIntoSpmgraph(localizedDescription: String)
+ case failedToSetupDynamicLoading(underlying: Error)
+}
+
+// MARK: - Abstraction and Implementation
+
+/// Represents a type that loads a spmgraph configuration
+public protocol SPMGraphLoadProtocol {
+ func run() async throws(SPMGraphLoadError)
+}
+
+/// A type that loads a spmgraph configuration
+public final class SPMGraphLoad: SPMGraphLoadProtocol {
+ private let input: SPMGraphLoadInput
+ private let buildDirectory: AbsolutePath
+
+ private var verbose: Bool {
+ input.verbose
+ }
+
+ private lazy var editPackageDirectory: AbsolutePath = buildDirectory.appending("spmgraph-config")
+ private lazy var dynamicLoadingFileDestination: AbsolutePath =
+ editPackageDirectory
+ .appending(component: "Sources")
+ .appending(component: "SPMGraphConfig")
+ .appending(component: "DoNotEdit_DynamicLoading")
+ .appending(extension: "swift")
+
+ public init(input: SPMGraphLoadInput) throws(SPMGraphLoadError) {
+ self.input = input
+ self.buildDirectory = input.buildDirectory
+ }
+
+ public func run() throws(SPMGraphLoadError) {
+ // Defines the path to the user configuration file
+ let userConfigFile = input.directory.appending("SPMGraphConfig.swift")
+
+ try load(userConfigFile: userConfigFile)
+ }
+}
+
+private extension SPMGraphLoad {
+ func load(userConfigFile: AbsolutePath) throws(SPMGraphLoadError) {
+ print("Loading your SPMGraphConfig.swift into spmgraph... please await")
+
+ try includeDynamicLoadingFile()
+
+ do {
+ if verbose {
+ try System.shared.run(
+ "swift",
+ "build",
+ "--package-path",
+ editPackageDirectory.pathString,
+ verbose: verbose
+ )
+ } else {
+ try System.shared.runAndCapture(
+ "swift",
+ "build",
+ "--package-path",
+ editPackageDirectory.pathString
+ )
+ }
+ } catch {
+ throw .failedToLoadTheConfigIntoSpmgraph(
+ localizedDescription: error.localizedDescription
+ )
+ }
+
+ try removeDynamicLoadingFile()
+
+ print("Finished loading")
+ }
+
+ func includeDynamicLoadingFile() throws(SPMGraphLoadError) {
+ do {
+ guard
+ let dynamicLoadingTemplateURL = Bundle.module.url(
+ forResource: "Resources/DoNotEdit_DynamicLoading",
+ withExtension: "txt"
+ )
+ else {
+ throw SPMGraphLoadError.failedToLoadTheConfigIntoSpmgraph(
+ localizedDescription: "Unable to read the dynamic loading template"
+ )
+ }
+
+ let dynamicLoadingTemplateFile = try AbsolutePath(
+ validating: dynamicLoadingTemplateURL.path()
+ )
+ // Copy the template DoNotEdit_DynamicLoading.swift file into the edit package
+ try localFileSystem.copy(
+ from: dynamicLoadingTemplateFile,
+ to: dynamicLoadingFileDestination
+ )
+ } catch {
+ throw .failedToSetupDynamicLoading(underlying: error)
+ }
+ }
+
+ func removeDynamicLoadingFile() throws(SPMGraphLoadError) {
+ do {
+ try FileManager.default.removeItem(at: dynamicLoadingFileDestination.asURL)
+ } catch {
+ throw .failedToSetupDynamicLoading(underlying: error)
+ }
+ }
+}
diff --git a/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift b/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift
new file mode 100644
index 0000000..2b736c7
--- /dev/null
+++ b/Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift
@@ -0,0 +1,347 @@
+import Basics
+import Foundation
+import PackageModel
+
+/// Defines the contract for building and loading the user's configuration dynamically.
+/// - warning: This API isn't intended to be used by `spmgraph` users directly, but is rather used internally by the tool
+/// so that the configuration is loaded correctly. **Do not conform to it**!
+open class SPMGraphConfigBuilder {
+ public init() {}
+
+ open func build() -> SPMGraphConfig {
+ fatalError("You have to override this method.")
+ }
+}
+
+/// Defines the `spmgraph` configuration for a given package definition that relies on it.
+/// It includes the dependency graph lint rules and settings, the selective testing setup, and whether the strict mode is enabled.
+/// - note: The user's `SPMGraphConfig.swift` must live in the same directory of the `Package.swift` that is under
+/// evaluation.
+public struct SPMGraphConfig: Sendable {
+ public struct Lint: Sendable {
+ public struct Rule: Sendable {
+ public let id: String
+ public let name: String
+ public let abstract: String
+ public var validate: @Sendable (Package, _ excludedSuffixes: [String]) -> [LocalizedError]
+
+ public init(
+ id: String,
+ name: String,
+ abstract: String,
+ validate: @Sendable @escaping (Package, _: [String]) -> [LocalizedError]
+ ) {
+ self.id = id
+ self.name = name
+ self.abstract = abstract
+ self.validate = validate
+ }
+ }
+
+ /// A set of Lint rules that traverse a loaded dependency graph and return errors on broken rules
+ public let rules: [Rule]
+ /// When **enabled** it returns a **failure exit** code on **any warnings**
+ public let isStrict: Bool
+ /// The number of allowed warnings for the strict mode.
+ /// - note: It can be useful to **bypass the strict mode in specific scenarios**. `The default is zero`.
+ public let expectedWarningsCount: UInt
+
+ public init(
+ rules: [Rule] = .default,
+ isStrict: Bool,
+ expectedWarningsCount: UInt = 0
+ ) {
+ self.rules = rules
+ self.isStrict = isStrict
+ self.expectedWarningsCount = expectedWarningsCount
+ }
+ }
+
+ /// Configuration for selective testing.
+ ///
+ /// It maps modules that should be built and tests that should be run based on changed files.
+ public struct Tests: Sendable {
+ /// Base branch to compare the changes against.
+ public let baseBranch: String
+
+ /// Initializes the selective tests configuration.
+ /// - Parameter baseBranch: Base branch to compare the changes against. It `defaults` to `main`.
+ public init(baseBranch: String = "main") {
+ self.baseBranch = baseBranch
+ }
+ }
+
+ public let lint: Lint
+ public let tests: Tests
+ /// Comma separated array of suffixes to exclude from the graph e.g. 'Tests','Live','TestSupport'.
+ public let excludedSuffixes: [String]
+
+ /// Initializes a ``SPMGraphConfig``.
+ /// - Parameters:
+ /// - lint: Configures the lint capability.
+ /// - tests: Configures the selective tests capability.
+ /// - excludedSuffixes: Comma separated array of suffixes to exclude from the graph e.g. 'Tests','Live','TestSupport'.
+ /// - verbose: Show extra logging for troubleshooting purposes.
+ public init(
+ lint: Lint,
+ tests: Tests = .init(),
+ excludedSuffixes: [String] = [],
+ verbose: Bool = false
+ ) {
+ self.lint = lint
+ self.tests = tests
+ self.excludedSuffixes = excludedSuffixes
+ }
+}
+
+typealias Validate = (Package, _ excludedSuffixes: [String]) -> [LocalizedError]
+
+public extension Array where Element == SPMGraphConfig.Lint.Rule {
+ static let `default`: [SPMGraphConfig.Lint.Rule] = [
+ .unusedDependencies,
+ .liveModuleLiveDependency(),
+ .baseOrInterfaceModuleLiveDependency(),
+ ]
+}
+
+public extension SPMGraphConfig.Lint.Rule {
+ static func liveModuleLiveDependency(
+ isLiveModule: @Sendable @escaping (Module) -> Bool = \.isLiveModule
+ ) -> Self {
+ Self(
+ id: "liveModuleLiveDependency",
+ name: "Live modules should not depend on other Live modules",
+ abstract:
+ "To keep the dependency graph flat and avoid depending on implementations, a Live Module should never depend on another Live module",
+ validate: { package, excludedSuffixes in
+ let liveModules = package.modules
+ .filter { !$0.containsOneOf(suffixes: excludedSuffixes) }
+ .filter(\.isLiveModule)
+ .sorted()
+
+ let errors: [SPMGraphConfig.Lint.Error] =
+ liveModules
+ .map { liveModule in
+ liveModule.dependencies
+ .compactMap(\.module)
+ .filter(\.isLiveModule == true)
+ .map { dependency in
+ SPMGraphConfig.Lint.Error.liveModuleLiveDependency(
+ moduleName: liveModule.name,
+ liveDependencyName: dependency.name
+ )
+ }
+ }
+ .reduce([], +)
+
+ return errors
+ }
+ )
+ }
+
+ static func baseOrInterfaceModuleLiveDependency(
+ isBaseModule: @Sendable @escaping (Module) -> Bool = { module in
+ !module.isLiveModule && !module.canDependOnLive
+ },
+ isLiveModule: @Sendable @escaping (Module) -> Bool = \.isLiveModule
+ ) -> Self {
+ Self(
+ id: "baseOrInterfaceModuleLiveDependency",
+ name: "Base or Interface modules should not depend on Live modules",
+ abstract:
+ "To keep the dependency graph flat and avoid depending on higher level, a Base or Interface Module should never depend on upper Live Modules",
+ validate: { package, excludedSuffixes in
+ let nonLiveModulesThatCannotDependOnLive = package.modules
+ .filter { !$0.containsOneOf(suffixes: excludedSuffixes) }
+ .filter(isBaseModule)
+ .filter { !isLiveModule($0) } // filters out live modules, those are covered by the liveModuleLiveDependency rule
+ .sorted()
+
+ let errors: [SPMGraphConfig.Lint.Error] =
+ nonLiveModulesThatCannotDependOnLive
+ .map { module in
+ module.dependencies
+ .compactMap(\.module)
+ .filter(isLiveModule)
+ .map { dependency in
+ SPMGraphConfig.Lint.Error.baseOrInterfaceModuleLiveDependency(
+ moduleName: module.name,
+ liveDependencyName: dependency.name
+ )
+ }
+ }
+ .reduce([], +)
+
+ return errors
+ }
+ )
+ }
+
+ static let unusedDependencies = Self(
+ id: "unusedDependencies",
+ name: "Unused linked dependencies",
+ abstract: """
+ To keep the project clean and avoid long compile times, a Module should not have any unused dependencies.
+ Note that for `@_exported` usages, there will be an error in case the only the exported module is used.
+ For example, module Networking exports module NetworkingHelpers, if only NetworkingHelpers is used by a target
+ there will be a lint error, while if both Networking and NetworkingHelpers are used there will be no error.
+ """,
+ validate: { package, excludedSuffixes in
+ let errors: [SPMGraphConfig.Lint.Error] = package.modules
+ .filter { !$0.containsOneOf(suffixes: excludedSuffixes) && !$0.isFeature }
+ .sorted()
+ .compactMap { module in
+ let dependencies = module
+ .dependenciesFilteringOutLiveInUITestSupport
+ .filter { dependency in
+ let isExcluded = dependency.containsOneOf(suffixes: excludedSuffixes)
+ return !isExcluded && dependency.shouldBeImported
+ }
+ let swiftFiles = try? findSwiftFiles(in: module.path.pathString)
+
+ return dependencies.compactMap { dependency in
+ let filePaths = swiftFiles ?? []
+ var isDependencyUsed = false
+ for filePath in filePaths {
+ let fileContent = try? String(contentsOfFile: filePath, encoding: .utf8)
+ let regexPattern =
+ "import (enum |struct |class )?(\\b\(NSRegularExpression.escapedPattern(for: dependency.name))\\b)"
+ if let regex = try? NSRegularExpression(pattern: regexPattern, options: []) {
+ let range = NSRange(location: 0, length: fileContent?.utf16.count ?? 0)
+ let match = regex.firstMatch(in: fileContent ?? "", options: [], range: range)
+ if match != nil {
+ isDependencyUsed = true
+ break
+ }
+ }
+ }
+
+ return isDependencyUsed
+ ? nil
+ : SPMGraphConfig.Lint.Error.unusedDependencies(
+ moduleName: module.name,
+ dependencyName: dependency.name
+ )
+ }
+ }
+ .flatMap { $0 }
+ return errors
+ }
+ )
+
+ static func findSwiftFiles(in directory: String) throws -> [String] {
+ let enumerator = FileManager.default.enumerator(atPath: directory)
+ var swiftFiles = [String]()
+ while let element = enumerator?.nextObject() as? String {
+ if element.hasSuffix(".swift") {
+ swiftFiles.append("\(directory)/\(element)")
+ }
+ }
+ return swiftFiles
+ }
+}
+
+public extension Module {
+ var isFeature: Bool {
+ name.contains("Feature")
+ }
+
+ var isLiveModule: Bool {
+ name.hasSuffix("Live")
+ }
+
+ var isApp: Bool {
+ name.contains("App") || name.hasSuffix("UI")
+ }
+
+ var isLiveTest: Bool {
+ name.hasSuffix("LiveTests")
+ }
+
+ var isLiveTestSupport: Bool {
+ name.contains("LiveTestSupport")
+ }
+
+ var isUITestSupport: Bool {
+ name.contains("UITestSupport")
+ || name.contains("UITestsSupport") // with `s`
+ && name != "ServerDrivenUITestSupport"
+ }
+
+ func containsOneOf(suffixes: [String]) -> Bool {
+ suffixes.contains(where: name.hasSuffix)
+ }
+
+ var canDependOnLive: Bool {
+ isFeature
+ || isApp
+ || isLiveTest
+ || isLiveTestSupport
+ || isUITestSupport
+ }
+}
+
+public extension Module.Dependency {
+ func containsOneOf(suffixes: [String]) -> Bool {
+ suffixes.contains(where: name.hasSuffix)
+ }
+}
+
+private extension Module.Dependency {
+ /// Whether the dependency requires an import or not. For example, macros and plugins don't require an import clause.
+ var shouldBeImported: Bool {
+ guard let module else { return true }
+
+ return module.type != .macro && module.type != .plugin
+ }
+}
+
+private extension Module {
+ var dependenciesFilteringOutLiveInUITestSupport: [Dependency] {
+ guard isUITestSupport else { return dependencies }
+
+ return dependencies.filter { dependency in
+ let isLiveDependency = dependency.module?.isLiveModule ?? false
+ return !isLiveDependency
+ }
+ }
+}
+
+extension Module: @retroactive Comparable {
+ public static func < (lhs: Module, rhs: Module) -> Bool {
+ lhs.name < rhs.name
+ }
+}
+
+extension SPMGraphConfig.Lint {
+ enum Error: LocalizedError {
+ case liveModuleLiveDependency(moduleName: String, liveDependencyName: String)
+ case baseOrInterfaceModuleLiveDependency(moduleName: String, liveDependencyName: String)
+ case unusedDependencies(moduleName: String, dependencyName: String)
+
+ var errorDescription: String? {
+ switch self {
+ case let .liveModuleLiveDependency(moduleName, liveDependencyName):
+ return "\(moduleName) must not depend on Live Module \(liveDependencyName)"
+ case let .baseOrInterfaceModuleLiveDependency(moduleName, liveDependencyName):
+ return "\(moduleName) must not depend on Live Module \(liveDependencyName)"
+ case let .unusedDependencies(moduleName, dependencyName):
+ return "\(moduleName) is not using \(dependencyName)"
+ }
+ }
+ }
+}
+
+func == (
+ lhs: KeyPath,
+ rhs: Value
+) -> (Root) -> Bool {
+ { $0[keyPath: lhs] == rhs }
+}
+
+func != (
+ lhs: KeyPath,
+ rhs: Value
+) -> (Root) -> Bool {
+ { $0[keyPath: lhs] != rhs }
+}
diff --git a/Sources/SPMGraphDescriptionInterface/SPMGraphConfigLoader.swift b/Sources/SPMGraphDescriptionInterface/SPMGraphConfigLoader.swift
new file mode 100644
index 0000000..5beadc7
--- /dev/null
+++ b/Sources/SPMGraphDescriptionInterface/SPMGraphConfigLoader.swift
@@ -0,0 +1,73 @@
+import Basics
+import Foundation
+
+public enum SPMGraphConfigLoaderError: LocalizedError {
+ case failedToLoadUserConfiguration(reason: String)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .failedToLoadUserConfiguration(reason):
+ """
+ Failed to load the `SPMGraphConfig.swift` file! Check if it exists and builds successfully by running `spmgraph edit`.
+ If it does exist and builds well, run `spmgraph load` and wait for your configuration to be loaded into spmgraph.
+ Reason: \(reason)
+ """
+ }
+ }
+}
+
+public protocol SPMGraphConfigLoading {
+ func load(buildDirectory: AbsolutePath) throws(SPMGraphConfigLoaderError) -> SPMGraphConfig
+}
+
+public struct SPMGraphConfigLoader: SPMGraphConfigLoading {
+ public init() {}
+
+ public func load(buildDirectory: AbsolutePath) throws(SPMGraphConfigLoaderError) -> SPMGraphConfig
+ {
+ do {
+ let spmgraphConfigDirectory = buildDirectory.appending("spmgraph-config")
+ let spmGraphConfig = try plugin(
+ at: "\(spmgraphConfigDirectory.pathString)/.build/debug/libSPMGraphDescription.dylib"
+ )
+ return spmGraphConfig
+ } catch {
+ throw error
+ }
+ }
+
+ private typealias InitFunction = @convention(c) () -> UnsafeMutableRawPointer
+
+ private func plugin(at path: String) throws(SPMGraphConfigLoaderError) -> SPMGraphConfig {
+ let dlopenReference = dlopen(path, RTLD_NOW | RTLD_LOCAL)
+ if dlopenReference != nil {
+ defer {
+ dlclose(dlopenReference)
+ }
+
+ let symbolName = "loadSPMGraphConfig"
+ let dlsymReference = dlsym(dlopenReference, symbolName)
+
+ if dlsymReference != nil {
+ let initFunction: InitFunction = unsafeBitCast(dlsymReference, to: InitFunction.self)
+ let pluginPointer = initFunction()
+ let builder = Unmanaged.fromOpaque(pluginPointer).takeRetainedValue()
+ return builder.build()
+ } else {
+ throw .failedToLoadUserConfiguration(
+ reason: "error loading lib: symbol \(symbolName) not found, path: \(path)"
+ )
+ }
+ } else {
+ if let error = dlerror() {
+ throw .failedToLoadUserConfiguration(
+ reason: "error opening dylib: \(String(format: "%s", error)), path: \(path)"
+ )
+ } else {
+ throw .failedToLoadUserConfiguration(
+ reason: "error opening dylib: unknown error, path: \(path)"
+ )
+ }
+ }
+ }
+}
diff --git a/Sources/SPMGraphExecutable/SPMGraph.swift b/Sources/SPMGraphExecutable/SPMGraph.swift
new file mode 100644
index 0000000..6bc40d5
--- /dev/null
+++ b/Sources/SPMGraphExecutable/SPMGraph.swift
@@ -0,0 +1,53 @@
+import ArgumentParser
+import Foundation
+
+struct SPMGraphArguments: ParsableArguments {
+ @Option(
+ name: [.customShort("e"), .long],
+ help:
+ "Comma separated array of suffixes to exclude from the graph e.g. 'Tests','Live','TestSupport'"
+ )
+ var excludedSuffixes: [String] = []
+
+ @Argument(
+ help: "Directory path of the Package.swift file"
+ )
+ var spmPackageDirectory: String
+
+ @Flag(
+ name: [.customLong("verbose"), .customShort("v")],
+ help: "Show extra logging for troubleshooting purposes."
+ )
+ var verbose: Bool = false
+
+ @Option(
+ help: """
+ A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ It defaults to a temporary directory.
+
+ Note: It enables controlling and caching the artifact that is generated from the user's `SPMGraphConfig` file.
+
+ Warning: Ensure this is consistent across commands, otherwise your configuration won't be correctly loaded!
+ """
+ )
+ var buildDirectory: String?
+}
+
+@main
+struct SPMGraph: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ abstract: "Utilities for managing your Package.swift dependency graph",
+ discussion: """
+ Visualization, Selective testing, and Linting of a Package.swift dependency graph
+ """,
+ version: "1.0.0",
+ subcommands: [
+ Edit.self,
+ Load.self,
+ Tests.self,
+ Lint.self,
+ Visualize.self,
+ ],
+ defaultSubcommand: Visualize.self
+ )
+}
diff --git a/Sources/SPMGraphExecutable/Subcommands/Edit.swift b/Sources/SPMGraphExecutable/Subcommands/Edit.swift
new file mode 100644
index 0000000..a887618
--- /dev/null
+++ b/Sources/SPMGraphExecutable/Subcommands/Edit.swift
@@ -0,0 +1,106 @@
+import ArgumentParser
+import Foundation
+import SPMGraphConfigSetup
+
+struct EditArguments: ParsableArguments {
+ @Flag(
+ name: [.customLong("verbose"), .customShort("v")],
+ help: "Show extra logging for troubleshooting purposes."
+ )
+ var verbose: Bool = false
+
+ @Argument(
+ help: "Directory path of the Package.swift file"
+ )
+ var spmPackageDirectory: String
+
+ @Option(
+ help: """
+ A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ It defaults to a temporary directory.
+
+ Note: It enables controlling and caching the artifact that is generated from the user's `SPMGraphConfig` file.
+
+ Warning: Ensure this is consistent across commands, otherwise your configuration won't be correctly loaded!
+ """
+ )
+ var buildDirectory: String?
+}
+
+struct Edit: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ abstract:
+ "Initializes or edit your spmgraph configuration, including your dependency graph rules written in Swift.",
+ discussion: """
+ It looks for an `SPMGraphConfig.swift` file in the same directory as the `Package.swift` under analyzes. If there's none, it creates a fresh one from a template.
+
+ Next, it generates a temporary package for editing your `SPMGraphConfig.swift`, where you customize multiple settings, from the expected warnings count to
+ writing your own dependency graph rules in Swift code.
+
+ Once the `SPMGraphConfig.swift` is edited, your configuration is dynamic loaded into spmgraph and leveraged on all other commands.
+ """,
+ version: "1.0.0"
+ )
+
+ @OptionGroup var arguments: EditArguments
+
+ mutating func run() async throws {
+ let spmgraphEdit = try SPMGraphEdit(
+ input: SPMGraphEditInput(
+ spmPackageDirectory: arguments.spmPackageDirectory,
+ buildDirectory: arguments.buildDirectory,
+ verbose: arguments.verbose
+ )
+ )
+ try await spmgraphEdit.run()
+ }
+}
+
+extension CleanExit {
+ static func make(from error: SPMGraphEditError) -> Self {
+ switch error {
+ case let .unableToLoadTemplates(bundle):
+ CleanExit.message(
+ """
+ Unable to load the template files for generating the temporary Package
+ Error: Missing resources in the bundle \(bundle)
+ """
+ )
+ case let .failedToCreateOrLoadConfigFile(underlyingError):
+ CleanExit.message(
+ """
+ Unable to create or load the user's SPMGraphConfig.swift file
+ Error: \(underlyingError.localizedDescription)
+ """
+ )
+ case let .failedToCreateEditPackage(underlyingError):
+ CleanExit.message(
+ """
+ Unable to create a temporary project for editing the SPMGraphConfig.swift file
+ Error: \(underlyingError.localizedDescription)
+ """
+ )
+ case let .failedToCopyTemplateFile(underlyingError):
+ CleanExit.message(
+ """
+ Unable to create a package for editing the SPMGraphConfig.swift file
+ Error: Failed to copy template file with error \(underlyingError.localizedDescription)
+ """
+ )
+ case let .failedToOpenEditPackageForEditing(underlyingError):
+ CleanExit.message(
+ """
+ Unable open the project for editing the SPMGraphConfig.swift file
+ Error: \(underlyingError.localizedDescription)
+ """
+ )
+ case let .failedToObserveConfigFileChanges(underlyingError):
+ CleanExit.message(
+ """
+ Unable to observe changes on the edited SPMGraphConfig.swift file and update spmgraph with it
+ Error: \(underlyingError.localizedDescription)
+ """
+ )
+ }
+ }
+}
diff --git a/Sources/SPMGraphExecutable/Subcommands/Lint.swift b/Sources/SPMGraphExecutable/Subcommands/Lint.swift
new file mode 100644
index 0000000..4082fe0
--- /dev/null
+++ b/Sources/SPMGraphExecutable/Subcommands/Lint.swift
@@ -0,0 +1,57 @@
+import ArgumentParser
+import SPMGraphLint
+
+struct LintArguments: ParsableArguments {
+ @OptionGroup var common: SPMGraphArguments
+
+ @Flag(
+ name: [.short, .long],
+ help: "Fails on warnings"
+ )
+ var strict: Bool = false
+
+ @Option(
+ name: [.customShort("o"), .customLong("output", withSingleDash: false)],
+ help:
+ "Relative path for an output file with the formatted lint results. By default the errors are only dumped into the sdtout."
+ )
+ var outputFilePath: String?
+
+ @Option(
+ name: [.customShort("c"), .customLong("warningsCount", withSingleDash: false)],
+ help:
+ "The number of allowed warnings for the strict mode. note: It can be useful to **bypass the strict mode in specific scenarios**. `The default is zero`."
+ )
+ public var expectedWarningsCount: UInt = 0
+}
+
+struct Lint: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ abstract:
+ "Lints your Package.swift dependency graph and uncovers configuration issues. Arguments take precedence over the matching `SPMGraphConfig.swift` options.",
+ discussion: """
+ Run checks on a given Package.swift and raises configuration issues and potential optimisations
+ that otherwise would be bubbled up by the build system later on.
+ """,
+ version: "1.0.0"
+ )
+
+ @OptionGroup var arguments: LintArguments
+
+ mutating func run() async throws {
+ let library = try SPMGraphLint(
+ input: SPMGraphLintInput(
+ spmPackageDirectory: arguments.common.spmPackageDirectory,
+ buildDirectory: arguments.common.buildDirectory,
+ excludedSuffixes: arguments.common.excludedSuffixes,
+ isStrict: arguments.strict,
+ verbose: arguments.common.verbose,
+ expectedWarningsCount: arguments.expectedWarningsCount,
+ outputFilePath: arguments.outputFilePath
+ )
+ )
+ try await library.run()
+ }
+}
+
+extension CommandConfiguration: @unchecked @retroactive Sendable {}
diff --git a/Sources/SPMGraphExecutable/Subcommands/Load.swift b/Sources/SPMGraphExecutable/Subcommands/Load.swift
new file mode 100644
index 0000000..618c079
--- /dev/null
+++ b/Sources/SPMGraphExecutable/Subcommands/Load.swift
@@ -0,0 +1,78 @@
+import ArgumentParser
+import Foundation
+import SPMGraphConfigSetup
+
+struct LoadArguments: ParsableArguments {
+ @Flag(
+ name: [.customLong("verbose"), .customShort("v")],
+ help: "Show extra logging for troubleshooting purposes."
+ )
+ var verbose: Bool = false
+
+ @Argument(
+ help: "Directory path of the SPMGraphConfig.swift file. The same as the `Package.swift` file."
+ )
+ var directory: String
+
+ @Option(
+ help: """
+ A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ It defaults to a temporary directory.
+
+ Note: It enables controlling and caching the artifact that is generated from the user's `SPMGraphConfig` file.
+
+ Warning: Ensure this is consistent across commands, otherwise your configuration won't be correctly loaded!
+ """
+ )
+ var buildDirectory: String?
+}
+
+struct Load: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ abstract: "Loads your configuration into spmgraph.",
+ discussion:
+ "It dynamically loads your `SPMGraphConfig.swift` file into spmgraph so that it can be used by the tool and leveraged on all other commands.",
+ version: "1.0.0"
+ )
+
+ @OptionGroup var arguments: LoadArguments
+
+ mutating func run() async throws {
+ let load = try SPMGraphLoad(
+ input: try SPMGraphLoadInput(
+ directory: arguments.directory,
+ buildDirectory: arguments.buildDirectory,
+ verbose: arguments.verbose
+ )
+ )
+ try load.run()
+ }
+}
+
+extension CleanExit {
+ static func make(from error: SPMGraphLoadError) -> Self {
+ switch error {
+ case let .failedToReadTheConfig(underlyingError):
+ CleanExit.message(
+ """
+ Unable to load your SPMGraphConfig.swift into spmgraph
+ Error: Unable to load the configuration and get the build directory with error: \(underlyingError.localizedDescription)
+ """
+ )
+ case let .failedToLoadTheConfigIntoSpmgraph(localizedDescription):
+ CleanExit.message(
+ """
+ Failed to load your SPMGraphConfig.swift into spmgraph
+ Error: \(localizedDescription)
+ """
+ )
+ case let .failedToSetupDynamicLoading(underlyingError):
+ CleanExit.message(
+ """
+ Failed to load your SPMGraphConfig.swift into spmgraph
+ Error: Failed to configure it for dynamic loading with error: \(underlyingError.localizedDescription)
+ """
+ )
+ }
+ }
+}
diff --git a/Sources/SPMGraphExecutable/Subcommands/Tests.swift b/Sources/SPMGraphExecutable/Subcommands/Tests.swift
new file mode 100644
index 0000000..b034324
--- /dev/null
+++ b/Sources/SPMGraphExecutable/Subcommands/Tests.swift
@@ -0,0 +1,62 @@
+import ArgumentParser
+import SPMGraphTests
+
+struct TestsArguments: ParsableArguments {
+ @OptionGroup var common: SPMGraphArguments
+
+ @Option(
+ name: [.customLong("files"), .customLong("changedFiles")],
+ help: "Optional list of changed files. Otherwise git versioning is used"
+ )
+ var changedFiles: [String] = [] // TODO: Change to AbsolutePath
+
+ @Option(
+ name: [.customLong("baseBranch"), .customLong("branch"), .short],
+ help: "Base branch to compare the changes against"
+ )
+ var baseBranch: String?
+
+ @Option(
+ name: [.customLong("output"), .customLong("outputMode"), .short],
+ help:
+ "The output mode. Options are: \(SPMGraphTests.OutputMode.allCases.map(\.rawValue).joined(separator: ", "))"
+ )
+ var outputMode: SPMGraphTests.OutputMode = .textDump
+
+ // TODO: Review if gitDir options is needed - generally git is in the root dir of the root Package
+}
+
+struct Tests: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ abstract:
+ "Selective testing. It maps modules that should be built and test modules that should be run based on git changes or a set of changed files.",
+ discussion: """
+ Given changed files, it traverses the dependency graph and defines which modules were affected.
+ Useful to optimize for building and running tests only for what changed.
+ """,
+ version: "1.0.0"
+ )
+
+ @OptionGroup var arguments: TestsArguments
+
+ mutating func run() async throws {
+ let library = try SPMGraphTests(
+ input: SPMGraphTestsInput(
+ spmPackageDirectory: arguments.common.spmPackageDirectory,
+ buildDirectory: arguments.common.buildDirectory,
+ excludedSuffixes: arguments.common.excludedSuffixes,
+ changedFiles: arguments.changedFiles,
+ baseBranch: arguments.baseBranch,
+ outputMode: arguments.outputMode,
+ verbose: arguments.common.verbose
+ )
+ )
+ try await library.run()
+ }
+}
+
+extension SPMGraphTests.OutputMode: ExpressibleByArgument {
+ public init?(argument: String) {
+ self.init(rawValue: argument)
+ }
+}
diff --git a/Sources/SPMGraphExecutable/Subcommands/Visualize.swift b/Sources/SPMGraphExecutable/Subcommands/Visualize.swift
new file mode 100644
index 0000000..f055135
--- /dev/null
+++ b/Sources/SPMGraphExecutable/Subcommands/Visualize.swift
@@ -0,0 +1,56 @@
+import ArgumentParser
+import SPMGraphVisualize
+
+struct VisualizeArguments: ParsableArguments {
+ @OptionGroup var common: SPMGraphArguments
+
+ @Option(
+ name: [.customShort("f"), .customLong("focus", withSingleDash: false)],
+ help: "Focus on a specific module by highlighting its edges (arrows) in a different color"
+ )
+ var focusedModule: String?
+
+ @Flag(
+ name: [.customShort("t"), .long],
+ help: "Flag to exclude third-party dependencies from the graph declared in the `Package.swift`"
+ )
+ var excludeThirdPartyDependencies = false
+
+ @Option(
+ name: [.customShort("o"), .customLong("output", withSingleDash: false)],
+ help:
+ "Custom output file path for the generated PNG file. Default will generate a 'graph.png' file in the current directory"
+ )
+ var outputFilePath: String?
+
+ @Option(
+ name: [.customShort("s"), .long],
+ help:
+ "Minimum vertical spacing between the ranks (levels) of the graph. A double value in inches."
+ )
+ var rankSpacing: Double = 3
+}
+
+struct Visualize: AsyncParsableCommand {
+ static let configuration = CommandConfiguration(
+ abstract: "Generates a visual representation of your dependency graph",
+ version: "1.0.0"
+ )
+
+ @OptionGroup var arguments: VisualizeArguments
+
+ func run() async throws {
+ let library = SPMGraphVisualize()
+ try await library.run(
+ input: SPMGraphVisualizeInput(
+ spmPackageDirectory: arguments.common.spmPackageDirectory,
+ excludedSuffixes: arguments.common.excludedSuffixes,
+ focusedModule: arguments.focusedModule,
+ excludeThirdPartyDependencies: arguments.excludeThirdPartyDependencies,
+ outputFilePath: arguments.outputFilePath,
+ rankSpacing: arguments.rankSpacing,
+ verbose: arguments.common.verbose
+ )
+ )
+ }
+}
diff --git a/Sources/SPMGraphLint/SPMGraphLint.swift b/Sources/SPMGraphLint/SPMGraphLint.swift
new file mode 100644
index 0000000..b23beba
--- /dev/null
+++ b/Sources/SPMGraphLint/SPMGraphLint.swift
@@ -0,0 +1,284 @@
+import Basics
+import Core
+import Foundation
+import PackageModel
+import SPMGraphDescriptionInterface
+
+// MARK: - Input
+
+public struct SPMGraphLintInput {
+ /// "Directory path of Package.swift file"
+ let spmPackageDirectory: AbsolutePath
+ /// A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ let buildDirectory: AbsolutePath
+ /// Comma separated array of suffixes to exclude from the graph e.g. 'Tests','Live','TestSupport'
+ let excludedSuffixes: [String]
+ /// Fails on warnings
+ let isStrict: Bool
+ /// Show extra logging for troubleshooting purposes
+ let verbose: Bool
+ /// The number of allowed warnings for the strict mode.
+ /// - note: It can be useful to **bypass the strict mode in specific scenarios**. `The default is zero`.
+ let expectedWarningsCount: UInt
+ /// Relative path for an output file with the formatted lint results. By default the errors are added only listed in the sdtout.
+ let outputFilePath: String?
+
+ /// Makes an instance of ``SPMGraphLintInput``
+ public init(
+ spmPackageDirectory: String,
+ buildDirectory: String?,
+ excludedSuffixes: [String],
+ isStrict: Bool,
+ verbose: Bool,
+ expectedWarningsCount: UInt,
+ outputFilePath: String?
+ ) throws {
+ self.spmPackageDirectory = try AbsolutePath.packagePath(spmPackageDirectory)
+ self.buildDirectory = try AbsolutePath.buildDirectory(buildDirectory)
+ self.excludedSuffixes = excludedSuffixes
+ self.isStrict = isStrict
+ self.verbose = verbose
+ self.expectedWarningsCount = expectedWarningsCount
+ self.outputFilePath = outputFilePath
+ }
+}
+
+// MARK: - Abstraction and Implementation
+
+/// Represents a type that lints a Package.swift dependency graph and uncovers configuration issues
+public protocol SPMGraphLintProtocol {
+ func run() async throws
+}
+
+/// A type that lints a Package.swift dependency graph and uncovers configuration issues
+public final class SPMGraphLint: SPMGraphLintProtocol {
+ private let packageLoader: PackageLoader
+ private let config: SPMGraphConfig
+ private let system: SystemProtocol
+ private let input: SPMGraphLintInput
+
+ private var rules: [SPMGraphConfig.Lint.Rule] {
+ config.lint.rules
+ }
+
+ private var isStrict: Bool {
+ if input.isStrict {
+ return true
+ } else {
+ return config.lint.isStrict
+ }
+ }
+
+ private var excludedSuffixes: [String] {
+ if !input.excludedSuffixes.isEmpty {
+ return input.excludedSuffixes
+ } else {
+ return config.excludedSuffixes
+ }
+ }
+
+ private var expectedWarningsCount: UInt {
+ if input.expectedWarningsCount > 0 {
+ return input.expectedWarningsCount
+ } else {
+ return config.lint.expectedWarningsCount
+ }
+ }
+
+ /// Makes an instance of ``SPMGraphLint`` from a given input
+ public convenience init(input: SPMGraphLintInput) throws {
+ try self.init(packageLoader: .live, input: input)
+ }
+
+ /// Makes an instance of ``SPMGraphLint``
+ ///
+ /// - Parameters:
+ /// - spmPackageDirectory: Path to the directory containing the `Package.swift` file
+ /// - packageLoader: dependency used to load a Package.swift
+ /// - system: dependency used to run shell commands
+ init(
+ packageLoader: PackageLoader = .live,
+ configLoader: SPMGraphConfigLoading = SPMGraphConfigLoader(),
+ system: SystemProtocol = System.shared,
+ input: SPMGraphLintInput
+ ) throws {
+ self.packageLoader = packageLoader
+ self.config = try configLoader.load(buildDirectory: input.buildDirectory)
+ self.system = system
+ self.input = input
+ }
+
+ /// Lints the Swift Package dependency graph
+ public func run() async throws {
+ let package = try await packageLoader.load(
+ input.spmPackageDirectory,
+ input.verbose
+ )
+
+ let result = lintGraph(
+ package: package,
+ excludedSuffixes: excludedSuffixes
+ )
+
+ if let outputFilePath = input.outputFilePath {
+ try generateOutput(lintMessage: result.message, outputFilePath: outputFilePath)
+ }
+
+ if result.hasErrors && isStrict {
+ throw SPMGraphLint.Error.lintFailedAndStrictIsOn
+ }
+ }
+}
+
+// MARK: - Private
+
+private extension SPMGraphLint {
+ struct Result: Equatable {
+ let hasErrors: Bool
+ let message: String
+ }
+
+ func lintGraph(package: Package, excludedSuffixes: [String]) -> Result {
+ var totalErrorsCount = 0
+ var lintMessage = ""
+
+ rules.forEach { rule in
+ printAndCollect("", lintMessage: &lintMessage)
+
+ printAndCollect(
+ "Running lint rule: \(rule.name)",
+ color: .cyan,
+ style: .bold,
+ lintMessage: &lintMessage
+ )
+ printAndCollect(
+ rule.abstract,
+ style: .bold,
+ terminator: "\n",
+ lintMessage: &lintMessage
+ )
+
+ let errors = rule.validate(package, excludedSuffixes)
+ totalErrorsCount += errors.count
+
+ if errors.isEmpty {
+ printAndCollect(
+ "โ
Found no issues",
+ color: .green,
+ terminator: "\n",
+ lintMessage: &lintMessage
+ )
+ } else if errors.count <= expectedWarningsCount {
+ printAndCollect(
+ "โ๏ธ Found \(errors.count) issues, which is within expected warnings count of \(expectedWarningsCount)!",
+ color: .green,
+ terminator: "\n",
+ lintMessage: &lintMessage
+ )
+ } else {
+ printAndCollect(
+ "Errors:",
+ color: .yellow,
+ lintMessage: &lintMessage
+ )
+ let errorsDescription = errors.map { "- โ ๏ธ \($0.localizedDescription)" }
+ printAndCollect(
+ errorsDescription.joined(separator: "\n"),
+ lintMessage: &lintMessage
+ )
+ printAndCollect(
+ "Found ",
+ color: .yellow,
+ terminator: "",
+ lintMessage: &lintMessage
+ )
+ printAndCollect(
+ "\(errors.count) \(errors.count == 1 ? "error" : "errors")!",
+ color: .yellow,
+ style: .bold,
+ terminator: "",
+ lintMessage: &lintMessage
+ )
+ printAndCollect(
+ " Lets fix them, humans ๐ค!",
+ color: .yellow,
+ lintMessage: &lintMessage
+ )
+ }
+ }
+
+ printAndCollect("\n", lintMessage: &lintMessage)
+ printAndCollect(
+ "โ ๏ธ Found a ",
+ color: .yellow,
+ terminator: "",
+ lintMessage: &lintMessage
+ )
+ printAndCollect(
+ "total of \(totalErrorsCount) errors ",
+ color: .yellow,
+ style: .bold,
+ terminator: "",
+ lintMessage: &lintMessage
+ )
+ printAndCollect(
+ "for all rules ran. Don't worry, everything is fixable!",
+ color: .yellow,
+ lintMessage: &lintMessage
+ )
+
+ return Result(
+ hasErrors: totalErrorsCount > expectedWarningsCount,
+ message: lintMessage
+ )
+ }
+
+ func printAndCollect(
+ _ message: String,
+ color: ANSIColor = .default,
+ style: FontStyle = .default,
+ terminator: String = "\n",
+ lintMessage: inout String
+ ) {
+ print(
+ "\(color.rawValue)\(style.rawValue)\(message)\(FontStyle.default.rawValue)\(ANSIColor.reset.rawValue)",
+ terminator: terminator
+ )
+
+ lintMessage.append(message + terminator)
+ }
+
+ /// Generates the output for the lint results
+ func generateOutput(lintMessage: String, outputFilePath: String) throws {
+ let fileURL = AbsolutePath.currentDir
+ .appending(outputFilePath)
+ .appending(extension: "txt")
+ .asURL
+
+ do {
+ try lintMessage.write(to: fileURL, atomically: true, encoding: .utf8)
+ print("โ
Successfully saved the lint output into \(fileURL)")
+ } catch {
+ throw SPMGraphLint.Error.failedToSaveOutputFile(error: error)
+ }
+ }
+}
+
+// MARK: - Error
+
+extension SPMGraphLint {
+ /// Possible Lint failure reasons
+ public enum Error: LocalizedError {
+ case lintFailedAndStrictIsOn
+ case failedToSaveOutputFile(error: Swift.Error)
+
+ public var errorDescription: String? {
+ switch self {
+ case .lintFailedAndStrictIsOn:
+ "Lint failed and strict flag is on!"
+ case let .failedToSaveOutputFile(error):
+ "Failed to save output file with error: \(error)"
+ }
+ }
+ }
+}
diff --git a/Sources/SPMGraphTests/SPMGraphTests.swift b/Sources/SPMGraphTests/SPMGraphTests.swift
new file mode 100644
index 0000000..ba2ad9b
--- /dev/null
+++ b/Sources/SPMGraphTests/SPMGraphTests.swift
@@ -0,0 +1,313 @@
+import Basics
+import Core
+import Foundation
+import PackageModel
+import SPMGraphDescriptionInterface
+
+// MARK: - Input
+
+public struct SPMGraphTestsInput {
+ /// "Directory path of Package.swift file"
+ let spmPackageDirectory: AbsolutePath
+ /// A custom build directory used to build the package used to edit and load the SPMGraphConfig.
+ let buildDirectory: AbsolutePath
+ /// Comma separated array of suffixes to exclude from the graph e.g. 'Tests','Live','TestSupport'
+ let excludedSuffixes: [String]
+ /// Optional list of changed files. Otherwise git versioning is used
+ let changedFiles: [String]
+ /// Base branch to compare the changes against
+ let baseBranch: String?
+ /// The output mode
+ let outputMode: SPMGraphTests.OutputMode // TODO: Check if it make sense in the SPMGraphConfig fle
+ /// Show extra logging for troubleshooting purposes
+ let verbose: Bool
+
+ /// Makes an instance of ``SPMGraphMapInput``
+ public init(
+ spmPackageDirectory: String,
+ buildDirectory: String?,
+ excludedSuffixes: [String],
+ changedFiles: [String],
+ baseBranch: String?,
+ outputMode: SPMGraphTests.OutputMode,
+ verbose: Bool
+ ) throws {
+ self.spmPackageDirectory = try AbsolutePath.packagePath(spmPackageDirectory)
+ self.buildDirectory = try AbsolutePath.buildDirectory(buildDirectory)
+ self.excludedSuffixes = excludedSuffixes
+ self.changedFiles = changedFiles
+ self.baseBranch = baseBranch
+ self.outputMode = outputMode
+ self.verbose = verbose
+ }
+}
+
+// MARK: - Abstraction and Implementation
+
+/// Represents a type that, given a set of inputs, maps modules that should be built and tests that should run based on changed files
+public protocol SSPMGraphTestsProtocol {
+ @discardableResult
+ func run() async throws -> [Module]
+}
+
+/// A type that maps modules that should be built and tests that should run based on changed files
+public final class SPMGraphTests: SSPMGraphTestsProtocol {
+ /// Defines how the output is generated
+ public enum OutputMode: String, Equatable, CaseIterable {
+ /// Dumps the list of test modules to run in a single line, following the `xcodebuild/fastlane scan expected format`
+ ///
+ /// `Example`: "BookingAssistantLiveTests,ActivityDetailsTests,ActivityDetailsCommonTests,ActivityAvailabilitiesLiveTests"
+ case textDump
+ /// Saves the list of test modules into an `output.txt` file in the `current dir`, following the `xcodebuild/fastlane scan expected format`
+ ///
+ /// `Example`: "BookingAssistantLiveTests,ActivityDetailsTests,ActivityDetailsCommonTests,ActivityAvailabilitiesLiveTests"
+ case textFile
+ }
+
+ private let packageLoader: PackageLoader
+ private let gitClient: GitClient
+ private let system: SystemProtocol
+ private let config: SPMGraphConfig
+ private let input: SPMGraphTestsInput
+
+ private var baseBranch: String {
+ if let baseBranch = input.baseBranch {
+ return baseBranch
+ }
+ return config.tests.baseBranch
+ }
+
+ private var excludedSuffixes: [String] {
+ if !input.excludedSuffixes.isEmpty {
+ return input.excludedSuffixes
+ } else {
+ return config.excludedSuffixes
+ }
+ }
+
+ /// Makes an instance of ``SPMGraphMap``
+ public convenience init(input: SPMGraphTestsInput) throws {
+ try self.init(packageLoader: .live, input: input)
+ }
+
+ /// Makes an instance of ``SPMGraphMap``
+ ///
+ /// - Parameters:
+ /// - packageLoader: dependency used to load a Package.swift
+ /// - gitClient: dependency to check for git changes
+ /// - system: dependency used to run shell commands
+ public init(
+ packageLoader: PackageLoader = .live,
+ configLoader: SPMGraphConfigLoading = SPMGraphConfigLoader(),
+ gitClient: GitClient = .makeLive(),
+ system: SystemProtocol = System.shared,
+ input: SPMGraphTestsInput
+ ) throws {
+ self.packageLoader = packageLoader
+ self.gitClient = gitClient
+ self.system = system
+ self.config = try configLoader.load(buildDirectory: input.buildDirectory)
+ self.input = input
+ }
+
+ /// Maps the test modules that should run based on a package graph and related git changes
+ /// - Returns: An array of test test modules to be run
+ @discardableResult
+ public func run() async throws -> [Module] {
+ // TODO: Abstract away `Module` so that users don't transitively depend on the SPM library
+
+ // check changed files and return in case there are none
+ let changedFiles: [AbsolutePath]
+ if input.changedFiles.isEmpty {
+ changedFiles = try gitClient.listChangedFiles(baseBranch)
+ .map(AbsolutePath.init(validating:))
+ } else {
+ let changedFilesString = input.changedFiles
+ changedFiles = try changedFilesString.map {
+ let relativePath = try RelativePath(validating: $0)
+ return AbsolutePath.currentDir.appending(relativePath)
+ }
+ }
+
+ guard changedFiles.isEmpty == false else {
+ if input.verbose {
+ try system.echo(
+ "There are no changes in the current git revision, skipping map.."
+ )
+ }
+ return []
+ }
+
+ if input.verbose {
+ try system.echo(
+ "changed files are \(changedFiles.map(\.description).joined(separator: "\n"))"
+ )
+ }
+
+ // if there are changed files, load the package and map the affected modules
+ let package = try await packageLoader.load(input.spmPackageDirectory, input.verbose)
+
+ // map affected modules
+ let affectedModules = mapAffectedModules(
+ package: package,
+ changedFiles: changedFiles,
+ verbose: input.verbose
+ )
+
+ if input.verbose {
+ if affectedModules.isEmpty {
+ try system.echo(
+ "No modules were changed"
+ )
+ } else {
+ try system.echo(
+ "The affected modules are: \(affectedModules.map(\.name).joined(separator: "\n"))"
+ )
+ }
+ }
+
+ // map tests modules that should run
+ let testModulesToRun = mapTestmodules(for: affectedModules, package: package)
+
+ if testModulesToRun.isEmpty {
+ try system.echo(
+ "No test modules to run"
+ )
+ } else {
+ try system.echo(
+ "The test modules to run are: \(testModulesToRun.map(\.name).joined(separator: "\n"))"
+ )
+ }
+
+ try generateOutput(testModulesToRun: testModulesToRun, outputMode: input.outputMode)
+
+ return testModulesToRun
+ }
+}
+
+// MARK: - Private
+
+private extension SPMGraphTests {
+ /// Maps and returns the modules that were affected by a set of changed files
+ ///
+ /// The following is **included**:
+ /// - modules that **contain changed files**
+ /// - modules that **directly depend** on modules that contain changed files
+ ///
+ /// The following is **not included**:
+ /// - **Package products** affected by the changes
+ /// - **Xcode project based modules** affected by package products/modules that changed, such as _App modules_
+ ///
+ /// **Example**:
+ /// - Sources/Routes/RouteA.swift changed
+ /// - The module `Routes` **is mapped**
+ /// - module `BookingRoute` and `CheckoutRoute` depends on `Routes`, they are **also mapped**
+ func mapAffectedModules(
+ package: Package,
+ changedFiles: [AbsolutePath],
+ verbose: Bool = false
+ ) -> [Module] {
+ var changedModules: [Module] = []
+ if changedFiles.contains(where: { $0.extension == "resolved" }) {
+ // If a third party dependency has changed, run all tests
+ changedModules = package.modules
+ if verbose {
+ try? system.echo(
+ "Package.resolved has changed, running all tests..."
+ )
+ }
+ } else {
+ changedModules = changedFiles.compactMap { changedFile in
+ package.modules
+ .first { module in
+ // ie "Modules/ActivityAvailabilities/ActivityAvailabilities/API/ActivityAvailabilitiesAPI.swift" contains Modules/ActivityAvailabilities/ActivityAvailabilities
+ // it is simpler than iterating over all sources of each module
+ changedFile.isDescendantOfOrEqual(to: module.path)
+ }
+ }
+ }
+
+ /// Maps modules that depend on the affected ones
+ /// - note: In the future it could check if changed code is public and directly used when mapping modules
+ let modulesThatDependOnChangedOnes: [Module]
+ if changedModules.isEmpty == false {
+ modulesThatDependOnChangedOnes = package.modules
+ .filter { $0.type != .test && $0.type != .systemModule }
+ /// should `snippet` be **filtered out too**??
+ .filter { changedModules.contains($0) == false }
+ .filter {
+ $0.dependencies
+ .compactMap(\.module)
+ .contains(where: changedModules.contains)
+ }
+ } else {
+ modulesThatDependOnChangedOnes = []
+ }
+
+ return changedModules + modulesThatDependOnChangedOnes
+ }
+
+ /// Maps the test modules that depend on a set of modules
+ func mapTestmodules(
+ for modules: [Module],
+ package: Package
+ ) -> [Module] {
+ var allTestModulesToRun = modules.filter(\.type == .test)
+ if modules.contains(where: { $0.type != .test }) {
+ let allNotTestAffectedModules = modules.filter(\.type != .test)
+ let allTestModules = package.modules.filter { $0.type == .test }
+
+ let testModulesToRun = allTestModules.filter {
+ $0.dependencies
+ .compactMap(\.module)
+ .contains(where: allNotTestAffectedModules.contains)
+ }
+
+ allTestModulesToRun += testModulesToRun
+ }
+
+ // Remove duplicate test modules
+ return allTestModulesToRun.spm_uniqueElements()
+ }
+
+ /// A function that generates the output with tests to run
+ /// - Parameters:
+ /// - testModulesToRun: All modules which tests need to run
+ /// - outputMode: Specifies the output mode
+ func generateOutput(testModulesToRun: [Module], outputMode: OutputMode) throws {
+ let inlineModuleNames = testModulesToRun.map(\.name).joined(separator: ",")
+
+ switch outputMode {
+ case .textDump:
+ try system.echo(inlineModuleNames)
+ case .textFile:
+ let url = AbsolutePath.currentDir.asURL
+ var fileURL = url.appendingPathComponent("output")
+ fileURL = fileURL.appendingPathExtension("txt")
+ do {
+ try inlineModuleNames.write(to: fileURL, atomically: true, encoding: .utf8)
+ try system.echo(
+ "โ
Successfully saved the formatted list of test modules to \(fileURL)"
+ )
+ } catch {
+ throw SPMGraphTests.Error.failedToSaveOutputFile(error: error)
+ }
+ }
+ }
+}
+
+// MARK: - Error
+
+extension SPMGraphTests {
+ /// Possible Map failure reasons
+ public enum Error: LocalizedError {
+ case failedToSaveOutputFile(error: Swift.Error)
+
+ public var errorDescription: String? {
+ switch self {
+ case let .failedToSaveOutputFile(error):
+ "Failed to save output file with error: \(error)"
+ }
+ }
+ }
+}
diff --git a/Sources/SPMGraphVisualize/GraphViz/GraphVizWrapper.swift b/Sources/SPMGraphVisualize/GraphViz/GraphVizWrapper.swift
new file mode 100644
index 0000000..6588639
--- /dev/null
+++ b/Sources/SPMGraphVisualize/GraphViz/GraphVizWrapper.swift
@@ -0,0 +1,26 @@
+import Core
+
+enum GraphVizWrapper {
+ static func installGraphVizIfNeeded() throws {
+ if try !isGraphVizInstalled() {
+ try installGraphViz()
+ }
+ }
+}
+
+private extension GraphVizWrapper {
+ static func isGraphVizInstalled() throws -> Bool {
+ try System.shared.runAndCapture("brew", "list", "--formula").contains("graphviz")
+ }
+
+ static func installGraphViz() throws {
+ print("Installing GraphViz...")
+ var env = System.env
+ env["HOMEBREW_NO_AUTO_UPDATE"] = "1"
+ try System.shared.run(
+ "brew",
+ "install",
+ "graphviz"
+ )
+ }
+}
diff --git a/Sources/SPMGraphVisualize/Helpers/Array+Extensions.swift b/Sources/SPMGraphVisualize/Helpers/Array+Extensions.swift
new file mode 100644
index 0000000..d588d3b
--- /dev/null
+++ b/Sources/SPMGraphVisualize/Helpers/Array+Extensions.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+extension Array {
+ subscript(safeIndex index: Int) -> Element? {
+ guard index >= 0, index < endIndex else { return nil }
+
+ return self[index]
+ }
+}
diff --git a/Sources/SPMGraphVisualize/Helpers/Edge+Factories.swift b/Sources/SPMGraphVisualize/Helpers/Edge+Factories.swift
new file mode 100644
index 0000000..cc6f7a2
--- /dev/null
+++ b/Sources/SPMGraphVisualize/Helpers/Edge+Factories.swift
@@ -0,0 +1,14 @@
+import GraphViz
+
+extension Edge {
+ static func make(
+ from: Node,
+ to: Node,
+ direction: Direction? = nil,
+ strokeColor: Color? = .named(.gray1)
+ ) -> Self {
+ var edge = Edge(from: from, to: to)
+ edge.strokeColor = strokeColor
+ return edge
+ }
+}
diff --git a/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift b/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift
new file mode 100644
index 0000000..f1bf84b
--- /dev/null
+++ b/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift
@@ -0,0 +1,3 @@
+func containsExcludedSuffix(moduleName: String, excludedSuffixes: [String]) -> Bool {
+ excludedSuffixes.contains(where: moduleName.hasSuffix)
+}
diff --git a/Sources/SPMGraphVisualize/Helpers/Node+Attributes.swift b/Sources/SPMGraphVisualize/Helpers/Node+Attributes.swift
new file mode 100644
index 0000000..fdb60e9
--- /dev/null
+++ b/Sources/SPMGraphVisualize/Helpers/Node+Attributes.swift
@@ -0,0 +1,69 @@
+import Foundation
+import GraphViz
+
+extension GraphViz.Node {
+ mutating func applyAttributes(attributes: NodeStyleAttributes?) {
+ self.fillColor = attributes?.fillColor
+ self.textColor = attributes?.textColor
+ self.strokeWidth = attributes?.strokeWidth
+ self.shape = attributes?.shape
+ self.fontName = attributes?.fontName
+ }
+}
+
+struct NodeStyleAttributes: Sendable {
+ let fillColor: GraphViz.Color?
+ var textColor: GraphViz.Color?
+ let strokeWidth: Double?
+ let shape: GraphViz.Node.Shape?
+ let fontName: String?
+
+ init(
+ fillColorName: GraphViz.Color.Name? = nil,
+ textColorName: GraphViz.Color.Name? = nil,
+ strokeWidth: Double? = nil,
+ shape: GraphViz.Node.Shape? = nil,
+ fontName: String? = nil
+ ) {
+ fillColor = fillColorName.map { GraphViz.Color.named($0) }
+ textColor = textColorName.map { GraphViz.Color.named($0) }
+ self.strokeWidth = strokeWidth
+ self.shape = shape
+ self.fontName = fontName
+ }
+}
+
+extension GraphViz.Color: @unchecked @retroactive Sendable {}
+extension GraphViz.Node.Shape: @unchecked @retroactive Sendable {}
+
+extension NodeStyleAttributes {
+ static let internalInterfaceModule = Self(
+ fillColorName: .lightblue,
+ shape: .rectangle,
+ fontName: .sfMonoRegular
+ )
+ static let internalLiveModule = Self(
+ fillColorName: .coral,
+ shape: .box3d,
+ fontName: .sfMonoRegular
+ )
+ static let thirdParty = Self(
+ fillColorName: .aquamarine,
+ shape: .oval,
+ fontName: .sfMonoRegular
+ )
+ static let testModule = Self(
+ fillColorName: .green,
+ shape: .octagon,
+ fontName: .sfMonoRegular
+ )
+ static let testSupportModule = Self(
+ fillColorName: .green2,
+ shape: .trapezium,
+ fontName: .sfMonoRegular
+ )
+}
+
+private extension String {
+ static let sfMonoRegular = "SF Mono Regular"
+}
diff --git a/Sources/SPMGraphVisualize/Helpers/Node+Factories.swift b/Sources/SPMGraphVisualize/Helpers/Node+Factories.swift
new file mode 100644
index 0000000..4e79258
--- /dev/null
+++ b/Sources/SPMGraphVisualize/Helpers/Node+Factories.swift
@@ -0,0 +1,36 @@
+import GraphViz
+
+extension Node {
+ static func make(
+ name: String,
+ attributes: NodeStyleAttributes? = .internalInterfaceModule
+ ) -> Node {
+ var node = Node(name)
+ let attributesToApply = node.customType?.attributes ?? attributes
+ node.applyAttributes(attributes: attributesToApply)
+ return node
+ }
+
+ enum CustomType {
+ case live
+ case tests
+ case testSupport
+
+ var attributes: NodeStyleAttributes {
+ switch self {
+ case .live: return .internalLiveModule
+ case .tests: return .testModule
+ case .testSupport: return .testSupportModule
+ }
+ }
+ }
+
+ var customType: CustomType? {
+ switch id {
+ case _ where id.hasSuffix("Tests"): return .tests
+ case _ where id.hasSuffix("Live") || id.hasSuffix("Feature"): return .live
+ case _ where id.hasSuffix("TestSupport"): return .testSupport
+ default: return nil
+ }
+ }
+}
diff --git a/Sources/SPMGraphVisualize/Package+externalDependencies.swift b/Sources/SPMGraphVisualize/Package+externalDependencies.swift
new file mode 100644
index 0000000..f1524a0
--- /dev/null
+++ b/Sources/SPMGraphVisualize/Package+externalDependencies.swift
@@ -0,0 +1,24 @@
+import Foundation
+import PackageModel
+
+extension Package {
+ func externalDependencies(
+ forModuleNames moduleNames: [String]
+ ) -> [Module.ProductReference] {
+ modules
+ .filter { moduleNames.contains($0.name) }
+ .map(\.dependencies)
+ .reduce(
+ [],
+ +
+ )
+ .compactMap(\.product)
+ .sorted(by: { $0.name < $1.name })
+ }
+
+ func externalDependencies(
+ forModuleName moduleName: String
+ ) -> [Module.ProductReference] {
+ externalDependencies(forModuleNames: [moduleName])
+ }
+}
diff --git a/Sources/SPMGraphVisualize/SPMGraphVisualize.swift b/Sources/SPMGraphVisualize/SPMGraphVisualize.swift
new file mode 100644
index 0000000..b1f4f85
--- /dev/null
+++ b/Sources/SPMGraphVisualize/SPMGraphVisualize.swift
@@ -0,0 +1,226 @@
+import Basics
+import Core
+import Foundation
+import GraphViz
+import PackageModel
+
+// MARK: - Input
+
+public struct SPMGraphVisualizeInput {
+ /// "Directory path of Package.swift file"
+ let spmPackageDirectory: AbsolutePath
+ /// Comma separated array of suffixes to exclude from the graph e.g. 'Tests','Live','TestSupport'
+ let excludedSuffixes: [String]
+ /// Focus on a specific module by highlighting its edges (arrows) in a different color
+ let focusedModule: String?
+ /// Flag to exclude third-party dependencies from the graph declared in the `Package.swift`
+ let excludeThirdPartyDependencies: Bool
+ /// Custom output file path for the generated PNG file. Default will generate a 'graph.png' file in the current directory
+ let outputFilePath: String?
+ /// Minimum vertical spacing between the ranks (levels) of the graph. Default is set to 4. Is a double value in inches.
+ let rankSpacing: Double
+ /// Show extra logging for troubleshooting purposes
+ let verbose: Bool
+
+ /// Makes an instance of ``SPMGraphVisualize``
+ public init(
+ spmPackageDirectory: String,
+ excludedSuffixes: [String],
+ focusedModule: String?,
+ excludeThirdPartyDependencies: Bool,
+ outputFilePath: String?,
+ rankSpacing: Double,
+ verbose: Bool
+ ) throws {
+ self.spmPackageDirectory = try AbsolutePath.packagePath(spmPackageDirectory)
+ self.excludedSuffixes = excludedSuffixes
+ self.focusedModule = focusedModule
+ self.excludeThirdPartyDependencies = excludeThirdPartyDependencies
+ self.outputFilePath = outputFilePath
+ self.rankSpacing = rankSpacing
+ self.verbose = verbose
+ }
+}
+
+// MARK: - Abstraction and Implementation
+
+/// Represents a type that can generate a visual representation of a Package.swift dependency graph
+public protocol SPMGraphVisualizeProtocol {
+ func run(input: SPMGraphVisualizeInput) async throws
+}
+
+/// A type that can generate a visual representation of a Package.swift dependency graph
+public final class SPMGraphVisualize: SPMGraphVisualizeProtocol {
+ let packageLoader: PackageLoader
+ let system: SystemProtocol
+
+ /// Makes an instance of ``SPMGraphVisualize``
+ public convenience init() {
+ self.init(packageLoader: .live)
+ }
+
+ /// Makes an instance of ``SPMGraphVisualize``
+ ///
+ /// - Parameters:
+ /// - packageLoader: dependency used to load a Package.swift
+ /// - system: dependency used to run shell commands
+ ///
+ /// - note: Should be public whenever all dependencies are abstracted
+ init(
+ packageLoader: PackageLoader,
+ system: SystemProtocol = System.shared
+ ) {
+ self.packageLoader = packageLoader
+ self.system = system
+ }
+
+ /// Generates a visual representation of a Package.swift dependency graph
+ /// - Parameters:
+ /// - input: A set of configuration inputs
+ public func run(input: SPMGraphVisualizeInput) async throws {
+ try GraphVizWrapper.installGraphVizIfNeeded()
+
+ let package = try await packageLoader.load(
+ input.spmPackageDirectory,
+ input.verbose
+ )
+
+ try await generateDependencyGraphFile(package: package, input: input)
+ }
+}
+
+// MARK: - Private
+
+private extension SPMGraphVisualize {
+ func generateDependencyGraphFile(
+ package: Package,
+ input: SPMGraphVisualizeInput
+ ) async throws {
+ let graph = generateDependencyGraph(package: package, input: input)
+
+ try await withCheckedThrowingContinuation { continuation in
+ graph.render(using: .dot, to: .png) { [weak system] result in
+ switch result {
+ case let .success(data):
+ let fileURL = URL(
+ fileURLWithPath: input.outputFilePath
+ ?? FileManager.default.currentDirectoryPath.appending("/graph.png")
+ )
+
+ do {
+ try data.write(to: fileURL)
+
+ try system?
+ .run(
+ "open",
+ fileURL.absoluteString,
+ verbose: true
+ )
+ } catch {
+ try? system?
+ .echo(
+ "Failed save and open graph visualization file with error: \(error.localizedDescription)"
+ )
+ }
+ case let .failure(error):
+ try? system?
+ .echo("Failed to render dependency graph with error: \(error.localizedDescription)")
+ }
+
+ continuation.resume(returning: ())
+ }
+ }
+ }
+
+ func generateDependencyGraph(
+ package: Package,
+ input: SPMGraphVisualizeInput
+ ) -> Graph {
+ var graph = Graph(directed: true)
+ var nodes = [Node]()
+ var thirdParties = [Node]()
+
+ package.modules.forEach { module in
+ guard
+ !containsExcludedSuffix(
+ moduleName: module.name,
+ excludedSuffixes: input.excludedSuffixes
+ )
+ else { return }
+
+ // Theming for test and testSupport
+ let fromNode = Node.make(name: module.name)
+ nodes.append(fromNode)
+
+ let externalDependencies = package.externalDependencies(forModuleName: module.name)
+
+ if !input.excludeThirdPartyDependencies {
+ externalDependencies
+ .map(\.name)
+ .forEach { name in
+ let thirdPartyNode = Node.make(name: name, attributes: .thirdParty)
+ thirdParties.append(thirdPartyNode)
+ graph.append(
+ Edge.make(
+ from: fromNode,
+ to: thirdPartyNode,
+ strokeColor: .named(
+ .makeNonHighlightedEdgeStrokeColor(hasFocusedModule: input.focusedModule != nil)
+ )
+ )
+ )
+ }
+ }
+ module
+ .dependencies
+ .compactMap(\.module)
+ .forEach { dependency in
+ guard
+ !containsExcludedSuffix(
+ moduleName: dependency.name,
+ excludedSuffixes: input.excludedSuffixes
+ )
+ else { return }
+
+ let dependencyNode = Node.make(name: dependency.name)
+ nodes.append(dependencyNode)
+
+ var isHighlighted = false
+ if let focusedModule = input.focusedModule {
+ isHighlighted =
+ fromNode.id == focusedModule
+ || dependencyNode.id == focusedModule
+ }
+
+ graph.append(
+ Edge.make(
+ from: fromNode,
+ to: dependencyNode,
+ strokeColor: .named(
+ isHighlighted
+ ? .red
+ : .makeNonHighlightedEdgeStrokeColor(hasFocusedModule: input.focusedModule != nil)
+ )
+ )
+ )
+ }
+ }
+
+ graph.rankSeparation = input.rankSpacing
+
+ let sortedNodes = Set(nodes + thirdParties).sorted { $0.id < $1.id }
+ graph.append(contentsOf: sortedNodes)
+
+ return graph
+ }
+}
+
+private extension GraphViz.Color.Name {
+ static func makeNonHighlightedEdgeStrokeColor(
+ hasFocusedModule: Bool
+ ) -> Self {
+ hasFocusedModule
+ ? .lightgray
+ : .gray1
+ }
+}