Skip to content

Commit 74db91b

Browse files
committed
SwiftPM generate-documentation subcommand
Create a subcommand that's capable of generating DocC documentation for a package, including its targets. Support natively the ability to document executable targets using the tool info dump facility available in the Swift Argument Parser and potentially others that conform to its protocol. Allow any target regardless of its type to include a DocC catalog of markdown files to document its target. This includes ones that don't normally have a mechanism to generate its own API reference. Provide an option to generate the internal facing documentation for a Package, including all of its targets, even ones that are not exported as products.
1 parent 4c4487b commit 74db91b

File tree

3 files changed

+373
-1
lines changed

3 files changed

+373
-1
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 += [
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import ArgumentParser
14+
import Basics
15+
import CoreCommands
16+
import Foundation
17+
import PackageModel
18+
import PackageGraph
19+
import Workspace
20+
import SPMBuildCore
21+
import ArgumentParserToolInfo
22+
import SymbolKit
23+
24+
extension CommandInfoV0 {
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+
)
32+
}
33+
34+
func toSymbols(_ path: [String] = []) -> [SymbolGraph.Symbol] {
35+
var symbols: [SymbolGraph.Symbol] = []
36+
37+
var myPath = path
38+
myPath.append(self.commandName)
39+
40+
guard myPath.last != "help" else {
41+
return []
42+
}
43+
44+
var docComments: SymbolGraph.LineList = if let abstract = self.abstract { .init([SymbolGraph.LineList.Line(text: abstract, range: nil )]) } else { .init([]) }
45+
46+
if let args = self.arguments, args.count != 0 {
47+
let commandString: String = myPath.joined(separator: " ")
48+
49+
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
50+
}
51+
52+
if let discussion = self.discussion {
53+
docComments = .init(docComments.lines + (discussion.split(separator: "\n").map({ SymbolGraph.LineList.Line(text: String($0), range: nil )})))
54+
}
55+
56+
for arg in self.arguments ?? [] {
57+
docComments = .init(docComments.lines + [SymbolGraph.LineList.Line(text: "## \(arg.identity())\n\n\(arg.abstract ?? "")\n\n" + (arg.discussion ?? ""), range: nil)])
58+
}
59+
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))
73+
}
74+
75+
return symbols
76+
}
77+
78+
/// Returns a mutl-line string that presents the arguments for a command.
79+
/// - Parameters:
80+
/// - startlength: The starting width of the line this multi-line string appends onto.
81+
/// - wraplength: The maximum width of the multi-linecode block.
82+
/// - Returns: A wrapped, multi-line string that wraps the commands arguments into a text block.
83+
public func usage(startlength: Int, wraplength: Int) -> String {
84+
guard let args = self.arguments else {
85+
return ""
86+
}
87+
88+
var multilineString = ""
89+
// This is a greedy algorithm to wrap the arguments into a
90+
// multi-line string that is expected to be returned within
91+
// a markdown code block (pre-formatted text).
92+
var currentLength = startlength
93+
for arg in args where arg.shouldDisplay {
94+
let nextUsage = arg.usage()
95+
if currentLength + arg.usage().count > wraplength {
96+
// the next usage() string exceeds the max width, wrap it.
97+
multilineString.append("\n \(nextUsage)")
98+
currentLength = nextUsage.count + 2 // prepend spacing length of 2
99+
} else {
100+
// the next usage() string doesn't exceed the max width
101+
multilineString.append(" \(nextUsage)")
102+
currentLength += nextUsage.count + 1
103+
}
104+
}
105+
return multilineString
106+
}
107+
}
108+
109+
extension ArgumentInfoV0 {
110+
/// Returns a string that describes the use of the argument.
111+
///
112+
/// If `shouldDisplay` is `false`, an empty string is returned.
113+
public func usage() -> String {
114+
guard self.shouldDisplay else {
115+
return ""
116+
}
117+
118+
let names: [String]
119+
120+
if let myNames = self.names {
121+
names = myNames.filter { $0.kind == .long }.map(\.name)
122+
} else if let preferred = self.preferredName {
123+
names = [preferred.name]
124+
} else if let value = self.valueName {
125+
names = [value]
126+
} else {
127+
return ""
128+
}
129+
130+
// TODO: default values, short, etc.
131+
132+
var inner: String
133+
switch self.kind {
134+
case .positional:
135+
inner = "<\(names.joined(separator: "|"))>"
136+
case .option:
137+
inner = "--\(names.joined(separator: "|"))=<\(self.valueName ?? "")>"
138+
case .flag:
139+
inner = "--\(names.joined(separator: "|"))"
140+
}
141+
142+
if self.isRepeating {
143+
inner += "..."
144+
}
145+
146+
if self.isOptional {
147+
return "[\(inner)]"
148+
}
149+
150+
return inner
151+
}
152+
153+
public func identity() -> String {
154+
let names: [String]
155+
if let myNames = self.names {
156+
names = myNames.filter { $0.kind == .long }.map(\.name)
157+
} else if let preferred = self.preferredName {
158+
names = [preferred.name]
159+
} else if let value = self.valueName {
160+
names = [value]
161+
} else {
162+
return ""
163+
}
164+
165+
// TODO: default values, values, short, etc.
166+
167+
let inner: String
168+
switch self.kind {
169+
case .positional:
170+
inner = "\(names.joined(separator: "|"))"
171+
case .option:
172+
inner = "--\(names.joined(separator: "|"))=\\<\(self.valueName ?? "")\\>"
173+
case .flag:
174+
inner = "--\(names.joined(separator: "|"))"
175+
}
176+
return inner
177+
}
178+
}
179+
180+
struct GenerateDocumentation: AsyncSwiftCommand {
181+
static let configuration = CommandConfiguration(
182+
abstract: "Generate documentation for a package, or targets")
183+
184+
@Flag(help: .init("Generate documentation for the internal targets of the package. Otherwise, it generates only documentation for the products of the package."))
185+
var internalDocs: Bool = false
186+
187+
@OptionGroup(visibility: .hidden)
188+
var globalOptions: GlobalOptions
189+
190+
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+
193+
let buildSystem = try await swiftCommandState.createBuildSystem()
194+
195+
let outputs = try await buildSystem.build(subset: .allExcludingTests, buildOutputs: [
196+
.symbolGraph(
197+
.init(
198+
// TODO make these all command-line parameters
199+
minimumAccessLevel: .public,
200+
includeInheritedDocs: true,
201+
includeSynthesized: true,
202+
includeSPI: true,
203+
emitExtensionBlocks: true
204+
)
205+
),
206+
.builtArtifacts,
207+
])
208+
209+
guard let symbolGraph = outputs.symbolGraph else {
210+
fatalError("Try again with swiftbuild build system") // FIXME - make this work with the native build system too
211+
}
212+
213+
guard let builtArtifacts = outputs.builtArtifacts else {
214+
fatalError("Could not get list of built artifacts")
215+
}
216+
217+
// The build system produced symbol graphs for us, one for each target.
218+
let buildPath = try swiftCommandState.productsBuildParameters.buildPath
219+
220+
var doccArchives: [String] = []
221+
let doccExecutable = try swiftCommandState.toolsBuildParameters.toolchain.toolchainDir.appending(components: ["usr", "bin", "docc"])
222+
223+
var modules: [ResolvedModule] = []
224+
var products: [ResolvedProduct] = []
225+
226+
// Copy the symbol graphs from the target-specific locations to the single output directory
227+
for rootPackage in try await buildSystem.getPackageGraph().rootPackages {
228+
if !internalDocs {
229+
for product in rootPackage.products {
230+
for module in product.modules {
231+
modules.append(module)
232+
}
233+
234+
products.append(product)
235+
}
236+
} else {
237+
modules.append(contentsOf: rootPackage.modules)
238+
products.append(contentsOf: rootPackage.products)
239+
}
240+
}
241+
242+
for product in products {
243+
if product.type == .executable {
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)")
269+
}
270+
}
271+
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. You can add your own documentation for this executable product by adding a documentation directory with the '.docc' file extension and your own DocC formatted markdown files in the module for this product.")
274+
continue
275+
}
276+
277+
let catalogArgs = if let doccCatalogDir {[doccCatalogDir.pathString]} else {[String]()}
278+
let graphArgs = if let symbolGraphDir {["--additional-symbol-graph-dir=\(symbolGraphDir)"]} else {[String]()}
279+
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)
299+
}
300+
}
301+
}
302+
303+
for module: ResolvedModule in modules {
304+
let symbolGraphDir = symbolGraph.outputLocationForTarget(module.name, try swiftCommandState.productsBuildParameters)
305+
let symbolGraphPath = buildPath.appending(components: symbolGraphDir)
306+
307+
// The DocC catalog for this module is any directory with the docc file extension
308+
let doccCatalogDir = module.underlying.others.first { sourceFile in
309+
return sourceFile.extension?.lowercased() == "docc"
310+
}
311+
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. You can write your own documentation for this target by creating a directory with a '.docc' file extension and adding DocC formatted markdown files.")
314+
continue
315+
}
316+
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)
325+
326+
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
327+
"convert",
328+
] + catalogArgs + [
329+
"--fallback-display-name=\(module.name)",
330+
"--fallback-bundle-identifier=\(module.name)",
331+
] + graphArgs + [
332+
"--output-path=\(archiveDir)",
333+
])
334+
process.waitUntilExit()
335+
336+
if swiftCommandState.fileSystem.exists(archiveDir) {
337+
doccArchives.append(archiveDir.pathString)
338+
}
339+
}
340+
341+
guard doccArchives.count > 0 else {
342+
print("No modules are available to document.")
343+
return
344+
}
345+
346+
let packageName = try await buildSystem.getPackageGraph().rootPackages.first!.identity.description
347+
let outputPath = buildPath.appending(components: ["Swift-DocC", packageName])
348+
349+
try? swiftCommandState.fileSystem.removeFileTree(outputPath) // docc merge requires an empty output directory
350+
try swiftCommandState.fileSystem.createDirectory(outputPath, recursive: true)
351+
352+
print("MERGE: \(doccArchives)")
353+
354+
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
355+
"merge",
356+
"--synthesized-landing-page-name=\(packageName)",
357+
"--synthesized-landing-page-kind=Package",
358+
] + doccArchives + [
359+
"--output-path=\(outputPath)"
360+
])
361+
process.waitUntilExit()
362+
363+
// TODO provide an option to set up an http server
364+
print("python3 -m http.server --directory \(outputPath)")
365+
print("http://localhost:8000/documentation")
366+
}
367+
}

0 commit comments

Comments
 (0)