Skip to content

Output Swift Build PIF JSON for Graphviz visualization #8539

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions Sources/Commands/SwiftBuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import PackageGraph

import SPMBuildCore
import XCBuildSupport
import SwiftBuildSupport

import class Basics.AsyncProcess
import var TSCBasic.stdoutStream
Expand Down Expand Up @@ -86,9 +87,14 @@ struct BuildCommandOptions: ParsableArguments {

/// Whether to output a graphviz file visualization of the combined job graph for all targets
@Flag(name: .customLong("print-manifest-job-graph"),
help: "Write the command graph for the build manifest as a graphviz file")
help: "Write the command graph for the build manifest as a Graphviz file")
var printManifestGraphviz: Bool = false

/// Whether to output a graphviz file visualization of the PIF JSON sent to Swift Build.
@Flag(name: .customLong("print-pif-manifest-graph"),
help: "Write the PIF JSON sent to Swift Build as a Graphviz file")
var printPIFManifestGraphviz: Bool = false

/// Specific target to build.
@Option(help: "Build the specified target")
var target: String?
Expand Down Expand Up @@ -144,7 +150,7 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
}
let buildManifest = try await buildOperation.getBuildManifest()
var serializer = DOTManifestSerializer(manifest: buildManifest)
// print to stdout
// Print to stdout.
let outputStream = stdoutStream
serializer.writeDOT(to: outputStream)
outputStream.flush()
Expand All @@ -162,8 +168,22 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
productsBuildParameters.testingParameters.enableCodeCoverage = true
toolsBuildParameters.testingParameters.enableCodeCoverage = true
}

try await build(swiftCommandState, subset: subset, productsBuildParameters: productsBuildParameters, toolsBuildParameters: toolsBuildParameters)

if self.options.printPIFManifestGraphviz {
productsBuildParameters.printPIFManifestGraphviz = true
toolsBuildParameters.printPIFManifestGraphviz = true
}

do {
try await build(
swiftCommandState,
subset: subset,
productsBuildParameters: productsBuildParameters,
toolsBuildParameters: toolsBuildParameters
)
} catch SwiftBuildSupport.PIFGenerationError.printedPIFManifestGraphviz {
throw ExitCode.success
}
}

