Skip to content

Commit ffc0031

Browse files
committed
DNM: generate-documentation basics
1 parent 4c4487b commit ffc0031

File tree

2 files changed

+388
-0
lines changed

2 files changed

+388
-0
lines changed
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
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+
23+
extension CommandInfoV0 {
24+
var doccReferenceFileName: String {
25+
doccReferenceTitle + ".md"
26+
}
27+
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+
}
37+
38+
var doccReferenceName: String {
39+
let parts = (superCommands ?? []) + [commandName]
40+
return parts.joined(separator: " ")
41+
}
42+
}
43+
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"
64+
}
65+
66+
if let abstract = self.abstract {
67+
result += "\(abstract)\n\n"
68+
}
69+
70+
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+
}
79+
80+
if let discussion = self.discussion {
81+
result += "\(discussion)\n\n"
82+
}
83+
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+
}
100+
}
101+
102+
for subcommand in self.subcommands ?? [] {
103+
result +=
104+
subcommand.toMarkdown(
105+
path + [self.commandName]) + "\n\n"
106+
}
107+
108+
return result
109+
}
110+
111+
/// Returns a mutl-line string that presents the arguments for a command.
112+
/// - Parameters:
113+
/// - startlength: The starting width of the line this multi-line string appends onto.
114+
/// - wraplength: The maximum width of the multi-linecode block.
115+
/// - Returns: A wrapped, multi-line string that wraps the commands arguments into a text block.
116+
public func usage(startlength: Int, wraplength: Int) -> String {
117+
guard let args = self.arguments else {
118+
return ""
119+
}
120+
121+
var multilineString = ""
122+
// This is a greedy algorithm to wrap the arguments into a
123+
// multi-line string that is expected to be returned within
124+
// a markdown code block (pre-formatted text).
125+
var currentLength = startlength
126+
for arg in args where arg.shouldDisplay {
127+
let nextUsage = arg.usage()
128+
if currentLength + arg.usage().count > wraplength {
129+
// the next usage() string exceeds the max width, wrap it.
130+
multilineString.append("\n \(nextUsage)")
131+
currentLength = nextUsage.count + 2 // prepend spacing length of 2
132+
} else {
133+
// the next usage() string doesn't exceed the max width
134+
multilineString.append(" \(nextUsage)")
135+
currentLength += nextUsage.count + 1
136+
}
137+
}
138+
return multilineString
139+
}
140+
}
141+
142+
extension ArgumentInfoV0 {
143+
/// Returns a string that describes the use of the argument.
144+
///
145+
/// If `shouldDisplay` is `false`, an empty string is returned.
146+
public func usage() -> String {
147+
guard self.shouldDisplay else {
148+
return ""
149+
}
150+
151+
let names: [String]
152+
153+
if let myNames = self.names {
154+
names = myNames.filter { $0.kind == .long }.map(\.name)
155+
} else if let preferred = self.preferredName {
156+
names = [preferred.name]
157+
} else if let value = self.valueName {
158+
names = [value]
159+
} else {
160+
return ""
161+
}
162+
163+
// TODO: default values, short, etc.
164+
165+
var inner: String
166+
switch self.kind {
167+
case .positional:
168+
inner = "<\(names.joined(separator: "|"))>"
169+
case .option:
170+
inner = "--\(names.joined(separator: "|"))=<\(self.valueName ?? "")>"
171+
case .flag:
172+
inner = "--\(names.joined(separator: "|"))"
173+
}
174+
175+
if self.isRepeating {
176+
inner += "..."
177+
}
178+
179+
if self.isOptional {
180+
return "[\(inner)]"
181+
}
182+
183+
return inner
184+
}
185+
186+
public func identity() -> String {
187+
let names: [String]
188+
if let myNames = self.names {
189+
names = myNames.filter { $0.kind == .long }.map(\.name)
190+
} else if let preferred = self.preferredName {
191+
names = [preferred.name]
192+
} else if let value = self.valueName {
193+
names = [value]
194+
} else {
195+
return ""
196+
}
197+
198+
// TODO: default values, values, short, etc.
199+
200+
let inner: String
201+
switch self.kind {
202+
case .positional:
203+
inner = "\(names.joined(separator: "|"))"
204+
case .option:
205+
inner = "--\(names.joined(separator: "|"))=\\<\(self.valueName ?? "")\\>"
206+
case .flag:
207+
inner = "--\(names.joined(separator: "|"))"
208+
}
209+
return inner
210+
}
211+
}
212+
213+
struct GenerateDocumentation: AsyncSwiftCommand {
214+
static let configuration = CommandConfiguration(
215+
abstract: "Generate documentation for a package, or targets")
216+
217+
@Flag(help: .init("Generate documentation for the internal targets of the package. Otherwise, it generates only documentation for the products of the package."))
218+
var internalDocs: Bool = false
219+
220+
@OptionGroup(visibility: .hidden)
221+
var globalOptions: GlobalOptions
222+
223+
func run(_ swiftCommandState: SwiftCommandState) async throws {
224+
let buildSystem = try await swiftCommandState.createBuildSystem()
225+
226+
// TODO build only the product related targets when not generating internal docs
227+
let outputs = try await buildSystem.build(subset: .allExcludingTests, buildOutputs: [
228+
.symbolGraph(
229+
.init(
230+
// TODO make these all command-line parameters
231+
minimumAccessLevel: .public,
232+
includeInheritedDocs: true,
233+
includeSynthesized: true,
234+
includeSPI: true,
235+
emitExtensionBlocks: true
236+
)
237+
),
238+
.builtArtifacts,
239+
])
240+
241+
guard let symbolGraph = outputs.symbolGraph else {
242+
fatalError("Try again with swiftbuild build system") // FIXME - make this work with the native build system too
243+
}
244+
245+
guard let builtArtifacts = outputs.builtArtifacts else {
246+
fatalError("Could not get list of built artifacts")
247+
}
248+
249+
// The build system produced symbol graphs for us, one for each target.
250+
let buildPath = try swiftCommandState.productsBuildParameters.buildPath
251+
252+
var doccArchives: [String] = []
253+
let doccExecutable = try swiftCommandState.toolsBuildParameters.toolchain.toolchainDir.appending(components: ["usr", "bin", "docc"])
254+
255+
var modules: [ResolvedModule] = []
256+
257+
// Copy the symbol graphs from the target-specific locations to the single output directory
258+
for rootPackage in try await buildSystem.getPackageGraph().rootPackages {
259+
if !internalDocs {
260+
for product in rootPackage.products {
261+
modules.append(contentsOf: product.modules)
262+
}
263+
} else {
264+
modules.append(contentsOf: rootPackage.modules)
265+
}
266+
}
267+
268+
for module: ResolvedModule in modules {
269+
// TODO handle executable modules differently using a command-line reference generator
270+
guard module.type != .test && module.type != .plugin else {
271+
continue
272+
}
273+
274+
if module.type == .executable {
275+
// TODO check on the tuple filtering based on the module name here
276+
// FIXME executables are at the product level, not the module level, this needs to be redone to fit that model
277+
let exec = builtArtifacts.filter({ $0.0 == "\(module.name.lowercased())-product" }).map( { $0.1.path } ).first
278+
279+
guard let exec else {
280+
print("Couldn't find \(module.name)")
281+
continue
282+
}
283+
284+
do {
285+
// FIXME run the executable within a very restricted sandbox
286+
let dumpHelpProcess = AsyncProcess(args: [exec, "--experimental-dump-help"], outputRedirection: .collect)
287+
try dumpHelpProcess.launch()
288+
let result = try await dumpHelpProcess.waitUntilExit()
289+
let output = try result.utf8Output()
290+
let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output)
291+
292+
let page = toolInfo.command.toMarkdown([])
293+
294+
// Creating a simple DocC catalog
295+
// TODO copy over an existing DocC catalog for this module if one exists already
296+
let catalogDir = buildPath.appending(components: ["tool-docc-catalog", module.name, "Documentation.docc"])
297+
try? swiftCommandState.fileSystem.removeFileTree(catalogDir)
298+
try swiftCommandState.fileSystem.createDirectory(catalogDir, recursive: true)
299+
let summaryMd = catalogDir.appending(components: ["\(module.name.lowercased()).md"])
300+
try swiftCommandState.fileSystem.writeFileContents(summaryMd, string: page)
301+
302+
let archiveDir = buildPath.appending(components: ["tool-docc-archive", "\(module.name).doccarchive"])
303+
try? swiftCommandState.fileSystem.removeFileTree(archiveDir)
304+
try swiftCommandState.fileSystem.createDirectory(archiveDir.parentDirectory, recursive: true)
305+
306+
print("CONVERT TOOL: \(module.name)")
307+
308+
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
309+
"convert",
310+
catalogDir.pathString,
311+
"--fallback-display-name=\(module.name)",
312+
"--fallback-bundle-identifier=\(module.name)",
313+
"--output-path=\(archiveDir)",
314+
])
315+
process.waitUntilExit()
316+
317+
if swiftCommandState.fileSystem.exists(archiveDir) {
318+
doccArchives.append(archiveDir.pathString)
319+
}
320+
} catch {
321+
print("warning: could not generate tool info documentation for \(module.name)")
322+
}
323+
324+
continue
325+
}
326+
327+
328+
let inputPathDir = symbolGraph.outputLocationForTarget(module.name, try swiftCommandState.productsBuildParameters)
329+
let inputPath = buildPath.appending(components: inputPathDir)
330+
let outputPathDir = [String](inputPathDir.dropLast()) + [inputPathDir.last!.replacing(".symbolgraphs", with: ".doccarchive")]
331+
let outputPath = buildPath.appending(components: outputPathDir)
332+
333+
// The DocC catalog for this module is any directory with the docc file extension
334+
let doccCatalog = module.underlying.others.first { sourceFile in
335+
return sourceFile.extension?.lowercased() == "docc"
336+
}
337+
338+
let catalogArgs = if let doccCatalog {[doccCatalog.pathString]} else {[String]()}
339+
340+
print("CONVERT: \(module.name)")
341+
342+
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
343+
"convert",
344+
] + catalogArgs + [
345+
"--fallback-display-name=\(module.name)",
346+
"--fallback-bundle-identifier=\(module.name)",
347+
"--additional-symbol-graph-dir=\(inputPath)",
348+
"--output-path=\(outputPath)",
349+
])
350+
process.waitUntilExit()
351+
352+
if swiftCommandState.fileSystem.exists(outputPath) {
353+
doccArchives.append(outputPath.pathString)
354+
}
355+
}
356+
357+
guard doccArchives.count > 0 else {
358+
// FIXME consider presenting just the README.md contents if possible
359+
print("No modules are available to document.")
360+
return
361+
}
362+
363+
let packageName = try await buildSystem.getPackageGraph().rootPackages.first!.identity.description
364+
let outputPath = buildPath.appending(components: ["Swift-DocC", packageName])
365+
366+
try? swiftCommandState.fileSystem.removeFileTree(outputPath) // docc merge requires an empty output directory
367+
try swiftCommandState.fileSystem.createDirectory(outputPath, recursive: true)
368+
369+
print("MERGE")
370+
371+
let process = try Process.run(URL(fileURLWithPath: doccExecutable.pathString), arguments: [
372+
"merge",
373+
"--synthesized-landing-page-name=\(packageName)",
374+
"--synthesized-landing-page-kind=Package",
375+
] + doccArchives + [
376+
"--output-path=\(outputPath)"
377+
])
378+
process.waitUntilExit()
379+
380+
// TODO provide an option to set up an http server
381+
print("python3 -m http.server --directory \(outputPath)")
382+
print("http://localhost:8000/documentation")
383+
384+
// 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)
385+
}
386+
}

Sources/Commands/PackageCommands/SwiftPackageCommand.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ public struct SwiftPackageCommand: AsyncParsableCommand {
7373
CompletionCommand.self,
7474
PluginCommand.self,
7575

76+
GenerateDocumentation.self,
77+
7678
DefaultCommand.self,
7779
]
7880
+ (ProcessInfo.processInfo.environment["SWIFTPM_ENABLE_SNIPPETS"] == "1" ? [Learn.self] : []),

0 commit comments

Comments
 (0)