diff --git a/Package.resolved b/Package.resolved index 7c7dfea..7adda74 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/apple/swift-argument-parser", "state": { "branch": null, - "revision": "df9ee6676cd5b3bf5b330ec7568a5644f547201b", - "version": "1.1.3" + "revision": "fddd1c00396eed152c45a46bea9f47b98e59301d", + "version": "1.2.0" } }, { diff --git a/Package.swift b/Package.swift index b32a508..a5feb90 100644 --- a/Package.swift +++ b/Package.swift @@ -9,11 +9,12 @@ let package = Package( products: [ .executable(name: "jungle", targets: ["jungle"]), .library(name: "PodExtractor", targets: ["PodExtractor"]), + .library(name: "SPMExtractor", targets: ["SPMExtractor"]), .library(name: "DependencyGraph", targets: ["DependencyGraph"]), .library(name: "Shell", targets: ["Shell"]) ], dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.3"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.0"), .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.1") ], targets: [ @@ -24,6 +25,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .target(name: "PodExtractor"), + .target(name: "SPMExtractor"), .target(name: "DependencyGraph"), .target(name: "Shell") ] @@ -33,12 +35,12 @@ let package = Package( dependencies: ["jungle"] ), - // PodExtractor + // Pod Extractor .target( name: "PodExtractor", dependencies: [ - .target(name: "DependencyModule"), - .target(name: "Shell"), + "DependencyModule", + "Shell", .product(name: "Yams", package: "Yams") ] ), @@ -46,7 +48,18 @@ let package = Package( name: "PodExtractorTests", dependencies: ["PodExtractor"] ), - + // SPM Extractor + .target( + name: "SPMExtractor", + dependencies: [ + "DependencyModule", + "Shell" + ] + ), + .testTarget( + name: "SPMExtractorTests", + dependencies: ["SPMExtractor"] + ), // DependencyGraph .target( name: "DependencyGraph", diff --git a/README.md b/README.md index f66ea4a..13fa9a1 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ [![Swift](https://github.com/xing/jungle/actions/workflows/swift.yml/badge.svg)](https://github.com/xing/jungle/actions/workflows/swift.yml) -A Swift command line tool to extract dependency information from a CocoaPods-based Xcode project. Currently, that´s what you can do: +A Swift CLI tool that generates complexity metrics information from a Cocoapods Xcode project or a SwiftPM package. Currently, that´s what you can do: - Dependency graph (dot format) - Cyclomatic complexity evaluation - Number of dependant modules -- Compare stats between different branches or even through the git history +- Compare stats between different branches +- Show stats along the git history You can read more information about dependency complexity in our Technical article ["How to control your dependencies"](https://tech.xing.com/how-to-control-your-ios-dependencies-7690cc7b1c40). @@ -48,14 +49,14 @@ swift build -c release ```shell OVERVIEW: Displays historic complexity of the dependency graph -USAGE: jungle history [--since ] [--pod ] --target [--output-format ] [] +USAGE: jungle history [--since ] [--module ] --target [--output-format ] [] ARGUMENTS: Path to the directory where Podfile.lock is located (default: .) OPTIONS: --since Equivalent to git-log --since: Eg: '6 months ago' (default: 6 months ago) - --pod The Pod to generate a report for. Specifying a pod disregards the target parameter + --module The Module to compare. If you specify something, target parameter will be ommited --target The target in your Podfile file to be used --output-format csv or json (default: csv) @@ -79,15 +80,15 @@ Now;Current;124;21063;; ```shell OVERVIEW: Compares the current complexity of the dependency graph to others versions in git -USAGE: jungle compare [--to ...] [--pod ] --target [] +USAGE: jungle compare [--to ...] [--module ] --target [] ARGUMENTS: - Path to the directory where Podfile.lock is located (default: .) + Path to the directory where Podfile.lock or Package.swift is located (default: .) OPTIONS: - --to The git objects to compare the current graph to. Eg: - 'main', 'my_branch', 'some_commit_hash'. (default: HEAD, main) - --pod The Pod to compare. Specifying a pod disregards the target parameter - --target The target in your Podfile file to be used + --to The git objects to compare the current graph to. Eg: - 'main', 'my_branch', 'some_commit_hash'. (default: HEAD, main, master) + --module The Module to compare. If you specify something, target parameter will be ommited + --target The target in your Podfile or Package.swift file to be used --version Show the version. -h, --help Show help information. ``` @@ -118,17 +119,19 @@ jungle compare --target App ProjectDirectory/ --to main ```shell OVERVIEW: Outputs the dependency graph in DOT format -USAGE: jungle graph [--of ] [--pod ] --target [] +USAGE: jungle graph [--of ] [--module ] --target [--use-multiedge] [--show-externals] [] ARGUMENTS: - Path to the directory where Podfile.lock is located (default: .) + Path to the directory where Podfile.lock or Package.swift is located (default: .) OPTIONS: --of A git object representing the version to draw the graph for. Eg: - 'main', 'my_branch', 'some_commit_hash'. - --pod The Pod to compare. Specifying a pod disregards the target parameter - --target The target in your Podfile file to be used + --module The Module to compare. If you specify something, target parameter will be ommited + --target The target in your Podfile or Package.swift file to be used + --use-multiedge Use multi-edge or unique-edge configuration + --show-externals Show Externals modules dependencies --version Show the version. - -h, --help Show help information + -h, --help Show help information. ``` diff --git a/Sources/DependencyGraph/Graph+Dot.swift b/Sources/DependencyGraph/Graph+Dot.swift index 0188c5a..595967e 100644 --- a/Sources/DependencyGraph/Graph+Dot.swift +++ b/Sources/DependencyGraph/Graph+Dot.swift @@ -1,35 +1,41 @@ import Foundation public extension Graph { - + var multiEdgeDOT: String { let edges = multiEdges .map { "\t \"\($0.source)\" -> \"\($0.target)\"" } .joined(separator: "\n") - let stats = "# nodes: \(nodes.count), edges: \(multiEdges.count), complexity: \(multiGraphComplexity)" - - return "\(header) \(edges) \(footer) \(stats)" + return "\(header(usingUniqueEdges: false)) \(edges) \(footer)" } + private var multiEdgeStats: String { + "nodes: \(nodes.count), edges: \(multiEdges.count), complexity: \(multiGraphComplexity)" + } + + private var uniqueEdgeStats: String { + "nodes: \(nodes.count), edges: \(uniqueEdges.count), complexity: \(regularGraphComplexity)" + } + var uniqueEdgeDOT: String { let edges = uniqueEdges .map { "\t \"\($0.source)\" -> \"\($0.target)\"" } .joined(separator: "\n") - let stats = "# nodes: \(nodes.count), edges: \(uniqueEdges.count), complexity: \(multiGraphComplexity)" - return "\(header) \(edges) \(footer) \(stats)" + return "\(header(usingUniqueEdges: true)) \(edges) \(footer)" } - private var header: String { + private func header(usingUniqueEdges: Bool) -> String { """ digraph DependencyGraph { - + labelloc=b + fontsize=20 + label = "\(usingUniqueEdges ? uniqueEdgeStats : multiEdgeStats)" graph [bgcolor=white,pad=2]; node [style=filled,shape=box,fillcolor=white,color=grey10,fontname=helveticaNeue,fontcolor=grey10,penwidth=2]; edge [dir=back,color=grey10,penwidth=1]; - """ } diff --git a/Sources/DependencyGraph/Graph+Make.swift b/Sources/DependencyGraph/Graph+Make.swift index bdba360..807d98b 100644 --- a/Sources/DependencyGraph/Graph+Make.swift +++ b/Sources/DependencyGraph/Graph+Make.swift @@ -6,9 +6,9 @@ public enum GraphError: Error { } public extension Graph { - static func make(rootTargetName name: String, dependencies: [Module], targetDependencies: [String]?) throws -> Graph { + static func make(rootTargetName name: String, modules: [Module], targetDependencies: [String]?) throws -> Graph { - let dependencies = dependencies + let dependencies = modules .filter { targetDependencies?.contains($0.name) ?? true } let appModule = Module(name: name, dependencies: dependencies.map(\.name)) diff --git a/Sources/DependencyModule/Module.swift b/Sources/DependencyModule/Module.swift index d3e7c48..f1adb3c 100644 --- a/Sources/DependencyModule/Module.swift +++ b/Sources/DependencyModule/Module.swift @@ -9,4 +9,3 @@ public struct Module: Hashable { self.dependencies = dependencies } } - diff --git a/Sources/PodExtractor/Module+Podfile.swift b/Sources/PodExtractor/Module+Podfile.swift index 1d2e86a..fe54b2b 100644 --- a/Sources/PodExtractor/Module+Podfile.swift +++ b/Sources/PodExtractor/Module+Podfile.swift @@ -83,7 +83,7 @@ public func modulesFromJSONPodfile(_ contents: String) throws -> [Module] { return targetsRaw.flatMap(\.asTarget) } -public func extractModulesFromPodfileLock(_ contents: String) throws -> [Module] { +public func extractModulesFromPodfileLock(_ contents: String, excludeExternals: Bool = true) throws -> [Module] { // parse YAML to JSON guard let yaml = try? Yams.load(yaml: contents) else { throw PodError.yamlParsingFailed @@ -99,9 +99,9 @@ public func extractModulesFromPodfileLock(_ contents: String) throws -> [Module] // parse JSON "SPEC REPOS" to [String] let externalsDictionary = podsDictionary["SPEC REPOS"] as? [AnyHashable: Any] - let externals = externalsDictionary?.values + let externals = excludeExternals ? externalsDictionary?.values .compactMap { $0 as? [String] } - .flatMap { $0 } ?? [] + .flatMap { $0 } ?? [] : [] let pods = try rawPods.map(extractPodFromJSON) diff --git a/Sources/SPMExtractor/Module+Package.swift b/Sources/SPMExtractor/Module+Package.swift new file mode 100644 index 0000000..7185e7c --- /dev/null +++ b/Sources/SPMExtractor/Module+Package.swift @@ -0,0 +1,80 @@ +import Foundation +import DependencyModule +import Shell + +public struct Package: Decodable { + public let targets: [Target] + + public struct Target: Decodable { + let name: String + let targetDependencies: [String]? + let productDependencies: [String]? + + var dependencies: [String] { + [targetDependencies, productDependencies].compactMap { $0 }.flatMap { $0 } + } + + enum CodingKeys: String, CodingKey { + case name + case targetDependencies = "target_dependencies" + case productDependencies = "product_dependencies" + } + } +} + +public enum TargetError: Error { + case targetNotFound(target: String) +} + +public enum PackageError: Error { + case nonDecodable(raw: String) +} + +extension TargetError: CustomStringConvertible { + public var description: String { + switch self { + case .targetNotFound(let target): + return "\"\(target)\" target not found in Package.swift!. Please, provide an existent target in your Package." + } + } +} + +public func extracPackageModules(from packageRaw: String, target: String) throws -> ([Module], [String]) { + + guard + let data = packageRaw.data(using: .utf8) + else { + throw PackageError.nonDecodable(raw: packageRaw) + } + + let package = try JSONDecoder().decode(Package.self, from: data) + + guard let targetModules = package.targets.filter({ $0.name == target }).first else { + throw TargetError.targetNotFound(target: target) + } + + let dependencies = extractDependencies(from: package, on: target) + let external = targetModules.productDependencies?.compactMap { Module(name: $0, dependencies: []) } ?? [] + + let targetDependencies = targetModules.dependencies + return (dependencies + external, targetDependencies) +} + + +public func extractDependencies(from package: Package, on target: String) -> [Module] { + guard + let targetModules = package.targets.filter({ $0.name == target }).first + else { + return [] + } + + var dependencies: Set = Set() + + for dependency in targetModules.dependencies { + let modules = extractDependencies(from: package, on: dependency) + for module in modules { + dependencies.insert(module) + } + } + return [Module(name: target, dependencies: targetModules.dependencies)] + Array(dependencies) +} diff --git a/Sources/Shell/Shell.swift b/Sources/Shell/Shell.swift index 479b440..f982292 100644 --- a/Sources/Shell/Shell.swift +++ b/Sources/Shell/Shell.swift @@ -1,23 +1,28 @@ import Foundation -public func shell(_ command: String, at currentDirectoryURL: URL? = nil) throws -> String { +@discardableResult public func shell(_ command: String, at directory: URL? = nil, skipErrorsOutput: Bool = true) throws -> String { let task = Process() let pipe = Pipe() + + let errorPipe = Pipe() + if !skipErrorsOutput { + task.standardError = pipe + } else { + task.standardError = errorPipe + } task.standardOutput = pipe - task.standardError = pipe task.arguments = ["--login", "-c", command] task.launchPath = "/bin/zsh" task.standardInput = nil - if let currentDirectoryURL = currentDirectoryURL { - task.currentDirectoryURL = currentDirectoryURL + if let directory = directory { + task.currentDirectoryURL = directory } try task.run() let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8)! + let output = String(data: data, encoding: .utf8) ?? "" return output } - diff --git a/Sources/jungle/Commands/CompareCommand.swift b/Sources/jungle/Commands/CompareCommand.swift index 37f65c3..639c7fd 100644 --- a/Sources/jungle/Commands/CompareCommand.swift +++ b/Sources/jungle/Commands/CompareCommand.swift @@ -2,6 +2,7 @@ import ArgumentParser import Foundation import DependencyGraph import PodExtractor +import SPMExtractor import DependencyModule import Shell @@ -31,19 +32,44 @@ struct CompareCommand: ParsableCommand { ) var gitObjects: [String] = ["HEAD", "main", "master"] - @Option(help: "The Pod to compare. If you specify something, target parameter will be ommited") - var pod: String? + @Option(help: "The Module to compare. If you specify something, target parameter will be ommited") + var module: String? - @Option(help: "The target in your Podfile file to be used") + @Option(help: "The target in your Podfile or Package.swift file to be used") var target: String - @Argument(help: "Path to the directory where Podfile.lock is located") + @Flag(help: "Use multi-edge or unique-edge configuration") + var useMultiedge: Bool = false + + @Argument(help: "Path to the directory where Podfile.lock or Package.swift is located") var directoryPath: String = "." func run() throws { let directoryPath = (directoryPath as NSString).expandingTildeInPath let directoryURL = URL(fileURLWithPath: directoryPath, isDirectory: true) + // Check when this contains a Package.swift or a Podfile + if FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("Package.swift").path) { + try processPackage(at: directoryURL) + } else { + try processPodfile(at: directoryURL) + } + } + + func processPackage(at directoryURL: URL) throws { + + let current = try process(target: target, directoryURL: directoryURL, usingMultiEdge: useMultiedge) + let outputs = try [current] + gitObjects.compactMap { + try process(label: $0, target: target, directoryURL: directoryURL, usingMultiEdge: useMultiedge) + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + let jsonData = try encoder.encode(outputs) + let jsonString = String(data: jsonData, encoding: .utf8)! + print(jsonString) + } + func processPodfile(at directoryURL: URL) throws { // Choose the target to analyze let podfileJSON = try shell("pod ipc podfile-json Podfile --silent", at: directoryURL) @@ -53,12 +79,14 @@ struct CompareCommand: ParsableCommand { let current = try process( label: "Current", - pod: pod, + pod: module, podfile: String(contentsOf: directoryURL.appendingPathComponent("Podfile.lock")), - target: currentTargetDependencies + target: currentTargetDependencies, + usingMultiEdge: useMultiedge ) let outputs = [current] + gitObjects.compactMap { + guard let podfile = try? shell("git show \($0):Podfile", at: directoryURL), let entryTargetDependencies = try? moduleFromPodfile(podfile, on: target) @@ -68,9 +96,10 @@ struct CompareCommand: ParsableCommand { return try? process( label: $0, - pod: pod, + pod: module, podfile: shell("git show \($0):Podfile.lock", at: directoryURL), - target: entryTargetDependencies + target: entryTargetDependencies, + usingMultiEdge: useMultiedge ) } @@ -82,15 +111,44 @@ struct CompareCommand: ParsableCommand { } } -func process(label: String, pod: String?, podfile: String, target: Module) throws -> CompareStatsOutput { +public func process(target: String, directoryURL: URL, usingMultiEdge: Bool) throws -> CompareStatsOutput? { + let packageRaw = try shell("swift package describe --type json", at: directoryURL) + let (dependencies, targetDependencies) = try extracPackageModules(from: packageRaw, target: target) + let graph = try Graph.make(rootTargetName: target, modules: dependencies, targetDependencies: targetDependencies) + let current = CompareStatsOutput(label: "Current", graph: graph, usingMultiEdge: usingMultiEdge) + return current +} + +public func process(label: String, target: String, directoryURL: URL, usingMultiEdge: Bool) throws -> CompareStatsOutput? { + guard let package = try? shell("git show \(label):Package.swift", at: directoryURL), !package.isEmpty else { + return nil + } + try shell("git show \(label):Package.swift > Package.swift.new", at: directoryURL) + try shell("mv Package.swift Package.swift.current", at: directoryURL) + try shell("mv Package.swift.new Package.swift", at: directoryURL) + guard + let packageRaw = try? shell("swift package describe --type json", at: directoryURL), + !packageRaw.isEmpty, + let (dependencies, targetDependencies) = try? extracPackageModules(from: packageRaw, target: target) + + else { + try shell("mv Package.swift.current Package.swift", at: directoryURL) + return nil + } + let current = try Graph.make(rootTargetName: target, modules: dependencies, targetDependencies: targetDependencies) + _ = try shell("mv Package.swift.current Package.swift", at: directoryURL) + return CompareStatsOutput(label: label, graph: current, usingMultiEdge: usingMultiEdge) +} + +public func process(label: String, pod: String?, podfile: String, target: Module, usingMultiEdge: Bool) throws -> CompareStatsOutput { let dependencies = try extractModulesFromPodfileLock(podfile) let graph: Graph if let pod = pod { graph = try Graph.makeForModule(name: pod, dependencies: dependencies) } else { - graph = try Graph.make(rootTargetName: target.name, dependencies: dependencies, targetDependencies: target.dependencies) + graph = try Graph.make(rootTargetName: target.name, modules: dependencies, targetDependencies: target.dependencies) } - - return CompareStatsOutput(label: label, graph: graph) + + return CompareStatsOutput(label: label, graph: graph, usingMultiEdge: usingMultiEdge) } diff --git a/Sources/jungle/Commands/GraphCommand.swift b/Sources/jungle/Commands/GraphCommand.swift index cb395be..b6c28d4 100644 --- a/Sources/jungle/Commands/GraphCommand.swift +++ b/Sources/jungle/Commands/GraphCommand.swift @@ -4,6 +4,7 @@ import DependencyGraph import PodExtractor import DependencyModule import Shell +import SPMExtractor struct GraphCommand: ParsableCommand { static var configuration = CommandConfiguration( @@ -17,18 +18,40 @@ struct GraphCommand: ParsableCommand { ) var gitObject: String? - @Option(help: "The Pod to compare. If you specify something, target parameter will be ommited") - var pod: String? + @Option(help: "The Module to compare. If you specify something, target parameter will be ommited") + var module: String? - @Option(help: "The target in your Podfile file to be used") + @Option(help: "The target in your Podfile or Package.swift file to be used") var target: String - @Argument(help: "Path to the directory where Podfile.lock is located") + @Flag(help: "Use multi-edge or unique-edge configuration") + var useMultiedge: Bool = false + + @Flag(help: "Show Externals modules dependencies") + var showExternals: Bool = false + + @Argument(help: "Path to the directory where Podfile.lock or Package.swift is located") var directoryPath: String = "." func run() throws { let directoryPath = (directoryPath as NSString).expandingTildeInPath let directoryURL = URL(fileURLWithPath: directoryPath, isDirectory: true) + + // Check when this contains a Package.swift or a Podfile + if FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("Package.swift").path) { + try processPackage(at: directoryURL) + } else { + try processPodfile(at: directoryURL) + } + } + func processPackage(at directoryURL: URL) throws { + let packageRaw = try shell("swift package describe --type json", at: directoryURL) + let (modules, targetDependencies) = try extracPackageModules(from: packageRaw, target: target) + let graph = try Graph.make(rootTargetName: target, modules: modules, targetDependencies: targetDependencies) + print(useMultiedge ? graph.multiEdgeDOT : graph.uniqueEdgeDOT) + } + + func processPodfile(at directoryURL: URL) throws { if let gitObject = gitObject { guard let podfile = try? shell("git show \(gitObject):Podfile", at: directoryURL), @@ -63,15 +86,15 @@ struct GraphCommand: ParsableCommand { } private func makeDOT(podfile: String, label: String, target: Module) throws -> String { - let dependencies = try extractModulesFromPodfileLock(podfile) + let dependencies = try extractModulesFromPodfileLock(podfile, excludeExternals: !showExternals) let graph: Graph - if let pod = pod { - graph = try Graph.makeForModule(name: pod, dependencies: dependencies) + if let module = module { + graph = try Graph.makeForModule(name: module, dependencies: dependencies) } else { - graph = try Graph.make(rootTargetName: target.name, dependencies: dependencies, targetDependencies: target.dependencies) + graph = try Graph.make(rootTargetName: target.name, modules: dependencies, targetDependencies: target.dependencies) } - return graph.multiEdgeDOT + return useMultiedge ? graph.multiEdgeDOT : graph.uniqueEdgeDOT } } diff --git a/Sources/jungle/Commands/HistoryCommand.swift b/Sources/jungle/Commands/HistoryCommand.swift index 2aeef89..f943d21 100644 --- a/Sources/jungle/Commands/HistoryCommand.swift +++ b/Sources/jungle/Commands/HistoryCommand.swift @@ -2,6 +2,7 @@ import ArgumentParser import Foundation import DependencyGraph import PodExtractor +import SPMExtractor import DependencyModule import Shell @@ -20,12 +21,15 @@ struct HistoryCommand: AsyncParsableCommand { @Option(help: "Equivalent to git-log --since: Eg: '6 months ago'") var since: String = "6 months ago" - @Option(help: "The Pod to compare. If you specify something, target parameter will be ommited") - var pod: String? + @Option(help: "The Module to compare. If you specify something, target parameter will be ommited") + var module: String? @Option(help: "The target in your Podfile file to be used") var target: String + @Flag(help: "Use multi-edge or unique-edge configuration") + var useMultiedge: Bool = false + @Option(help: "csv or json") var outputFormat: OutputFormat = .csv @@ -35,8 +39,72 @@ struct HistoryCommand: AsyncParsableCommand { func run() async throws { let directoryPath = (directoryPath as NSString).expandingTildeInPath let directoryURL = URL(fileURLWithPath: directoryPath, isDirectory: true) - let podfileURL = directoryURL.appendingPathComponent("Podfile.lock") + + // Check when this contains a Package.swift or a Podfile + if FileManager.default.fileExists(atPath: directoryURL.appendingPathComponent("Package.swift").path) { + try await processPackage(at: directoryURL) + } else { + try await processPodfile(at: directoryURL) + } + } + + func processPackage(at directoryURL: URL) async throws { + let packageRaw = try shell("swift package describe --type json", at: directoryURL) + + let first = try await GitLogEntry.current.process(package: packageRaw, target: target, usingMultiEdge: useMultiedge) + + let gitLog = "git log --since='\(since)' --first-parent --format='%h;%aI;%an;%s' -- Package.swift" + let logs = try shell(gitLog, at: directoryURL) + .split(separator: "\n") + .reversed() + .map(String.init) + .map(GitLogEntry.parse) + + + var previous: [HistoryStatsOutput] = [] + + for entry in logs { + if let result = try? await process(entry: entry, target: target, directoryURL: directoryURL) { + previous.append(result) + } + } + + let output = previous + [first] + + switch outputFormat { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + let data = try encoder.encode(output) + let string = String(data: data, encoding: .utf8)! + print(string) + case .csv: + output.forEach { print($0.csv) } + } + } + + public func process(entry: GitLogEntry, target: String, directoryURL: URL) async throws -> HistoryStatsOutput? { + guard let package = try? shell("git show \(entry.revision):Package.swift", at: directoryURL), !package.isEmpty else { + return nil + } + try shell("git show \(entry.revision):Package.swift > Package.swift.new", at: directoryURL) + try shell("mv Package.swift Package.swift.current", at: directoryURL) + try shell("mv Package.swift.new Package.swift", at: directoryURL) + guard + let packageRaw = try? shell("swift package describe --type json", at: directoryURL), + !packageRaw.isEmpty + else { + try shell("mv Package.swift.current Package.swift", at: directoryURL) + return nil + } + + try shell("mv Package.swift.current Package.swift", at: directoryURL) + + return try await entry.process(package: packageRaw, target: target, usingMultiEdge: useMultiedge) + } + func processPodfile(at directoryURL: URL) async throws { + let podfileURL = directoryURL.appendingPathComponent("Podfile.lock") // Choose the target to analyze let podfileJSON = try shell("pod ipc podfile-json Podfile --silent", at: directoryURL) @@ -54,7 +122,7 @@ struct HistoryCommand: AsyncParsableCommand { .map(GitLogEntry.parse) // process Podfile.lock in current directory - let current = try await GitLogEntry.current.process(pod: pod, podfile: String(contentsOf: podfileURL), target: currentTargetDependencies) + let current = try await GitLogEntry.current.process(pod: module, podfile: String(contentsOf: podfileURL), target: currentTargetDependencies, usingMultiEdge: useMultiedge) // process Podfile.lock for past commits @@ -71,9 +139,10 @@ struct HistoryCommand: AsyncParsableCommand { } return try? await entry.process( - pod: pod, + pod: module, podfile: shell("git show \(entry.revision):Podfile.lock", at: directoryURL), - target: entryTargetDependencies + target: entryTargetDependencies, + usingMultiEdge: useMultiedge ) } } @@ -101,16 +170,22 @@ struct HistoryCommand: AsyncParsableCommand { } extension GitLogEntry { - func process(pod: String?, podfile: String, target: Module) async throws -> HistoryStatsOutput { + func process(pod: String?, podfile: String, target: Module, usingMultiEdge: Bool) async throws -> HistoryStatsOutput { let dependencies = try extractModulesFromPodfileLock(podfile) let graph: Graph if let pod = pod { graph = try Graph.makeForModule(name: pod, dependencies: dependencies) } else { - graph = try Graph.make(rootTargetName: target.name, dependencies: dependencies, targetDependencies: target.dependencies) + graph = try Graph.make(rootTargetName: target.name, modules: dependencies, targetDependencies: target.dependencies) } - return HistoryStatsOutput(entry: self, graph: graph) + return HistoryStatsOutput(entry: self, graph: graph, usingMultiEdge: usingMultiEdge) + } + + func process(package: String, target: String, usingMultiEdge: Bool) async throws -> HistoryStatsOutput { + let (dependencies, targetDependencies) = try extracPackageModules(from: package, target: target) + let graph = try Graph.make(rootTargetName: target, modules: dependencies, targetDependencies: targetDependencies) + return HistoryStatsOutput(entry: self, graph: graph, usingMultiEdge: usingMultiEdge) } } diff --git a/Sources/jungle/Commands/Main.swift b/Sources/jungle/Commands/Main.swift index 1f325d5..cf0060e 100644 --- a/Sources/jungle/Commands/Main.swift +++ b/Sources/jungle/Commands/Main.swift @@ -4,8 +4,8 @@ import ArgumentParser struct Jungle: AsyncParsableCommand { static var configuration = CommandConfiguration( commandName: "jungle", - abstract: "Displays dependency statistics", - version: "1.0.3", + abstract: "SwiftPM and Cocoapods based projects complexity analyzer.", + version: "2.0.0", subcommands: [HistoryCommand.self, CompareCommand.self, GraphCommand.self], defaultSubcommand: CompareCommand.self ) diff --git a/Sources/jungle/Models/Outputs.swift b/Sources/jungle/Models/Outputs.swift index 21caf4a..8830411 100644 --- a/Sources/jungle/Models/Outputs.swift +++ b/Sources/jungle/Models/Outputs.swift @@ -10,11 +10,11 @@ struct HistoryStatsOutput: Codable { let author: String? let message: String? - init(entry: GitLogEntry, graph: Graph) { + init(entry: GitLogEntry, graph: Graph, usingMultiEdge: Bool) { timestamp = entry.timestamp revision = entry.revision moduleCount = graph.nodes.count - complexity = graph.multiGraphComplexity + complexity = usingMultiEdge ? graph.multiGraphComplexity : graph.regularGraphComplexity author = entry.author message = entry.message } @@ -31,14 +31,14 @@ struct HistoryStatsOutput: Codable { } } -struct CompareStatsOutput: Codable { - let name: String - let moduleCount: Int - let complexity: Int +public struct CompareStatsOutput: Codable { + public let name: String + public let moduleCount: Int + public let complexity: Int - init(label: String, graph: Graph) { + init(label: String, graph: Graph, usingMultiEdge: Bool) { name = label moduleCount = graph.nodes.count - complexity = graph.multiGraphComplexity + complexity = usingMultiEdge ? graph.multiGraphComplexity : graph.regularGraphComplexity } } diff --git a/Tests/DependencyGraphTests/DependencyGraphTests.swift b/Tests/DependencyGraphTests/DependencyGraphTests.swift index 090dc8b..a601222 100644 --- a/Tests/DependencyGraphTests/DependencyGraphTests.swift +++ b/Tests/DependencyGraphTests/DependencyGraphTests.swift @@ -36,7 +36,7 @@ final class DependencyGraphTests: XCTestCase { let b = Module(name: "B", dependencies: ["B1", "B2", "C"]) let c = Module(name: "C", dependencies: ["C1", "C2"]) - let graph = try Graph.make(rootTargetName: "Top", dependencies: [a, b, c], targetDependencies: nil) + let graph = try Graph.make(rootTargetName: "Top", modules: [a, b, c], targetDependencies: nil) XCTAssertEqual(graph.multiEdges.count, 15) XCTAssertEqual(graph.uniqueEdges.count, 11) XCTAssertEqual(graph.nodes.count, 10) diff --git a/Tests/SPMExtractorTests/SPMExtractorTests.swift b/Tests/SPMExtractorTests/SPMExtractorTests.swift new file mode 100644 index 0000000..0f78a14 --- /dev/null +++ b/Tests/SPMExtractorTests/SPMExtractorTests.swift @@ -0,0 +1,343 @@ +import XCTest +@testable import SPMExtractor + +final class SPMExtractorTests: XCTestCase { + func testTargetModulesFromPackage() throws { + let rawPackage = """ + { + "dependencies" : [ + { + "identity" : "swift-argument-parser", + "requirement" : { + "range" : [ + { + "lower_bound" : "1.1.3", + "upper_bound" : "2.0.0" + } + ] + }, + "type" : "sourceControl", + "url" : "https://github.com/apple/swift-argument-parser" + }, + { + "identity" : "yams", + "requirement" : { + "range" : [ + { + "lower_bound" : "5.0.1", + "upper_bound" : "6.0.0" + } + ] + }, + "type" : "sourceControl", + "url" : "https://github.com/jpsim/Yams.git" + } + ], + "manifest_display_name" : "jungle", + "name" : "jungle", + "path" : "/Users/oswaldo.rubio/Developer/jungle/jungle", + "platforms" : [ + { + "name" : "macos", + "version" : "12.0" + } + ], + "products" : [ + { + "name" : "jungle", + "targets" : [ + "jungle" + ], + "type" : { + "executable" : null + } + }, + { + "name" : "PodExtractor", + "targets" : [ + "PodExtractor" + ], + "type" : { + "library" : [ + "automatic" + ] + } + }, + { + "name" : "SPMExtractor", + "targets" : [ + "SPMExtractor" + ], + "type" : { + "library" : [ + "automatic" + ] + } + }, + { + "name" : "DependencyGraph", + "targets" : [ + "DependencyGraph" + ], + "type" : { + "library" : [ + "automatic" + ] + } + }, + { + "name" : "Shell", + "targets" : [ + "Shell" + ], + "type" : { + "library" : [ + "automatic" + ] + } + } + ], + "targets" : [ + { + "c99name" : "jungleTests", + "module_type" : "SwiftTarget", + "name" : "jungleTests", + "path" : "Tests/jungleTests", + "sources" : [ + "GitLogEntryTests.swift" + ], + "target_dependencies" : [ + "jungle" + ], + "type" : "test" + }, + { + "c99name" : "jungle", + "module_type" : "SwiftTarget", + "name" : "jungle", + "path" : "Sources/jungle", + "product_dependencies" : [ + "ArgumentParser" + ], + "product_memberships" : [ + "jungle" + ], + "sources" : [ + "Commands/CompareCommand.swift", + "Commands/GraphCommand.swift", + "Commands/HistoryCommand.swift", + "Commands/Main.swift", + "Models/GitLogEntry.swift", + "Models/Outputs.swift" + ], + "target_dependencies" : [ + "PodExtractor", + "SPMExtractor", + "DependencyGraph", + "Shell" + ], + "type" : "executable" + }, + { + "c99name" : "Shell", + "module_type" : "SwiftTarget", + "name" : "Shell", + "path" : "Sources/Shell", + "product_memberships" : [ + "jungle", + "PodExtractor", + "SPMExtractor", + "Shell" + ], + "sources" : [ + "Shell.swift" + ], + "type" : "library" + }, + { + "c99name" : "SPMExtractorTests", + "module_type" : "SwiftTarget", + "name" : "SPMExtractorTests", + "path" : "Tests/SPMExtractorTests", + "sources" : [ + "File.swift" + ], + "target_dependencies" : [ + "SPMExtractor" + ], + "type" : "test" + }, + { + "c99name" : "SPMExtractor", + "module_type" : "SwiftTarget", + "name" : "SPMExtractor", + "path" : "Sources/SPMExtractor", + "product_memberships" : [ + "jungle", + "SPMExtractor" + ], + "sources" : [ + "Module+Package.swift" + ], + "target_dependencies" : [ + "DependencyModule", + "Shell", + "DependencyGraph" + ], + "type" : "library" + }, + { + "c99name" : "PodExtractorTests", + "module_type" : "SwiftTarget", + "name" : "PodExtractorTests", + "path" : "Tests/PodExtractorTests", + "sources" : [ + "PodExtractorTests.swift" + ], + "target_dependencies" : [ + "PodExtractor" + ], + "type" : "test" + }, + { + "c99name" : "PodExtractor", + "module_type" : "SwiftTarget", + "name" : "PodExtractor", + "path" : "Sources/PodExtractor", + "product_dependencies" : [ + "Yams" + ], + "product_memberships" : [ + "jungle", + "PodExtractor" + ], + "sources" : [ + "Module+Podfile.swift" + ], + "target_dependencies" : [ + "DependencyModule", + "Shell" + ], + "type" : "library" + }, + { + "c99name" : "DependencyModule", + "module_type" : "SwiftTarget", + "name" : "DependencyModule", + "path" : "Sources/DependencyModule", + "product_memberships" : [ + "jungle", + "PodExtractor", + "SPMExtractor", + "DependencyGraph" + ], + "sources" : [ + "Module.swift" + ], + "type" : "library" + }, + { + "c99name" : "DependencyGraphTests", + "module_type" : "SwiftTarget", + "name" : "DependencyGraphTests", + "path" : "Tests/DependencyGraphTests", + "sources" : [ + "DependencyGraphTests.swift" + ], + "target_dependencies" : [ + "DependencyGraph" + ], + "type" : "test" + }, + { + "c99name" : "DependencyGraph", + "module_type" : "SwiftTarget", + "name" : "DependencyGraph", + "path" : "Sources/DependencyGraph", + "product_memberships" : [ + "jungle", + "SPMExtractor", + "DependencyGraph" + ], + "sources" : [ + "Graph+Dot.swift", + "Graph+Make.swift", + "Graph+Stats.swift", + "Graph.swift" + ], + "target_dependencies" : [ + "DependencyModule" + ], + "type" : "library" + } + ], + "tools_version" : "5.5" + } + """ + + let (dependencies, targetDependencies) = try extracPackageModules(from: rawPackage, target: "SPMExtractor") + + XCTAssertEqual(dependencies.map(\.name).sorted(), ["DependencyGraph", "DependencyModule", "SPMExtractor", "Shell"]) + XCTAssertEqual(targetDependencies.sorted(), ["DependencyGraph", "DependencyModule", "Shell"]) + } + + func testNonExistentTargetModulesFromPackage() throws { + let rawPackage = """ + { + "dependencies" : [ + + ], + "manifest_display_name" : "Example", + "name" : "Example", + "path" : "/Users/oswaldo.rubio/Desktop/Example", + "platforms" : [ + + ], + "products" : [ + { + "name" : "Example", + "targets" : [ + "Example" + ], + "type" : { + "library" : [ + "automatic" + ] + } + } + ], + "targets" : [ + { + "c99name" : "ExampleTests", + "module_type" : "SwiftTarget", + "name" : "ExampleTests", + "path" : "Tests/ExampleTests", + "sources" : [ + "ExampleTests.swift" + ], + "target_dependencies" : [ + "Example" + ], + "type" : "test" + }, + { + "c99name" : "Example", + "module_type" : "SwiftTarget", + "name" : "Example", + "path" : "Sources/Example", + "product_memberships" : [ + "Example" + ], + "sources" : [ + "Example.swift" + ], + "type" : "library" + } + ], + "tools_version" : "5.7" + } + """ + + XCTAssertThrowsError(try extracPackageModules(from: rawPackage, target: "NonExistentTarget")) + } +} diff --git a/Tests/jungleTests/GitLogEntryTests.swift b/Tests/jungleTests/GitLogEntryTests.swift index 26cf895..10e7b5f 100644 --- a/Tests/jungleTests/GitLogEntryTests.swift +++ b/Tests/jungleTests/GitLogEntryTests.swift @@ -35,7 +35,7 @@ final class GitLogEntryTests: XCTestCase { let target: Module = .init(name: "T", dependencies: ["A", "C"]) - let row = try await entry.process(pod: nil, podfile: podfile, target: target).csv + let row = try await entry.process(pod: nil, podfile: podfile, target: target, usingMultiEdge: true).csv XCTAssertEqual(row.description, "2022-07-09T21:29:20+02:00;abbd80e;4;1;Shammi Didla;restructure . something") } }