Skip to content

Commit b842488

Browse files
authored
Output Swift Build PIF JSON for Graphviz visualization (#8539)
### Motivation: Improve visualization/debuggability of the PIF JSON sent over to Swift Build (when using the new `--build-system swiftbuild` option). ### Modifications: This PR introduces the `--print-pif-manifest-graph` option to write out the PIF JSON sent to Swift Build as a Graphviz file. This follows a similar design we used for the existing `--print-manifest-job-graph` option. All the serialization happens in the new `DotPIFSerializer.swift` file, with this function as the entry point: func writePIF(_ workspace: PIF.Workspace, toDOT outputStream: OutputByteStream) ### Result: With Graphviz tool (https://graphviz.org) installed (i.e., provides the `dot` command below), we can then run commands like this: $ swift build --build-system swiftbuild --print-pif-manifest-graph | dot -Tpng > PIF.png
1 parent e952244 commit b842488

File tree

8 files changed

+290
-19
lines changed

8 files changed

+290
-19
lines changed

Sources/Commands/SwiftBuildCommand.swift

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import PackageGraph
2222

2323
import SPMBuildCore
2424
import XCBuildSupport
25+
import SwiftBuildSupport
2526

2627
import class Basics.AsyncProcess
2728
import var TSCBasic.stdoutStream
@@ -86,9 +87,14 @@ struct BuildCommandOptions: ParsableArguments {
8687

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

93+
/// Whether to output a graphviz file visualization of the PIF JSON sent to Swift Build.
94+
@Flag(name: .customLong("print-pif-manifest-graph"),
95+
help: "Write the PIF JSON sent to Swift Build as a Graphviz file")
96+
var printPIFManifestGraphviz: Bool = false
97+
9298
/// Specific target to build.
9399
@Option(help: "Build the specified target")
94100
var target: String?
@@ -144,7 +150,7 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
144150
}
145151
let buildManifest = try await buildOperation.getBuildManifest()
146152
var serializer = DOTManifestSerializer(manifest: buildManifest)
147-
// print to stdout
153+
// Print to stdout.
148154
let outputStream = stdoutStream
149155
serializer.writeDOT(to: outputStream)
150156
outputStream.flush()
@@ -162,8 +168,22 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
162168
productsBuildParameters.testingParameters.enableCodeCoverage = true
163169
toolsBuildParameters.testingParameters.enableCodeCoverage = true
164170
}
165-
166-
try await build(swiftCommandState, subset: subset, productsBuildParameters: productsBuildParameters, toolsBuildParameters: toolsBuildParameters)
171+
172+
if self.options.printPIFManifestGraphviz {
173+
productsBuildParameters.printPIFManifestGraphviz = true
174+
toolsBuildParameters.printPIFManifestGraphviz = true
175+
}
176+
177+
do {
178+
try await build(
179+
swiftCommandState,
180+
subset: subset,
181+
productsBuildParameters: productsBuildParameters,
182+
toolsBuildParameters: toolsBuildParameters
183+
)
184+
} catch SwiftBuildSupport.PIFGenerationError.printedPIFManifestGraphviz {
185+
throw ExitCode.success
186+
}
167187
}
168188

