feat: Working on command-line documentation.
Some checks failed
CI / Ubuntu (push) Has been cancelled

This commit is contained in:
2024-12-27 14:51:55 -05:00
parent 8d73287a60
commit 4420bd428a
28 changed files with 674 additions and 80 deletions

View File

@@ -0,0 +1,311 @@
/*
This file contains helpers for generating the documentation for the commands.
*/
import ArgumentParser
import CliDoc
import Rainbow
protocol CommandRepresentable: AsyncParsableCommand {
static var commandName: String { get }
static var parentCommand: String? { get }
}
extension CommandRepresentable {
static var parentCommand: String? { nil }
static func makeExample(
label: String,
example: String,
includesAppName: Bool = true
) -> AppExample {
.init(
label: label,
parentCommand: parentCommand,
commandName: commandName,
includesAppName: includesAppName,
example: example
)
}
}
extension Abstract where Content == String {
static func `default`(_ string: String) -> Self {
.init { string.blue }
}
}
struct Note<Content: TextNode>: TextNode {
let content: Content
init(
@TextBuilder _ content: () -> Content
) {
self.content = content()
}
var body: some TextNode {
LabeledContent {
content.italic()
} label: {
"Note:".yellow.bold
}
.style(.vertical())
}
}
extension Note where Content == AnyTextNode {
static func `default`(
notes: [String],
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true
) -> Self {
var notes = notes
if usesConfigurationFileNote {
notes.insert(
"Most options are not required when a configuration file is setup for your project.",
at: 0
)
}
if usesConfigurationMergingNote {
if usesConfigurationFileNote {
notes.insert(
"Any configuration options get merged with the loaded project configuration file.",
at: 1
)
} else {
notes.insert(
"Any configuration options get merged with the loaded project configuration file.",
at: 0
)
}
}
return .init {
VStack {
notes.enumerated().map { "\($0 + 1). \($1)" }
}
.eraseToAnyTextNode()
}
}
}
extension Discussion where Content == AnyTextNode {
static func `default`<Preamble: TextNode, Trailing: TextNode>(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true,
@TextBuilder preamble: () -> Preamble,
@TextBuilder trailing: () -> Trailing
) -> Self {
Discussion {
VStack {
preamble().italic()
Note.default(
notes: notes,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote
)
if let examples {
ExampleSection.default(examples: examples, usesExtraOptions: usesExtraOptions)
}
trailing()
}
.separator(.newLine(count: 2))
.eraseToAnyTextNode()
}
}
static func `default`<Preamble: TextNode>(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true,
@TextBuilder preamble: () -> Preamble
) -> Self {
.default(
notes: notes,
examples: examples,
usesExtraOptions: usesExtraOptions,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote,
preamble: preamble,
trailing: {
if usesExtraOptions {
ImportantNote.extraOptionsNote
} else {
Empty()
}
}
)
}
static func `default`(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true
) -> Self {
.default(
notes: notes,
examples: examples,
usesExtraOptions: usesExtraOptions,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote,
preamble: { Empty() },
trailing: { Empty() }
)
}
}
extension ExampleSection where Header == String, Label == String {
static func `default`(
examples: [AppExample] = [],
usesExtraOptions: Bool = true
) -> some TextNode {
var examples: [AppExample] = examples
if usesExtraOptions {
examples = examples.appendingExtraOptionsExample()
}
return Self(
"Examples:",
label: "A few common usage examples.",
examples: examples.map(\.example)
)
.style(AppExampleSectionStyle())
}
}
struct AppExampleSectionStyle: ExampleSectionStyle {
func render(content: ExampleSectionConfiguration) -> some TextNode {
Section {
VStack {
content.examples.map { example in
VStack {
example.label.color(.green).bold()
ShellCommand(example.example).style(.default)
}
}
}
.separator(.newLine(count: 2))
} header: {
HStack {
content.title.color(.blue).bold()
content.label.italic()
}
}
}
}
struct AppExample {
let label: String
let parentCommand: String?
let commandName: String
let includesAppName: Bool
let exampleText: String
init(
label: String,
parentCommand: String? = nil,
commandName: String,
includesAppName: Bool = true,
example exampleText: String
) {
self.label = label
self.parentCommand = parentCommand
self.commandName = commandName
self.includesAppName = includesAppName
self.exampleText = exampleText
}
var example: Example {
var exampleString = "\(commandName) \(exampleText)"
if let parentCommand {
exampleString = "\(parentCommand) \(exampleString)"
}
if includesAppName {
exampleString = "\(Application.commandName) \(exampleString)"
}
return (label: label, example: exampleString)
}
}
extension Array where Element == AppExample {
func appendingExtraOptionsExample() -> Self {
guard let first = first else { return self }
var output = self
output.append(.init(
label: "Passing extra options to custom strategy.",
parentCommand: first.parentCommand,
commandName: first.commandName,
includesAppName: first.includesAppName,
example: "--custom-command -- git describe --tags --exact-match"
))
return output
}
}
struct ImportantNote<Content: TextNode>: TextNode {
let content: Content
init(
@TextBuilder _ content: () -> Content
) {
self.content = content()
}
var body: some TextNode {
LabeledContent {
content.italic()
} label: {
"Important Note:".red.bold
}
.style(.vertical())
}
}
extension ImportantNote where Content == String {
static var extraOptionsNote: Self {
.init {
"""
Extra options / flags for custom strategies must proceed a `--` or you may get an undefined option error.
"""
}
}
}
extension Usage where Content == AnyTextNode {
static func `default`(parentCommand: String? = nil, commandName: String?) -> Self {
var commandString = commandName == nil ? "" : "\(commandName!)"
if let parentCommand {
commandString = "\(parentCommand) \(commandString)"
}
commandString = commandString == "" ? "\(Application.commandName)" : "\(Application.commandName) \(commandString)"
return .init {
HStack {
commandString.blue
"[<options>]".green
"--"
"[<extra-options> ...]".cyan
}
.eraseToAnyTextNode()
}
}
}