private func build(
Expand Down
6 changes: 3 additions & 3 deletions Sources/Commands/Utilities/DOTManifestSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import LLBuildManifest

import protocol TSCBasic.OutputByteStream

/// Serializes an LLBuildManifest graph to a .dot file
/// Serializes an LLBuildManifest graph to a .dot file.
struct DOTManifestSerializer {
var kindCounter = [String: Int]()
var hasEmittedStyling = Set<String>()
Expand All @@ -25,7 +25,7 @@ struct DOTManifestSerializer {
self.manifest = manifest
}

/// Gets a unique label for a job name
/// Gets a unique label for a job name.
mutating func label(for command: Command) -> String {
let toolName = "\(type(of: command.tool).name)"
var label = toolName
Expand All @@ -36,7 +36,7 @@ struct DOTManifestSerializer {
return label
}

/// Quote the name and escape the quotes and backslashes
/// Quote the name and escape the quotes and backslashes.
func quoteName(_ name: String) -> String {
"\"" + name.replacing("\"", with: "\\\"")
.replacing("\\", with: "\\\\") + "\""
Expand Down
2 changes: 2 additions & 0 deletions Sources/SPMBuildCore/BuildParameters/BuildParameters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ public struct BuildParameters: Encodable {

public var shouldSkipBuilding: Bool

public var printPIFManifestGraphviz: Bool = false

/// Do minimal build to prepare for indexing
public var prepareForIndexing: PrepareForIndexingMode

Expand Down
227 changes: 227 additions & 0 deletions Sources/SwiftBuildSupport/DotPIFSerializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
//
// DotPIFSerializer.swift
// SwiftPM
//
// Created by Paulo Mattos on 2025-04-18.
//

import Basics
import Foundation
import protocol TSCBasic.OutputByteStream

#if canImport(SwiftBuild)
import SwiftBuild

/// Serializes the specified PIF as a **Graphviz** directed graph.
///
/// * [DOT command line](https://graphviz.org/doc/info/command.html)
/// * [DOT language specs](https://graphviz.org/doc/info/lang.html)
func writePIF(_ workspace: PIF.Workspace, toDOT outputStream: OutputByteStream) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the main entry point for construction the Graphviz compatible PIF graph.

var graph = DotPIFSerializer()

graph.node(
id: workspace.id,
label: "<workspace>\n\(workspace.id)",
shape: "box3d",
color: .black,
fontsize: 7
)

for project in workspace.projects.map(\.underlying) {
graph.edge(from: workspace.id, to: project.id, color: .lightskyblue)
graph.node(
id: project.id,
label: "<project>\n\(project.id)",
shape: "box3d",
color: .gray56,
fontsize: 7
)

for target in project.targets {
graph.edge(from: project.id, to: target.id, color: .lightskyblue)

switch target {
case .target(let target):
graph.node(
id: target.id,
label: "<target>\n\(target.id)\nproduct type: \(target.productType)\n\(target.buildPhases.summary)",
shape: "box",
color: .gray88,
fontsize: 5
)

case .aggregate:
graph.node(
id: target.id,
label: "<aggregate target>\n\(target.id)",
shape: "folder",
color: .gray88,
fontsize: 5,
style: "bold"
)
}

for targetDependency in target.common.dependencies {
let linked = target.isLinkedAgainst(dependencyId: targetDependency.targetId)
graph.edge(from: target.id, to: targetDependency.targetId, color: .gray40, style: linked ? "filled" : "dotted")
}
}
}

graph.write(to: outputStream)
}

fileprivate struct DotPIFSerializer {
private var objects: [String] = []

mutating func write(to outputStream: OutputByteStream) {
func write(_ object: String) { outputStream.write("\(object)\n") }

write("digraph PIF {")
write(" dpi=400;") // i.e., MacBook Pro 16" is 226 pixels per inch (3072 x 1920).
for object in objects {
write(" \(object);")
}
write("}")
}

mutating func node(
id: PIF.GUID,
label: String? = nil,
shape: String? = nil,
color: Color? = nil,
fontname: String? = "SF Mono Light",
fontsize: Int? = nil,
style: String? = nil,
margin: Int? = nil
) {
var attributes: [String] = []

if let label { attributes.append("label=\(label.quote)") }
if let shape { attributes.append("shape=\(shape)") }
if let color { attributes.append("color=\(color)") }

if let fontname { attributes.append("fontname=\(fontname.quote)") }
if let fontsize { attributes.append("fontsize=\(fontsize)") }

if let style { attributes.append("style=\(style)") }
if let margin { attributes.append("margin=\(margin)") }

var node = "\(id.quote)"
if !attributes.isEmpty {
let attributesList = attributes.joined(separator: ", ")
node += " [\(attributesList)]"
}
objects.append(node)
}

mutating func edge(
from left: PIF.GUID,
to right: PIF.GUID,
color: Color? = nil,
style: String? = nil
) {
var attributes: [String] = []

if let color { attributes.append("color=\(color)") }
if let style { attributes.append("style=\(style)") }

var edge = "\(left.quote) -> \(right.quote)"
if !attributes.isEmpty {
let attributesList = attributes.joined(separator: ", ")
edge += " [\(attributesList)]"
}
objects.append(edge)
}

/// Graphviz default color scheme is **X11**:
/// * https://graphviz.org/doc/info/colors.html
enum Color: String {
case black
case gray
case gray40
case gray56
case gray88
case lightskyblue
}
}

// MARK: - Helpers

fileprivate extension ProjectModel.BaseTarget {
func isLinkedAgainst(dependencyId: ProjectModel.GUID) -> Bool {
for buildPhase in self.common.buildPhases {
switch buildPhase {
case .frameworks(let frameworksPhase):
for buildFile in frameworksPhase.files {
switch buildFile.ref {
case .reference(let id):
if dependencyId == id { return true }
case .targetProduct(let id):
if dependencyId == id { return true }
}
}

case .sources, .shellScript, .headers, .copyFiles, .copyBundleResources:
break
}
}
return false
}
}

fileprivate extension [ProjectModel.BuildPhase] {
var summary: String {
var phases: [String] = []

for buildPhase in self {
switch buildPhase {
case .sources(let sourcesPhase):
var sources = "sources: "
if sourcesPhase.files.count == 1 {
sources += "1 source file"
} else {
sources += "\(sourcesPhase.files.count) source files"
}
phases.append(sources)

case .frameworks(let frameworksPhase):
var frameworks = "frameworks: "
if frameworksPhase.files.count == 1 {
frameworks += "1 linked target"
} else {
frameworks += "\(frameworksPhase.files.count) linked targets"
}
phases.append(frameworks)

case .shellScript:
phases.append("shellScript: 1 shell script")

case .headers, .copyFiles, .copyBundleResources:
break
}
}

guard !phases.isEmpty else { return "" }
return phases.joined(separator: "\n")
}
}

fileprivate extension PIF.GUID {
var quote: String {
self.value.quote
}
}

fileprivate extension String {
/// Quote the name and escape the quotes and backslashes.
var quote: String {
"\"" + self
.replacing("\"", with: "\\\"")
.replacing("\\", with: "\\\\")
.replacing("\n", with: "\\n") +
"\""
}
}

#endif
17 changes: 10 additions & 7 deletions Sources/SwiftBuildSupport/PIF.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,18 @@ public enum PIF {
public final class Workspace: HighLevelObject {
override class var type: String { "workspace" }

public let guid: GUID
public let id: GUID
public var name: String
public var path: AbsolutePath
public var projects: [Project]
var signature: String?

public init(guid: GUID, name: String, path: AbsolutePath, projects: [ProjectModel.Project]) {
precondition(!guid.value.isEmpty)
public init(id: GUID, name: String, path: AbsolutePath, projects: [ProjectModel.Project]) {
precondition(!id.value.isEmpty)
precondition(!name.isEmpty)
precondition(Set(projects.map(\.id)).count == projects.count)

self.guid = guid
self.id = id
self.name = name
self.path = path
self.projects = projects.map { Project(wrapping: $0) }
Expand All @@ -145,7 +145,7 @@ public enum PIF {
var superContainer = encoder.container(keyedBy: HighLevelObject.CodingKeys.self)
var contents = superContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: .contents)

try contents.encode("\(guid)", forKey: .guid)
try contents.encode("\(id)", forKey: .guid)
try contents.encode(name, forKey: .name)
try contents.encode(path, forKey: .path)
try contents.encode(projects.map(\.signature), forKey: .projects)
Expand All @@ -158,11 +158,12 @@ public enum PIF {
}
}

// FIXME: Delete this (https://github.com/swiftlang/swift-package-manager/issues/8552).
public required init(from decoder: Decoder) throws {
let superContainer = try decoder.container(keyedBy: HighLevelObject.CodingKeys.self)
let contents = try superContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: .contents)

self.guid = try contents.decode(GUID.self, forKey: .guid)
self.id = try contents.decode(GUID.self, forKey: .guid)
self.name = try contents.decode(String.self, forKey: .name)
self.path = try contents.decode(AbsolutePath.self, forKey: .path)
self.projects = try contents.decode([Project].self, forKey: .projects)
Expand Down Expand Up @@ -205,6 +206,7 @@ public enum PIF {
}
}

// FIXME: Delete this (https://github.com/swiftlang/swift-package-manager/issues/8552).
public required init(from decoder: Decoder) throws {
let superContainer = try decoder.container(keyedBy: HighLevelObject.CodingKeys.self)
self.underlying = try superContainer.decode(ProjectModel.Project.self, forKey: .contents)
Expand Down Expand Up @@ -242,7 +244,8 @@ public enum PIF {
}

public required init(from decoder: Decoder) throws {
// FIXME: Remove all support for decoding PIF objects in SwiftBuildSupport? rdar://149003797
// FIXME: Remove all support for decoding PIF objects in SwiftBuildSupport?
// (https://github.com/swiftlang/swift-package-manager/issues/8552)
fatalError("Decoding not implemented")
/*
let superContainer = try decoder.container(keyedBy: HighLevelObject.CodingKeys.self)
Expand Down
Loading