169189
private func build(

Sources/Commands/Utilities/DOTManifestSerializer.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import LLBuildManifest
1414

1515
import protocol TSCBasic.OutputByteStream
1616

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

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

39-
/// Quote the name and escape the quotes and backslashes
39+
/// Quote the name and escape the quotes and backslashes.
4040
func quoteName(_ name: String) -> String {
4141
"\"" + name.replacing("\"", with: "\\\"")
4242
.replacing("\\", with: "\\\\") + "\""

Sources/SPMBuildCore/BuildParameters/BuildParameters.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ public struct BuildParameters: Encodable {
127127

128128
public var shouldSkipBuilding: Bool
129129

130+
public var printPIFManifestGraphviz: Bool = false
131+
130132
/// Do minimal build to prepare for indexing
131133
public var prepareForIndexing: PrepareForIndexingMode
132134

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
//
2+
// DotPIFSerializer.swift
3+
// SwiftPM
4+
//
5+
// Created by Paulo Mattos on 2025-04-18.
6+
//
7+
8+
import Basics
9+
import Foundation
10+
import protocol TSCBasic.OutputByteStream
11+
12+
#if canImport(SwiftBuild)
13+
import SwiftBuild
14+
15+
/// Serializes the specified PIF as a **Graphviz** directed graph.
16+
///
17+
/// * [DOT command line](https://graphviz.org/doc/info/command.html)
18+
/// * [DOT language specs](https://graphviz.org/doc/info/lang.html)
19+
func writePIF(_ workspace: PIF.Workspace, toDOT outputStream: OutputByteStream) {
20+
var graph = DotPIFSerializer()
21+
22+
graph.node(
23+
id: workspace.id,
24+
label: "<workspace>\n\(workspace.id)",
25+
shape: "box3d",
26+
color: .black,
27+
fontsize: 7
28+
)
29+
30+
for project in workspace.projects.map(\.underlying) {
31+
graph.edge(from: workspace.id, to: project.id, color: .lightskyblue)
32+
graph.node(
33+
id: project.id,
34+
label: "<project>\n\(project.id)",
35+
shape: "box3d",
36+
color: .gray56,
37+
fontsize: 7
38+
)
39+
40+
for target in project.targets {
41+
graph.edge(from: project.id, to: target.id, color: .lightskyblue)
42+
43+
switch target {
44+
case .target(let target):
45+
graph.node(
46+
id: target.id,
47+
label: "<target>\n\(target.id)\nproduct type: \(target.productType)\n\(target.buildPhases.summary)",
48+
shape: "box",
49+
color: .gray88,
50+
fontsize: 5
51+
)
52+
53+
case .aggregate:
54+
graph.node(
55+
id: target.id,
56+
label: "<aggregate target>\n\(target.id)",
57+
shape: "folder",
58+
color: .gray88,
59+
fontsize: 5,
60+
style: "bold"
61+
)
62+
}
63+
64+
for targetDependency in target.common.dependencies {
65+
let linked = target.isLinkedAgainst(dependencyId: targetDependency.targetId)
66+
graph.edge(from: target.id, to: targetDependency.targetId, color: .gray40, style: linked ? "filled" : "dotted")
67+
}
68+
}
69+
}
70+
71+
graph.write(to: outputStream)
72+
}
73+
74+
fileprivate struct DotPIFSerializer {
75+
private var objects: [String] = []
76+
77+
mutating func write(to outputStream: OutputByteStream) {
78+
func write(_ object: String) { outputStream.write("\(object)\n") }
79+
80+
write("digraph PIF {")
81+
write(" dpi=400;") // i.e., MacBook Pro 16" is 226 pixels per inch (3072 x 1920).
82+
for object in objects {
83+
write(" \(object);")
84+
}
85+
write("}")
86+
}
87+
88+
mutating func node(
89+
id: PIF.GUID,
90+
label: String? = nil,
91+
shape: String? = nil,
92+
color: Color? = nil,
93+
fontname: String? = "SF Mono Light",
94+
fontsize: Int? = nil,
95+
style: String? = nil,
96+
margin: Int? = nil
97+
) {
98+
var attributes: [String] = []
99+
100+
if let label { attributes.append("label=\(label.quote)") }
101+
if let shape { attributes.append("shape=\(shape)") }
102+
if let color { attributes.append("color=\(color)") }
103+
104+
if let fontname { attributes.append("fontname=\(fontname.quote)") }
105+
if let fontsize { attributes.append("fontsize=\(fontsize)") }
106+
107+
if let style { attributes.append("style=\(style)") }
108+
if let margin { attributes.append("margin=\(margin)") }
109+
110+
var node = "\(id.quote)"
111+
if !attributes.isEmpty {
112+
let attributesList = attributes.joined(separator: ", ")
113+
node += " [\(attributesList)]"
114+
}
115+
objects.append(node)
116+
}
117+
118+
mutating func edge(
119+
from left: PIF.GUID,
120+
to right: PIF.GUID,
121+
color: Color? = nil,
122+
style: String? = nil
123+
) {
124+
var attributes: [String] = []
125+
126+
if let color { attributes.append("color=\(color)") }
127+
if let style { attributes.append("style=\(style)") }
128+
129+
var edge = "\(left.quote) -> \(right.quote)"
130+
if !attributes.isEmpty {
131+
let attributesList = attributes.joined(separator: ", ")
132+
edge += " [\(attributesList)]"
133+
}
134+
objects.append(edge)
135+
}
136+
137+
/// Graphviz default color scheme is **X11**:
138+
/// * https://graphviz.org/doc/info/colors.html
139+
enum Color: String {
140+
case black
141+
case gray
142+
case gray40
143+
case gray56
144+
case gray88
145+
case lightskyblue
146+
}
147+
}
148+
149+
// MARK: - Helpers
150+
151+
fileprivate extension ProjectModel.BaseTarget {
152+
func isLinkedAgainst(dependencyId: ProjectModel.GUID) -> Bool {
153+
for buildPhase in self.common.buildPhases {
154+
switch buildPhase {
155+
case .frameworks(let frameworksPhase):
156+
for buildFile in frameworksPhase.files {
157+
switch buildFile.ref {
158+
case .reference(let id):
159+
if dependencyId == id { return true }
160+
case .targetProduct(let id):
161+
if dependencyId == id { return true }
162+
}
163+
}
164+
165+
case .sources, .shellScript, .headers, .copyFiles, .copyBundleResources:
166+
break
167+
}
168+
}
169+
return false
170+
}
171+
}
172+
173+
fileprivate extension [ProjectModel.BuildPhase] {
174+
var summary: String {
175+
var phases: [String] = []
176+
177+
for buildPhase in self {
178+
switch buildPhase {
179+
case .sources(let sourcesPhase):
180+
var sources = "sources: "
181+
if sourcesPhase.files.count == 1 {
182+
sources += "1 source file"
183+
} else {
184+
sources += "\(sourcesPhase.files.count) source files"
185+
}
186+
phases.append(sources)
187+
188+
case .frameworks(let frameworksPhase):
189+
var frameworks = "frameworks: "
190+
if frameworksPhase.files.count == 1 {
191+
frameworks += "1 linked target"
192+
} else {
193+
frameworks += "\(frameworksPhase.files.count) linked targets"
194+
}
195+
phases.append(frameworks)
196+
197+
case .shellScript:
198+
phases.append("shellScript: 1 shell script")
199+
200+
case .headers, .copyFiles, .copyBundleResources:
201+
break
202+
}
203+
}
204+
205+
guard !phases.isEmpty else { return "" }
206+
return phases.joined(separator: "\n")
207+
}
208+
}
209+
210+
fileprivate extension PIF.GUID {
211+
var quote: String {
212+
self.value.quote
213+
}
214+
}
215+
216+
fileprivate extension String {
217+
/// Quote the name and escape the quotes and backslashes.
218+
var quote: String {
219+
"\"" + self
220+
.replacing("\"", with: "\\\"")
221+
.replacing("\\", with: "\\\\")
222+
.replacing("\n", with: "\\n") +
223+
"\""
224+
}
225+
}
226+
227+
#endif

Sources/SwiftBuildSupport/PIF.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,18 @@ public enum PIF {
117117
public final class Workspace: HighLevelObject {
118118
override class var type: String { "workspace" }
119119

120-
public let guid: GUID
120+
public let id: GUID
121121
public var name: String
122122
public var path: AbsolutePath
123123
public var projects: [Project]
124124
var signature: String?
125125

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

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

148-
try contents.encode("\(guid)", forKey: .guid)
148+
try contents.encode("\(id)", forKey: .guid)
149149
try contents.encode(name, forKey: .name)
150150
try contents.encode(path, forKey: .path)
151151
try contents.encode(projects.map(\.signature), forKey: .projects)
@@ -158,11 +158,12 @@ public enum PIF {
158158
}
159159
}
160160

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

165-
self.guid = try contents.decode(GUID.self, forKey: .guid)
166+
self.id = try contents.decode(GUID.self, forKey: .guid)
166167
self.name = try contents.decode(String.self, forKey: .name)
167168
self.path = try contents.decode(AbsolutePath.self, forKey: .path)
168169
self.projects = try contents.decode([Project].self, forKey: .projects)
@@ -205,6 +206,7 @@ public enum PIF {
205206
}
206207
}
207208

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

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

0 commit comments

Comments
 (0)