Skip to content

Commit 48a799e

Browse files
authored
Add experimental manual page generation (#332)
- Adds a swift package manager command plugin called GenerateManualPlugin. The plugin can be invoked from the command line using `swift package experimental-generate-manual`. The plugin is prefixed for now with "experimental-" to indicate it is not mature and may see breaking changes to its CLI and output in the future. The plugin can be can be used to generate a manual in MDoc syntax for any swift-argument-parser tool that can be executed via `tool --experimental-dump-info`. - The plugin works by converting the `ToolInfoV0` structure from the `ArgumentParserToolInfo` library into MDoc AST nodes using a custom (SwiftUI-esk) result builder DSL. The MDoc AST is then lowered to a string and written to disk. - The MDoc AST included is not general purpose and doesn't represent the true language exactly, so it is private to the underlying `generate-manual` tool. In the future it would be interesting to finish fleshing out this MDoc library and spin it out, however this is not a priority. - Next steps include: - Improving the command line interface for the plugin. - Adding support for "extended discussions" to Commands and exposing this information in manuals. - Further improve the escaping logic to properly escape MDoc macros that might happen to appear in user's help strings. - Ingesting external content a-la swift-docc so the entire tool documentation does not need to be included in the binary itself. - Bug fixes and addressing developer/user feedback. Built with love, @rauhul
1 parent 78213f3 commit 48a799e

39 files changed

+3771
-4
lines changed

Package.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ var package = Package(
2121
],
2222
dependencies: [],
2323
targets: [
24+
// Core Library
2425
.target(
2526
name: "ArgumentParser",
2627
dependencies: ["ArgumentParserToolInfo"],
@@ -34,6 +35,7 @@ var package = Package(
3435
dependencies: [],
3536
exclude: ["CMakeLists.txt"]),
3637

38+
// Examples
3739
.executableTarget(
3840
name: "roll",
3941
dependencies: ["ArgumentParser"],
@@ -47,6 +49,7 @@ var package = Package(
4749
dependencies: ["ArgumentParser"],
4850
path: "Examples/repeat"),
4951

52+
// Tests
5053
.testTarget(
5154
name: "ArgumentParserEndToEndTests",
5255
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
@@ -68,10 +71,13 @@ var package = Package(
6871

6972
#if swift(>=5.6) && os(macOS)
7073
package.targets.append(contentsOf: [
74+
// Examples
7175
.executableTarget(
7276
name: "count-lines",
7377
dependencies: ["ArgumentParser"],
7478
path: "Examples/count-lines"),
79+
80+
// Tools
7581
.executableTarget(
7682
name: "changelog-authors",
7783
dependencies: ["ArgumentParser"],

[email protected]

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// swift-tools-version:5.6
2+
//===----------------------------------------------------------*- swift -*-===//
3+
//
4+
// This source file is part of the Swift Argument Parser open source project
5+
//
6+
// Copyright (c) 2020 Apple Inc. and the Swift project authors
7+
// Licensed under Apache License v2.0 with Runtime Library Exception
8+
//
9+
// See https://swift.org/LICENSE.txt for license information
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import PackageDescription
14+
15+
var package = Package(
16+
name: "swift-argument-parser",
17+
products: [
18+
.library(
19+
name: "ArgumentParser",
20+
targets: ["ArgumentParser"]),
21+
],
22+
dependencies: [],
23+
targets: [
24+
// Core Library
25+
.target(
26+
name: "ArgumentParser",
27+
dependencies: ["ArgumentParserToolInfo"],
28+
exclude: ["CMakeLists.txt"]),
29+
.target(
30+
name: "ArgumentParserTestHelpers",
31+
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"],
32+
exclude: ["CMakeLists.txt"]),
33+
.target(
34+
name: "ArgumentParserToolInfo",
35+
dependencies: [ ],
36+
exclude: ["CMakeLists.txt"]),
37+
38+
// Plugins
39+
.plugin(
40+
name: "GenerateManualPlugin",
41+
capability: .command(
42+
intent: .custom(
43+
verb: "experimental-generate-manual",
44+
description: "Generate a manual entry for a specified target.")),
45+
dependencies: ["generate-manual"]),
46+
47+
// Examples
48+
.executableTarget(
49+
name: "roll",
50+
dependencies: ["ArgumentParser"],
51+
path: "Examples/roll"),
52+
.executableTarget(
53+
name: "math",
54+
dependencies: ["ArgumentParser"],
55+
path: "Examples/math"),
56+
.executableTarget(
57+
name: "repeat",
58+
dependencies: ["ArgumentParser"],
59+
path: "Examples/repeat"),
60+
61+
// Tools
62+
.executableTarget(
63+
name: "generate-manual",
64+
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"],
65+
path: "Tools/generate-manual"),
66+
67+
// Tests
68+
.testTarget(
69+
name: "ArgumentParserEndToEndTests",
70+
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
71+
exclude: ["CMakeLists.txt"]),
72+
.testTarget(
73+
name: "ArgumentParserExampleTests",
74+
dependencies: ["ArgumentParserTestHelpers"],
75+
resources: [.copy("CountLinesTest.txt")]),
76+
.testTarget(
77+
name: "ArgumentParserGenerateManualTests",
78+
dependencies: ["ArgumentParserTestHelpers"]),
79+
.testTarget(
80+
name: "ArgumentParserPackageManagerTests",
81+
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
82+
exclude: ["CMakeLists.txt"]),
83+
.testTarget(
84+
name: "ArgumentParserUnitTests",
85+
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
86+
exclude: ["CMakeLists.txt"]),
87+
]
88+
)
89+
90+
#if os(macOS)
91+
package.targets.append(contentsOf: [
92+
// Examples
93+
.executableTarget(
94+
name: "count-lines",
95+
dependencies: ["ArgumentParser"],
96+
path: "Examples/count-lines"),
97+
98+
// Tools
99+
.executableTarget(
100+
name: "changelog-authors",
101+
dependencies: ["ArgumentParser"],
102+
path: "Tools/changelog-authors"),
103+
])
104+
#endif
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
import PackagePlugin
13+
import Foundation
14+
15+
@main
16+
struct GenerateManualPlugin: CommandPlugin {
17+
func performCommand(
18+
context: PluginContext,
19+
arguments: [String]
20+
) async throws {
21+
// Locate generation tool.
22+
let generationToolFile = try context.tool(named: "generate-manual").path
23+
24+
// Create an extractor to extract plugin-only arguments from the `arguments`
25+
// array.
26+
var extractor = ArgumentExtractor(arguments)
27+
28+
// Run generation tool once if help is requested.
29+
if extractor.helpRequest() {
30+
try generationToolFile.exec(arguments: ["--help"])
31+
print("""
32+
ADDITIONAL OPTIONS:
33+
--configuration <configuration>
34+
Tool build configuration used to generate the
35+
manual. (default: release)
36+
37+
NOTE: The "GenerateManual" plugin handles passing the "<tool>" and
38+
"--output-directory <output-directory>" arguments. Manually supplying
39+
these arguments will result in a runtime failure.
40+
""")
41+
return
42+
}
43+
44+
// Extract configuration argument before making it to the
45+
// "generate-manual" tool.
46+
let configuration = try extractor.configuration()
47+
48+
// Build all products first.
49+
print("Building package in \(configuration) mode...")
50+
let buildResult = try packageManager.build(
51+
.all(includingTests: false),
52+
parameters: .init(configuration: configuration))
53+
54+
guard buildResult.succeeded else {
55+
throw GenerateManualPluginError.buildFailed(buildResult.logText)
56+
}
57+
print("Built package in \(configuration) mode")
58+
59+
// Run generate-manual on all executable artifacts.
60+
for builtArtifact in buildResult.builtArtifacts {
61+
// Skip non-executable targets
62+
guard builtArtifact.kind == .executable else { continue }
63+
64+
// Skip executables without a matching product.
65+
guard let product = builtArtifact.matchingProduct(context: context)
66+
else { continue }
67+
68+
// Skip products without a dependency on ArgumentParser.
69+
guard product.hasDependency(named: "ArgumentParser") else { continue }
70+
71+
// Get the artifacts name.
72+
let executableName = builtArtifact.path.lastComponent
73+
print("Generating manual for \(executableName)...")
74+
75+
// Create output directory.
76+
let outputDirectory = context
77+
.pluginWorkDirectory
78+
.appending(executableName)
79+
try outputDirectory.createOutputDirectory()
80+
81+
// Create generation tool arguments.
82+
var generationToolArguments = [
83+
builtArtifact.path.string,
84+
"--output-directory",
85+
outputDirectory.string
86+
]
87+
generationToolArguments.append(
88+
contentsOf: extractor.unextractedOptionsOrFlags)
89+
90+
// Spawn generation tool.
91+
try generationToolFile.exec(arguments: generationToolArguments)
92+
print("Generated manual in '\(outputDirectory)'")
93+
}
94+
}
95+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
import Foundation
13+
import PackagePlugin
14+
15+
enum GenerateManualPluginError: Error {
16+
case unknownBuildConfiguration(String)
17+
case buildFailed(String)
18+
case createOutputDirectoryFailed(Error)
19+
case subprocessFailedNonZeroExit(Path, Int32)
20+
case subprocessFailedError(Path, Error)
21+
}
22+
23+
extension GenerateManualPluginError: CustomStringConvertible {
24+
var description: String {
25+
switch self {
26+
case .unknownBuildConfiguration(let configuration):
27+
return "Build failed: Unknown build configuration '\(configuration)'."
28+
case .buildFailed(let logText):
29+
return "Build failed: \(logText)."
30+
case .createOutputDirectoryFailed(let error):
31+
return """
32+
Failed to create output directory: '\(error.localizedDescription)'
33+
"""
34+
case .subprocessFailedNonZeroExit(let tool, let exitCode):
35+
return """
36+
'\(tool.lastComponent)' invocation failed with a nonzero exit code: \
37+
'\(exitCode)'.
38+
"""
39+
case .subprocessFailedError(let tool, let error):
40+
return """
41+
'\(tool.lastComponent)' invocation failed: \
42+
'\(error.localizedDescription)'
43+
"""
44+
}
45+
}
46+
}
47+
48+
extension GenerateManualPluginError: LocalizedError {
49+
var localizedDescription: String { self.description }
50+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
import Foundation
13+
import PackagePlugin
14+
15+
extension ArgumentExtractor {
16+
mutating func helpRequest() -> Bool {
17+
self.extractFlag(named: "help") > 0
18+
}
19+
20+
mutating func configuration() throws -> PackageManager.BuildConfiguration {
21+
switch self.extractOption(named: "configuration").first {
22+
case .some(let configurationString):
23+
switch configurationString {
24+
case "debug":
25+
return .debug
26+
case "release":
27+
return .release
28+
default:
29+
throw GenerateManualPluginError
30+
.unknownBuildConfiguration(configurationString)
31+
}
32+
case .none:
33+
return .release
34+
}
35+
}
36+
}
37+
38+
extension Path {
39+
func createOutputDirectory() throws {
40+
do {
41+
try FileManager.default.createDirectory(
42+
atPath: self.string,
43+
withIntermediateDirectories: true)
44+
} catch {
45+
throw GenerateManualPluginError.createOutputDirectoryFailed(error)
46+
}
47+
}
48+
49+
func exec(arguments: [String]) throws {
50+
do {
51+
let process = Process()
52+
process.executableURL = URL(fileURLWithPath: self.string)
53+
process.arguments = arguments
54+
try process.run()
55+
process.waitUntilExit()
56+
guard
57+
process.terminationReason == .exit,
58+
process.terminationStatus == 0
59+
else {
60+
throw GenerateManualPluginError.subprocessFailedNonZeroExit(
61+
self, process.terminationStatus)
62+
}
63+
} catch {
64+
throw GenerateManualPluginError.subprocessFailedError(self, error)
65+
}
66+
}
67+
}
68+
69+
extension PackageManager.BuildResult.BuiltArtifact {
70+
func matchingProduct(context: PluginContext) -> Product? {
71+
context
72+
.package
73+
.products
74+
.first { $0.name == self.path.lastComponent }
75+
}
76+
}
77+
78+
extension Product {
79+
func hasDependency(named name: String) -> Bool {
80+
recursiveTargetDependencies
81+
.contains { $0.name == name }
82+
}
83+
84+
var recursiveTargetDependencies: [Target] {
85+
var dependencies = [Target.ID: Target]()
86+
for target in self.targets {
87+
for dependency in target.recursiveTargetDependencies {
88+
dependencies[dependency.id] = dependency
89+
}
90+
}
91+
return Array(dependencies.values)
92+
}
93+
}

0 commit comments

Comments
 (0)