|
| 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 | +} |
0 commit comments