From 7926375714ef6c73fbd5aa1468a2cf13fe53be1a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 04:24:47 +0000 Subject: [PATCH 1/3] Add SPM plugin for generating Markdown CLI docs Implements the swift-cli-docs-plugin: a Swift Package Manager command plugin that turns any Swift Argument Parser CLI into beautiful Markdown documentation. Architecture: - Plugin invokes a helper executable that parses --experimental-dump-help - ContextBuilder pre-computes everything (synopsis, defaults, links, escapes) into a stable RenderContext view-model - Stencil templates are logic-less: they only iterate and substitute - Three built-in themes (default, minimal, github) with user theme overrides - Multi-file and single-file layouts, configured via .swift-cli-docs.yml Includes unit tests with fixtures, an Examples/DemoCLI consumer, and CI matrix for macOS + Linux on Swift 5.9 and 6.0. https://claude.ai/code/session_01L8VEwZS2SbtRfPpGMK924y --- .github/workflows/ci.yml | 41 +++ Examples/DemoCLI/.swift-cli-docs.yml | 16 + Examples/DemoCLI/Package.swift | 22 ++ .../DemoCLI/Sources/DemoCLI/DemoCLI.swift | 51 +++ Package.swift | 76 +++++ Plugins/SwiftCLIDocsPlugin/Plugin.swift | 136 ++++++++ README.md | 161 ++++++++- Sources/CLIDocsCore/Config/ConfigLoader.swift | 97 ++++++ Sources/CLIDocsCore/Config/DocsConfig.swift | 210 ++++++++++++ .../CLIDocsCore/Pipeline/DocsGenerator.swift | 83 +++++ .../CLIDocsCore/Pipeline/DumpHelpRunner.swift | 92 ++++++ .../Render/Filters/MarkdownFilters.swift | 18 + .../CLIDocsCore/Render/MultiFileLayout.swift | 42 +++ .../CLIDocsCore/Render/SingleFileLayout.swift | 18 + .../CLIDocsCore/Render/StencilEngine.swift | 38 +++ .../CLIDocsCore/Render/ThemeResolver.swift | 70 ++++ .../Resources/Themes/default/command.stencil | 47 +++ .../Resources/Themes/default/index.stencil | 20 ++ .../partials/_arguments_section.stencil | 4 + .../default/partials/_subcommands.stencil | 4 + .../Themes/default/partials/_toc.stencil | 2 + .../Resources/Themes/default/single.stencil | 24 ++ .../Resources/Themes/github/command.stencil | 50 +++ .../partials/_arguments_section.stencil | 4 + .../partials/_arguments_section.stencil | 2 + .../minimal/partials/_subcommands.stencil | 2 + Sources/CLIDocsCore/Util/MarkdownEscape.swift | 65 ++++ Sources/CLIDocsCore/Util/PathUtil.swift | 28 ++ .../CLIDocsCore/ViewModels/ArgumentView.swift | 65 ++++ .../CLIDocsCore/ViewModels/CommandView.swift | 214 ++++++++++++ .../ViewModels/ContextBuilder.swift | 312 ++++++++++++++++++ .../ViewModels/DefaultsFormatter.swift | 73 ++++ .../ViewModels/RenderContext.swift | 107 ++++++ .../ViewModels/SynopsisBuilder.swift | 52 +++ Sources/swift-cli-docs/GenerateCommand.swift | 85 +++++ Tests/CLIDocsCoreTests/ConfigTests.swift | 95 ++++++ .../ContextBuilderTests.swift | 70 ++++ .../CLIDocsCoreTests/DocsGeneratorTests.swift | 75 +++++ .../Fixtures/nested-tool.json | 33 ++ .../Fixtures/simple-tool.json | 46 +++ .../MarkdownEscapeTests.swift | 18 + .../SynopsisBuilderTests.swift | 61 ++++ 42 files changed, 2728 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Examples/DemoCLI/.swift-cli-docs.yml create mode 100644 Examples/DemoCLI/Package.swift create mode 100644 Examples/DemoCLI/Sources/DemoCLI/DemoCLI.swift create mode 100644 Package.swift create mode 100644 Plugins/SwiftCLIDocsPlugin/Plugin.swift create mode 100644 Sources/CLIDocsCore/Config/ConfigLoader.swift create mode 100644 Sources/CLIDocsCore/Config/DocsConfig.swift create mode 100644 Sources/CLIDocsCore/Pipeline/DocsGenerator.swift create mode 100644 Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift create mode 100644 Sources/CLIDocsCore/Render/Filters/MarkdownFilters.swift create mode 100644 Sources/CLIDocsCore/Render/MultiFileLayout.swift create mode 100644 Sources/CLIDocsCore/Render/SingleFileLayout.swift create mode 100644 Sources/CLIDocsCore/Render/StencilEngine.swift create mode 100644 Sources/CLIDocsCore/Render/ThemeResolver.swift create mode 100644 Sources/CLIDocsCore/Resources/Themes/default/command.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/default/index.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/default/partials/_arguments_section.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/default/partials/_subcommands.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/default/partials/_toc.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/default/single.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/github/command.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/github/partials/_arguments_section.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/minimal/partials/_arguments_section.stencil create mode 100644 Sources/CLIDocsCore/Resources/Themes/minimal/partials/_subcommands.stencil create mode 100644 Sources/CLIDocsCore/Util/MarkdownEscape.swift create mode 100644 Sources/CLIDocsCore/Util/PathUtil.swift create mode 100644 Sources/CLIDocsCore/ViewModels/ArgumentView.swift create mode 100644 Sources/CLIDocsCore/ViewModels/CommandView.swift create mode 100644 Sources/CLIDocsCore/ViewModels/ContextBuilder.swift create mode 100644 Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift create mode 100644 Sources/CLIDocsCore/ViewModels/RenderContext.swift create mode 100644 Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift create mode 100644 Sources/swift-cli-docs/GenerateCommand.swift create mode 100644 Tests/CLIDocsCoreTests/ConfigTests.swift create mode 100644 Tests/CLIDocsCoreTests/ContextBuilderTests.swift create mode 100644 Tests/CLIDocsCoreTests/DocsGeneratorTests.swift create mode 100644 Tests/CLIDocsCoreTests/Fixtures/nested-tool.json create mode 100644 Tests/CLIDocsCoreTests/Fixtures/simple-tool.json create mode 100644 Tests/CLIDocsCoreTests/MarkdownEscapeTests.swift create mode 100644 Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ade66d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + macos: + name: macOS / Swift ${{ matrix.swift }} + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + swift: ["5.9", "6.0"] + steps: + - uses: actions/checkout@v4 + - uses: SwiftyLab/setup-swift@latest + with: + swift-version: ${{ matrix.swift }} + - name: Build + run: swift build + - name: Test + run: swift test --enable-code-coverage + + linux: + name: Linux / Swift ${{ matrix.swift }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + swift: ["5.9", "6.0"] + container: + image: swift:${{ matrix.swift }}-jammy + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build + - name: Test + run: swift test diff --git a/Examples/DemoCLI/.swift-cli-docs.yml b/Examples/DemoCLI/.swift-cli-docs.yml new file mode 100644 index 0000000..6fd55a1 --- /dev/null +++ b/Examples/DemoCLI/.swift-cli-docs.yml @@ -0,0 +1,16 @@ +target: demo +output: + directory: docs + layout: multi-file +metadata: + title: Demo CLI + description: Showcase tool for swift-cli-docs-plugin. + repository: https://github.com/ilia3546/swift-cli-docs-plugin +theme: + name: default + toc: true +overrides: + "demo build": + examples: + - title: Build a project in release mode + code: demo build --release /path/to/project diff --git a/Examples/DemoCLI/Package.swift b/Examples/DemoCLI/Package.swift new file mode 100644 index 0000000..479c417 --- /dev/null +++ b/Examples/DemoCLI/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "DemoCLI", + platforms: [.macOS(.v12)], + products: [ + .executable(name: "demo", targets: ["DemoCLI"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), + .package(path: "../../"), + ], + targets: [ + .executableTarget( + name: "DemoCLI", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + ] +) diff --git a/Examples/DemoCLI/Sources/DemoCLI/DemoCLI.swift b/Examples/DemoCLI/Sources/DemoCLI/DemoCLI.swift new file mode 100644 index 0000000..671ef4f --- /dev/null +++ b/Examples/DemoCLI/Sources/DemoCLI/DemoCLI.swift @@ -0,0 +1,51 @@ +import ArgumentParser + +@main +struct Demo: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "demo", + abstract: "A demo CLI to showcase swift-cli-docs-plugin.", + discussion: """ + This tool exists only so you can run `swift package generate-docs` and see + what kind of Markdown the plugin produces for a small Argument Parser CLI. + """, + subcommands: [Build.self, Test.self] + ) +} + +struct Build: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "build", + abstract: "Build the demo project." + ) + + @Flag(name: .long, help: "Build in release configuration.") + var release: Bool = false + + @Option(name: [.short, .long], help: "Build target name.") + var target: String? + + @Argument(help: "Path to the project root.") + var path: String + + func run() throws { + print("build \(path) release=\(release) target=\(target ?? "")") + } +} + +struct Test: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "test", + abstract: "Run the test suite." + ) + + @Option(name: .long, help: "Filter tests by name.") + var filter: String? + + @Flag(name: .long, help: "Show verbose output.") + var verbose: Bool = false + + func run() throws { + print("test filter=\(filter ?? "*") verbose=\(verbose)") + } +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..28526d9 --- /dev/null +++ b/Package.swift @@ -0,0 +1,76 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "swift-cli-docs-plugin", + platforms: [ + .macOS(.v12), + ], + products: [ + .plugin( + name: "SwiftCLIDocsPlugin", + targets: ["SwiftCLIDocsPlugin"] + ), + .executable( + name: "swift-cli-docs", + targets: ["swift-cli-docs"] + ), + .library( + name: "CLIDocsCore", + targets: ["CLIDocsCore"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"), + .package(url: "https://github.com/jpsim/Yams", from: "5.1.0"), + .package(url: "https://github.com/stencilproject/Stencil", from: "0.15.1"), + .package(url: "https://github.com/kylef/PathKit", from: "1.0.1"), + ], + targets: [ + .plugin( + name: "SwiftCLIDocsPlugin", + capability: .command( + intent: .custom( + verb: "generate-docs", + description: "Generate Markdown documentation for Swift Argument Parser CLI tools." + ), + permissions: [ + .writeToPackageDirectory(reason: "Writes generated Markdown documentation to the configured output directory."), + ] + ), + dependencies: [ + .target(name: "swift-cli-docs"), + ], + path: "Plugins/SwiftCLIDocsPlugin" + ), + .executableTarget( + name: "swift-cli-docs", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .target(name: "CLIDocsCore"), + ], + path: "Sources/swift-cli-docs" + ), + .target( + name: "CLIDocsCore", + dependencies: [ + .product(name: "ArgumentParserToolInfo", package: "swift-argument-parser"), + .product(name: "Yams", package: "Yams"), + .product(name: "Stencil", package: "Stencil"), + .product(name: "PathKit", package: "PathKit"), + ], + path: "Sources/CLIDocsCore", + resources: [ + .copy("Resources/Themes"), + ] + ), + .testTarget( + name: "CLIDocsCoreTests", + dependencies: ["CLIDocsCore"], + path: "Tests/CLIDocsCoreTests", + resources: [ + .copy("Fixtures"), + ] + ), + ] +) diff --git a/Plugins/SwiftCLIDocsPlugin/Plugin.swift b/Plugins/SwiftCLIDocsPlugin/Plugin.swift new file mode 100644 index 0000000..bfcff8f --- /dev/null +++ b/Plugins/SwiftCLIDocsPlugin/Plugin.swift @@ -0,0 +1,136 @@ +import Foundation +import PackagePlugin + +@main +struct SwiftCLIDocsPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) async throws { + try run( + packageDirectory: context.package.directory.string, + executableTargets: context.package.targets.compactMap { $0 as? SwiftSourceModuleTarget } + .filter { $0.kind == .executable } + .map { $0.name }, + tool: try context.tool(named: "swift-cli-docs"), + arguments: arguments, + buildExecutable: { name in + let result = try self.packageManager.build( + .product(name), + parameters: .init(configuration: .release, logging: .concise) + ) + guard result.succeeded else { + Diagnostics.error("Failed to build executable '\(name)':\n\(result.logText)") + throw PluginError.buildFailed(name) + } + guard let artifact = result.builtArtifacts.first(where: { $0.kind == .executable && $0.path.lastComponent == name }) + ?? result.builtArtifacts.first(where: { $0.kind == .executable }) + else { + throw PluginError.builtArtifactNotFound(name) + } + return artifact.path.string + } + ) + } + + /// Pure logic that's also reusable from XcodeCommandPlugin. + func run( + packageDirectory: String, + executableTargets: [String], + tool: PluginContext.Tool, + arguments: [String], + buildExecutable: (String) throws -> String + ) throws { + var extractor = ArgumentExtractor(arguments) + let target = extractor.extractOption(named: "target").last + let configPath = extractor.extractOption(named: "config").last + let output = extractor.extractOption(named: "output").last + let layout = extractor.extractOption(named: "layout").last + let theme = extractor.extractOption(named: "theme").last + let themePath = extractor.extractOption(named: "theme-path").last + + let resolvedTarget: String + if let target { + guard executableTargets.contains(target) else { + throw PluginError.unknownTarget(target, available: executableTargets) + } + resolvedTarget = target + } else if executableTargets.count == 1, let only = executableTargets.first { + resolvedTarget = only + } else if executableTargets.isEmpty { + throw PluginError.noExecutableTargets + } else { + throw PluginError.targetRequired(available: executableTargets) + } + + let executablePath = try buildExecutable(resolvedTarget) + + var helperArgs: [String] = [ + "--package-root", packageDirectory, + "--target", resolvedTarget, + "--executable", executablePath, + ] + if let configPath { helperArgs += ["--config", configPath] } + if let output { helperArgs += ["--output", output] } + if let layout { helperArgs += ["--layout", layout] } + if let theme { helperArgs += ["--theme", theme] } + if let themePath { helperArgs += ["--theme-path", themePath] } + + let process = Process() + process.executableURL = URL(fileURLWithPath: tool.path.string) + process.arguments = helperArgs + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw PluginError.helperFailed(status: process.terminationStatus) + } + } +} + +enum PluginError: Error, CustomStringConvertible { + case noExecutableTargets + case targetRequired(available: [String]) + case unknownTarget(String, available: [String]) + case buildFailed(String) + case builtArtifactNotFound(String) + case helperFailed(status: Int32) + + var description: String { + switch self { + case .noExecutableTargets: + return "This package has no executable targets. swift-cli-docs needs an executable target that uses Swift Argument Parser." + case .targetRequired(let avail): + return "Multiple executable targets present. Pick one with `--target`. Available: \(avail.joined(separator: ", "))." + case .unknownTarget(let name, let avail): + return "Unknown target '\(name)'. Available executable targets: \(avail.joined(separator: ", "))." + case .buildFailed(let name): + return "Building target '\(name)' failed." + case .builtArtifactNotFound(let name): + return "Could not find a built executable artifact for '\(name)' in the build result." + case .helperFailed(let status): + return "swift-cli-docs helper exited with status \(status)." + } + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +extension SwiftCLIDocsPlugin: XcodeCommandPlugin { + func performCommand(context: XcodePluginContext, arguments: [String]) throws { + let executableTargets = context.xcodeProject.targets.compactMap { target -> String? in + // In Xcode contexts there's no direct target.kind == .executable check. + // We rely on the user to pass --target explicitly. + return target.displayName + } + + try run( + packageDirectory: context.xcodeProject.directory.string, + executableTargets: executableTargets, + tool: try context.tool(named: "swift-cli-docs"), + arguments: arguments, + buildExecutable: { name in + Diagnostics.error("Building executable artifacts from Xcode contexts is not supported. Pass --executable after building the target manually.") + throw PluginError.buildFailed(name) + } + ) + } +} +#endif diff --git a/README.md b/README.md index 5b7bd93..027d037 100644 --- a/README.md +++ b/README.md @@ -1 +1,160 @@ -# swift-cli-docs-plugin \ No newline at end of file +# swift-cli-docs-plugin + +A Swift Package Manager command plugin that generates beautiful Markdown +documentation for any CLI tool built with [swift-argument-parser][sap]. +It reads the tool's `--experimental-dump-help` JSON, applies your YAML +configuration, and renders Markdown via Stencil templates. + +[sap]: https://github.com/apple/swift-argument-parser + +## Why + +The official [`generate-manual`][generate-manual] plugin produces man pages, +and [`generate-docc-reference`][generate-docc-reference] produces DocC. There +was no first-class option for plain Markdown that drops cleanly into a README, +GitHub Pages, mdBook, or MkDocs. This plugin fills that gap. + +[generate-manual]: https://github.com/apple/swift-argument-parser/tree/main/Plugins/GenerateManualPlugin +[generate-docc-reference]: https://github.com/swiftlang/swift-argument-parser-docs + +## Quick start + +Add the plugin to your package's dependencies: + +```swift +.package(url: "https://github.com/ilia3546/swift-cli-docs-plugin", from: "0.1.0"), +``` + +Generate docs for your CLI: + +```bash +swift package --allow-writing-to-package-directory generate-docs +``` + +You'll find Markdown output under `docs/` (configurable). For a CLI with +multiple executable targets, pass `--target `. + +## Configuration + +Drop a `.swift-cli-docs.yml` next to your `Package.swift`. All keys are optional. + +```yaml +target: MyCLI + +output: + directory: docs + layout: multi-file # multi-file | single-file + filename: "{command}.md" + index: INDEX.md + +metadata: + title: MyCLI + description: A sweet little CLI. + version: 1.2.3 + repository: https://github.com/me/mycli + +theme: + name: default # default | minimal | github + path: themes/my-theme # optional: path to a directory of .stencil files + headingDepth: 1 + toc: true + showAliases: true + showHidden: false + codeFence: bash + variables: + accent: "🚀" + +sections: + order: [overview, usage, arguments, options, flags, subcommands, examples, footer] + custom: + overview: docs/snippets/overview.md + footer: docs/snippets/footer.md + +include: ["*"] +exclude: ["mycli internal-*"] + +overrides: + "mycli build": + abstract: "Build the project." + examples: + - title: "Release build" + code: "mycli build --release" +``` + +CLI flags override YAML values: + +```bash +swift package generate-docs \ + --target MyCLI \ + --layout single-file \ + --theme github \ + --output docs/cli +``` + +## Built-in themes + +| Theme | Look | +| --- | --- | +| `default` | Tables for arguments, simple headings, no emoji. Works anywhere Markdown does. | +| `minimal` | Bullet lists instead of tables, no TOC by default. Compact for small CLIs. | +| `github` | `
` blocks, badges, suited for GitHub README rendering. | + +## Custom themes + +A theme is a directory of Stencil templates. Two files are required at the +root: `command.stencil` (one command) and `index.stencil` (the entry index for +multi-file mode). For single-file mode, supply `single.stencil` as well. + +Point `theme.path` at your directory. Missing files fall back to the `default` +theme automatically, so you can override one partial without re-implementing +the whole thing. + +### RenderContext (template variables) + +Templates receive a stable, pre-computed view-model. No need for filters that +build synopses, escape Markdown, or compute links — that all happens in Swift. + +```text +RenderContext + meta: { title, description?, version?, repository? } + theme: { name, headingDepth, toc, showAliases, codeFence, emoji, variables } + command: CommandView? # in command.stencil + commands: [CommandView] # in single.stencil + index: IndexView? # in index.stencil + +CommandView + name, fullPath, anchor, headingPrefix + abstract, abstractEscaped + discussion, discussionEscaped + aliases: [String], hasAliases + synopsis: String + argumentSections: [ { title, kind, arguments: [ArgumentView] } ] + hasArguments + subcommands: [ { name, fullPath, abstract, abstractEscaped, link } ] + hasSubcommands + examples: [ { title, titleEscaped, code, codeFenced } ] + hasExamples + customSections: [String: String] + isHidden + +ArgumentView + kind # "positional" | "option" | "flag" + displayName # "-v, --verbose " + primaryName, anchor + description, descriptionEscaped + defaultDisplay, hasDefault + isRequired, isRepeating + valueRangeText, hasValueRange +``` + +The only filter the engine registers is `mdEscape`, for defensive escaping of +arbitrary `theme.variables` values. Everything else is precomputed. + +## Status + +Pre-1.0. The `RenderContext` shape is the public contract for custom themes; +breaking changes will be reflected in the version number. + +## License + +Apache 2.0. diff --git a/Sources/CLIDocsCore/Config/ConfigLoader.swift b/Sources/CLIDocsCore/Config/ConfigLoader.swift new file mode 100644 index 0000000..bfa6154 --- /dev/null +++ b/Sources/CLIDocsCore/Config/ConfigLoader.swift @@ -0,0 +1,97 @@ +import Foundation +import Yams + +public enum ConfigLoaderError: Error, CustomStringConvertible { + case fileNotFound(URL) + case unreadable(URL, underlying: Error) + case decoding(URL, underlying: Error) + + public var description: String { + switch self { + case .fileNotFound(let url): + return "Config file not found at \(url.path)." + case .unreadable(let url, let err): + return "Could not read config at \(url.path): \(err)" + case .decoding(let url, let err): + return "Could not decode YAML at \(url.path): \(err)" + } + } +} + +public struct ConfigLoader { + public static let defaultFilenames = [ + ".swift-cli-docs.yml", + ".swift-cli-docs.yaml", + "swift-cli-docs.yml", + "swift-cli-docs.yaml", + ] + + public init() {} + + /// Locate a config file by searching the given directory for the default filenames. + /// Returns nil if no file is present (caller can fall back to defaults). + public func locateConfig(in directory: URL) -> URL? { + for name in Self.defaultFilenames { + let candidate = directory.appendingPathComponent(name) + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + return nil + } + + public func load(from url: URL) throws -> DocsConfig { + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw ConfigLoaderError.unreadable(url, underlying: error) + } + guard let yaml = String(data: data, encoding: .utf8) else { + throw ConfigLoaderError.unreadable( + url, + underlying: NSError(domain: "ConfigLoader", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Not valid UTF-8"]) + ) + } + do { + return try YAMLDecoder().decode(DocsConfig.self, from: yaml) + } catch { + throw ConfigLoaderError.decoding(url, underlying: error) + } + } + + /// Apply CLI overrides on top of a parsed config. CLI flags always win. + public func merge(base: DocsConfig, with overrides: CLIOverrides) -> DocsConfig { + var cfg = base + if let v = overrides.target { cfg.target = v } + if let v = overrides.outputDirectory { cfg.output.directory = v } + if let v = overrides.layout { cfg.output.layout = v } + if let v = overrides.themeName { cfg.theme.name = v } + if let v = overrides.themePath { cfg.theme.path = v } + return cfg + } +} + +/// CLI flag values that can override the YAML config. +public struct CLIOverrides: Sendable { + public var target: String? + public var outputDirectory: String? + public var layout: OutputConfig.Layout? + public var themeName: String? + public var themePath: String? + + public init( + target: String? = nil, + outputDirectory: String? = nil, + layout: OutputConfig.Layout? = nil, + themeName: String? = nil, + themePath: String? = nil + ) { + self.target = target + self.outputDirectory = outputDirectory + self.layout = layout + self.themeName = themeName + self.themePath = themePath + } +} diff --git a/Sources/CLIDocsCore/Config/DocsConfig.swift b/Sources/CLIDocsCore/Config/DocsConfig.swift new file mode 100644 index 0000000..30a3b2a --- /dev/null +++ b/Sources/CLIDocsCore/Config/DocsConfig.swift @@ -0,0 +1,210 @@ +import Foundation + +public struct DocsConfig: Codable, Equatable, Sendable { + public var target: String? + public var output: OutputConfig + public var metadata: MetadataConfig + public var theme: ThemeConfig + public var sections: SectionsConfig + public var include: [String] + public var exclude: [String] + public var overrides: [String: CommandOverride] + + public init( + target: String? = nil, + output: OutputConfig = .init(), + metadata: MetadataConfig = .init(), + theme: ThemeConfig = .init(), + sections: SectionsConfig = .init(), + include: [String] = ["*"], + exclude: [String] = [], + overrides: [String: CommandOverride] = [:] + ) { + self.target = target + self.output = output + self.metadata = metadata + self.theme = theme + self.sections = sections + self.include = include + self.exclude = exclude + self.overrides = overrides + } + + public static let `default` = DocsConfig() + + private enum CodingKeys: String, CodingKey { + case target, output, metadata, theme, sections, include, exclude, overrides + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.target = try c.decodeIfPresent(String.self, forKey: .target) + self.output = try c.decodeIfPresent(OutputConfig.self, forKey: .output) ?? .init() + self.metadata = try c.decodeIfPresent(MetadataConfig.self, forKey: .metadata) ?? .init() + self.theme = try c.decodeIfPresent(ThemeConfig.self, forKey: .theme) ?? .init() + self.sections = try c.decodeIfPresent(SectionsConfig.self, forKey: .sections) ?? .init() + self.include = try c.decodeIfPresent([String].self, forKey: .include) ?? ["*"] + self.exclude = try c.decodeIfPresent([String].self, forKey: .exclude) ?? [] + self.overrides = try c.decodeIfPresent([String: CommandOverride].self, forKey: .overrides) ?? [:] + } +} + +public struct OutputConfig: Codable, Equatable, Sendable { + public enum Layout: String, Codable, Sendable { + case multiFile = "multi-file" + case singleFile = "single-file" + } + + public var directory: String + public var layout: Layout + public var filename: String + public var index: String + + public init( + directory: String = "docs", + layout: Layout = .multiFile, + filename: String = "{command}.md", + index: String = "INDEX.md" + ) { + self.directory = directory + self.layout = layout + self.filename = filename + self.index = index + } + + private enum CodingKeys: String, CodingKey { + case directory, layout, filename, index + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.directory = try c.decodeIfPresent(String.self, forKey: .directory) ?? "docs" + self.layout = try c.decodeIfPresent(Layout.self, forKey: .layout) ?? .multiFile + self.filename = try c.decodeIfPresent(String.self, forKey: .filename) ?? "{command}.md" + self.index = try c.decodeIfPresent(String.self, forKey: .index) ?? "INDEX.md" + } +} + +public struct MetadataConfig: Codable, Equatable, Sendable { + public var title: String? + public var description: String? + public var version: String? + public var repository: String? + + public init( + title: String? = nil, + description: String? = nil, + version: String? = nil, + repository: String? = nil + ) { + self.title = title + self.description = description + self.version = version + self.repository = repository + } +} + +public struct ThemeConfig: Codable, Equatable, Sendable { + public var name: String + public var path: String? + public var headingDepth: Int + public var toc: Bool + public var showAliases: Bool + public var showHidden: Bool + public var codeFence: String + public var emoji: Bool + public var variables: [String: String] + + public init( + name: String = "default", + path: String? = nil, + headingDepth: Int = 1, + toc: Bool = true, + showAliases: Bool = true, + showHidden: Bool = false, + codeFence: String = "bash", + emoji: Bool = false, + variables: [String: String] = [:] + ) { + self.name = name + self.path = path + self.headingDepth = headingDepth + self.toc = toc + self.showAliases = showAliases + self.showHidden = showHidden + self.codeFence = codeFence + self.emoji = emoji + self.variables = variables + } + + private enum CodingKeys: String, CodingKey { + case name, path, headingDepth, toc, showAliases, showHidden, codeFence, emoji, variables + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? "default" + self.path = try c.decodeIfPresent(String.self, forKey: .path) + self.headingDepth = try c.decodeIfPresent(Int.self, forKey: .headingDepth) ?? 1 + self.toc = try c.decodeIfPresent(Bool.self, forKey: .toc) ?? true + self.showAliases = try c.decodeIfPresent(Bool.self, forKey: .showAliases) ?? true + self.showHidden = try c.decodeIfPresent(Bool.self, forKey: .showHidden) ?? false + self.codeFence = try c.decodeIfPresent(String.self, forKey: .codeFence) ?? "bash" + self.emoji = try c.decodeIfPresent(Bool.self, forKey: .emoji) ?? false + self.variables = try c.decodeIfPresent([String: String].self, forKey: .variables) ?? [:] + } +} + +public struct SectionsConfig: Codable, Equatable, Sendable { + public static let defaultOrder = [ + "overview", "usage", "arguments", "options", "flags", + "subcommands", "examples", "footer", + ] + + public var order: [String] + public var custom: [String: String] + + public init(order: [String] = SectionsConfig.defaultOrder, custom: [String: String] = [:]) { + self.order = order + self.custom = custom + } + + private enum CodingKeys: String, CodingKey { case order, custom } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.order = try c.decodeIfPresent([String].self, forKey: .order) ?? SectionsConfig.defaultOrder + self.custom = try c.decodeIfPresent([String: String].self, forKey: .custom) ?? [:] + } +} + +public struct CommandOverride: Codable, Equatable, Sendable { + public var abstract: String? + public var discussion: String? + public var examples: [ExampleOverride] + + public init(abstract: String? = nil, discussion: String? = nil, examples: [ExampleOverride] = []) { + self.abstract = abstract + self.discussion = discussion + self.examples = examples + } + + private enum CodingKeys: String, CodingKey { case abstract, discussion, examples } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.abstract = try c.decodeIfPresent(String.self, forKey: .abstract) + self.discussion = try c.decodeIfPresent(String.self, forKey: .discussion) + self.examples = try c.decodeIfPresent([ExampleOverride].self, forKey: .examples) ?? [] + } +} + +public struct ExampleOverride: Codable, Equatable, Sendable { + public var title: String + public var code: String + + public init(title: String, code: String) { + self.title = title + self.code = code + } +} diff --git a/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift b/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift new file mode 100644 index 0000000..2e76d59 --- /dev/null +++ b/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift @@ -0,0 +1,83 @@ +import Foundation +import ArgumentParserToolInfo + +public enum DocsGeneratorError: Error, CustomStringConvertible { + case writeFailed(URL, underlying: Error) + + public var description: String { + switch self { + case .writeFailed(let url, let err): + return "Could not write file at \(url.path): \(err)" + } + } +} + +/// Top-level orchestrator. Glues `ContextBuilder` → templates → disk together. +public struct DocsGenerator { + public let config: DocsConfig + + public init(config: DocsConfig) { + self.config = config + } + + /// Render the given tool to in-memory files. Pure function — no disk I/O. + /// Useful for tests and for callers that want to inspect the output. + public func renderFiles(from tool: ToolInfoV0) throws -> [GeneratedFile] { + let resolver = ThemeResolver() + let searchPaths = try resolver.resolveSearchPaths( + themeName: config.theme.name, + userThemePath: config.theme.path + ) + let engine = StencilEngine(searchPaths: searchPaths) + + let rootName = tool.command.commandName + let linkResolver: LinkResolver = { + switch config.output.layout { + case .singleFile: + return AnchorLinkResolver() + case .multiFile: + return RelativeFileLinkResolver( + filenameTemplate: config.output.filename, + rootName: rootName + ) + } + }() + let builder = ContextBuilder(config: config, linkResolver: linkResolver) + let commands = builder.buildAllCommandViews(from: tool) + let index = builder.buildIndexView(from: tool) + let meta = builder.buildMeta(from: tool) + let theme = builder.buildTheme() + + switch config.output.layout { + case .singleFile: + let layout = SingleFileLayout(engine: engine, config: config) + return try layout.render(meta: meta, theme: theme, commands: commands, index: index, rootName: rootName) + case .multiFile: + let layout = MultiFileLayout(engine: engine, config: config) + return try layout.render(meta: meta, theme: theme, commands: commands, index: index, rootName: rootName) + } + } + + /// Render and write to disk under the given package directory. + /// `packageRoot` is treated as the base for resolving `output.directory`. + @discardableResult + public func generate(from tool: ToolInfoV0, packageRoot: URL) throws -> [URL] { + let files = try renderFiles(from: tool) + let outputDir = packageRoot.appendingPathComponent(config.output.directory, isDirectory: true) + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + + var writtenURLs: [URL] = [] + for file in files { + let target = outputDir.appendingPathComponent(file.relativePath) + let parent = target.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + do { + try file.contents.write(to: target, atomically: true, encoding: .utf8) + } catch { + throw DocsGeneratorError.writeFailed(target, underlying: error) + } + writtenURLs.append(target) + } + return writtenURLs + } +} diff --git a/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift b/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift new file mode 100644 index 0000000..bd9b35d --- /dev/null +++ b/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift @@ -0,0 +1,92 @@ +import Foundation +import ArgumentParserToolInfo + +public enum DumpHelpRunnerError: Error, CustomStringConvertible { + case executableNotFound(URL) + case launchFailed(URL, underlying: Error) + case nonZeroExit(URL, status: Int32, stderr: String) + case noOutput(URL) + case unsupportedSerializationVersion(Int) + case decoding(underlying: Error, raw: String) + + public var description: String { + switch self { + case .executableNotFound(let url): + return "Executable not found at \(url.path)." + case .launchFailed(let url, let err): + return "Failed to launch \(url.path): \(err)" + case .nonZeroExit(let url, let status, let stderr): + return "\(url.lastPathComponent) exited with status \(status). stderr:\n\(stderr)" + case .noOutput(let url): + return "\(url.lastPathComponent) produced no --experimental-dump-help output." + case .unsupportedSerializationVersion(let v): + return "Unsupported ToolInfo serialization version: \(v). Expected 0." + case .decoding(let err, let raw): + return "Failed to decode --experimental-dump-help JSON: \(err)\nFirst 200 chars: \(String(raw.prefix(200)))" + } + } +} + +/// Runs an executable with `--experimental-dump-help` and decodes the result into `ToolInfoV0`. +public struct DumpHelpRunner { + public init() {} + + public func run(executable: URL) throws -> ToolInfoV0 { + guard FileManager.default.isExecutableFile(atPath: executable.path) else { + throw DumpHelpRunnerError.executableNotFound(executable) + } + + let process = Process() + process.executableURL = executable + process.arguments = ["--experimental-dump-help"] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + do { + try process.run() + } catch { + throw DumpHelpRunnerError.launchFailed(executable, underlying: error) + } + + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errString = String(data: errData, encoding: .utf8) ?? "" + throw DumpHelpRunnerError.nonZeroExit( + executable, + status: process.terminationStatus, + stderr: errString + ) + } + + guard !outData.isEmpty else { throw DumpHelpRunnerError.noOutput(executable) } + return try decode(outData) + } + + public func decode(_ data: Data) throws -> ToolInfoV0 { + let decoder = JSONDecoder() + do { + let header = try decoder.decode(ToolInfoHeader.self, from: data) + guard header.serializationVersion == 0 else { + throw DumpHelpRunnerError.unsupportedSerializationVersion(header.serializationVersion) + } + } catch let err as DumpHelpRunnerError { + throw err + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + throw DumpHelpRunnerError.decoding(underlying: error, raw: raw) + } + + do { + return try decoder.decode(ToolInfoV0.self, from: data) + } catch { + let raw = String(data: data, encoding: .utf8) ?? "" + throw DumpHelpRunnerError.decoding(underlying: error, raw: raw) + } + } +} diff --git a/Sources/CLIDocsCore/Render/Filters/MarkdownFilters.swift b/Sources/CLIDocsCore/Render/Filters/MarkdownFilters.swift new file mode 100644 index 0000000..6d9ee4c --- /dev/null +++ b/Sources/CLIDocsCore/Render/Filters/MarkdownFilters.swift @@ -0,0 +1,18 @@ +import Foundation +import Stencil + +public enum MarkdownFilters { + /// Register the small set of filters our themes need. + /// Currently: only `mdEscape` for defensive escaping of arbitrary `theme.variables` + /// values. Everything else is pre-computed in `ContextBuilder`. + public static func register(in ext: Extension) { + ext.registerFilter("mdEscape") { value -> Any? in + guard let s = value as? String else { return value } + return MarkdownEscape.inline(s) + } + ext.registerFilter("mdAnchor") { value -> Any? in + guard let s = value as? String else { return value } + return MarkdownEscape.anchor(s) + } + } +} diff --git a/Sources/CLIDocsCore/Render/MultiFileLayout.swift b/Sources/CLIDocsCore/Render/MultiFileLayout.swift new file mode 100644 index 0000000..84e74a2 --- /dev/null +++ b/Sources/CLIDocsCore/Render/MultiFileLayout.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct GeneratedFile: Sendable, Equatable { + public var relativePath: String + public var contents: String + + public init(relativePath: String, contents: String) { + self.relativePath = relativePath + self.contents = contents + } +} + +public struct MultiFileLayout { + public let engine: StencilEngine + public let config: DocsConfig + + public init(engine: StencilEngine, config: DocsConfig) { + self.engine = engine + self.config = config + } + + public func render(meta: MetaView, theme: ThemeView, commands: [CommandView], index: IndexView, rootName: String) throws -> [GeneratedFile] { + var files: [GeneratedFile] = [] + + for command in commands { + let context = RenderContext(meta: meta, theme: theme, command: command).asDictionary() + let body = try engine.render(template: "command.stencil", context: context) + let path = FilenameRenderer.render( + template: config.output.filename, + fullPath: command.fullPath.split(separator: " ").map(String.init), + rootName: rootName + ) + files.append(GeneratedFile(relativePath: path, contents: body)) + } + + let indexContext = RenderContext(meta: meta, theme: theme, commands: commands, index: index).asDictionary() + let indexBody = try engine.render(template: "index.stencil", context: indexContext) + files.append(GeneratedFile(relativePath: config.output.index, contents: indexBody)) + + return files + } +} diff --git a/Sources/CLIDocsCore/Render/SingleFileLayout.swift b/Sources/CLIDocsCore/Render/SingleFileLayout.swift new file mode 100644 index 0000000..b85362d --- /dev/null +++ b/Sources/CLIDocsCore/Render/SingleFileLayout.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct SingleFileLayout { + public let engine: StencilEngine + public let config: DocsConfig + + public init(engine: StencilEngine, config: DocsConfig) { + self.engine = engine + self.config = config + } + + public func render(meta: MetaView, theme: ThemeView, commands: [CommandView], index: IndexView, rootName: String) throws -> [GeneratedFile] { + let context = RenderContext(meta: meta, theme: theme, commands: commands, index: index).asDictionary() + let body = try engine.render(template: "single.stencil", context: context) + let filename = "\(rootName).md" + return [GeneratedFile(relativePath: filename, contents: body)] + } +} diff --git a/Sources/CLIDocsCore/Render/StencilEngine.swift b/Sources/CLIDocsCore/Render/StencilEngine.swift new file mode 100644 index 0000000..d10ab32 --- /dev/null +++ b/Sources/CLIDocsCore/Render/StencilEngine.swift @@ -0,0 +1,38 @@ +import Foundation +import Stencil +import PathKit + +public enum StencilEngineError: Error, CustomStringConvertible { + case rendering(template: String, underlying: Error) + + public var description: String { + switch self { + case .rendering(let name, let err): + return "Failed to render Stencil template '\(name)': \(err)" + } + } +} + +/// Thin wrapper around Stencil that wires up a multi-root file-system loader and +/// our small set of Markdown filters. Templates are resolved in the order returned +/// by `ThemeResolver`, so user themes can override individual files while still +/// inheriting partials from the default theme. +public final class StencilEngine { + private let environment: Environment + + public init(searchPaths: [URL]) { + let paths = searchPaths.map { Path($0.path) } + let loader = FileSystemLoader(paths: paths) + let ext = Extension() + MarkdownFilters.register(in: ext) + self.environment = Environment(loader: loader, extensions: [ext]) + } + + public func render(template: String, context: [String: Any]) throws -> String { + do { + return try environment.renderTemplate(name: template, context: context) + } catch { + throw StencilEngineError.rendering(template: template, underlying: error) + } + } +} diff --git a/Sources/CLIDocsCore/Render/ThemeResolver.swift b/Sources/CLIDocsCore/Render/ThemeResolver.swift new file mode 100644 index 0000000..9d65963 --- /dev/null +++ b/Sources/CLIDocsCore/Render/ThemeResolver.swift @@ -0,0 +1,70 @@ +import Foundation + +public enum ThemeResolverError: Error, CustomStringConvertible { + case unknownBuiltinTheme(String) + case userThemeNotFound(URL) + case bundleResourcesMissing + case templateMissing(name: String, locations: [URL]) + + public var description: String { + switch self { + case .unknownBuiltinTheme(let name): + return "Unknown built-in theme '\(name)'. Known: default, minimal, github." + case .userThemeNotFound(let url): + return "User theme directory not found at \(url.path)." + case .bundleResourcesMissing: + return "Could not locate bundled theme resources. Was the package built with the proper resources declaration?" + case .templateMissing(let name, let locations): + let paths = locations.map { $0.path }.joined(separator: ", ") + return "Stencil template '\(name)' not found in: \(paths)" + } + } +} + +/// Locates the directories that hold the chosen theme's `.stencil` files. +/// Returns an ordered list of search roots: user theme first (if any), then the +/// built-in theme so that user themes can omit partials and fall back. +public struct ThemeResolver { + public static let knownBuiltinThemes: Set = ["default", "minimal", "github"] + + public init() {} + + public func resolveSearchPaths(themeName: String, userThemePath: String?) throws -> [URL] { + var paths: [URL] = [] + + if let userPath = userThemePath { + let url = URL(fileURLWithPath: userPath, isDirectory: true) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else { + throw ThemeResolverError.userThemeNotFound(url) + } + paths.append(url) + } + + guard Self.knownBuiltinThemes.contains(themeName) else { + throw ThemeResolverError.unknownBuiltinTheme(themeName) + } + + let bundleThemes = try locateBundledThemesRoot() + paths.append(bundleThemes.appendingPathComponent(themeName, isDirectory: true)) + + // Always fall back to "default" so themes that extend default can find shared partials. + if themeName != "default" { + paths.append(bundleThemes.appendingPathComponent("default", isDirectory: true)) + } + + return paths + } + + private func locateBundledThemesRoot() throws -> URL { + // .copy("Resources/Themes") puts a "Themes" directory at the bundle root. + if let url = Bundle.module.url(forResource: "Themes", withExtension: nil) { + return url + } + // Fallback: derive from bundleURL when running in test bundles where + // url(forResource:) sometimes returns nil for directories. + let derived = Bundle.module.bundleURL.appendingPathComponent("Themes", isDirectory: true) + if FileManager.default.fileExists(atPath: derived.path) { return derived } + throw ThemeResolverError.bundleResourcesMissing + } +} diff --git a/Sources/CLIDocsCore/Resources/Themes/default/command.stencil b/Sources/CLIDocsCore/Resources/Themes/default/command.stencil new file mode 100644 index 0000000..ab98f22 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/default/command.stencil @@ -0,0 +1,47 @@ +{{ command.headingPrefix }} {{ command.fullPath }} +{% if command.abstract %} +_{{ command.abstractEscaped }}_ +{% endif %} +{% if command.synopsis %} +**Usage** + +```{{ theme.codeFence }} +{{ command.synopsis }} +``` +{% endif %} +{% if command.discussion %} + +{{ command.discussion }} +{% endif %} +{% if command.hasAliases %} + +**Aliases:** {% for alias in command.aliases %}`{{ alias }}`{% if not forloop.last %}, {% endif %}{% endfor %} +{% endif %} +{% if command.hasArguments %} +{% for section in command.argumentSections %} + +{{ command.headingPrefix }}# {{ section.title }} + +{% include "partials/_arguments_section.stencil" %} +{% endfor %} +{% endif %} +{% if command.hasSubcommands %} + +{{ command.headingPrefix }}# Subcommands + +{% include "partials/_subcommands.stencil" %} +{% endif %} +{% if command.hasExamples %} + +{{ command.headingPrefix }}# Examples + +{% for example in command.examples %} +**{{ example.titleEscaped }}** + +{{ example.codeFenced }} +{% endfor %} +{% endif %} +{% if command.customSections.footer %} + +{{ command.customSections.footer }} +{% endif %} diff --git a/Sources/CLIDocsCore/Resources/Themes/default/index.stencil b/Sources/CLIDocsCore/Resources/Themes/default/index.stencil new file mode 100644 index 0000000..26bdcc8 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/default/index.stencil @@ -0,0 +1,20 @@ +# {{ meta.title }} +{% if meta.description %} +_{{ meta.description }}_ +{% endif %} +{% if meta.version %} + +**Version:** `{{ meta.version }}` +{% endif %} +{% if meta.repository %} + +**Repository:** <{{ meta.repository }}> +{% endif %} + +## Commands + +{% include "partials/_toc.stencil" %} +{% if command.customSections.footer %} + +{{ command.customSections.footer }} +{% endif %} diff --git a/Sources/CLIDocsCore/Resources/Themes/default/partials/_arguments_section.stencil b/Sources/CLIDocsCore/Resources/Themes/default/partials/_arguments_section.stencil new file mode 100644 index 0000000..9f465e2 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/default/partials/_arguments_section.stencil @@ -0,0 +1,4 @@ +| Name | Default | Description | +| --- | --- | --- | +{% for arg in section.arguments %}| `{{ arg.displayName }}`{% if arg.isRequired %} **(required)**{% endif %}{% if arg.isRepeating %} _(repeating)_{% endif %} | `{{ arg.defaultDisplay }}` | {{ arg.descriptionEscaped }}{% if arg.hasValueRange %} _{{ arg.valueRangeText }}_{% endif %} | +{% endfor %} diff --git a/Sources/CLIDocsCore/Resources/Themes/default/partials/_subcommands.stencil b/Sources/CLIDocsCore/Resources/Themes/default/partials/_subcommands.stencil new file mode 100644 index 0000000..aeb0405 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/default/partials/_subcommands.stencil @@ -0,0 +1,4 @@ +| Command | Description | +| --- | --- | +{% for sub in command.subcommands %}| [`{{ sub.name }}`]({{ sub.link }}) | {{ sub.abstractEscaped }} | +{% endfor %} diff --git a/Sources/CLIDocsCore/Resources/Themes/default/partials/_toc.stencil b/Sources/CLIDocsCore/Resources/Themes/default/partials/_toc.stencil new file mode 100644 index 0000000..eed64a0 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/default/partials/_toc.stencil @@ -0,0 +1,2 @@ +{% for node in index.nodes %}{{ node.indent }}- [`{{ node.fullPath }}`]({{ node.link }}){% if node.abstract %} — {{ node.abstractEscaped }}{% endif %} +{% endfor %} diff --git a/Sources/CLIDocsCore/Resources/Themes/default/single.stencil b/Sources/CLIDocsCore/Resources/Themes/default/single.stencil new file mode 100644 index 0000000..64a5715 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/default/single.stencil @@ -0,0 +1,24 @@ +# {{ meta.title }} +{% if meta.description %} +_{{ meta.description }}_ +{% endif %} +{% if meta.version %} + +**Version:** `{{ meta.version }}` +{% endif %} +{% if meta.repository %} + +**Repository:** <{{ meta.repository }}> +{% endif %} +{% if theme.toc %} + +## Table of Contents + +{% include "partials/_toc.stencil" %} +{% endif %} +{% for command in commands %} + +--- + +{% include "command.stencil" %} +{% endfor %} diff --git a/Sources/CLIDocsCore/Resources/Themes/github/command.stencil b/Sources/CLIDocsCore/Resources/Themes/github/command.stencil new file mode 100644 index 0000000..2443f4f --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/github/command.stencil @@ -0,0 +1,50 @@ +{{ command.headingPrefix }} {{ command.fullPath }} +{% if command.abstract %} +> {{ command.abstractEscaped }} +{% endif %} +{% if command.synopsis %} + +```{{ theme.codeFence }} +{{ command.synopsis }} +``` +{% endif %} +{% if command.discussion %} + +{{ command.discussion }} +{% endif %} +{% if command.hasAliases %} + +**Aliases:** {% for alias in command.aliases %}`{{ alias }}`{% if not forloop.last %}, {% endif %}{% endfor %} +{% endif %} +{% if command.hasArguments %} +{% for section in command.argumentSections %} + +
{{ section.title }} + +{% include "partials/_arguments_section.stencil" %} + +
+{% endfor %} +{% endif %} +{% if command.hasSubcommands %} + +
Subcommands + +{% include "partials/_subcommands.stencil" %} + +
+{% endif %} +{% if command.hasExamples %} + +{{ command.headingPrefix }}# Examples + +{% for example in command.examples %} +**{{ example.titleEscaped }}** + +{{ example.codeFenced }} +{% endfor %} +{% endif %} +{% if command.customSections.footer %} + +{{ command.customSections.footer }} +{% endif %} diff --git a/Sources/CLIDocsCore/Resources/Themes/github/partials/_arguments_section.stencil b/Sources/CLIDocsCore/Resources/Themes/github/partials/_arguments_section.stencil new file mode 100644 index 0000000..7dbfd6a --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/github/partials/_arguments_section.stencil @@ -0,0 +1,4 @@ +| Name | Default | Description | +| --- | --- | --- | +{% for arg in section.arguments %}| `{{ arg.displayName }}`{% if arg.isRequired %} ![required](https://img.shields.io/badge/required-red){% endif %}{% if arg.isRepeating %} ![repeating](https://img.shields.io/badge/repeating-blue){% endif %} | `{{ arg.defaultDisplay }}` | {{ arg.descriptionEscaped }}{% if arg.hasValueRange %} _{{ arg.valueRangeText }}_{% endif %} | +{% endfor %} diff --git a/Sources/CLIDocsCore/Resources/Themes/minimal/partials/_arguments_section.stencil b/Sources/CLIDocsCore/Resources/Themes/minimal/partials/_arguments_section.stencil new file mode 100644 index 0000000..6a380f1 --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/minimal/partials/_arguments_section.stencil @@ -0,0 +1,2 @@ +{% for arg in section.arguments %}- `{{ arg.displayName }}`{% if arg.isRequired %} **(required)**{% endif %}{% if arg.isRepeating %} _(repeating)_{% endif %}{% if arg.hasDefault %} (default `{{ arg.defaultDisplay }}`){% endif %} — {{ arg.descriptionEscaped }}{% if arg.hasValueRange %} _{{ arg.valueRangeText }}_{% endif %} +{% endfor %} diff --git a/Sources/CLIDocsCore/Resources/Themes/minimal/partials/_subcommands.stencil b/Sources/CLIDocsCore/Resources/Themes/minimal/partials/_subcommands.stencil new file mode 100644 index 0000000..2b06bcd --- /dev/null +++ b/Sources/CLIDocsCore/Resources/Themes/minimal/partials/_subcommands.stencil @@ -0,0 +1,2 @@ +{% for sub in command.subcommands %}- [`{{ sub.name }}`]({{ sub.link }}){% if sub.abstract %} — {{ sub.abstractEscaped }}{% endif %} +{% endfor %} diff --git a/Sources/CLIDocsCore/Util/MarkdownEscape.swift b/Sources/CLIDocsCore/Util/MarkdownEscape.swift new file mode 100644 index 0000000..73837f8 --- /dev/null +++ b/Sources/CLIDocsCore/Util/MarkdownEscape.swift @@ -0,0 +1,65 @@ +import Foundation + +public enum MarkdownEscape { + /// Escape characters that have meaning in Markdown when embedding arbitrary text + /// inside paragraphs or table cells. Conservative: covers `|`, `*`, `_`, `<`, `` ` ``, `[`, `]`. + public static func inline(_ s: String) -> String { + var out = "" + out.reserveCapacity(s.count) + for ch in s { + switch ch { + case "\\", "`", "*", "_", "{", "}", "[", "]", "(", ")", "#", "+", "-", "!", "|", "<", ">": + out.append("\\") + out.append(ch) + default: + out.append(ch) + } + } + return out + } + + /// Escape just the table-breaking characters (newlines, pipes). Useful when the surrounding + /// markdown is already a code-fence or pre-formatted block. + public static func tableCell(_ s: String) -> String { + var out = "" + out.reserveCapacity(s.count) + for ch in s { + switch ch { + case "|": + out.append("\\|") + case "\n", "\r": + out.append(" ") + default: + out.append(ch) + } + } + return out + } + + /// Convert arbitrary command path or name to a stable lowercase anchor slug + /// safe for use in Markdown intra-document links and as filenames. + public static func anchor(_ s: String) -> String { + let lowered = s.lowercased() + var out = "" + out.reserveCapacity(lowered.count) + var lastWasDash = false + for ch in lowered { + if ch.isLetter || ch.isNumber { + out.append(ch) + lastWasDash = false + } else if ch == "-" || ch == "_" { + out.append(ch) + lastWasDash = (ch == "-") + } else if ch.isWhitespace || ch == "/" || ch == "." { + if !lastWasDash { + out.append("-") + lastWasDash = true + } + } + } + // Trim leading/trailing dashes + while out.first == "-" { out.removeFirst() } + while out.last == "-" { out.removeLast() } + return out + } +} diff --git a/Sources/CLIDocsCore/Util/PathUtil.swift b/Sources/CLIDocsCore/Util/PathUtil.swift new file mode 100644 index 0000000..205f694 --- /dev/null +++ b/Sources/CLIDocsCore/Util/PathUtil.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum PathUtil { + /// Glob-style match supporting `*` (any chars except spaces) and `?`. + /// Matches against the full command path like `"mycli build"`. + public static func matches(pattern: String, value: String) -> Bool { + if pattern == "*" { return true } + return regex(for: pattern).map { $0.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)) != nil } ?? (pattern == value) + } + + private static func regex(for pattern: String) -> NSRegularExpression? { + var rx = "^" + for ch in pattern { + switch ch { + case "*": + rx += ".*" + case "?": + rx += "." + case ".", "+", "(", ")", "[", "]", "{", "}", "^", "$", "|", "\\": + rx += "\\\(ch)" + default: + rx.append(ch) + } + } + rx += "$" + return try? NSRegularExpression(pattern: rx, options: []) + } +} diff --git a/Sources/CLIDocsCore/ViewModels/ArgumentView.swift b/Sources/CLIDocsCore/ViewModels/ArgumentView.swift new file mode 100644 index 0000000..b788c79 --- /dev/null +++ b/Sources/CLIDocsCore/ViewModels/ArgumentView.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Pre-computed view of a single argument (positional, option, or flag). +public struct ArgumentView: Sendable, Equatable { + public enum Kind: String, Sendable, Equatable { + case positional, option, flag + } + + public var kind: Kind + public var displayName: String + public var primaryName: String + public var anchor: String + public var description: String + public var descriptionEscaped: String + public var defaultDisplay: String + public var hasDefault: Bool + public var isRequired: Bool + public var isRepeating: Bool + public var valueRangeText: String + public var hasValueRange: Bool + + public init( + kind: Kind, + displayName: String, + primaryName: String, + anchor: String, + description: String, + descriptionEscaped: String, + defaultDisplay: String, + hasDefault: Bool, + isRequired: Bool, + isRepeating: Bool, + valueRangeText: String + ) { + self.kind = kind + self.displayName = displayName + self.primaryName = primaryName + self.anchor = anchor + self.description = description + self.descriptionEscaped = descriptionEscaped + self.defaultDisplay = defaultDisplay + self.hasDefault = hasDefault + self.isRequired = isRequired + self.isRepeating = isRepeating + self.valueRangeText = valueRangeText + self.hasValueRange = !valueRangeText.isEmpty + } + + public func asDictionary() -> [String: Any] { + [ + "kind": kind.rawValue, + "displayName": displayName, + "primaryName": primaryName, + "anchor": anchor, + "description": description, + "descriptionEscaped": descriptionEscaped, + "defaultDisplay": defaultDisplay, + "hasDefault": hasDefault, + "isRequired": isRequired, + "isRepeating": isRepeating, + "valueRangeText": valueRangeText, + "hasValueRange": hasValueRange, + ] + } +} diff --git a/Sources/CLIDocsCore/ViewModels/CommandView.swift b/Sources/CLIDocsCore/ViewModels/CommandView.swift new file mode 100644 index 0000000..cb19b5c --- /dev/null +++ b/Sources/CLIDocsCore/ViewModels/CommandView.swift @@ -0,0 +1,214 @@ +import Foundation + +/// Pre-computed view of a single command, ready to be rendered by a template. +public struct CommandView: Sendable, Equatable { + public var name: String + public var fullPath: String + public var anchor: String + public var headingPrefix: String + public var abstract: String + public var abstractEscaped: String + public var discussion: String + public var discussionEscaped: String + public var aliases: [String] + public var hasAliases: Bool + public var synopsis: String + public var argumentSections: [ArgumentSectionView] + public var hasArguments: Bool + public var subcommands: [SubcommandLinkView] + public var hasSubcommands: Bool + public var examples: [ExampleView] + public var hasExamples: Bool + public var customSections: [String: String] + public var sectionOrder: [String] + public var isHidden: Bool + + public init( + name: String, + fullPath: String, + anchor: String, + headingPrefix: String, + abstract: String, + abstractEscaped: String, + discussion: String, + discussionEscaped: String, + aliases: [String], + synopsis: String, + argumentSections: [ArgumentSectionView], + subcommands: [SubcommandLinkView], + examples: [ExampleView], + customSections: [String: String], + sectionOrder: [String], + isHidden: Bool + ) { + self.name = name + self.fullPath = fullPath + self.anchor = anchor + self.headingPrefix = headingPrefix + self.abstract = abstract + self.abstractEscaped = abstractEscaped + self.discussion = discussion + self.discussionEscaped = discussionEscaped + self.aliases = aliases + self.hasAliases = !aliases.isEmpty + self.synopsis = synopsis + self.argumentSections = argumentSections + self.hasArguments = !argumentSections.isEmpty + self.subcommands = subcommands + self.hasSubcommands = !subcommands.isEmpty + self.examples = examples + self.hasExamples = !examples.isEmpty + self.customSections = customSections + self.sectionOrder = sectionOrder + self.isHidden = isHidden + } + + public func asDictionary() -> [String: Any] { + [ + "name": name, + "fullPath": fullPath, + "anchor": anchor, + "headingPrefix": headingPrefix, + "abstract": abstract, + "abstractEscaped": abstractEscaped, + "discussion": discussion, + "discussionEscaped": discussionEscaped, + "aliases": aliases, + "hasAliases": hasAliases, + "synopsis": synopsis, + "argumentSections": argumentSections.map { $0.asDictionary() }, + "hasArguments": hasArguments, + "subcommands": subcommands.map { $0.asDictionary() }, + "hasSubcommands": hasSubcommands, + "examples": examples.map { $0.asDictionary() }, + "hasExamples": hasExamples, + "customSections": customSections, + "sectionOrder": sectionOrder, + "isHidden": isHidden, + ] + } +} + +public struct ArgumentSectionView: Sendable, Equatable { + public enum Kind: String, Sendable, Equatable { + case positional, option, flag, mixed + } + + public var title: String + public var kind: Kind + public var arguments: [ArgumentView] + + public init(title: String, kind: Kind, arguments: [ArgumentView]) { + self.title = title + self.kind = kind + self.arguments = arguments + } + + public func asDictionary() -> [String: Any] { + [ + "title": title, + "kind": kind.rawValue, + "arguments": arguments.map { $0.asDictionary() }, + ] + } +} + +public struct SubcommandLinkView: Sendable, Equatable { + public var name: String + public var fullPath: String + public var abstract: String + public var abstractEscaped: String + public var link: String + + public init(name: String, fullPath: String, abstract: String, abstractEscaped: String, link: String) { + self.name = name + self.fullPath = fullPath + self.abstract = abstract + self.abstractEscaped = abstractEscaped + self.link = link + } + + public func asDictionary() -> [String: Any] { + [ + "name": name, + "fullPath": fullPath, + "abstract": abstract, + "abstractEscaped": abstractEscaped, + "link": link, + ] + } +} + +public struct ExampleView: Sendable, Equatable { + public var title: String + public var titleEscaped: String + public var code: String + public var codeFenced: String + + public init(title: String, titleEscaped: String, code: String, codeFenced: String) { + self.title = title + self.titleEscaped = titleEscaped + self.code = code + self.codeFenced = codeFenced + } + + public func asDictionary() -> [String: Any] { + [ + "title": title, + "titleEscaped": titleEscaped, + "code": code, + "codeFenced": codeFenced, + ] + } +} + +public struct IndexView: Sendable, Equatable { + public var nodes: [IndexNodeView] + + public init(nodes: [IndexNodeView]) { + self.nodes = nodes + } + + public func asDictionary() -> [String: Any] { + ["nodes": nodes.map { $0.asDictionary() }] + } +} + +public struct IndexNodeView: Sendable, Equatable { + public var name: String + public var fullPath: String + public var abstract: String + public var abstractEscaped: String + public var link: String + public var depth: Int + public var indent: String + + public init( + name: String, + fullPath: String, + abstract: String, + abstractEscaped: String, + link: String, + depth: Int + ) { + self.name = name + self.fullPath = fullPath + self.abstract = abstract + self.abstractEscaped = abstractEscaped + self.link = link + self.depth = depth + self.indent = String(repeating: " ", count: max(0, depth)) + } + + public func asDictionary() -> [String: Any] { + [ + "name": name, + "fullPath": fullPath, + "abstract": abstract, + "abstractEscaped": abstractEscaped, + "link": link, + "depth": depth, + "indent": indent, + ] + } +} diff --git a/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift b/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift new file mode 100644 index 0000000..41bef72 --- /dev/null +++ b/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift @@ -0,0 +1,312 @@ +import Foundation +import ArgumentParserToolInfo + +/// Strategy for turning a command's full path into a markdown link target. +/// Multi-file: relative file path. Single-file: anchor fragment. +public protocol LinkResolver: Sendable { + func link(for fullPath: [String]) -> String +} + +public struct AnchorLinkResolver: LinkResolver, Sendable { + public init() {} + public func link(for fullPath: [String]) -> String { + "#" + MarkdownEscape.anchor(fullPath.joined(separator: " ")) + } +} + +public struct RelativeFileLinkResolver: LinkResolver, Sendable { + public let filenameTemplate: String + public let rootName: String + + public init(filenameTemplate: String, rootName: String) { + self.filenameTemplate = filenameTemplate + self.rootName = rootName + } + + public func link(for fullPath: [String]) -> String { + FilenameRenderer.render(template: filenameTemplate, fullPath: fullPath, rootName: rootName) + } +} + +public enum FilenameRenderer { + public static func render(template: String, fullPath: [String], rootName: String) -> String { + var commandSlug = MarkdownEscape.anchor(fullPath.joined(separator: " ")) + if commandSlug.isEmpty { commandSlug = MarkdownEscape.anchor(rootName) } + let parentSlug: String = fullPath.count > 1 + ? MarkdownEscape.anchor(fullPath.dropLast().joined(separator: " ")) + : "" + return template + .replacingOccurrences(of: "{command}", with: commandSlug) + .replacingOccurrences(of: "{parent}", with: parentSlug) + } +} + +public struct ContextBuilder { + public let config: DocsConfig + public let linkResolver: LinkResolver + + public init(config: DocsConfig, linkResolver: LinkResolver) { + self.config = config + self.linkResolver = linkResolver + } + + /// Flatten the command tree and produce one `CommandView` per visible command. + public func buildAllCommandViews(from tool: ToolInfoV0) -> [CommandView] { + var out: [CommandView] = [] + walk(command: tool.command, path: [], rootName: tool.command.commandName) { fullPath, info, depth in + if let view = self.makeCommandView(info: info, fullPath: fullPath, depth: depth) { + out.append(view) + } + } + return out + } + + /// Heading prefix that respects the configured layout. In multi-file, every + /// page starts at `theme.headingDepth`. In single-file, subcommands nest + /// deeper so they appear as proper sub-sections. + private func headingPrefix(forDepth depth: Int) -> String { + let level: Int = { + switch config.output.layout { + case .multiFile: return max(1, config.theme.headingDepth) + case .singleFile: return max(1, config.theme.headingDepth) + depth + } + }() + let clamped = min(level, 6) + return String(repeating: "#", count: clamped) + } + + public func buildIndexView(from tool: ToolInfoV0) -> IndexView { + var nodes: [IndexNodeView] = [] + walk(command: tool.command, path: [], rootName: tool.command.commandName) { fullPath, info, depth in + guard self.shouldInclude(fullPath: fullPath, info: info) else { return } + let abstract = self.resolveAbstract(info: info, fullPath: fullPath) + nodes.append( + IndexNodeView( + name: info.commandName, + fullPath: fullPath.joined(separator: " "), + abstract: abstract, + abstractEscaped: MarkdownEscape.inline(abstract), + link: self.linkResolver.link(for: fullPath), + depth: depth + ) + ) + } + return IndexView(nodes: nodes) + } + + public func buildMeta(from tool: ToolInfoV0) -> MetaView { + let title = config.metadata.title ?? tool.command.commandName + return MetaView( + title: title, + description: config.metadata.description ?? tool.command.abstract, + version: config.metadata.version, + repository: config.metadata.repository + ) + } + + public func buildTheme() -> ThemeView { + ThemeView( + name: config.theme.name, + headingDepth: config.theme.headingDepth, + toc: config.theme.toc, + showAliases: config.theme.showAliases, + codeFence: config.theme.codeFence, + emoji: config.theme.emoji, + variables: config.theme.variables + ) + } + + // MARK: - Internals + + private func walk( + command: CommandInfoV0, + path: [String], + rootName: String, + visit: ([String], CommandInfoV0, Int) -> Void + ) { + let fullPath = path.isEmpty ? [command.commandName] : path + [command.commandName] + let depth = fullPath.count - 1 + visit(fullPath, command, depth) + for sub in command.subcommands ?? [] { + walk(command: sub, path: fullPath, rootName: rootName, visit: visit) + } + } + + private func makeCommandView(info: CommandInfoV0, fullPath: [String], depth: Int) -> CommandView? { + guard shouldInclude(fullPath: fullPath, info: info) else { return nil } + + let pathKey = fullPath.joined(separator: " ") + let override = config.overrides[pathKey] + + let abstract = resolveAbstract(info: info, fullPath: fullPath) + let discussion = override?.discussion ?? info.discussion ?? "" + + let aliases = config.theme.showAliases ? (info.aliases ?? []) : [] + + let synopsis = SynopsisBuilder.build(commandPath: fullPath, arguments: info.arguments) + + let argumentSections = makeArgumentSections(info: info) + let subcommands = makeSubcommandLinks(info: info, parentPath: fullPath) + let examples = makeExamples(info: info, override: override) + let customSections = resolveCustomSections() + + let prefix = headingPrefix(forDepth: depth) + + return CommandView( + name: info.commandName, + fullPath: pathKey, + anchor: MarkdownEscape.anchor(pathKey), + headingPrefix: prefix, + abstract: abstract, + abstractEscaped: MarkdownEscape.inline(abstract), + discussion: discussion, + discussionEscaped: MarkdownEscape.inline(discussion), + aliases: aliases, + synopsis: synopsis, + argumentSections: argumentSections, + subcommands: subcommands, + examples: examples, + customSections: customSections, + sectionOrder: config.sections.order, + isHidden: !info.shouldDisplay + ) + } + + private func resolveAbstract(info: CommandInfoV0, fullPath: [String]) -> String { + let pathKey = fullPath.joined(separator: " ") + if let override = config.overrides[pathKey]?.abstract { return override } + return info.abstract ?? "" + } + + private func makeArgumentSections(info: CommandInfoV0) -> [ArgumentSectionView] { + let allArgs = (info.arguments ?? []).filter { config.theme.showHidden || $0.shouldDisplay } + guard !allArgs.isEmpty else { return [] } + + // Group by sectionTitle when set, else by kind. + var bySection: [String: [ArgumentInfoV0]] = [:] + var sectionOrder: [String] = [] + for arg in allArgs { + let key = arg.sectionTitle ?? defaultSectionTitle(for: arg.kind) + if bySection[key] == nil { + bySection[key] = [] + sectionOrder.append(key) + } + bySection[key]?.append(arg) + } + + return sectionOrder.map { title in + let bucket = bySection[title] ?? [] + return ArgumentSectionView( + title: title, + kind: detectKind(of: bucket), + arguments: bucket.map(makeArgumentView(_:)) + ) + } + } + + private func defaultSectionTitle(for kind: ArgumentInfoV0.KindV0) -> String { + switch kind { + case .positional: return "Arguments" + case .option: return "Options" + case .flag: return "Flags" + } + } + + private func detectKind(of args: [ArgumentInfoV0]) -> ArgumentSectionView.Kind { + guard let first = args.first else { return .mixed } + if args.allSatisfy({ $0.kind == first.kind }) { + switch first.kind { + case .positional: return .positional + case .option: return .option + case .flag: return .flag + } + } + return .mixed + } + + private func makeArgumentView(_ arg: ArgumentInfoV0) -> ArgumentView { + let kind: ArgumentView.Kind = { + switch arg.kind { + case .positional: return .positional + case .option: return .option + case .flag: return .flag + } + }() + + let abstract = arg.abstract ?? "" + let discussion = arg.discussion ?? "" + let combinedDescription: String = { + if !abstract.isEmpty && !discussion.isEmpty { return "\(abstract)\n\n\(discussion)" } + return abstract.isEmpty ? discussion : abstract + }() + + let displayName = DefaultsFormatter.displayName(for: arg) + let primaryName = DefaultsFormatter.primaryName(for: arg) + let defaultDisplay = DefaultsFormatter.format(arg.defaultValue) + let valueRange = DefaultsFormatter.valueRangeText(allValueStrings: arg.allValueStrings) + let isRequired = !arg.isOptional && !DefaultsFormatter.hasDefault(arg.defaultValue) + + return ArgumentView( + kind: kind, + displayName: displayName, + primaryName: primaryName, + anchor: MarkdownEscape.anchor(primaryName), + description: combinedDescription, + descriptionEscaped: MarkdownEscape.tableCell(combinedDescription), + defaultDisplay: defaultDisplay, + hasDefault: DefaultsFormatter.hasDefault(arg.defaultValue), + isRequired: isRequired, + isRepeating: arg.isRepeating, + valueRangeText: valueRange + ) + } + + private func makeSubcommandLinks(info: CommandInfoV0, parentPath: [String]) -> [SubcommandLinkView] { + let subs = info.subcommands ?? [] + return subs.compactMap { sub in + let fullPath = parentPath + [sub.commandName] + guard shouldInclude(fullPath: fullPath, info: sub) else { return nil } + let abstract = resolveAbstract(info: sub, fullPath: fullPath) + return SubcommandLinkView( + name: sub.commandName, + fullPath: fullPath.joined(separator: " "), + abstract: abstract, + abstractEscaped: MarkdownEscape.inline(abstract), + link: linkResolver.link(for: fullPath) + ) + } + } + + private func makeExamples(info: CommandInfoV0, override: CommandOverride?) -> [ExampleView] { + let raws = override?.examples ?? [] + return raws.map { ex in + let fence = config.theme.codeFence + let codeFenced = "```\(fence)\n\(ex.code)\n```" + return ExampleView( + title: ex.title, + titleEscaped: MarkdownEscape.inline(ex.title), + code: ex.code, + codeFenced: codeFenced + ) + } + } + + private func resolveCustomSections() -> [String: String] { + var out: [String: String] = [:] + for (name, path) in config.sections.custom { + if let content = try? String(contentsOfFile: path, encoding: .utf8) { + out[name] = content + } + } + return out + } + + private func shouldInclude(fullPath: [String], info: CommandInfoV0) -> Bool { + if !config.theme.showHidden && !info.shouldDisplay { return false } + let pathKey = fullPath.joined(separator: " ") + let inIncluded = config.include.contains { PathUtil.matches(pattern: $0, value: pathKey) } + if !inIncluded { return false } + let inExcluded = config.exclude.contains { PathUtil.matches(pattern: $0, value: pathKey) } + return !inExcluded + } +} diff --git a/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift b/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift new file mode 100644 index 0000000..3f87c4c --- /dev/null +++ b/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift @@ -0,0 +1,73 @@ +import Foundation +import ArgumentParserToolInfo + +public enum DefaultsFormatter { + public static let placeholder = "—" + + /// Format an `ArgumentInfoV0.defaultValue` for display in the docs. + /// Returns `placeholder` if there is no default to show. + public static func format(_ value: String?) -> String { + guard let v = value, !v.isEmpty else { return placeholder } + return v + } + + /// True if the given default-value string represents a real default we should show. + public static func hasDefault(_ value: String?) -> Bool { + guard let v = value else { return false } + return !v.isEmpty + } + + /// "one of: a, b, c" or "" if there is no list of allowed values. + public static func valueRangeText(allValueStrings: [String]?) -> String { + guard let values = allValueStrings, !values.isEmpty else { return "" } + return "one of: " + values.joined(separator: ", ") + } + + /// Best display name for an argument: + /// - positional → `` or `` + /// - option/flag → "-s, --long []" + public static func displayName(for argument: ArgumentInfoV0) -> String { + switch argument.kind { + case .positional: + let value = argument.valueName ?? "value" + return argument.isRepeating ? "<\(value)> ..." : "<\(value)>" + case .option: + let names = formattedNames(argument) + let value = argument.valueName ?? "value" + let suffix = argument.isRepeating ? " <\(value)> ..." : " <\(value)>" + return names + suffix + case .flag: + return formattedNames(argument) + } + } + + /// "-s, --long" — joined `NameInfoV0` array with the proper dash prefix. + public static func formattedNames(_ argument: ArgumentInfoV0) -> String { + guard let names = argument.names, !names.isEmpty else { + return argument.valueName.map { "<\($0)>" } ?? "" + } + return names.map { Self.formatted(name: $0) }.joined(separator: ", ") + } + + public static func formatted(name: ArgumentInfoV0.NameInfoV0) -> String { + switch name.kind { + case .long: + return "--\(name.name)" + case .short: + return "-\(name.name)" + case .longWithSingleDash: + return "-\(name.name)" + } + } + + /// Pick a stable primary name to use in anchors and synopses. + public static func primaryName(for argument: ArgumentInfoV0) -> String { + if let preferred = argument.preferredName { + return preferred.name + } + if let first = argument.names?.first { + return first.name + } + return argument.valueName ?? "argument" + } +} diff --git a/Sources/CLIDocsCore/ViewModels/RenderContext.swift b/Sources/CLIDocsCore/ViewModels/RenderContext.swift new file mode 100644 index 0000000..100a14a --- /dev/null +++ b/Sources/CLIDocsCore/ViewModels/RenderContext.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Top-level context root passed to every Stencil template. +/// +/// All fields are pre-computed strings, booleans, or arrays of view-models — never +/// raw `ToolInfoV0` types. This keeps templates logic-less and gives us a stable +/// public API for custom themes. +public struct RenderContext: Sendable, Equatable { + public var meta: MetaView + public var theme: ThemeView + public var command: CommandView? + public var commands: [CommandView] + public var index: IndexView? + + public init( + meta: MetaView, + theme: ThemeView, + command: CommandView? = nil, + commands: [CommandView] = [], + index: IndexView? = nil + ) { + self.meta = meta + self.theme = theme + self.command = command + self.commands = commands + self.index = index + } + + /// Convert to a Stencil-compatible dictionary tree. + public func asDictionary() -> [String: Any] { + var dict: [String: Any] = [ + "meta": meta.asDictionary(), + "theme": theme.asDictionary(), + "commands": commands.map { $0.asDictionary() }, + ] + if let command { dict["command"] = command.asDictionary() } + if let index { dict["index"] = index.asDictionary() } + return dict + } +} + +public struct MetaView: Sendable, Equatable { + public var title: String + public var description: String? + public var version: String? + public var repository: String? + + public init( + title: String, + description: String? = nil, + version: String? = nil, + repository: String? = nil + ) { + self.title = title + self.description = description + self.version = version + self.repository = repository + } + + public func asDictionary() -> [String: Any] { + var d: [String: Any] = ["title": title] + if let description { d["description"] = description } + if let version { d["version"] = version } + if let repository { d["repository"] = repository } + return d + } +} + +public struct ThemeView: Sendable, Equatable { + public var name: String + public var headingDepth: Int + public var toc: Bool + public var showAliases: Bool + public var codeFence: String + public var emoji: Bool + public var variables: [String: String] + + public init( + name: String, + headingDepth: Int, + toc: Bool, + showAliases: Bool, + codeFence: String, + emoji: Bool, + variables: [String: String] + ) { + self.name = name + self.headingDepth = headingDepth + self.toc = toc + self.showAliases = showAliases + self.codeFence = codeFence + self.emoji = emoji + self.variables = variables + } + + public func asDictionary() -> [String: Any] { + [ + "name": name, + "headingDepth": headingDepth, + "toc": toc, + "showAliases": showAliases, + "codeFence": codeFence, + "emoji": emoji, + "variables": variables, + ] + } +} diff --git a/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift b/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift new file mode 100644 index 0000000..3076c21 --- /dev/null +++ b/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift @@ -0,0 +1,52 @@ +import Foundation +import ArgumentParserToolInfo + +/// Builds the `Usage:` synopsis line for a command. +public enum SynopsisBuilder { + /// Compose a usage line like `mycli build [--release] [--target ] ...`. + /// The leading invocation token is the full command path so users can copy/paste. + public static func build(commandPath: [String], arguments: [ArgumentInfoV0]?) -> String { + var parts: [String] = commandPath + let visible = (arguments ?? []).filter { $0.shouldDisplay } + + let nonPositional = visible.filter { $0.kind != .positional } + let positional = visible.filter { $0.kind == .positional } + + for arg in nonPositional { + parts.append(synopsisFragment(for: arg)) + } + for arg in positional { + parts.append(synopsisFragment(for: arg)) + } + return parts.joined(separator: " ") + } + + private static func synopsisFragment(for arg: ArgumentInfoV0) -> String { + switch arg.kind { + case .positional: + let value = arg.valueName ?? "value" + let core = arg.isRepeating ? "<\(value)>..." : "<\(value)>" + return arg.isOptional ? "[\(core)]" : core + case .option: + let name = bestSynopsisName(arg) + let value = arg.valueName ?? "value" + let trailing = arg.isRepeating ? " <\(value)> ..." : " <\(value)>" + let core = "\(name)\(trailing)" + return arg.isOptional ? "[\(core)]" : core + case .flag: + let name = bestSynopsisName(arg) + return arg.isOptional ? "[\(name)]" : name + } + } + + /// For the synopsis we prefer the shortest preferred name to keep the line compact. + private static func bestSynopsisName(_ arg: ArgumentInfoV0) -> String { + if let preferred = arg.preferredName { + return DefaultsFormatter.formatted(name: preferred) + } + if let first = arg.names?.first { + return DefaultsFormatter.formatted(name: first) + } + return arg.valueName ?? "value" + } +} diff --git a/Sources/swift-cli-docs/GenerateCommand.swift b/Sources/swift-cli-docs/GenerateCommand.swift new file mode 100644 index 0000000..9400c08 --- /dev/null +++ b/Sources/swift-cli-docs/GenerateCommand.swift @@ -0,0 +1,85 @@ +import Foundation +import ArgumentParser +import ArgumentParserToolInfo +import CLIDocsCore + +@main +struct SwiftCLIDocs: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "swift-cli-docs", + abstract: "Generate Markdown documentation for a Swift Argument Parser CLI tool.", + discussion: """ + This is the helper invoked by the SwiftCLIDocsPlugin SPM command plugin. + It can also be used standalone — point `--executable` at any tool that + implements `--experimental-dump-help`. + """ + ) + + @Option(name: .long, help: "Path to the built executable to introspect.") + var executable: String? + + @Option(name: .long, help: "Path to a pre-recorded --experimental-dump-help JSON. Useful for tests.") + var jsonInput: String? + + @Option(name: .long, help: "Name of the executable target inside the package (used for messaging).") + var target: String? + + @Option(name: .long, help: "Path to the YAML config. Defaults to .swift-cli-docs.yml in --package-root.") + var config: String? + + @Option(name: .long, help: "Override output directory.") + var output: String? + + @Option(name: .long, help: "Override layout: multi-file or single-file.") + var layout: String? + + @Option(name: .long, help: "Override theme: default, minimal, github.") + var theme: String? + + @Option(name: .long, help: "Override path to a user theme directory.") + var themePath: String? + + @Option(name: .long, help: "Package root directory. Used for resolving config and output paths.") + var packageRoot: String = "." + + func run() throws { + let packageRootURL = URL(fileURLWithPath: packageRoot, isDirectory: true) + + let loader = ConfigLoader() + var loaded: DocsConfig + if let configPath = config { + loaded = try loader.load(from: URL(fileURLWithPath: configPath)) + } else if let url = loader.locateConfig(in: packageRootURL) { + loaded = try loader.load(from: url) + } else { + loaded = DocsConfig.default + } + + let overrides = CLIOverrides( + target: target, + outputDirectory: output, + layout: layout.flatMap(OutputConfig.Layout.init(rawValue:)), + themeName: theme, + themePath: themePath + ) + let cfg = loader.merge(base: loaded, with: overrides) + + let toolInfo = try loadToolInfo() + let generator = DocsGenerator(config: cfg) + let written = try generator.generate(from: toolInfo, packageRoot: packageRootURL) + + FileHandle.standardError.write(Data("Wrote \(written.count) file(s) to \(cfg.output.directory).\n".utf8)) + } + + private func loadToolInfo() throws -> ToolInfoV0 { + let runner = DumpHelpRunner() + if let json = jsonInput { + let data = try Data(contentsOf: URL(fileURLWithPath: json)) + return try runner.decode(data) + } + guard let exe = executable else { + throw ValidationError("Either --executable or --json-input must be provided.") + } + return try runner.run(executable: URL(fileURLWithPath: exe)) + } +} diff --git a/Tests/CLIDocsCoreTests/ConfigTests.swift b/Tests/CLIDocsCoreTests/ConfigTests.swift new file mode 100644 index 0000000..3d6f68d --- /dev/null +++ b/Tests/CLIDocsCoreTests/ConfigTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import CLIDocsCore + +final class ConfigTests: XCTestCase { + func testDefaultValues() { + let cfg = DocsConfig() + XCTAssertEqual(cfg.output.directory, "docs") + XCTAssertEqual(cfg.output.layout, .multiFile) + XCTAssertEqual(cfg.output.filename, "{command}.md") + XCTAssertEqual(cfg.output.index, "INDEX.md") + XCTAssertEqual(cfg.theme.name, "default") + XCTAssertEqual(cfg.theme.headingDepth, 1) + XCTAssertEqual(cfg.theme.codeFence, "bash") + XCTAssertTrue(cfg.theme.toc) + XCTAssertFalse(cfg.theme.showHidden) + XCTAssertEqual(cfg.include, ["*"]) + XCTAssertTrue(cfg.exclude.isEmpty) + } + + func testYAMLRoundtrip() throws { + let yaml = """ + target: MyCLI + output: + directory: docs/cli + layout: single-file + filename: "{command}.md" + metadata: + title: "Demo" + description: "Hello" + theme: + name: github + headingDepth: 2 + toc: false + codeFence: shell + variables: + accent: "🚀" + sections: + order: [overview, usage, options] + custom: + footer: footer.md + include: ["mycli build*"] + exclude: ["mycli internal-*"] + overrides: + "mycli build": + abstract: "Custom abstract" + examples: + - title: "Basic" + code: "mycli build --release" + """ + let url = makeTempYAML(yaml) + defer { try? FileManager.default.removeItem(at: url) } + + let cfg = try ConfigLoader().load(from: url) + XCTAssertEqual(cfg.target, "MyCLI") + XCTAssertEqual(cfg.output.directory, "docs/cli") + XCTAssertEqual(cfg.output.layout, .singleFile) + XCTAssertEqual(cfg.metadata.title, "Demo") + XCTAssertEqual(cfg.theme.name, "github") + XCTAssertEqual(cfg.theme.headingDepth, 2) + XCTAssertFalse(cfg.theme.toc) + XCTAssertEqual(cfg.theme.codeFence, "shell") + XCTAssertEqual(cfg.theme.variables["accent"], "🚀") + XCTAssertEqual(cfg.include, ["mycli build*"]) + XCTAssertEqual(cfg.exclude, ["mycli internal-*"]) + XCTAssertEqual(cfg.overrides["mycli build"]?.abstract, "Custom abstract") + XCTAssertEqual(cfg.overrides["mycli build"]?.examples.first?.code, "mycli build --release") + } + + func testEmptyYAMLUsesDefaults() throws { + let url = makeTempYAML("{}\n") + defer { try? FileManager.default.removeItem(at: url) } + let cfg = try ConfigLoader().load(from: url) + XCTAssertEqual(cfg, DocsConfig.default) + } + + func testCLIOverridesWin() { + var base = DocsConfig() + base.theme.name = "default" + base.output.directory = "docs" + let merged = ConfigLoader().merge( + base: base, + with: CLIOverrides(target: "X", outputDirectory: "out", layout: .singleFile, themeName: "github", themePath: nil) + ) + XCTAssertEqual(merged.target, "X") + XCTAssertEqual(merged.output.directory, "out") + XCTAssertEqual(merged.output.layout, .singleFile) + XCTAssertEqual(merged.theme.name, "github") + } + + private func makeTempYAML(_ contents: String) -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".yml") + try! contents.write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Tests/CLIDocsCoreTests/ContextBuilderTests.swift b/Tests/CLIDocsCoreTests/ContextBuilderTests.swift new file mode 100644 index 0000000..10fd165 --- /dev/null +++ b/Tests/CLIDocsCoreTests/ContextBuilderTests.swift @@ -0,0 +1,70 @@ +import XCTest +import ArgumentParserToolInfo +@testable import CLIDocsCore + +final class ContextBuilderTests: XCTestCase { + func testSimpleToolBuildsExpectedCommandView() throws { + let tool = try loadFixture("simple-tool") + let cfg = DocsConfig() + let builder = ContextBuilder( + config: cfg, + linkResolver: RelativeFileLinkResolver(filenameTemplate: cfg.output.filename, rootName: tool.command.commandName) + ) + let views = builder.buildAllCommandViews(from: tool) + XCTAssertEqual(views.count, 1) + let v = views[0] + XCTAssertEqual(v.name, "demo") + XCTAssertEqual(v.fullPath, "demo") + XCTAssertEqual(v.headingPrefix, "#") + XCTAssertTrue(v.synopsis.contains("[--verbose ]")) + XCTAssertTrue(v.synopsis.contains("[--release]")) + XCTAssertTrue(v.synopsis.contains("...")) + XCTAssertEqual(v.argumentSections.count, 3) + let optionsSection = v.argumentSections.first { $0.title == "Options" } + XCTAssertNotNil(optionsSection) + XCTAssertEqual(optionsSection?.arguments.first?.defaultDisplay, "1") + XCTAssertEqual(optionsSection?.arguments.first?.valueRangeText, "one of: 0, 1, 2") + } + + func testNestedToolHidesNonDisplayedSubcommands() throws { + let tool = try loadFixture("nested-tool") + let cfg = DocsConfig() + let builder = ContextBuilder( + config: cfg, + linkResolver: AnchorLinkResolver() + ) + let views = builder.buildAllCommandViews(from: tool) + let names = views.map(\.fullPath) + XCTAssertEqual(names, ["demo", "demo build"]) + XCTAssertFalse(names.contains("demo internal-debug")) + } + + func testIncludeExcludeFilters() throws { + let tool = try loadFixture("nested-tool") + var cfg = DocsConfig() + cfg.exclude = ["demo build"] + let builder = ContextBuilder(config: cfg, linkResolver: AnchorLinkResolver()) + let views = builder.buildAllCommandViews(from: tool) + XCTAssertEqual(views.map(\.fullPath), ["demo"]) + } + + func testOverrideAbstractWins() throws { + let tool = try loadFixture("simple-tool") + var cfg = DocsConfig() + cfg.overrides["demo"] = CommandOverride(abstract: "Custom!", examples: [.init(title: "Hello", code: "demo /tmp")]) + let builder = ContextBuilder(config: cfg, linkResolver: AnchorLinkResolver()) + let views = builder.buildAllCommandViews(from: tool) + XCTAssertEqual(views.first?.abstract, "Custom!") + XCTAssertEqual(views.first?.examples.first?.title, "Hello") + XCTAssertEqual(views.first?.examples.first?.codeFenced, "```bash\ndemo /tmp\n```") + } + + private func loadFixture(_ name: String) throws -> ToolInfoV0 { + guard let url = Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") else { + XCTFail("Fixture \(name).json not found") + throw NSError(domain: "test", code: -1) + } + let data = try Data(contentsOf: url) + return try DumpHelpRunner().decode(data) + } +} diff --git a/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift b/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift new file mode 100644 index 0000000..1da0284 --- /dev/null +++ b/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift @@ -0,0 +1,75 @@ +import XCTest +import ArgumentParserToolInfo +@testable import CLIDocsCore + +final class DocsGeneratorTests: XCTestCase { + func testMultiFileLayoutProducesCommandFilesAndIndex() throws { + let tool = try loadFixture("nested-tool") + var cfg = DocsConfig() + cfg.theme.name = "default" + cfg.output.layout = .multiFile + + let generator = DocsGenerator(config: cfg) + let files = try generator.renderFiles(from: tool) + + let paths = files.map(\.relativePath).sorted() + XCTAssertTrue(paths.contains("INDEX.md")) + XCTAssertTrue(paths.contains("demo.md")) + XCTAssertTrue(paths.contains("demo-build.md")) + XCTAssertFalse(paths.contains("demo-internal-debug.md")) + + let demoBuild = files.first { $0.relativePath == "demo-build.md" } + XCTAssertNotNil(demoBuild) + XCTAssertTrue(demoBuild!.contents.contains("# demo build")) + XCTAssertTrue(demoBuild!.contents.contains("Build the project.")) + XCTAssertTrue(demoBuild!.contents.contains("--release")) + } + + func testSingleFileLayoutProducesOneFile() throws { + let tool = try loadFixture("simple-tool") + var cfg = DocsConfig() + cfg.theme.name = "default" + cfg.output.layout = .singleFile + let generator = DocsGenerator(config: cfg) + let files = try generator.renderFiles(from: tool) + XCTAssertEqual(files.count, 1) + XCTAssertEqual(files[0].relativePath, "demo.md") + XCTAssertTrue(files[0].contents.contains("# Demo") || files[0].contents.contains("# demo")) + XCTAssertTrue(files[0].contents.contains("Verbosity level.")) + } + + func testMinimalThemeRendersAsLists() throws { + let tool = try loadFixture("simple-tool") + var cfg = DocsConfig() + cfg.theme.name = "minimal" + cfg.output.layout = .multiFile + let generator = DocsGenerator(config: cfg) + let files = try generator.renderFiles(from: tool) + let demoFile = files.first { $0.relativePath == "demo.md" } + XCTAssertNotNil(demoFile) + // Minimal theme uses list bullets, not table pipes inside arguments section. + XCTAssertTrue(demoFile!.contents.contains("- `--verbose `")) + XCTAssertFalse(demoFile!.contents.contains("| Name | Default | Description |")) + } + + func testGithubThemeRendersDetailsBlocks() throws { + let tool = try loadFixture("simple-tool") + var cfg = DocsConfig() + cfg.theme.name = "github" + cfg.output.layout = .multiFile + let generator = DocsGenerator(config: cfg) + let files = try generator.renderFiles(from: tool) + let demoFile = files.first { $0.relativePath == "demo.md" } + XCTAssertNotNil(demoFile) + XCTAssertTrue(demoFile!.contents.contains("
")) + } + + private func loadFixture(_ name: String) throws -> ToolInfoV0 { + guard let url = Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures") else { + XCTFail("Fixture \(name).json not found") + throw NSError(domain: "test", code: -1) + } + let data = try Data(contentsOf: url) + return try DumpHelpRunner().decode(data) + } +} diff --git a/Tests/CLIDocsCoreTests/Fixtures/nested-tool.json b/Tests/CLIDocsCoreTests/Fixtures/nested-tool.json new file mode 100644 index 0000000..457a35e --- /dev/null +++ b/Tests/CLIDocsCoreTests/Fixtures/nested-tool.json @@ -0,0 +1,33 @@ +{ + "serializationVersion": 0, + "command": { + "commandName": "demo", + "abstract": "Nested demo tool.", + "shouldDisplay": true, + "subcommands": [ + { + "commandName": "build", + "abstract": "Build the project.", + "shouldDisplay": true, + "arguments": [ + { + "kind": "flag", + "shouldDisplay": true, + "isOptional": true, + "isRepeating": false, + "parsingStrategy": "default", + "names": [{"kind": "long", "name": "release"}], + "preferredName": {"kind": "long", "name": "release"}, + "abstract": "Use release config." + } + ] + }, + { + "commandName": "internal-debug", + "abstract": "Internal debug command.", + "shouldDisplay": false, + "arguments": [] + } + ] + } +} diff --git a/Tests/CLIDocsCoreTests/Fixtures/simple-tool.json b/Tests/CLIDocsCoreTests/Fixtures/simple-tool.json new file mode 100644 index 0000000..1dbab56 --- /dev/null +++ b/Tests/CLIDocsCoreTests/Fixtures/simple-tool.json @@ -0,0 +1,46 @@ +{ + "serializationVersion": 0, + "command": { + "commandName": "demo", + "abstract": "A demo CLI tool.", + "discussion": "Use this tool to demo argument parsing.", + "shouldDisplay": true, + "arguments": [ + { + "kind": "option", + "shouldDisplay": true, + "isOptional": true, + "isRepeating": false, + "parsingStrategy": "default", + "names": [ + {"kind": "short", "name": "v"}, + {"kind": "long", "name": "verbose"} + ], + "preferredName": {"kind": "long", "name": "verbose"}, + "valueName": "level", + "defaultValue": "1", + "allValueStrings": ["0", "1", "2"], + "abstract": "Verbosity level." + }, + { + "kind": "flag", + "shouldDisplay": true, + "isOptional": true, + "isRepeating": false, + "parsingStrategy": "default", + "names": [{"kind": "long", "name": "release"}], + "preferredName": {"kind": "long", "name": "release"}, + "abstract": "Build in release configuration." + }, + { + "kind": "positional", + "shouldDisplay": true, + "isOptional": false, + "isRepeating": true, + "parsingStrategy": "default", + "valueName": "path", + "abstract": "One or more paths to process." + } + ] + } +} diff --git a/Tests/CLIDocsCoreTests/MarkdownEscapeTests.swift b/Tests/CLIDocsCoreTests/MarkdownEscapeTests.swift new file mode 100644 index 0000000..e9d5d2e --- /dev/null +++ b/Tests/CLIDocsCoreTests/MarkdownEscapeTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import CLIDocsCore + +final class MarkdownEscapeTests: XCTestCase { + func testInlineEscapesPipesAndStars() { + XCTAssertEqual(MarkdownEscape.inline("a|b*c"), "a\\|b\\*c") + } + + func testTableCellReplacesNewlines() { + XCTAssertEqual(MarkdownEscape.tableCell("a\nb|c"), "a b\\|c") + } + + func testAnchorSlug() { + XCTAssertEqual(MarkdownEscape.anchor("MyCLI build release"), "mycli-build-release") + XCTAssertEqual(MarkdownEscape.anchor("a/b.c d"), "a-b-c-d") + XCTAssertEqual(MarkdownEscape.anchor("---hello---"), "hello") + } +} diff --git a/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift b/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift new file mode 100644 index 0000000..7d10bbf --- /dev/null +++ b/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift @@ -0,0 +1,61 @@ +import XCTest +import ArgumentParserToolInfo +@testable import CLIDocsCore + +final class SynopsisBuilderTests: XCTestCase { + func testSimpleSynopsisOrderingPlacesPositionalsLast() throws { + let json = """ + { + "serializationVersion": 0, + "command": { + "commandName": "demo", + "shouldDisplay": true, + "arguments": [ + {"kind": "positional", "shouldDisplay": true, "isOptional": false, "isRepeating": false, "parsingStrategy": "default", "valueName": "path"}, + {"kind": "flag", "shouldDisplay": true, "isOptional": true, "isRepeating": false, "parsingStrategy": "default", "names": [{"kind": "long", "name": "release"}], "preferredName": {"kind": "long", "name": "release"}} + ] + } + } + """ + let tool = try DumpHelpRunner().decode(Data(json.utf8)) + let synopsis = SynopsisBuilder.build(commandPath: ["demo"], arguments: tool.command.arguments) + XCTAssertEqual(synopsis, "demo [--release] ") + } + + func testRepeatingPositionalGetsEllipsis() throws { + let json = """ + { + "serializationVersion": 0, + "command": { + "commandName": "demo", + "shouldDisplay": true, + "arguments": [ + {"kind": "positional", "shouldDisplay": true, "isOptional": false, "isRepeating": true, "parsingStrategy": "default", "valueName": "path"} + ] + } + } + """ + let tool = try DumpHelpRunner().decode(Data(json.utf8)) + let synopsis = SynopsisBuilder.build(commandPath: ["demo"], arguments: tool.command.arguments) + XCTAssertEqual(synopsis, "demo ...") + } + + func testHiddenArgsExcludedFromSynopsis() throws { + let json = """ + { + "serializationVersion": 0, + "command": { + "commandName": "demo", + "shouldDisplay": true, + "arguments": [ + {"kind": "flag", "shouldDisplay": false, "isOptional": true, "isRepeating": false, "parsingStrategy": "default", "names": [{"kind": "long", "name": "secret"}], "preferredName": {"kind": "long", "name": "secret"}}, + {"kind": "flag", "shouldDisplay": true, "isOptional": true, "isRepeating": false, "parsingStrategy": "default", "names": [{"kind": "long", "name": "verbose"}], "preferredName": {"kind": "long", "name": "verbose"}} + ] + } + } + """ + let tool = try DumpHelpRunner().decode(Data(json.utf8)) + let synopsis = SynopsisBuilder.build(commandPath: ["demo"], arguments: tool.command.arguments) + XCTAssertEqual(synopsis, "demo [--verbose]") + } +} From 9338302a24d7afb48170dd424ade404c40d55753 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 05:25:23 +0000 Subject: [PATCH 2/3] Vendor ToolInfoV0 schema instead of importing ArgumentParserToolInfo ArgumentParserToolInfo is an internal target of swift-argument-parser, not a public product, so it can't be imported from another package. Re-declare the Codable schema (ToolInfoV0, CommandInfoV0, ArgumentInfoV0, NameInfoV0) locally so DumpHelpRunner can decode --experimental-dump-help JSON without the upstream module. https://claude.ai/code/session_01L8VEwZS2SbtRfPpGMK924y --- Package.swift | 1 - .../CLIDocsCore/Pipeline/DocsGenerator.swift | 1 - .../CLIDocsCore/Pipeline/DumpHelpRunner.swift | 1 - Sources/CLIDocsCore/Pipeline/ToolInfo.swift | 172 ++++++++++++++++++ .../ViewModels/ContextBuilder.swift | 1 - .../ViewModels/DefaultsFormatter.swift | 1 - .../ViewModels/SynopsisBuilder.swift | 1 - Sources/swift-cli-docs/GenerateCommand.swift | 1 - .../ContextBuilderTests.swift | 1 - .../CLIDocsCoreTests/DocsGeneratorTests.swift | 1 - .../SynopsisBuilderTests.swift | 1 - 11 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 Sources/CLIDocsCore/Pipeline/ToolInfo.swift diff --git a/Package.swift b/Package.swift index 28526d9..0697d66 100644 --- a/Package.swift +++ b/Package.swift @@ -54,7 +54,6 @@ let package = Package( .target( name: "CLIDocsCore", dependencies: [ - .product(name: "ArgumentParserToolInfo", package: "swift-argument-parser"), .product(name: "Yams", package: "Yams"), .product(name: "Stencil", package: "Stencil"), .product(name: "PathKit", package: "PathKit"), diff --git a/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift b/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift index 2e76d59..714b5f6 100644 --- a/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift +++ b/Sources/CLIDocsCore/Pipeline/DocsGenerator.swift @@ -1,5 +1,4 @@ import Foundation -import ArgumentParserToolInfo public enum DocsGeneratorError: Error, CustomStringConvertible { case writeFailed(URL, underlying: Error) diff --git a/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift b/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift index bd9b35d..4b11652 100644 --- a/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift +++ b/Sources/CLIDocsCore/Pipeline/DumpHelpRunner.swift @@ -1,5 +1,4 @@ import Foundation -import ArgumentParserToolInfo public enum DumpHelpRunnerError: Error, CustomStringConvertible { case executableNotFound(URL) diff --git a/Sources/CLIDocsCore/Pipeline/ToolInfo.swift b/Sources/CLIDocsCore/Pipeline/ToolInfo.swift new file mode 100644 index 0000000..6e1f397 --- /dev/null +++ b/Sources/CLIDocsCore/Pipeline/ToolInfo.swift @@ -0,0 +1,172 @@ +import Foundation + +/// Header decoded first to validate the serialization version of `--experimental-dump-help`. +public struct ToolInfoHeader: Codable, Sendable { + public let serializationVersion: Int +} + +/// Local Codable mirror of swift-argument-parser's `ArgumentParserToolInfo.ToolInfoV0`. +/// +/// We can't depend on the upstream module because it's not exposed as an SPM product, +/// so we re-declare the schema here. Field names match the JSON keys produced by +/// `--experimental-dump-help` so decoding is straightforward. +public struct ToolInfoV0: Codable, Sendable { + public var serializationVersion: Int + public var command: CommandInfoV0 + + public init(serializationVersion: Int = 0, command: CommandInfoV0) { + self.serializationVersion = serializationVersion + self.command = command + } +} + +public struct CommandInfoV0: Codable, Sendable { + public var commandName: String + public var abstract: String? + public var discussion: String? + public var aliases: [String]? + public var defaultSubcommand: String? + public var superCommands: [String]? + public var shouldDisplay: Bool + public var subcommands: [CommandInfoV0]? + public var arguments: [ArgumentInfoV0]? + + public init( + commandName: String, + abstract: String? = nil, + discussion: String? = nil, + aliases: [String]? = nil, + defaultSubcommand: String? = nil, + superCommands: [String]? = nil, + shouldDisplay: Bool = true, + subcommands: [CommandInfoV0]? = nil, + arguments: [ArgumentInfoV0]? = nil + ) { + self.commandName = commandName + self.abstract = abstract + self.discussion = discussion + self.aliases = aliases + self.defaultSubcommand = defaultSubcommand + self.superCommands = superCommands + self.shouldDisplay = shouldDisplay + self.subcommands = subcommands + self.arguments = arguments + } + + private enum CodingKeys: String, CodingKey { + case commandName, abstract, discussion, aliases, defaultSubcommand, + superCommands, shouldDisplay, subcommands, arguments + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.commandName = try c.decode(String.self, forKey: .commandName) + self.abstract = try c.decodeIfPresent(String.self, forKey: .abstract) + self.discussion = try c.decodeIfPresent(String.self, forKey: .discussion) + self.aliases = try c.decodeIfPresent([String].self, forKey: .aliases) + self.defaultSubcommand = try c.decodeIfPresent(String.self, forKey: .defaultSubcommand) + self.superCommands = try c.decodeIfPresent([String].self, forKey: .superCommands) + self.shouldDisplay = try c.decodeIfPresent(Bool.self, forKey: .shouldDisplay) ?? true + self.subcommands = try c.decodeIfPresent([CommandInfoV0].self, forKey: .subcommands) + self.arguments = try c.decodeIfPresent([ArgumentInfoV0].self, forKey: .arguments) + } +} + +public struct ArgumentInfoV0: Codable, Sendable { + public enum KindV0: String, Codable, Sendable { + case positional + case option + case flag + } + + public struct NameInfoV0: Codable, Sendable { + public enum KindV0: String, Codable, Sendable { + case long + case short + case longWithSingleDash + } + + public var kind: KindV0 + public var name: String + + public init(kind: KindV0, name: String) { + self.kind = kind + self.name = name + } + } + + public var kind: KindV0 + public var shouldDisplay: Bool + public var sectionTitle: String? + public var isOptional: Bool + public var isRepeating: Bool + public var parsingStrategy: String? + public var names: [NameInfoV0]? + public var preferredName: NameInfoV0? + public var valueName: String? + public var defaultValue: String? + public var allValueStrings: [String]? + public var allValueDescriptions: [String: String]? + public var completionKind: String? + public var abstract: String? + public var discussion: String? + + public init( + kind: KindV0, + shouldDisplay: Bool = true, + sectionTitle: String? = nil, + isOptional: Bool = true, + isRepeating: Bool = false, + parsingStrategy: String? = nil, + names: [NameInfoV0]? = nil, + preferredName: NameInfoV0? = nil, + valueName: String? = nil, + defaultValue: String? = nil, + allValueStrings: [String]? = nil, + allValueDescriptions: [String: String]? = nil, + completionKind: String? = nil, + abstract: String? = nil, + discussion: String? = nil + ) { + self.kind = kind + self.shouldDisplay = shouldDisplay + self.sectionTitle = sectionTitle + self.isOptional = isOptional + self.isRepeating = isRepeating + self.parsingStrategy = parsingStrategy + self.names = names + self.preferredName = preferredName + self.valueName = valueName + self.defaultValue = defaultValue + self.allValueStrings = allValueStrings + self.allValueDescriptions = allValueDescriptions + self.completionKind = completionKind + self.abstract = abstract + self.discussion = discussion + } + + private enum CodingKeys: String, CodingKey { + case kind, shouldDisplay, sectionTitle, isOptional, isRepeating, parsingStrategy, + names, preferredName, valueName, defaultValue, allValueStrings, + allValueDescriptions, completionKind, abstract, discussion + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.kind = try c.decode(KindV0.self, forKey: .kind) + self.shouldDisplay = try c.decodeIfPresent(Bool.self, forKey: .shouldDisplay) ?? true + self.sectionTitle = try c.decodeIfPresent(String.self, forKey: .sectionTitle) + self.isOptional = try c.decodeIfPresent(Bool.self, forKey: .isOptional) ?? true + self.isRepeating = try c.decodeIfPresent(Bool.self, forKey: .isRepeating) ?? false + self.parsingStrategy = try c.decodeIfPresent(String.self, forKey: .parsingStrategy) + self.names = try c.decodeIfPresent([NameInfoV0].self, forKey: .names) + self.preferredName = try c.decodeIfPresent(NameInfoV0.self, forKey: .preferredName) + self.valueName = try c.decodeIfPresent(String.self, forKey: .valueName) + self.defaultValue = try c.decodeIfPresent(String.self, forKey: .defaultValue) + self.allValueStrings = try c.decodeIfPresent([String].self, forKey: .allValueStrings) + self.allValueDescriptions = try c.decodeIfPresent([String: String].self, forKey: .allValueDescriptions) + self.completionKind = try c.decodeIfPresent(String.self, forKey: .completionKind) + self.abstract = try c.decodeIfPresent(String.self, forKey: .abstract) + self.discussion = try c.decodeIfPresent(String.self, forKey: .discussion) + } +} diff --git a/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift b/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift index 41bef72..d732b84 100644 --- a/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift +++ b/Sources/CLIDocsCore/ViewModels/ContextBuilder.swift @@ -1,5 +1,4 @@ import Foundation -import ArgumentParserToolInfo /// Strategy for turning a command's full path into a markdown link target. /// Multi-file: relative file path. Single-file: anchor fragment. diff --git a/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift b/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift index 3f87c4c..a057cf7 100644 --- a/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift +++ b/Sources/CLIDocsCore/ViewModels/DefaultsFormatter.swift @@ -1,5 +1,4 @@ import Foundation -import ArgumentParserToolInfo public enum DefaultsFormatter { public static let placeholder = "—" diff --git a/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift b/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift index 3076c21..01a592e 100644 --- a/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift +++ b/Sources/CLIDocsCore/ViewModels/SynopsisBuilder.swift @@ -1,5 +1,4 @@ import Foundation -import ArgumentParserToolInfo /// Builds the `Usage:` synopsis line for a command. public enum SynopsisBuilder { diff --git a/Sources/swift-cli-docs/GenerateCommand.swift b/Sources/swift-cli-docs/GenerateCommand.swift index 9400c08..cf9aa41 100644 --- a/Sources/swift-cli-docs/GenerateCommand.swift +++ b/Sources/swift-cli-docs/GenerateCommand.swift @@ -1,6 +1,5 @@ import Foundation import ArgumentParser -import ArgumentParserToolInfo import CLIDocsCore @main diff --git a/Tests/CLIDocsCoreTests/ContextBuilderTests.swift b/Tests/CLIDocsCoreTests/ContextBuilderTests.swift index 10fd165..9adc4b7 100644 --- a/Tests/CLIDocsCoreTests/ContextBuilderTests.swift +++ b/Tests/CLIDocsCoreTests/ContextBuilderTests.swift @@ -1,5 +1,4 @@ import XCTest -import ArgumentParserToolInfo @testable import CLIDocsCore final class ContextBuilderTests: XCTestCase { diff --git a/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift b/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift index 1da0284..4c04323 100644 --- a/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift +++ b/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift @@ -1,5 +1,4 @@ import XCTest -import ArgumentParserToolInfo @testable import CLIDocsCore final class DocsGeneratorTests: XCTestCase { diff --git a/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift b/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift index 7d10bbf..487e058 100644 --- a/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift +++ b/Tests/CLIDocsCoreTests/SynopsisBuilderTests.swift @@ -1,5 +1,4 @@ import XCTest -import ArgumentParserToolInfo @testable import CLIDocsCore final class SynopsisBuilderTests: XCTestCase { From e06e5d308abb075fa37267d8a7f473f2a5bbc901 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 5 May 2026 05:27:39 +0000 Subject: [PATCH 3/3] Fix testMinimalThemeRendersAsLists expected substring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit displayName for an option includes all of its names joined by commas, so the rendered output is "- \`-v, --verbose \`" — not "- \`--verbose \`". Update the assertion to match. https://claude.ai/code/session_01L8VEwZS2SbtRfPpGMK924y --- Tests/CLIDocsCoreTests/DocsGeneratorTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift b/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift index 4c04323..18d8cb2 100644 --- a/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift +++ b/Tests/CLIDocsCoreTests/DocsGeneratorTests.swift @@ -47,7 +47,7 @@ final class DocsGeneratorTests: XCTestCase { let demoFile = files.first { $0.relativePath == "demo.md" } XCTAssertNotNil(demoFile) // Minimal theme uses list bullets, not table pipes inside arguments section. - XCTAssertTrue(demoFile!.contents.contains("- `--verbose `")) + XCTAssertTrue(demoFile!.contents.contains("- `-v, --verbose `")) XCTAssertFalse(demoFile!.contents.contains("| Name | Default | Description |")) }