From 86c5c7c70579debfb257ac3759751c3c339e36b6 Mon Sep 17 00:00:00 2001 From: Felipe Marino Date: Thu, 18 Sep 2025 16:16:40 +0200 Subject: [PATCH 1/6] Add initial version * It has both the `edit` and `load` functionalities that support custom linter set ups --- .gitignore | 8 + .../xcschemes/SPMGraphKit.xcscheme | 66 ++++ .../xcschemes/SPMGraphLint.xcscheme | 66 ++++ .../xcschemes/SPMGraphMap.xcscheme | 66 ++++ .../xcschemes/SPMGraphVisualize.xcscheme | 66 ++++ .../xcschemes/spmgraph-Package.xcscheme | 173 +++++++++ .../xcshareddata/xcschemes/spmgraph.xcscheme | 97 +++++ Package.resolved | 132 +++++++ Package.swift | 144 ++++++++ .../Core/Extensions/AbsolutePath+Core.swift | 32 ++ Sources/Core/Extensions/KeyPath+Core.swift | 13 + .../Core/Extensions/PackageModel+Core.swift | 7 + Sources/Core/GitClient.swift | 41 +++ Sources/Core/PackageLoader.swift | 34 ++ Sources/Core/System.swift | 234 ++++++++++++ .../Resources/DoNotEdit_DynamicLoading.txt | 14 + .../SPMGraphConfigSetup/Resources/Package.txt | 47 +++ .../Resources/SPMGraphConfig.txt | 166 +++++++++ .../SPMGraphConfigSetup/SPMGraphEdit.swift | 248 +++++++++++++ .../SPMGraphConfigSetup/SPMGraphLoad.swift | 138 +++++++ .../SPMGraphConfigInterface.swift | 347 ++++++++++++++++++ .../SPMGraphConfigLoader.swift | 73 ++++ Sources/SPMGraphExecutable/SPMGraph.swift | 53 +++ .../SPMGraphExecutable/Subcommands/Edit.swift | 106 ++++++ .../SPMGraphExecutable/Subcommands/Lint.swift | 57 +++ .../SPMGraphExecutable/Subcommands/Load.swift | 78 ++++ .../Subcommands/Tests.swift | 62 ++++ .../Subcommands/Visualize.swift | 56 +++ Sources/SPMGraphLint/SPMGraphLint.swift | 284 ++++++++++++++ Sources/SPMGraphTests/SPMGraphTests.swift | 317 ++++++++++++++++ .../GraphViz/GraphVizWrapper.swift | 26 ++ .../Helpers/Array+Extensions.swift | 9 + .../Helpers/Edge+Factories.swift | 14 + .../Helpers/HelperFunctions.swift | 9 + .../Helpers/Node+Attributes.swift | 69 ++++ .../Helpers/Node+Factories.swift | 36 ++ .../Package+externalDependencies.swift | 24 ++ .../SPMGraphVisualize/SPMGraphVisualize.swift | 226 ++++++++++++ 38 files changed, 3638 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SPMGraphKit.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SPMGraphLint.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SPMGraphMap.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/SPMGraphVisualize.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/spmgraph-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/Core/Extensions/AbsolutePath+Core.swift create mode 100644 Sources/Core/Extensions/KeyPath+Core.swift create mode 100644 Sources/Core/Extensions/PackageModel+Core.swift create mode 100644 Sources/Core/GitClient.swift create mode 100644 Sources/Core/PackageLoader.swift create mode 100644 Sources/Core/System.swift create mode 100644 Sources/SPMGraphConfigSetup/Resources/DoNotEdit_DynamicLoading.txt create mode 100644 Sources/SPMGraphConfigSetup/Resources/Package.txt create mode 100644 Sources/SPMGraphConfigSetup/Resources/SPMGraphConfig.txt create mode 100644 Sources/SPMGraphConfigSetup/SPMGraphEdit.swift create mode 100644 Sources/SPMGraphConfigSetup/SPMGraphLoad.swift create mode 100644 Sources/SPMGraphDescriptionInterface/SPMGraphConfigInterface.swift create mode 100644 Sources/SPMGraphDescriptionInterface/SPMGraphConfigLoader.swift create mode 100644 Sources/SPMGraphExecutable/SPMGraph.swift create mode 100644 Sources/SPMGraphExecutable/Subcommands/Edit.swift create mode 100644 Sources/SPMGraphExecutable/Subcommands/Lint.swift create mode 100644 Sources/SPMGraphExecutable/Subcommands/Load.swift create mode 100644 Sources/SPMGraphExecutable/Subcommands/Tests.swift create mode 100644 Sources/SPMGraphExecutable/Subcommands/Visualize.swift create mode 100644 Sources/SPMGraphLint/SPMGraphLint.swift create mode 100644 Sources/SPMGraphTests/SPMGraphTests.swift create mode 100644 Sources/SPMGraphVisualize/GraphViz/GraphVizWrapper.swift create mode 100644 Sources/SPMGraphVisualize/Helpers/Array+Extensions.swift create mode 100644 Sources/SPMGraphVisualize/Helpers/Edge+Factories.swift create mode 100644 Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift create mode 100644 Sources/SPMGraphVisualize/Helpers/Node+Attributes.swift create mode 100644 Sources/SPMGraphVisualize/Helpers/Node+Factories.swift create mode 100644 Sources/SPMGraphVisualize/Package+externalDependencies.swift create mode 100644 Sources/SPMGraphVisualize/SPMGraphVisualize.swift 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..1e98d37 --- /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..38ff924 --- /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: TP-8515: 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/Extensions/PackageModel+Core.swift b/Sources/Core/Extensions/PackageModel+Core.swift new file mode 100644 index 0000000..28de54d --- /dev/null +++ b/Sources/Core/Extensions/PackageModel+Core.swift @@ -0,0 +1,7 @@ +import PackageModel + +public extension Module { + var isLiveModule: Bool { + name.hasSuffix("Live") + } +} 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..f572b43 --- /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: TP-8515: 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..aefc39f --- /dev/null +++ b/Sources/SPMGraphTests/SPMGraphTests.swift @@ -0,0 +1,317 @@ +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 + } + + return Array( // Maps the Array from a Set to ensure there are no duplicates. To be revisited. + Set( + allTestModulesToRun + .filter(\.name != "WorldTests") // hack to unblock CI, check back soon + ) + ) + } + + /// 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..04f9a84 --- /dev/null +++ b/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift @@ -0,0 +1,9 @@ +func containsExcludedSuffix(moduleName: String, excludedSuffixes: [String]) -> Bool { + var containsExcludedSuffix = false + excludedSuffixes.forEach { suffix in + if moduleName.hasSuffix(suffix) { + containsExcludedSuffix = true + } + } + return containsExcludedSuffix +} 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 + } +} From 353b927f2efac8898702977023121e6f459f0472 Mon Sep 17 00:00:00 2001 From: Felipe Marino Date: Thu, 18 Sep 2025 16:24:04 +0200 Subject: [PATCH 2/6] Exclude custom development dir and package path --- .swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme index 1e98d37..421afe1 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/spmgraph.xcscheme @@ -34,7 +34,7 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" - useCustomWorkingDirectory = "YES" + useCustomWorkingDirectory = "NO" customWorkingDirectory = "/Users/marino.felipe/Documents/projects/iOS/spmgraph" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" @@ -65,7 +65,7 @@ isEnabled = "NO"> From 71e1f0911ce72e07d0513af5498de344a62eb37c Mon Sep 17 00:00:00 2001 From: Felipe Marino Date: Thu, 18 Sep 2025 16:30:46 +0200 Subject: [PATCH 3/6] Delete unused code --- Sources/Core/Extensions/PackageModel+Core.swift | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 Sources/Core/Extensions/PackageModel+Core.swift diff --git a/Sources/Core/Extensions/PackageModel+Core.swift b/Sources/Core/Extensions/PackageModel+Core.swift deleted file mode 100644 index 28de54d..0000000 --- a/Sources/Core/Extensions/PackageModel+Core.swift +++ /dev/null @@ -1,7 +0,0 @@ -import PackageModel - -public extension Module { - var isLiveModule: Bool { - name.hasSuffix("Live") - } -} From bec3ec91861b1df6cab0361676888bb1303cb9c6 Mon Sep 17 00:00:00 2001 From: Felipe Marino Date: Thu, 18 Sep 2025 16:32:38 +0200 Subject: [PATCH 4/6] Improve code --- Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift b/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift index 04f9a84..f1bf84b 100644 --- a/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift +++ b/Sources/SPMGraphVisualize/Helpers/HelperFunctions.swift @@ -1,9 +1,3 @@ func containsExcludedSuffix(moduleName: String, excludedSuffixes: [String]) -> Bool { - var containsExcludedSuffix = false - excludedSuffixes.forEach { suffix in - if moduleName.hasSuffix(suffix) { - containsExcludedSuffix = true - } - } - return containsExcludedSuffix + excludedSuffixes.contains(where: moduleName.hasSuffix) } From 347446118d446073328a443a98a3ee7cc87afb8d Mon Sep 17 00:00:00 2001 From: Felipe Marino Date: Thu, 18 Sep 2025 16:34:52 +0200 Subject: [PATCH 5/6] Remove TP references --- Package.swift | 2 +- Sources/SPMGraphConfigSetup/Resources/Package.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 38ff924..68860e0 100644 --- a/Package.swift +++ b/Package.swift @@ -38,7 +38,7 @@ let package = Package( .upToNextMinor(from: "1.2.2") ), - // TODO: TP-8515: Review which tag / Swift release to use + // 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 // diff --git a/Sources/SPMGraphConfigSetup/Resources/Package.txt b/Sources/SPMGraphConfigSetup/Resources/Package.txt index f572b43..6b2619f 100644 --- a/Sources/SPMGraphConfigSetup/Resources/Package.txt +++ b/Sources/SPMGraphConfigSetup/Resources/Package.txt @@ -19,7 +19,7 @@ let package = Package( dependencies: [ // TODO: Replace by remote when made public .package(path: "/Users/marino.felipe/Documents/projects/iOS/spmgraph"), - // TODO: TP-8515: Review which tag / Swift release to use + // 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 // From 12ab2aba05cb5c17f1c0f350706bda5ce7fc5b59 Mon Sep 17 00:00:00 2001 From: Felipe Marino Date: Thu, 18 Sep 2025 16:47:34 +0200 Subject: [PATCH 6/6] Remove custom filter --- Sources/SPMGraphTests/SPMGraphTests.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/SPMGraphTests/SPMGraphTests.swift b/Sources/SPMGraphTests/SPMGraphTests.swift index aefc39f..ba2ad9b 100644 --- a/Sources/SPMGraphTests/SPMGraphTests.swift +++ b/Sources/SPMGraphTests/SPMGraphTests.swift @@ -266,12 +266,8 @@ private extension SPMGraphTests { allTestModulesToRun += testModulesToRun } - return Array( // Maps the Array from a Set to ensure there are no duplicates. To be revisited. - Set( - allTestModulesToRun - .filter(\.name != "WorldTests") // hack to unblock CI, check back soon - ) - ) + // Remove duplicate test modules + return allTestModulesToRun.spm_uniqueElements() } /// A function that generates the output with tests to run