From a0f8611a7693888e55ec05fa0a464e87945dda09 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Thu, 26 Dec 2024 12:13:42 -0500 Subject: [PATCH] feat: Working on command-line documentation. --- Package.resolved | 11 +- Package.swift | 4 +- .../Configuration+merge.swift | 21 +- Sources/bump-version/Application.swift | 4 +- .../bump-version/Commands/BuildCommand.swift | 8 +- .../bump-version/Commands/BumpCommand.swift | 18 +- .../bump-version/Commands/ConfigCommand.swift | 110 +++++-- .../Commands/GenerateCommand.swift | 8 +- Sources/bump-version/GlobalOptions.swift | 4 +- Sources/bump-version/Helpers/DocHelpers.swift | 307 ++++++++++++++++++ .../Helpers/GlobalOptions+run.swift | 1 - .../ConfigurationClientTests.swift | 60 +++- 12 files changed, 506 insertions(+), 50 deletions(-) create mode 100644 Sources/bump-version/Helpers/DocHelpers.swift diff --git a/Package.resolved b/Package.resolved index 79f37da..be780ce 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6ab0a9c883cfa1490d249a344074ad27369033fab78e1a90272ef07339a8c0ab", + "originHash" : "3640b52c8069b868611efbfbd9b7545872526454802225747f7cd878062df1db", "pins" : [ { "identity" : "combine-schedulers", @@ -28,6 +28,15 @@ "version" : "1.5.0" } }, + { + "identity" : "swift-cli-doc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/m-housh/swift-cli-doc.git", + "state" : { + "revision" : "bbace73d974fd3e6985461431692bea773c7c5d8", + "version" : "0.2.1" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 33013b9..7d23503 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"), .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.2"), + .package(url: "https://github.com/m-housh/swift-cli-doc.git", from: "0.2.1"), .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"), @@ -32,7 +33,8 @@ let package = Package( dependencies: [ "CliClient", .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "CustomDump", package: "swift-custom-dump") + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "CliDoc", package: "swift-cli-doc") ] ), .target( diff --git a/Sources/ConfigurationClient/Configuration+merge.swift b/Sources/ConfigurationClient/Configuration+merge.swift index 8f355f5..9b11054 100644 --- a/Sources/ConfigurationClient/Configuration+merge.swift +++ b/Sources/ConfigurationClient/Configuration+merge.swift @@ -1,7 +1,6 @@ import Dependencies import FileClient import Foundation -import ShellClient @_spi(Internal) public extension Configuration { @@ -58,19 +57,15 @@ public extension Configuration.SemVar { @_spi(Internal) public extension Configuration.VersionStrategy { func merging(_ other: Self?) -> Self { - if let otherBranch = other?.branch { - guard let branch else { - return .branch(otherBranch) - } - return .branch(branch.merging(otherBranch)) - } + guard let other else { return self } - guard let branch else { - guard let semvar else { - return other ?? self - } - return .semvar(semvar.merging(other?.semvar)) + switch other { + case .branch: + guard let branch else { return other } + return .branch(branch.merging(other.branch)) + case .semvar: + guard let semvar else { return other } + return .semvar(semvar.merging(other.semvar)) } - return .branch(branch.merging(other?.branch)) } } diff --git a/Sources/bump-version/Application.swift b/Sources/bump-version/Application.swift index 01f2743..26fa391 100644 --- a/Sources/bump-version/Application.swift +++ b/Sources/bump-version/Application.swift @@ -3,8 +3,10 @@ import Foundation @main struct Application: AsyncParsableCommand { + static let commandName = "bump-version" + static let configuration: CommandConfiguration = .init( - commandName: "bump-version", + commandName: commandName, version: VERSION ?? "0.0.0", subcommands: [ BuildCommand.self, diff --git a/Sources/bump-version/Commands/BuildCommand.swift b/Sources/bump-version/Commands/BuildCommand.swift index 38824d3..5de13b8 100644 --- a/Sources/bump-version/Commands/BuildCommand.swift +++ b/Sources/bump-version/Commands/BuildCommand.swift @@ -1,15 +1,19 @@ import ArgumentParser import CliClient +import CliDoc import Foundation import ShellClient +// NOTE: This command is only used with the build with version plugin. struct BuildCommand: AsyncParsableCommand { static let commandName = "build" static let configuration: CommandConfiguration = .init( commandName: Self.commandName, - abstract: "Used for the build with version plugin.", - discussion: "This should generally not be interacted with directly, outside of the build plugin.", + abstract: Abstract.default("Used for the build with version plugin.").render(), + discussion: Discussion { + "This should generally not be interacted with directly, outside of the build plugin." + }, shouldDisplay: false ) diff --git a/Sources/bump-version/Commands/BumpCommand.swift b/Sources/bump-version/Commands/BumpCommand.swift index fba2a87..5ef7b74 100644 --- a/Sources/bump-version/Commands/BumpCommand.swift +++ b/Sources/bump-version/Commands/BumpCommand.swift @@ -1,14 +1,28 @@ import ArgumentParser import CliClient +import CliDoc import Dependencies -struct BumpCommand: AsyncParsableCommand { +struct BumpCommand: CommandRepresentable { static let commandName = "bump" static let configuration = CommandConfiguration( commandName: Self.commandName, - abstract: "Bump version of a command-line tool." + abstract: Abstract.default("Bump version of a command-line tool."), + usage: Usage.default(commandName: nil), + discussion: Discussion.default(examples: [ + makeExample( + label: "Basic usage, bump the minor version.", + example: "--minor", + includesAppName: false + ), + makeExample( + label: "Dry run, just show what the bumped version would be.", + example: "--minor --dry-run", + includesAppName: false + ) + ]) ) @OptionGroup var globals: GlobalOptions diff --git a/Sources/bump-version/Commands/ConfigCommand.swift b/Sources/bump-version/Commands/ConfigCommand.swift index 0268519..5bcc83b 100644 --- a/Sources/bump-version/Commands/ConfigCommand.swift +++ b/Sources/bump-version/Commands/ConfigCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import CliClient +import CliDoc import ConfigurationClient import CustomDump import Dependencies @@ -7,9 +8,11 @@ import FileClient import Foundation struct ConfigCommand: AsyncParsableCommand { + static let commandName = "config" + static let configuration = CommandConfiguration( - commandName: "config", - abstract: "Configuration commands", + commandName: commandName, + abstract: Abstract.default("Configuration commands").render(), subcommands: [ DumpConfig.self, GenerateConfig.self @@ -19,19 +22,40 @@ struct ConfigCommand: AsyncParsableCommand { extension ConfigCommand { - struct DumpConfig: AsyncParsableCommand { + struct DumpConfig: CommandRepresentable { static let commandName = "dump" + static let parentCommand = ConfigCommand.commandName static let configuration = CommandConfiguration( commandName: Self.commandName, - abstract: "Inspect the parsed configuration.", - discussion: """ - This will load any configuration and merge the options passed in. Then print it to stdout. - The default style is to print the output in `swift`, however you can use the `--print` flag to - print the output in `json`. - """, + abstract: Abstract.default("Inspect the parsed configuration."), + usage: Usage.default(parentCommand: ConfigCommand.commandName, commandName: Self.commandName), + discussion: Discussion.default( + notes: [ + """ + The default style is to print the output in `json`, however you can use the `--swift` flag to + print the output in `swift`. + """ + ], + examples: [ + makeExample(label: "Show the project configuration.", example: ""), + makeExample( + label: "Update a configuration file with the dumped output", + example: "--disable-pre-release > .bump-version.prod.json" + ) + ] + ) { + """ + Loads the project configuration file (if applicable) and merges the options passed in, + then prints the configuration to stdout. + """ + }, aliases: ["d"] ) + @Flag( + help: "Change the style of what get's printed." + ) + fileprivate var printStyle: PrintStyle = .json @OptionGroup var globals: ConfigCommandOptions @@ -40,14 +64,28 @@ extension ConfigCommand { .shared(command: Self.commandName) .runClient(\.parsedConfiguration) - try globals.printConfiguration(configuration) + try globals.printConfiguration(configuration, style: printStyle) } } - struct GenerateConfig: AsyncParsableCommand { + struct GenerateConfig: CommandRepresentable { + static let commandName = "generate" + static let parentCommand = ConfigCommand.commandName + static let configuration: CommandConfiguration = .init( - commandName: "generate", - abstract: "Generate a configuration file.", + commandName: commandName, + abstract: Abstract.default("Generate a configuration file, based on the given options.").render(), + usage: Usage.default(parentCommand: ConfigCommand.commandName, commandName: commandName), + discussion: Discussion.default(examples: [ + makeExample( + label: "Generate a configuration file for the 'foo' target.", + example: "-m foo" + ), + makeExample( + label: "Show the output and don't write to a file.", + example: "-m foo --print" + ) + ]), aliases: ["g"] ) @@ -56,6 +94,12 @@ extension ConfigCommand { ) var style: ConfigCommand.Style = .semvar + @Flag( + name: .customLong("print"), + help: "Print json to stdout." + ) + var printJson: Bool = false + @OptionGroup var globals: ConfigCommandOptions func run() async throws { @@ -67,7 +111,7 @@ extension ConfigCommand { extraOptions: globals.extraOptions ) - switch globals.printJson { + switch printJson { case true: try globals.handlePrintJson(configuration) case false: @@ -109,14 +153,14 @@ extension ConfigCommand { @dynamicMemberLookup struct ConfigCommandOptions: ParsableArguments { - @Flag( - name: .customLong("print"), - help: "Print style to stdout." - ) - var printJson: Bool = false - @OptionGroup var configOptions: ConfigurationOptions + @Flag( + name: .shortAndLong, + help: "Increase logging level, can be passed multiple times (example: -vvv)." + ) + var verbose: Int + @Argument( help: """ Arguments / options used for custom pre-release, options / flags must proceed a '--' in @@ -131,10 +175,16 @@ extension ConfigCommand { } } +private extension ConfigCommand.DumpConfig { + enum PrintStyle: EnumerableFlag { + case json, swift + } +} + private extension ConfigCommand.ConfigCommandOptions { func shared(command: String) throws -> CliClient.SharedOptions { - try configOptions.shared(command: command, extraOptions: extraOptions, verbose: 2) + try configOptions.shared(command: command, extraOptions: extraOptions, verbose: verbose) } func handlePrintJson(_ configuration: Configuration) throws { @@ -149,12 +199,22 @@ private extension ConfigCommand.ConfigCommandOptions { print(string) } - func printConfiguration(_ configuration: Configuration) throws { - guard printJson else { + func printConfiguration( + _ configuration: Configuration, + style: ConfigCommand.DumpConfig.PrintStyle + ) throws { + switch style { + case .json: + try handlePrintJson(configuration) + case .swift: customDump(configuration) - return } - try handlePrintJson(configuration) + + // guard printJson else { + // customDump(configuration) + // return + // } + // try handlePrintJson(configuration) } } diff --git a/Sources/bump-version/Commands/GenerateCommand.swift b/Sources/bump-version/Commands/GenerateCommand.swift index 274fb83..088e439 100644 --- a/Sources/bump-version/Commands/GenerateCommand.swift +++ b/Sources/bump-version/Commands/GenerateCommand.swift @@ -1,5 +1,6 @@ import ArgumentParser import CliClient +import CliDoc import Dependencies import Foundation import ShellClient @@ -9,8 +10,11 @@ struct GenerateCommand: AsyncParsableCommand { static let configuration: CommandConfiguration = .init( commandName: Self.commandName, - abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.", - discussion: "This command can be interacted with directly, outside of the plugin usage context." + abstract: Abstract.default("Generates a version file in your project.").render(), + usage: Usage.default(commandName: Self.commandName), + discussion: Discussion { + "This command can be interacted with directly, outside of the plugin usage context." + } ) @OptionGroup var globals: GlobalOptions diff --git a/Sources/bump-version/GlobalOptions.swift b/Sources/bump-version/GlobalOptions.swift index 2174e36..7559499 100644 --- a/Sources/bump-version/GlobalOptions.swift +++ b/Sources/bump-version/GlobalOptions.swift @@ -5,6 +5,8 @@ import Dependencies import Foundation import Rainbow +// TODO: Add an option to not load project configuration. + struct GlobalOptions: ParsableArguments { @OptionGroup @@ -41,7 +43,7 @@ struct GlobalOptions: ParsableArguments { struct ConfigurationOptions: ParsableArguments { @Option( name: [.customShort("f"), .long], - help: "Specify the path to a configuration file.", + help: "Specify the path to a configuration file. (default: .bump-version.json)", completion: .file(extensions: ["json"]) ) var configurationFile: String? diff --git a/Sources/bump-version/Helpers/DocHelpers.swift b/Sources/bump-version/Helpers/DocHelpers.swift new file mode 100644 index 0000000..dc5d6e3 --- /dev/null +++ b/Sources/bump-version/Helpers/DocHelpers.swift @@ -0,0 +1,307 @@ +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: 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`( + 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`( + 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: 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 + "[]".green + "--" + "[ ...]".cyan + } + .eraseToAnyTextNode() + } + } +} diff --git a/Sources/bump-version/Helpers/GlobalOptions+run.swift b/Sources/bump-version/Helpers/GlobalOptions+run.swift index 063eab3..580f9ec 100644 --- a/Sources/bump-version/Helpers/GlobalOptions+run.swift +++ b/Sources/bump-version/Helpers/GlobalOptions+run.swift @@ -167,7 +167,6 @@ extension ConfigurationOptions { ) } - // TODO: Need to potentially do something different with passing branch. func shared( command: String, dryRun: Bool = true, diff --git a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift index d220e43..935cc88 100644 --- a/Tests/ConfigurationClientTests/ConfigurationClientTests.swift +++ b/Tests/ConfigurationClientTests/ConfigurationClientTests.swift @@ -1,4 +1,4 @@ -import ConfigurationClient +@_spi(Internal) import ConfigurationClient import Dependencies import Foundation import Testing @@ -74,6 +74,64 @@ struct ConfigurationClientTests { } } + @Test + func mergingBranch() { + let branch = Configuration.Branch(includeCommitSha: false) + let branch2 = Configuration.Branch(includeCommitSha: true) + let merged = branch.merging(branch2) + #expect(merged == branch2) + + let merged2 = branch.merging(nil) + #expect(merged2 == branch) + } + + @Test + func mergingSemvar() { + let strategy1 = Configuration.VersionStrategy.semvar(.init()) + let other = Configuration.VersionStrategy.semvar(.init( + allowPreRelease: true, + preRelease: .init(prefix: "foo", strategy: .gitTag), + requireExistingFile: true, + requireExistingSemVar: true, + strategy: .gitTag() + )) + let merged = strategy1.merging(other) + #expect(merged == other) + + let otherMerged = other.merging(strategy1) + #expect(otherMerged == other) + } + + @Test + func mergingTarget() { + let config1 = Configuration(target: .init(path: "foo")) + let config2 = Configuration(target: .init(module: .init("bar"))) + + let merged = config1.merging(config2) + #expect(merged.target! == .init(module: .init("bar"))) + + let merged2 = merged.merging(config1) + #expect(merged2.target! == .init(path: "foo")) + + let merged3 = merged2.merging(nil) + #expect(merged3 == merged2) + } + + @Test + func mergingVersionStrategy() { + let version = Configuration.VersionStrategy.semvar(.init()) + let version2 = Configuration.VersionStrategy.branch(.init()) + + let merged = version.merging(version2) + #expect(merged == version2) + + let merged2 = merged.merging(.branch(includeCommitSha: false)) + #expect(merged2.branch!.includeCommitSha == false) + + let merged3 = version2.merging(version) + #expect(merged3 == version) + } + func run( setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in }, operation: () async throws -> Void