Skip to content

Commit

Permalink
Add experimental manual page generation (#332)
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
rauhul committed Jun 6, 2022
1 parent 78213f3 commit 48a799e
Show file tree
Hide file tree
Showing 39 changed files with 3,771 additions and 4 deletions.
6 changes: 6 additions & 0 deletions Package.swift
Expand Up @@ -21,6 +21,7 @@ var package = Package(
],
dependencies: [],
targets: [
// Core Library
.target(
name: "ArgumentParser",
dependencies: ["ArgumentParserToolInfo"],
Expand All @@ -34,6 +35,7 @@ var package = Package(
dependencies: [],
exclude: ["CMakeLists.txt"]),

// Examples
.executableTarget(
name: "roll",
dependencies: ["ArgumentParser"],
Expand All @@ -47,6 +49,7 @@ var package = Package(
dependencies: ["ArgumentParser"],
path: "Examples/repeat"),

// Tests
.testTarget(
name: "ArgumentParserEndToEndTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
Expand All @@ -68,10 +71,13 @@ var package = Package(

#if swift(>=5.6) && os(macOS)
package.targets.append(contentsOf: [
// Examples
.executableTarget(
name: "count-lines",
dependencies: ["ArgumentParser"],
path: "Examples/count-lines"),

// Tools
.executableTarget(
name: "changelog-authors",
dependencies: ["ArgumentParser"],
Expand Down
104 changes: 104 additions & 0 deletions Package@swift-5.6.swift
@@ -0,0 +1,104 @@
// swift-tools-version:5.6
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import PackageDescription

var package = Package(
name: "swift-argument-parser",
products: [
.library(
name: "ArgumentParser",
targets: ["ArgumentParser"]),
],
dependencies: [],
targets: [
// Core Library
.target(
name: "ArgumentParser",
dependencies: ["ArgumentParserToolInfo"],
exclude: ["CMakeLists.txt"]),
.target(
name: "ArgumentParserTestHelpers",
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"],
exclude: ["CMakeLists.txt"]),
.target(
name: "ArgumentParserToolInfo",
dependencies: [ ],
exclude: ["CMakeLists.txt"]),

// Plugins
.plugin(
name: "GenerateManualPlugin",
capability: .command(
intent: .custom(
verb: "experimental-generate-manual",
description: "Generate a manual entry for a specified target.")),
dependencies: ["generate-manual"]),

// Examples
.executableTarget(
name: "roll",
dependencies: ["ArgumentParser"],
path: "Examples/roll"),
.executableTarget(
name: "math",
dependencies: ["ArgumentParser"],
path: "Examples/math"),
.executableTarget(
name: "repeat",
dependencies: ["ArgumentParser"],
path: "Examples/repeat"),

// Tools
.executableTarget(
name: "generate-manual",
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"],
path: "Tools/generate-manual"),

// Tests
.testTarget(
name: "ArgumentParserEndToEndTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserExampleTests",
dependencies: ["ArgumentParserTestHelpers"],
resources: [.copy("CountLinesTest.txt")]),
.testTarget(
name: "ArgumentParserGenerateManualTests",
dependencies: ["ArgumentParserTestHelpers"]),
.testTarget(
name: "ArgumentParserPackageManagerTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserUnitTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
]
)

#if os(macOS)
package.targets.append(contentsOf: [
// Examples
.executableTarget(
name: "count-lines",
dependencies: ["ArgumentParser"],
path: "Examples/count-lines"),

// Tools
.executableTarget(
name: "changelog-authors",
dependencies: ["ArgumentParser"],
path: "Tools/changelog-authors"),
])
#endif
95 changes: 95 additions & 0 deletions Plugins/GenerateManualPlugin/GenerateManualPlugin.swift
@@ -0,0 +1,95 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2021 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import PackagePlugin
import Foundation

@main
struct GenerateManualPlugin: CommandPlugin {
func performCommand(
context: PluginContext,
arguments: [String]
) async throws {
// Locate generation tool.
let generationToolFile = try context.tool(named: "generate-manual").path

// Create an extractor to extract plugin-only arguments from the `arguments`
// array.
var extractor = ArgumentExtractor(arguments)

// Run generation tool once if help is requested.
if extractor.helpRequest() {
try generationToolFile.exec(arguments: ["--help"])
print("""
ADDITIONAL OPTIONS:
--configuration <configuration>
Tool build configuration used to generate the
manual. (default: release)
NOTE: The "GenerateManual" plugin handles passing the "<tool>" and
"--output-directory <output-directory>" arguments. Manually supplying
these arguments will result in a runtime failure.
""")
return
}

// Extract configuration argument before making it to the
// "generate-manual" tool.
let configuration = try extractor.configuration()

// Build all products first.
print("Building package in \(configuration) mode...")
let buildResult = try packageManager.build(
.all(includingTests: false),
parameters: .init(configuration: configuration))

guard buildResult.succeeded else {
throw GenerateManualPluginError.buildFailed(buildResult.logText)
}
print("Built package in \(configuration) mode")

// Run generate-manual on all executable artifacts.
for builtArtifact in buildResult.builtArtifacts {
// Skip non-executable targets
guard builtArtifact.kind == .executable else { continue }

// Skip executables without a matching product.
guard let product = builtArtifact.matchingProduct(context: context)
else { continue }

// Skip products without a dependency on ArgumentParser.
guard product.hasDependency(named: "ArgumentParser") else { continue }

// Get the artifacts name.
let executableName = builtArtifact.path.lastComponent
print("Generating manual for \(executableName)...")

// Create output directory.
let outputDirectory = context
.pluginWorkDirectory
.appending(executableName)
try outputDirectory.createOutputDirectory()

// Create generation tool arguments.
var generationToolArguments = [
builtArtifact.path.string,
"--output-directory",
outputDirectory.string
]
generationToolArguments.append(
contentsOf: extractor.unextractedOptionsOrFlags)

// Spawn generation tool.
try generationToolFile.exec(arguments: generationToolArguments)
print("Generated manual in '\(outputDirectory)'")
}
}
}
50 changes: 50 additions & 0 deletions Plugins/GenerateManualPlugin/GenerateManualPluginError.swift
@@ -0,0 +1,50 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2021 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import Foundation
import PackagePlugin

enum GenerateManualPluginError: Error {
case unknownBuildConfiguration(String)
case buildFailed(String)
case createOutputDirectoryFailed(Error)
case subprocessFailedNonZeroExit(Path, Int32)
case subprocessFailedError(Path, Error)
}

extension GenerateManualPluginError: CustomStringConvertible {
var description: String {
switch self {
case .unknownBuildConfiguration(let configuration):
return "Build failed: Unknown build configuration '\(configuration)'."
case .buildFailed(let logText):
return "Build failed: \(logText)."
case .createOutputDirectoryFailed(let error):
return """
Failed to create output directory: '\(error.localizedDescription)'
"""
case .subprocessFailedNonZeroExit(let tool, let exitCode):
return """
'\(tool.lastComponent)' invocation failed with a nonzero exit code: \
'\(exitCode)'.
"""
case .subprocessFailedError(let tool, let error):
return """
'\(tool.lastComponent)' invocation failed: \
'\(error.localizedDescription)'
"""
}
}
}

extension GenerateManualPluginError: LocalizedError {
var localizedDescription: String { self.description }
}
93 changes: 93 additions & 0 deletions Plugins/GenerateManualPlugin/PackagePlugin+Helpers.swift
@@ -0,0 +1,93 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2021 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import Foundation
import PackagePlugin

extension ArgumentExtractor {
mutating func helpRequest() -> Bool {
self.extractFlag(named: "help") > 0
}

mutating func configuration() throws -> PackageManager.BuildConfiguration {
switch self.extractOption(named: "configuration").first {
case .some(let configurationString):
switch configurationString {
case "debug":
return .debug
case "release":
return .release
default:
throw GenerateManualPluginError
.unknownBuildConfiguration(configurationString)
}
case .none:
return .release
}
}
}

extension Path {
func createOutputDirectory() throws {
do {
try FileManager.default.createDirectory(
atPath: self.string,
withIntermediateDirectories: true)
} catch {
throw GenerateManualPluginError.createOutputDirectoryFailed(error)
}
}

func exec(arguments: [String]) throws {
do {
let process = Process()
process.executableURL = URL(fileURLWithPath: self.string)
process.arguments = arguments
try process.run()
process.waitUntilExit()
guard
process.terminationReason == .exit,
process.terminationStatus == 0
else {
throw GenerateManualPluginError.subprocessFailedNonZeroExit(
self, process.terminationStatus)
}
} catch {
throw GenerateManualPluginError.subprocessFailedError(self, error)
}
}
}

extension PackageManager.BuildResult.BuiltArtifact {
func matchingProduct(context: PluginContext) -> Product? {
context
.package
.products
.first { $0.name == self.path.lastComponent }
}
}

extension Product {
func hasDependency(named name: String) -> Bool {
recursiveTargetDependencies
.contains { $0.name == name }
}

var recursiveTargetDependencies: [Target] {
var dependencies = [Target.ID: Target]()
for target in self.targets {
for dependency in target.recursiveTargetDependencies {
dependencies[dependency.id] = dependency
}
}
return Array(dependencies.values)
}
}

0 comments on commit 48a799e

Please sign in to comment.