Skip to content

Commit e5c66f9

Browse files
committed
Output Swift Build PIF as Graphviz
1 parent 95ce2a3 commit e5c66f9

File tree

8 files changed

+273
-16
lines changed

8 files changed

+273
-16
lines changed

Sources/Commands/SwiftBuildCommand.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,14 @@ struct BuildCommandOptions: ParsableArguments {
8686

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

92+
/// Whether to output a graphviz file visualization of the PIF JSON sent to Swift Build.
93+
@Flag(name: .customLong("print-pif-manifest-graph"),
94+
help: "Write the PIF JSON sent to Swift Build as a Graphviz file")
95+
var printPIFManifestGraphviz: Bool = false
96+
9297
/// Specific target to build.
9398
@Option(help: "Build the specified target")
9499
var target: String?
@@ -144,7 +149,7 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
144149
}
145150
let buildManifest = try await buildOperation.getBuildManifest()
146151
var serializer = DOTManifestSerializer(manifest: buildManifest)
147-
// print to stdout
152+
// Print to stdout.
148153
let outputStream = stdoutStream
149154
serializer.writeDOT(to: outputStream)
150155
outputStream.flush()
@@ -162,7 +167,12 @@ public struct SwiftBuildCommand: AsyncSwiftCommand {
162167
productsBuildParameters.testingParameters.enableCodeCoverage = true
163168
toolsBuildParameters.testingParameters.enableCodeCoverage = true
164169
}
165-
170+
171+
if self.options.printPIFManifestGraphviz {
172+
productsBuildParameters.printPIFManifestGraphviz = true
173+
toolsBuildParameters.printPIFManifestGraphviz = true
174+
}
175+
166176
try await build(swiftCommandState, subset: subset, productsBuildParameters: productsBuildParameters, toolsBuildParameters: toolsBuildParameters)
167177
}
168178

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
@@ -126,6 +126,8 @@ public struct BuildParameters: Encodable {
126126

127127
public var shouldSkipBuilding: Bool
128128

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

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=260;") // 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: 8 additions & 6 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 (rdar://149003797).
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 (rdar://149003797).
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)

0 commit comments

Comments
 (0)