View File

@@ -0,0 +1,190 @@
import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import GitClient
@discardableResult
func withSetupDependencies<T>(
_ operation: () async throws -> T
) async throws -> T {
try await withDependencies {
$0.fileClient = .liveValue
$0.gitClient = .liveValue
$0.cliClient = .liveValue
$0.configurationClient = .liveValue
} operation: {
try await operation()
}
}
extension CliClient.SharedOptions {
func runClient<T>(
_ keyPath: KeyPath<CliClient, @Sendable (Self) async throws -> T>
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](self)
}
}
func runClient<A, T>(
_ keyPath: KeyPath<CliClient, @Sendable (A, Self) async throws -> T>,
args: A
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](args, self)
}
}
}
extension GlobalOptions {
func run(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> String>,
command: String
) async throws {
let output = try await shared(command: command).runClient(keyPath)
print(output)
}
func run<T>(
_ keyPath: KeyPath<CliClient, @Sendable (T, CliClient.SharedOptions) async throws -> String>,
command: String,
args: T
) async throws {
let output = try await shared(command: command).runClient(keyPath, args: args)
print(output)
}
func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(
command: command,
dryRun: dryRun,
extraOptions: extraOptions,
gitDirectory: gitDirectory,
verbose: verbose
)
}
}
private extension TargetOptions {
func configTarget() throws -> Configuration.Target? {
guard let targetFilePath else {
guard let targetModule else {
return nil
}
return .init(module: .init(targetModule, fileName: targetFileName))
}
return .init(path: targetFilePath)
}
}
extension PreReleaseOptions {
func configPreReleaseStrategy(
includeCommitSha: Bool,
extraOptions: [String]
) throws -> Configuration.PreRelease? {
if useBranchAsPreRelease {
return .init(prefix: preReleasePrefix, strategy: .branch(includeCommitSha: includeCommitSha))
} else if useTagAsPreRelease {
return .init(prefix: preReleasePrefix, strategy: .gitTag)
} else if customPreRelease {
guard extraOptions.count > 0 else {
throw ExtraOptionsEmpty()
}
return .init(prefix: preReleasePrefix, strategy: .command(arguments: extraOptions))
} else if let preReleasePrefix {
return .init(prefix: preReleasePrefix, strategy: nil)
}
return nil
}
}
extension SemVarOptions {
func parseStrategy(extraOptions: [String]) throws -> Configuration.SemVar.Strategy? {
@Dependency(\.logger) var logger
guard customCommand else {
guard gitTag else { return nil }
return .gitTag(exactMatch: requireExactMatch)
}
guard extraOptions.count > 0 else {
logger.error("""
Extra options are empty, this does not make sense when using a custom command
strategy.
""")
throw ExtraOptionsEmpty()
}
return .command(arguments: extraOptions)
}
func configSemVarOptions(
includeCommitSha: Bool,
extraOptions: [String]
) throws -> Configuration.SemVar {
@Dependency(\.logger) var logger
// TODO: Update when / if there's an update config command.
if customCommand && preRelease.customPreRelease {
logger.warning("""
Custom pre-release can not be used at same time as custom command.
Ignoring pre-release...
""")
}
return try .init(
allowPreRelease: !preRelease.disablePreRelease,
preRelease: customCommand ? nil : preRelease.configPreReleaseStrategy(
includeCommitSha: includeCommitSha,
extraOptions: extraOptions
),
// Use nil here if false, which makes them not get used in json / file output, which makes
// user config smaller.
requireExistingFile: requireExistingFile ? true : nil,
requireExistingSemVar: requireExistingSemvar ? true : nil,
strategy: parseStrategy(extraOptions: extraOptions)
)
}
}
extension ConfigurationOptions {
func target() throws -> Configuration.Target? {
try targetOptions.configTarget()
}
func semvarOptions(
extraOptions: [String]
) throws -> Configuration.SemVar {
try semvarOptions.configSemVarOptions(
includeCommitSha: commitSha,
extraOptions: extraOptions
)
}
func shared(
command: String,
dryRun: Bool = true,
extraOptions: [String] = [],
gitDirectory: String? = nil,
verbose: Int = 0
) throws -> CliClient.SharedOptions {
try .init(
allowPreReleaseTag: !semvarOptions.preRelease.disablePreRelease,
dryRun: dryRun,
gitDirectory: gitDirectory,
loggingOptions: .init(command: command, verbose: verbose),
target: target(),
branch: semvarOptions.gitTag ? nil : .init(includeCommitSha: commitSha),
semvar: semvarOptions(extraOptions: extraOptions),
configurationFile: configurationFile
)
}
}
struct ExtraOptionsEmpty: Error {}