Skip to content

Commit

Permalink
Clean up, allow for graph dumping, fix the graph
Browse files Browse the repository at this point in the history
This change adds:

* the ability to write a DOT file representing the
dependency graph of the project with new CLI option
* fixes the graph by correcting a mistake where edges could be linked
  multiple times, and removes original transitive depependency finding
  since that's now in the graph
* adds support for 'embed frameworks' copy files build phase
  • Loading branch information
NinjaLikesCheez committed Oct 17, 2023
1 parent 3e4feae commit ba93033
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 36 deletions.
33 changes: 27 additions & 6 deletions PBXProjParser/Sources/PBXProjParser/XcodeProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,9 @@ public struct XcodeProject {

packages = model.objects(of: .swiftPackageProductDependency, as: XCSwiftPackageProductDependency.self)

// First pass - get all the direct dependencies
// get all the direct dependencies
targets.forEach { determineDirectDependencies($0) }

// Second pass - get all the transitive dependencies
targets.forEach { determineTransitiveDependencies($0) }

targets.forEach { target in
logger.debug("target: \(target.name). Dependencies: \(target.targetDependencies.map { $0.1.name })")
}
Expand Down Expand Up @@ -105,8 +102,13 @@ public struct XcodeProject {
.compactMap { model.object(forKey: $0, as: XCSwiftPackageProductDependency.self) }
.forEach { target.add(dependency: .package($0)) }

// Calculate dependencies from "Embed Frameworks" copy files build phase
let embeddedFrameworks = determineEmbeddedFrameworksDependencies(target, with: model)

// Calculate the dependencies from "Link Binary with Library" build phase
let buildFiles = determineBuildPhaseFrameworkDependencies(target, with: model)
let linkLibraries = determineBuildPhaseFrameworkDependencies(target, with: model)

let buildFiles = embeddedFrameworks + linkLibraries

// Now, we have two potential targets - file & package dependencies.
// File dependencies will likely have a reference in another Xcode Project. We might not have seen said project yet, so we need to offload discovery until after we've parsed all projects...
Expand Down Expand Up @@ -205,6 +207,25 @@ private func determineBuildPhaseFrameworkDependencies(_ target: PBXNativeTarget,
return []
}

return buildPhase.files
return buildPhase
.files
.compactMap { model.object(forKey: $0, as: PBXBuildFile.self) }
}

private func determineEmbeddedFrameworksDependencies(_ target: PBXNativeTarget, with model: PBXProj) -> [PBXBuildFile] {
// Find the "Embed Frameworks" build phase (copy files build phase)
let buildPhases = target
.buildPhases
.compactMap { model.object(forKey: $0, as: PBXCopyFilesBuildPhase.self) }

return buildPhases
.flatMap { $0.files }
.compactMap { model.object(forKey: $0, as: PBXBuildFile.self) }
.filter { file in
guard let ref = file.fileRef else { return false }
guard let object = model.object(forKey: ref, as: PBXFileReference.self) else { return false }
guard object.explicitFileType == "wrapper.framework" else { return false }

return true
}
}
15 changes: 15 additions & 0 deletions Sources/GenIR/Dependency Graph/DependencyGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// Created by Thomas Hedderwick on 28/08/2023.
//

import Foundation

/// A directed graph that maps dependencies between targets (nodes) via edges (directions between nodes)
class DependencyGraph {
/// All the nodes in the graph
Expand Down Expand Up @@ -42,6 +44,19 @@ class DependencyGraph {
return depthFirstSearch(startingAt: targetNode)
}

func toDot(_ path: String) throws {
var contents = "digraph DependencyGraph {\n"

for node in nodes {
for edge in node.edges.filter({ $0.relationship == .dependency }) {
contents.append("\(node.name.replacingOccurrences(of: "-", with: "_")) -> \(edge.to.name.replacingOccurrences(of: "-", with: "_"))\n")
}
}

contents.append("}")
try contents.write(toFile: path, atomically: true, encoding: .utf8)
}

/// Perform a depth-first search starting at the provided node
/// - Parameter node: the node whose children to search through
/// - Returns: an array of nodes ordered by a depth-first search approach
Expand Down
4 changes: 3 additions & 1 deletion Sources/GenIR/Dependency Graph/Node.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ class Node {
/// Adds an edge to this node
/// - Parameter edge: the edge to add
func add(edge: Edge) {
edges.append(edge)
if edges.filter({ $0.to.name == edge.to.name }).count == 0 {
edges.append(edge)
}
}
}

Expand Down
10 changes: 9 additions & 1 deletion Sources/GenIR/GenIR.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ struct IREmitterCommand: ParsableCommand {
@Flag(help: "Runs the tool without outputting IR to disk (i.e. leaving out the compiler command runner stage)")
var dryRun = false

@Flag(help: "Output the dependency graph as .dot files to the output directory - debug only")
var dumpDependencyGraph = false

mutating func validate() throws {
if debug {
logger.logLevel = .debug
Expand Down Expand Up @@ -121,7 +124,12 @@ struct IREmitterCommand: ParsableCommand {
)
try runner.run(targets: targets)

let postprocessor = try OutputPostprocessor(archive: archive, output: output, targets: targets)
let postprocessor = try OutputPostprocessor(
archive: archive,
output: output,
targets: targets,
dumpGraph: dumpDependencyGraph
)
try postprocessor.process(targets: &targets)
}

Expand Down
53 changes: 25 additions & 28 deletions Sources/GenIR/OutputPostprocessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation
import PBXProjParser

private typealias FilenameAndSize = (String, Int)
private typealias SizeAndCreation = (Int, Date)

/// The `OutputPostprocessor` is responsible for trying to match the IR output of the `CompilerCommandRunner` with the products in the `xcarchive`.
/// The `CompilerCommandRunner` will output IR with it's product name, but doesn't take into account the linking of products into each other.
Expand All @@ -26,17 +26,24 @@ class OutputPostprocessor {

private let graph: DependencyGraph

private var seenConflictingFiles: [URL: [FilenameAndSize]] = [:]
private var seenConflictingFiles: [URL: [SizeAndCreation]] = [:]

private let manager: FileManager = .default

init(archive: URL, output: URL, targets: Targets) throws {
init(archive: URL, output: URL, targets: Targets, dumpGraph: Bool) throws {
self.output = output
self.archive = archive

dynamicDependencyToPath = dynamicDependencies(in: self.archive)

graph = DependencyGraphBuilder.build(targets: targets)
if dumpGraph {
do {
try graph.toDot(output.appendingPathComponent("graph.dot").filePath)
} catch {
logger.error("toDot error: \(error)")
}
}
}

/// Starts the OutputPostprocessor
Expand Down Expand Up @@ -78,8 +85,8 @@ class OutputPostprocessor {
) throws -> Set<URL> {
let chain = graph.chain(for: target)

logger.info("Chain for target: \(target.nameForOutput):\n")
chain.forEach { logger.info("\($0)") }
logger.debug("Chain for target: \(target.nameForOutput):\n")
chain.forEach { logger.debug("\($0)") }

// We want to process the chain, visiting each node _shallowly_ and copy it's dependencies into it's parent
var processed = Set<URL>()
Expand Down Expand Up @@ -164,48 +171,39 @@ class OutputPostprocessor {
/// - source: source file path
/// - destination: destination file path
private func copyFileUniquingConflictingFiles(source: URL, destination: URL) throws {
/// Returns the size of the path. Throws is attributes cannot be found for this file path.
/// - Parameter path: the path to get the file size of
/// - Returns: the size of the file at path, if it was able to be converted to an integer
func size(for path: URL) throws -> Int? {
do {
return try manager.attributesOfItem(atPath: path.filePath)[.size] as? Int
} catch {
logger.debug("Couldn't get size attribute for path: \(path.filePath)")
throw error
}
}
let destinationAttributes = try manager.attributesOfItem(atPath: destination.filePath)
let sourceAttributes = try manager.attributesOfItem(atPath: source.filePath)

guard
let destinationSize = try size(for: destination),
let sourceSize = try size(for: source)
let destinationSize = destinationAttributes[.size] as? Int,
let sourceSize = sourceAttributes[.size] as? Int,
let destinationCreatedDate = destinationAttributes[.creationDate] as? Date,
let sourceCreatedDate = sourceAttributes[.creationDate] as? Date
else {
logger.debug("Failed to get attributes for source: \(source) & destination: \(destination)")
return
}

let uniqueDestinationURL = manager.uniqueFilename(directory: destination.deletingLastPathComponent(), filename: source.lastPathComponent)

// TODO: Should we use all file attributes here? What about created date etc?
if seenConflictingFiles[source] == nil {
seenConflictingFiles[source] = [(source.lastPathComponent, sourceSize)]
seenConflictingFiles[source] = [(sourceSize, sourceCreatedDate)]
}

for (_, size) in seenConflictingFiles[source]! where size == destinationSize {
logger.debug("Ignoring copy of: \(destination.lastPathComponent) as the sizes where the same")
for (size, date) in seenConflictingFiles[source]! where size == destinationSize && date == destinationCreatedDate {
return
}

seenConflictingFiles[source]!.append((uniqueDestinationURL.lastPathComponent, destinationSize))

seenConflictingFiles[source]!.append((destinationSize, destinationCreatedDate))
logger.debug("Copying source \(source) to destination: \(uniqueDestinationURL)")
try manager.copyItem(at: source, to: uniqueDestinationURL)
}
}

// swiftlint:disable private_over_fileprivate
/// Returns a map of dynamic objects in the provided path
/// - Parameter xcarchive: the path to search through
/// - Returns: a mapping of filename to filepath for dynamic objects in the provided path
fileprivate func dynamicDependencies(in xcarchive: URL) -> [String: URL] {
private func dynamicDependencies(in xcarchive: URL) -> [String: URL] {
let searchPath = baseSearchPath(startingAt: xcarchive)
logger.debug("Using search path for dynamic dependencies: \(searchPath)")

Expand All @@ -224,7 +222,7 @@ fileprivate func dynamicDependencies(in xcarchive: URL) -> [String: URL] {
/// Returns the base URL to start searching inside an xcarchive
/// - Parameter path: the original path, should be an xcarchive
/// - Returns: the path to start a dependency search from
fileprivate func baseSearchPath(startingAt path: URL) -> URL {
private func baseSearchPath(startingAt path: URL) -> URL {
let productsPath = path.appendingPathComponent("Products")
let applicationsPath = productsPath.appendingPathComponent("Applications")
let frameworkPath = productsPath.appendingPathComponent("Library").appendingPathComponent("Framework")
Expand Down Expand Up @@ -254,4 +252,3 @@ fileprivate func baseSearchPath(startingAt path: URL) -> URL {
logger.debug("Couldn't determine the base search path for the xcarchive, using: \(productsPath)")
return productsPath
}
// swiftlint:enable private_over_fileprivate

0 comments on commit ba93033

Please sign in to comment.