Skip to content

Commit 6153314

Browse files
committed
Generate symbol graph data for command-line tool info
Make any target documentable if it contains a DocC catalog
1 parent 5f14952 commit 6153314

File tree

2 files changed

+117
-143
lines changed

2 files changed

+117
-143
lines changed

Package.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ let package = Package(
575575
dependencies: [
576576
.product(name: "ArgumentParser", package: "swift-argument-parser"),
577577
.product(name: "OrderedCollections", package: "swift-collections"),
578+
.product(name: "SymbolKit", package: "swift-docc-symbolkit"),
578579
"Basics",
579580
"BinarySymbols",
580581
"Build",
@@ -1110,6 +1111,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
11101111
.package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMinor(from: "1.5.1")),
11111112
.package(url: "https://github.com/apple/swift-crypto.git", .upToNextMinor(from: "3.0.0")),
11121113
.package(url: "https://github.com/swiftlang/swift-syntax.git", branch: relatedDependenciesBranch),
1114+
.package(url: "https://github.com/swiftlang/swift-docc-symbolkit.git", branch: relatedDependenciesBranch),
11131115
.package(url: "https://github.com/apple/swift-system.git", from: "1.1.1"),
11141116
.package(url: "https://github.com/apple/swift-collections.git", "1.0.1" ..< "1.2.0"),
11151117
.package(url: "https://github.com/apple/swift-certificates.git", "1.0.1" ..< "1.6.0"),
@@ -1163,7 +1165,8 @@ if !shoudUseSwiftBuildFramework {
11631165

11641166
if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
11651167
package.dependencies += [
1166-
.package(url: "https://github.com/swiftlang/swift-build.git", branch: relatedDependenciesBranch),
1168+
//.package(url: "https://github.com/swiftlang/swift-build.git", branch: relatedDependenciesBranch),
1169+
.package(path: "../swift-build"),
11671170
]
11681171
} else {
11691172
package.dependencies += [

Sources/Commands/PackageCommands/GenerateDocumentation.swift

Lines changed: 113 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -19,93 +19,60 @@ import PackageGraph
1919
import Workspace
2020
import SPMBuildCore
2121
import ArgumentParserToolInfo
22+
import SymbolKit
2223

2324
extension CommandInfoV0 {
24-
var doccReferenceFileName: String {
25-
doccReferenceTitle + ".md"
25+
func toSymbolGraph() -> SymbolGraph {
26+
return SymbolGraph(
27+
metadata: SymbolGraph.Metadata(formatVersion: .init(major: 0, minor: 6, patch: 0), generator: "SwiftPM"),
28+
module: SymbolGraph.Module(name: self.commandName, platform: .init(architecture: "arm64", vendor: nil, operatingSystem: .init(name: "macOS"), environment: nil)),
29+
symbols: toSymbols(),
30+
relationships: []
31+
)
2632
}
2733

28-
var doccReferenceDocumentTitle: String {
29-
let parts = (superCommands ?? []) + [commandName]
30-
return parts.joined(separator: ".").uppercased()
31-
}
32-
33-
var doccReferenceTitle: String {
34-
let parts = (superCommands ?? []) + [commandName]
35-
return parts.joined(separator: ".")
36-
}
34+
func toSymbols(_ path: [String] = []) -> [SymbolGraph.Symbol] {
35+
var symbols: [SymbolGraph.Symbol] = []
3736

38-
var doccReferenceName: String {
39-
let parts = (superCommands ?? []) + [commandName]
40-
return parts.joined(separator: " ")
41-
}
42-
}
37+
var myPath = path
38+
myPath.append(self.commandName)
4339

44-
extension CommandInfoV0 {
45-
/// Recursively parses a command to generate markdown content that describes the command.
46-
/// - Parameters:
47-
/// - path: The path of subcommands from the root command.
48-
/// - markdownStyle: The flavor of markdown to emit, either `docc` or `github`
49-
/// - Returns: A multi-line markdown file that describes the command.
50-
///
51-
/// If `path` is empty, it represents a top-level command.
52-
/// Otherwise it's a subcommand, potentially recursive to multiple levels.
53-
func toMarkdown(_ path: [String]) -> String {
54-
var result =
55-
String(repeating: "#", count: path.count + 1)
56-
+ " \(self.doccReferenceTitle)\n\n"
57-
58-
// sets the max width for generating code blocks of content based
59-
// on the style
60-
let blockWrapLength: Int = 60
61-
62-
if path.count == 0 {
63-
result += "<!-- Generated by swift-argument-parser -->\n\n"
40+
guard myPath.last != "help" else {
41+
return []
6442
}
6543

66-
if let abstract = self.abstract {
67-
result += "\(abstract)\n\n"
68-
}
44+
var line = 0
45+
var docComments: SymbolGraph.LineList = if let abstract = self.abstract { .init([SymbolGraph.LineList.Line(text: abstract, range: nil )]) } else { .init([]) }
46+
line += 2
6947

7048
if let args = self.arguments, args.count != 0 {
71-
result += "```\n"
72-
let commandString = (path + [self.commandName]).joined(separator: " ")
73-
result +=
74-
commandString
75-
+ self.usage(
76-
startlength: commandString.count, wraplength: blockWrapLength)
77-
result += "\n```\n\n"
78-
}
49+
let commandString: String = myPath.joined(separator: " ")
7950

80-
if let discussion = self.discussion {
81-
result += "\(discussion)\n\n"
51+
docComments = .init(docComments.lines + [SymbolGraph.LineList.Line(text: "```\n" + commandString + self.usage(startlength: commandString.count, wraplength: 60) + "\n```", range: nil )]) // TODO parameterize the wrap length
52+
line += 2
8253
}
8354

84-
if let args = self.arguments {
85-
for arg in args {
86-
guard arg.shouldDisplay else {
87-
continue
88-
}
89-
90-
result += "- term **\(arg.identity())**:\n\n"
91-
92-
if let abstract = arg.abstract {
93-
result += "*\(abstract)*\n\n"
94-
}
95-
if let discussion = arg.discussion {
96-
result += discussion + "\n\n"
97-
}
98-
result += "\n"
99-
}
55+
if let discussion = self.discussion {
56+
docComments = .init(docComments.lines + (discussion.split(separator: "\n").map({ SymbolGraph.LineList.Line(text: String($0), range: nil )})))
57+
line += 2
10058
}
10159

102-
for subcommand in self.subcommands ?? [] {
103-
result +=
104-
subcommand.toMarkdown(
105-
path + [self.commandName]) + "\n\n"
60+
// TODO: Maybe someday there will be command-line semantics for the symbols and then these can be declared with more sensible categories
61+
symbols.append(SymbolGraph.Symbol(
62+
identifier: .init(precise: "s:\(myPath.joined(separator: " "))", interfaceLanguage: "swift"),
63+
names: .init(title: self.commandName, navigator: nil, subHeading: nil, prose: nil),
64+
pathComponents: myPath,
65+
docComment: docComments,
66+
accessLevel: SymbolGraph.Symbol.AccessControl(rawValue: "public"),
67+
kind: SymbolGraph.Symbol.Kind(parsedIdentifier: .`func`, displayName: "command"),
68+
mixins: [:]
69+
))
70+
71+
for cmd in self.subcommands ?? [] {
72+
symbols.append(contentsOf: cmd.toSymbols(myPath))
10673
}
10774

108-
return result
75+
return symbols
10976
}
11077

11178
/// Returns a mutl-line string that presents the arguments for a command.
@@ -221,9 +188,10 @@ struct GenerateDocumentation: AsyncSwiftCommand {
221188
var globalOptions: GlobalOptions
222189

223190
func run(_ swiftCommandState: SwiftCommandState) async throws {
191+
// TODO someday we might be able to populate the landing page with details about the package as a whole, such as traits, or even a DocC catalog that covers package-level topics
192+
224193
let buildSystem = try await swiftCommandState.createBuildSystem()
225194

226-
// TODO build only the product related targets when not generating internal docs
227195
let outputs = try await buildSystem.build(subset: .allExcludingTests, buildOutputs: [
228196
.symbolGraph(
229197
.init(
@@ -267,105 +235,110 @@ struct GenerateDocumentation: AsyncSwiftCommand {
267235
}
268236
} else {
269237
modules.append(contentsOf: rootPackage.modules)
238+
products.append(contentsOf: rootPackage.products)
270239
}
271240
}
272241

273242
for product in products {
274243
if product.type == .executable {
275-
let exec = builtArtifacts.filter({ $0.1.kind == .executable && $0.0 == "\(product.name)-product" }).first?.1.path
276-
277-
guard let exec else {
278-
print("Couldn't find \(product.name)")
279-
continue
244+
let doccCatalogDir = product.modules.first?.underlying.others.filter({ $0.extension?.lowercased() == "docc" }).first
245+
var symbolGraphDir: AbsolutePath? = nil
246+
247+
if let exec = builtArtifacts.filter({ $0.1.kind == .executable && $0.0 == "\(product.name)-product" }).first?.1.path {
248+
do {
249+
// FIXME run the executable within a very restricted sandbox
250+
let dumpHelpProcess = AsyncProcess(args: [exec, "--experimental-dump-help"], outputRedirection: .collect)
251+
try dumpHelpProcess.launch()
252+
let result = try await dumpHelpProcess.waitUntilExit()
253+
let output = try result.utf8Output()
254+
let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output)
255+
256+
// Creating a symbol graph that represents the command-line structure
257+
symbolGraphDir = buildPath.appending(components: ["tool-symbol-graph", product.name])
258+
guard let graphDir = symbolGraphDir else {fatalError()}
259+
260+
try? swiftCommandState.fileSystem.removeFileTree(graphDir)
261+
try swiftCommandState.fileSystem.createDirectory(graphDir, recursive: true)
262+
263+
let graph = toolInfo.command.toSymbolGraph()
264+
let doc = try JSONEncoder().encode(graph)
265+
let graphFile = graphDir.appending(components: ["\(product.name).symbols.json"])
266+
try swiftCommandState.fileSystem.writeFileContents(graphFile, data: doc)
267+
} catch {
268+
print("warning: could not generate tool info documentation for \(product.name)")
280269
}
281-
282-
do {
283-
// FIXME run the executable within a very restricted sandbox
284-
let dumpHelpProcess = AsyncProcess(args: [exec, "--experimental-dump-help"], outputRedirection: .collect)
285-
try dumpHelpProcess.launch()
286-
let result = try await dumpHelpProcess.waitUntilExit()
287-
let output = try result.utf8Output()
288-
let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output)
289-
290-
let page = toolInfo.command.toMarkdown([])
291-
292-
// Creating a simple DocC catalog
293-
// TODO copy over an existing DocC catalog for this module if one exists already
294-
let catalogDir = buildPath.appending(components: ["tool-docc-catalog", product.name, "Documentation.docc"])
295-
try? swiftCommandState.fileSystem.removeFileTree(catalogDir)
296-
try swiftCommandState.fileSystem.createDirectory(catalogDir, recursive: true)
297-
let summaryMd = catalogDir.appending(components: ["\(product.name.lowercased()).md"])
298-
try swiftCommandState.fileSystem.writeFileContents(summaryMd, string: page)
299-
300-
let archiveDir = buildPath.appending(components: ["tool-docc-archive", "\(product.name).doccarchive"])
301-
try? swiftCommandState.fileSystem.removeFileTree(archiveDir)
302-
try swiftCommandState.fileSystem.createDirectory(archiveDir.parentDirectory, recursive: true)
303-
304-
print("CONVERT TOOL: \(product.name)")
305-
306-
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
307-
"convert",
308-
catalogDir.pathString,
309-
"--fallback-display-name=\(product.name)",
310-
"--fallback-bundle-identifier=\(product.name)",
311-
"--output-path=\(archiveDir)",
312-
])
313-
process.waitUntilExit()
314-
315-
if swiftCommandState.fileSystem.exists(archiveDir) {
316-
doccArchives.append(archiveDir.pathString)
317-
}
318-
} catch {
319-
print("warning: could not generate tool info documentation for \(product.name)")
320-
}
321-
322-
continue
323270
}
324-
}
325271

326-
for module: ResolvedModule in modules {
327-
guard module.type != .test && module.type != .plugin && module.type != .executable else {
328-
continue
272+
guard doccCatalogDir != nil || symbolGraphDir != nil else {
273+
print("Skipping \(product.name) because there is no DocC catalog and there is no symbol graph that could be generated for it.")
274+
continue
329275
}
330276

331-
let inputPathDir = symbolGraph.outputLocationForTarget(module.name, try swiftCommandState.productsBuildParameters)
332-
let inputPath = buildPath.appending(components: inputPathDir)
277+
let catalogArgs = if let doccCatalogDir {[doccCatalogDir.pathString]} else {[String]()}
278+
let graphArgs = if let symbolGraphDir {["--additional-symbol-graph-dir=\(symbolGraphDir)"]} else {[String]()}
333279

334-
guard swiftCommandState.fileSystem.exists(inputPath) else {
335-
continue
280+
print("CONVERTING: \(product.name)")
281+
282+
let archiveDir = buildPath.appending(components: ["tool-docc-archive", "\(product.name).doccarchive"])
283+
try? swiftCommandState.fileSystem.removeFileTree(archiveDir)
284+
try swiftCommandState.fileSystem.createDirectory(archiveDir.parentDirectory, recursive: true)
285+
286+
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
287+
"convert",
288+
] + catalogArgs + [
289+
"--fallback-display-name=\(product.name)",
290+
"--fallback-bundle-identifier=\(product.name)",
291+
] + graphArgs + [
292+
"--output-path=\(archiveDir)",
293+
])
294+
process.waitUntilExit()
295+
296+
if swiftCommandState.fileSystem.exists(archiveDir) {
297+
print("SUCCESS!")
298+
doccArchives.append(archiveDir.pathString)
336299
}
300+
}
301+
}
337302

338-
let outputPathDir = [String](inputPathDir.dropLast()) + [inputPathDir.last!.replacing(".symbolgraphs", with: ".doccarchive")]
339-
let outputPath = buildPath.appending(components: outputPathDir)
303+
for module: ResolvedModule in modules {
304+
let symbolGraphDir = symbolGraph.outputLocationForTarget(module.name, try swiftCommandState.productsBuildParameters)
305+
let symbolGraphPath = buildPath.appending(components: symbolGraphDir)
340306

341307
// The DocC catalog for this module is any directory with the docc file extension
342-
let doccCatalog = module.underlying.others.first { sourceFile in
308+
let doccCatalogDir = module.underlying.others.first { sourceFile in
343309
return sourceFile.extension?.lowercased() == "docc"
344310
}
345311

346-
let catalogArgs = if let doccCatalog {[doccCatalog.pathString]} else {[String]()}
312+
guard doccCatalogDir != nil || swiftCommandState.fileSystem.exists(symbolGraphPath) else {
313+
print("Skipping \(module.name) because there is no DocC catalog and there is no symbol graph that could be generated for it.")
314+
continue
315+
}
347316

348-
print("CONVERT: \(module.name)")
317+
let catalogArgs = if let doccCatalogDir {[doccCatalogDir.pathString]} else {[String]()}
318+
let graphArgs = if swiftCommandState.fileSystem.exists(symbolGraphPath) {["--additional-symbol-graph-dir=\(symbolGraphPath)"]} else {[String]()}
319+
320+
print("CONVERTING: \(module.name)")
321+
322+
let archiveDir = buildPath.appending(components: ["module-docc-archive", "\(module.name).doccarchive"])
323+
try? swiftCommandState.fileSystem.removeFileTree(archiveDir)
324+
try swiftCommandState.fileSystem.createDirectory(archiveDir.parentDirectory, recursive: true)
349325

350-
print("docc convert \(catalogArgs.joined(separator: " ")) \(["--fallback-display-name=\(module.name)", "--fallback-bundle-identifier=\(module.name)", "--additional-symbol-graph-dir=\(inputPath)", "--output-path=\(outputPath)"].joined(separator: " "))")
351-
352326
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
353327
"convert",
354328
] + catalogArgs + [
355329
"--fallback-display-name=\(module.name)",
356330
"--fallback-bundle-identifier=\(module.name)",
357-
"--additional-symbol-graph-dir=\(inputPath)",
358-
"--output-path=\(outputPath)",
331+
] + graphArgs + [
332+
"--output-path=\(archiveDir)",
359333
])
360334
process.waitUntilExit()
361335

362-
if swiftCommandState.fileSystem.exists(outputPath) {
363-
doccArchives.append(outputPath.pathString)
336+
if swiftCommandState.fileSystem.exists(archiveDir) {
337+
doccArchives.append(archiveDir.pathString)
364338
}
365339
}
366340

367341
guard doccArchives.count > 0 else {
368-
// FIXME consider presenting just the README.md contents if possible
369342
print("No modules are available to document.")
370343
return
371344
}
@@ -376,7 +349,7 @@ struct GenerateDocumentation: AsyncSwiftCommand {
376349
try? swiftCommandState.fileSystem.removeFileTree(outputPath) // docc merge requires an empty output directory
377350
try swiftCommandState.fileSystem.createDirectory(outputPath, recursive: true)
378351

379-
print("MERGE")
352+
print("MERGE: \(doccArchives)")
380353

381354
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
382355
"merge",
@@ -390,7 +363,5 @@ struct GenerateDocumentation: AsyncSwiftCommand {
390363
// TODO provide an option to set up an http server
391364
print("python3 -m http.server --directory \(outputPath)")
392365
print("http://localhost:8000/documentation")
393-
394-
// TODO figure out how to monitor for changes in preview mode so that it automatically updates itself (perhaps sourcekit-lsp/vscode is a much better way forward)
395-
}
366+
}
396367
}

0 commit comments

Comments
 (0)