Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions Examples/DemoCLI/.swift-cli-docs.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions Examples/DemoCLI/Package.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
),
]
)
51 changes: 51 additions & 0 deletions Examples/DemoCLI/Sources/DemoCLI/DemoCLI.swift
Original file line number Diff line number Diff line change
@@ -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 ?? "<auto>")")
}
}

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)")
}
}
75 changes: 75 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// 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: "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"),
]
),
]
)
136 changes: 136 additions & 0 deletions Plugins/SwiftCLIDocsPlugin/Plugin.swift
Original file line number Diff line number Diff line change
@@ -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 <path> after building the target manually.")
throw PluginError.buildFailed(name)
}
)
}
}
#endif
Loading
Loading