Skip to content

Commit

Permalink
SwiftPM support (#11)
Browse files Browse the repository at this point in the history
* new spm command

* integrate SPM package processing in graph command

* added multi-edge configuration for graph

* show complexity info in label at bottom

* remove extra character

* fixed package

* fix issue with grah

* update documentation

* fix graph issues

* added new option to show externals dependencies

* added unit tests

* remove dependency

* remove unused import

* compare working with SPM

* refactor compare command

* refactor compare command

* implemented history command for SPM

* update documentation

* fix order

* make graph command to update graph and stats according to the parameter

* adapt compare command to multi/regular config

* adapt history command
  • Loading branch information
osrufung authored Nov 11, 2022
1 parent 6a7e372 commit c7b9de1
Show file tree
Hide file tree
Showing 17 changed files with 689 additions and 84 deletions.
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
{
Expand Down
23 changes: 18 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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")
]
Expand All @@ -33,20 +35,31 @@ 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")
]
),
.testTarget(
name: "PodExtractorTests",
dependencies: ["PodExtractor"]
),

// SPM Extractor
.target(
name: "SPMExtractor",
dependencies: [
"DependencyModule",
"Shell"
]
),
.testTarget(
name: "SPMExtractorTests",
dependencies: ["SPMExtractor"]
),
// DependencyGraph
.target(
name: "DependencyGraph",
Expand Down
31 changes: 17 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -48,14 +49,14 @@ swift build -c release
```shell
OVERVIEW: Displays historic complexity of the dependency graph

USAGE: jungle history [--since <since>] [--pod <pod>] --target <target> [--output-format <output-format>] [<directory-path>]
USAGE: jungle history [--since <since>] [--module <module>] --target <target> [--output-format <output-format>] [<directory-path>]

ARGUMENTS:
<directory-path> Path to the directory where Podfile.lock is located (default: .)

OPTIONS:
--since <since> Equivalent to git-log --since: Eg: '6 months ago' (default: 6 months ago)
--pod <pod> The Pod to generate a report for. Specifying a pod disregards the target parameter
--module <module> The Module to compare. If you specify something, target parameter will be ommited
--target <target> The target in your Podfile file to be used
--output-format <output-format>
csv or json (default: csv)
Expand All @@ -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 <git-object> ...] [--pod <pod>] --target <target> [<directory-path>]
USAGE: jungle compare [--to <git-object> ...] [--module <module>] --target <target> [<directory-path>]

ARGUMENTS:
<directory-path> Path to the directory where Podfile.lock is located (default: .)
<directory-path> Path to the directory where Podfile.lock or Package.swift is located (default: .)

OPTIONS:
--to <git-object> The git objects to compare the current graph to. Eg: - 'main', 'my_branch', 'some_commit_hash'. (default: HEAD, main)
--pod <pod> The Pod to compare. Specifying a pod disregards the target parameter
--target <target> The target in your Podfile file to be used
--to <git-object> The git objects to compare the current graph to. Eg: - 'main', 'my_branch', 'some_commit_hash'. (default: HEAD, main, master)
--module <module> The Module to compare. If you specify something, target parameter will be ommited
--target <target> The target in your Podfile or Package.swift file to be used
--version Show the version.
-h, --help Show help information.
```
Expand Down Expand Up @@ -118,17 +119,19 @@ jungle compare --target App ProjectDirectory/ --to main
```shell
OVERVIEW: Outputs the dependency graph in DOT format

USAGE: jungle graph [--of <git-object>] [--pod <pod>] --target <target> [<directory-path>]
USAGE: jungle graph [--of <git-object>] [--module <module>] --target <target> [--use-multiedge] [--show-externals] [<directory-path>]

ARGUMENTS:
<directory-path> Path to the directory where Podfile.lock is located (default: .)
<directory-path> Path to the directory where Podfile.lock or Package.swift is located (default: .)

OPTIONS:
--of <git-object> A git object representing the version to draw the graph for. Eg: - 'main', 'my_branch', 'some_commit_hash'.
--pod <pod> The Pod to compare. Specifying a pod disregards the target parameter
--target <target> The target in your Podfile file to be used
--module <module> The Module to compare. If you specify something, target parameter will be ommited
--target <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.

```

Expand Down
24 changes: 15 additions & 9 deletions Sources/DependencyGraph/Graph+Dot.swift
Original file line number Diff line number Diff line change
@@ -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];
"""
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/DependencyGraph/Graph+Make.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 0 additions & 1 deletion Sources/DependencyModule/Module.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ public struct Module: Hashable {
self.dependencies = dependencies
}
}

6 changes: 3 additions & 3 deletions Sources/PodExtractor/Module+Podfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
80 changes: 80 additions & 0 deletions Sources/SPMExtractor/Module+Package.swift
Original file line number Diff line number Diff line change
@@ -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<Module> = 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)
}
17 changes: 11 additions & 6 deletions Sources/Shell/Shell.swift
Original file line number Diff line number Diff line change
@@ -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
}

Loading

0 comments on commit c7b9de1

Please sign in to comment.