diff --git a/README.md b/README.md index 4214c8c..e3058f4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Swift CLI tool that generates complexity metrics information from a Cocoapods - Number of dependant modules - Compare stats between different branches - Show stats along the git history +- Show dependant targets 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). @@ -145,6 +146,32 @@ jungle modules --target jungle ``` +### Get dependant modules + +```shell +OVERVIEW: Outputs a sorted list of targets that depends on the specified one in target + +USAGE: jungle dependant --target [--show-only-tests] [] + +ARGUMENTS: + Path to the directory where Podfile.lock or Package.swift is located (default: .) + +OPTIONS: + --target The target in your Podfile or Package.swift file to be used + --show-only-tests Show only Test targets + --version Show the version. + -h, --help Show help information. + +``` + +Example: + +```shell +jungle dependant --target SamplePackage $HOME/Desktop/SamplePackage + +Library, LibraryTests, SamplePackageTests + + ### Visualize Complexity Graphs ```shell diff --git a/Sources/DependencyModule/Module.swift b/Sources/DependencyModule/Module.swift index f1adb3c..7304a0f 100644 --- a/Sources/DependencyModule/Module.swift +++ b/Sources/DependencyModule/Module.swift @@ -3,9 +3,15 @@ import Foundation public struct Module: Hashable { public let name: String public let dependencies: [String] - - public init(name: String, dependencies: [String]) { + public let type: ModuleType + public init(name: String, dependencies: [String], type: ModuleType = .library) { self.name = name self.dependencies = dependencies + self.type = type + } + + public enum ModuleType { + case library + case test } } diff --git a/Sources/PodExtractor/Module+Podfile.swift b/Sources/PodExtractor/Module+Podfile.swift index fe54b2b..3bccf9a 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, excludeExternals: Bool = true) throws -> [Module] { +public func extractModulesFromPodfileLock(_ contents: String, excludeExternals: Bool = true, excludeTests: Bool = true) throws -> [Module] { // parse YAML to JSON guard let yaml = try? Yams.load(yaml: contents) else { throw PodError.yamlParsingFailed @@ -108,7 +108,9 @@ public func extractModulesFromPodfileLock(_ contents: String, excludeExternals: // Exclude Test and External Pods let podsWithoutExternalsOrSubspecs = pods .filter { !externals.contains($0.name) } - .filter { !$0.name.contains("/") } // SubSpecs like Tests and Externals Subspecs + .filter { + return excludeTests ? !$0.name.contains("/") : true + } // SubSpecs like Tests and Externals Subspecs return podsWithoutExternalsOrSubspecs } @@ -121,9 +123,13 @@ private func extractPodFromJSON(_ json: Any) throws -> Module { let name = container.keys.first, let dependencies = container.values.first { + let podComponents = name.components(separatedBy: "/") + let podType: Module.ModuleType = podComponents.count > 1 && podComponents[1].contains("Tests") ? .test : .library + return try .init( name: clean(name), - dependencies: dependencies.map(clean) + dependencies: dependencies.map(clean), + type: podType ) } else { diff --git a/Sources/SPMExtractor/Module+Package.swift b/Sources/SPMExtractor/Module+Package.swift index 7185e7c..5d86a5a 100644 --- a/Sources/SPMExtractor/Module+Package.swift +++ b/Sources/SPMExtractor/Module+Package.swift @@ -9,6 +9,7 @@ public struct Package: Decodable { let name: String let targetDependencies: [String]? let productDependencies: [String]? + let type: TargetType var dependencies: [String] { [targetDependencies, productDependencies].compactMap { $0 }.flatMap { $0 } @@ -18,6 +19,13 @@ public struct Package: Decodable { case name case targetDependencies = "target_dependencies" case productDependencies = "product_dependencies" + case type + } + + public enum TargetType: String, Decodable { + case library + case test + case executable } } } @@ -60,6 +68,53 @@ public func extracPackageModules(from packageRaw: String, target: String) throws return (dependencies + external, targetDependencies) } +public func extractDependantTargets(from packageRaw: String, target: String) throws -> [Module] { + guard + let data = packageRaw.data(using: .utf8) + else { + throw PackageError.nonDecodable(raw: packageRaw) + } + + let package = try JSONDecoder().decode(Package.self, from: data) + + guard let target = package.targets.filter({ $0.name == target }).first else { + throw TargetError.targetNotFound(target: target) + } + + let dependantTargets: [Module] = package.targets + .filter { $0.dependencies.contains(target.name) } + .compactMap { .init(name: $0.name, dependencies: $0.dependencies, type: $0.type == .library ? .library : .test ) } + + guard !dependantTargets.isEmpty else { + return dependantTargets + } + + var indirectTargets: [Module] = [] + + try dependantTargets.forEach { target in + indirectTargets += try extractDependantTargets(from: packageRaw, target: target.name) + } + + return dependantTargets + indirectTargets +} + +public func extractDependantTargets(from modules: [Module], for target: String) throws -> [Module] { + + let dependantTargets: [Module] = modules + .filter { $0.dependencies.contains(target) || $0.name.components(separatedBy: "/").count == 2 && $0.name.components(separatedBy: "/").first == target } + + guard !dependantTargets.isEmpty else { + return dependantTargets + } + + var indirectTargets: [Module] = [] + + try dependantTargets.forEach { + indirectTargets += try extractDependantTargets(from: modules, for: $0.name) + } + + return dependantTargets + indirectTargets +} public func extractDependencies(from package: Package, on target: String) -> [Module] { guard diff --git a/Sources/jungle/Commands/DependantCommand.swift b/Sources/jungle/Commands/DependantCommand.swift new file mode 100644 index 0000000..d481cce --- /dev/null +++ b/Sources/jungle/Commands/DependantCommand.swift @@ -0,0 +1,56 @@ +import Foundation +import ArgumentParser +import Shell +import SPMExtractor +import DependencyModule +import PodExtractor + +struct DependantCommand: ParsableCommand { + static var configuration = CommandConfiguration( + commandName: "dependant", + abstract: "Outputs a sorted list of targets that depends on the specified one in target" + ) + + @Option(help: "The target in your Podfile or Package.swift file to be used") + var target: String + + @Flag(help: "Show only Test targets") + var showOnlyTests: 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) + } + } + + private func processPackage(at directoryURL: URL) throws { + let packageRaw = try shell("swift package describe --type json", at: directoryURL) + let targets = try extractDependantTargets(from: packageRaw, target: target) + processOutput(for: targets) + } + + private func processPodfile(at directoryURL: URL) throws { + let podfileLock = try shell("git show HEAD:Podfile.lock", at: directoryURL) + let allPodfileModules = try extractModulesFromPodfileLock(podfileLock, excludeTests: false) + let targets = try extractDependantTargets(from: allPodfileModules, for: target) + processOutput(for: targets) + } + + private func processOutput(for modules: [Module]) { + let output = Array(Set(modules)) + .filter { showOnlyTests ? $0.type == .test : true } + .map(\.name) + .sorted() + .joined(separator: ", ") + print(output) + } +} diff --git a/Sources/jungle/Commands/Main.swift b/Sources/jungle/Commands/Main.swift index dbf828c..098a1a7 100644 --- a/Sources/jungle/Commands/Main.swift +++ b/Sources/jungle/Commands/Main.swift @@ -5,8 +5,8 @@ struct Jungle: AsyncParsableCommand { static var configuration = CommandConfiguration( commandName: "jungle", abstract: "SwiftPM and Cocoapods based projects complexity analyzer.", - version: "2.1.1", - subcommands: [HistoryCommand.self, CompareCommand.self, GraphCommand.self, ModulesCommand.self], + version: "2.2.0", + subcommands: [HistoryCommand.self, CompareCommand.self, GraphCommand.self, ModulesCommand.self, DependantCommand.self], defaultSubcommand: CompareCommand.self ) } diff --git a/Tests/SPMExtractorTests/SPMExtractorTests.swift b/Tests/SPMExtractorTests/SPMExtractorTests.swift index 0f78a14..b5313ca 100644 --- a/Tests/SPMExtractorTests/SPMExtractorTests.swift +++ b/Tests/SPMExtractorTests/SPMExtractorTests.swift @@ -340,4 +340,104 @@ final class SPMExtractorTests: XCTestCase { XCTAssertThrowsError(try extracPackageModules(from: rawPackage, target: "NonExistentTarget")) } + + func testTargetDependantFromTarget() throws { + let rawPackage = """ + { + "dependencies" : [ + { + "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" : "SamplePackage", + "name" : "SamplePackage", + "path" : "/Users/oswaldo.rubio/Desktop/SamplePackage", + "platforms" : [ + + ], + "products" : [ + { + "name" : "SamplePackage", + "targets" : [ + "SamplePackage" + ], + "type" : { + "library" : [ + "automatic" + ] + } + } + ], + "targets" : [ + { + "c99name" : "SamplePackageTests", + "module_type" : "SwiftTarget", + "name" : "SamplePackageTests", + "path" : "Tests/SamplePackageTests", + "sources" : [ + "SamplePackageTests.swift" + ], + "target_dependencies" : [ + "SamplePackage" + ], + "type" : "test" + }, + { + "c99name" : "SamplePackage", + "module_type" : "SwiftTarget", + "name" : "SamplePackage", + "path" : "Sources/SamplePackage", + "product_memberships" : [ + "SamplePackage" + ], + "sources" : [ + "SamplePackage.swift" + ], + "type" : "library" + }, + { + "c99name" : "LibraryTests", + "module_type" : "SwiftTarget", + "name" : "LibraryTests", + "path" : "Tests/LibraryTests", + "sources" : [ + "File.swift" + ], + "target_dependencies" : [ + "Library" + ], + "type" : "test" + }, + { + "c99name" : "Library", + "module_type" : "SwiftTarget", + "name" : "Library", + "path" : "Sources/Library", + "sources" : [ + "File.swift" + ], + "target_dependencies" : [ + "SamplePackage" + ], + "type" : "library" + } + ], + "tools_version" : "5.7" + } + """ + + + let dependant = try extractDependantTargets(from: rawPackage, target: "SamplePackage") + XCTAssertEqual(dependant.map(\.name).sorted(), ["Library", "LibraryTests", "SamplePackageTests"]) + } }