diff --git a/Sources/ArgumentParser/Documentation.docc/Articles/DeclaringArguments.md b/Sources/ArgumentParser/Documentation.docc/Articles/DeclaringArguments.md index 5ce6d8e0f..19ad62abf 100644 --- a/Sources/ArgumentParser/Documentation.docc/Articles/DeclaringArguments.md +++ b/Sources/ArgumentParser/Documentation.docc/Articles/DeclaringArguments.md @@ -81,7 +81,7 @@ struct Lucky: ParsableCommand { ``` ``` -% lucky +% lucky Your lucky numbers are: 7 14 21 % lucky 1 2 3 @@ -376,6 +376,54 @@ The `.scanningForValue` strategy, on the other hand, looks ahead in the list of Verbose: true, name: Tomás, file: none ``` +#### Parsing strategies with default values + +You can provide default values directly in parsing strategies using `.scanningForValue(default:)`. This strategy provides a default value when the option is specified without a value, while keeping the property `nil` when the option is not provided at all: + +```swift +struct BuildTool: ParsableCommand { + @Option(parsing: .scanningForValue(default: "release")) + var configuration: String? + + @Flag var verbose = false + @Argument var target: String + + mutating func run() throws { + let config = configuration ?? "default" + print("Building \(target) in \(config) mode") + if verbose { + print("Verbose output enabled") + } + } +} +``` + +With this approach: +- When `--configuration` is not provided: `configuration` is `nil` +- When `--configuration` is provided without a value: `configuration` uses the default `"release"` +- When `--configuration` is provided with a value: `configuration` uses that explicit value + +``` +% build-tool MyApp +Building MyApp in default mode +% build-tool MyApp --configuration +Building MyApp in release mode +% build-tool MyApp --configuration debug +Building MyApp in debug mode +% build-tool MyApp --configuration debug --verbose +Building MyApp in debug mode +Verbose output enabled +``` + +The `.scanningForValue(default:)` strategy respects argument terminators (`--`), ensuring that values after the terminator are treated as positional arguments rather than option values: + +``` +% build-tool MyApp --configuration -- debug +Building MyApp in release mode +``` + +In this example, `debug` is treated as a positional argument rather than the value for `--configuration`, so the default value `"release"` is used instead. + #### Alternative array parsing strategies The default strategy for parsing options as arrays is to read each value from a key-value pair. For example, this command expects zero or more input file names: @@ -479,7 +527,7 @@ When appropriate, you can process supported arguments and ignore unknown ones by ```swift struct Example: ParsableCommand { @Flag var verbose = false - + @Argument(parsing: .allUnrecognized) var unknowns: [String] = [] diff --git a/Sources/ArgumentParser/Parsable Properties/Option.swift b/Sources/ArgumentParser/Parsable Properties/Option.swift index 024a0232a..eec5e147a 100644 --- a/Sources/ArgumentParser/Parsable Properties/Option.swift +++ b/Sources/ArgumentParser/Parsable Properties/Option.swift @@ -20,17 +20,28 @@ /// their default value. Options that are neither declared as `Optional` nor /// given a default value are required for users of your command-line tool. /// -/// For example, the following program defines three options: +/// ## Specifying Default Values +/// +/// There are multiple ways to specify default values for options: +/// +/// 1. **Property initialization**: Assign a default value when declaring the property. +/// 2. **Parsing strategy with default**: Use `.scanningForValue(default:)` to specify +/// a default value that will be used when the option is not provided. +/// +/// For example, the following program defines options with different default approaches: /// /// ```swift /// @main /// struct Greet: ParsableCommand { -/// @Option var greeting = "Hello" +/// @Option var greeting = "Hello" // Property initialization /// @Option var age: Int? = nil /// @Option var name: String +/// @Option(parsing: .scanningForValue(default: "Guest")) +/// var fallbackName: String? // Parsing strategy default /// /// mutating func run() { -/// print("\(greeting) \(name)!") +/// let actualName = name.isEmpty ? fallbackName : name +/// print("\(greeting) \(actualName)!") /// if let age { /// print("Congrats on making it to the ripe old age of \(age)!") /// } @@ -41,17 +52,29 @@ /// `greeting` has a default value of `"Hello"`, which can be overridden by /// providing a different string as an argument, while `age` defaults to `nil`. /// `name` is a required option because it is non-`nil` and has no default -/// value. +/// value. `fallbackName` uses a parsing strategy default and will be set to +/// `"Guest"` when the option is not provided. /// /// $ greet --name Alicia /// Hello Alicia! /// $ greet --age 28 --name Seungchin --greeting Hi /// Hi Seungchin! /// Congrats on making it to the ripe old age of 28! +/// $ greet --fallback-name Bob --name "" +/// Hello Bob! @propertyWrapper public struct Option: Decodable, ParsedWrapper { internal var _parsedValue: Parsed + /// Error message for conflicting default values between property initialization and parsing strategy. + private static var conflictingDefaultsErrorMessage: String { + [ + "Cannot specify both a wrapped value (property default) and a parsing strategy default with .scanningForValue(default:).", + "Use either property initialization (var name: String = \"defaultValue\") or parsing strategy default", + "(@Option(parsing: .scanningForValue(default: \"defaultValue\")) var name: String), but not both.", + ].joined(separator: " ") + } + internal init(_parsedValue: Parsed) { self._parsedValue = _parsedValue } @@ -159,6 +182,18 @@ public struct SingleValueParsingStrategy: Hashable { public static var scanningForValue: SingleValueParsingStrategy { self.init(base: .scanningForValue) } + + /// Parse the next input, as long as that input can't be interpreted as + /// an option or flag, with a default value when the option is not provided. + /// + /// - Parameter defaultValue: The default value to use when the option is not provided. + /// - Returns: A parsing strategy that scans for value with a default. + public static func scanningForValue(default defaultValue: T) + -> SingleValueParsingStrategy + where T: ExpressibleByArgument { + self.init( + base: .scanningForValueWithDefault(defaultValue.defaultValueDescription)) + } } extension SingleValueParsingStrategy: Sendable {} @@ -273,6 +308,11 @@ extension Option where Value: ExpressibleByArgument { help: ArgumentHelp? = nil, completion: CompletionKind? = nil ) { + // Validate that we don't have conflicting default values + if case .scanningForValueWithDefault = parsingStrategy.base { + fatalError(Self.conflictingDefaultsErrorMessage) + } + self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -307,6 +347,11 @@ extension Option where Value: ExpressibleByArgument { completion: CompletionKind?, help: ArgumentHelp? ) { + // Validate that we don't have conflicting default values + if case .scanningForValueWithDefault = parsingStrategy.base { + fatalError(Self.conflictingDefaultsErrorMessage) + } + self.init( wrappedValue: _wrappedValue, name: name, @@ -336,7 +381,11 @@ extension Option where Value: ExpressibleByArgument { parsing parsingStrategy: SingleValueParsingStrategy = .next, help: ArgumentHelp? = nil, completion: CompletionKind? = nil - ) { + ) where Value: ExpressibleByArgument { + // This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed + let defaultValue: Value? = nil + let actualParsingStrategy = parsingStrategy.base + self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -350,8 +399,8 @@ extension Option where Value: ExpressibleByArgument { visibility: help?.visibility ?? .default, argumentType: Value.self ), - parsingStrategy: parsingStrategy.base, - initial: nil, + parsingStrategy: actualParsingStrategy, + initial: defaultValue, completion: completion) return ArgumentSet(arg) @@ -392,6 +441,11 @@ extension Option { completion: CompletionKind? = nil, transform: @Sendable @escaping (String) throws -> Value ) { + // Validate that we don't have conflicting default values + if case .scanningForValueWithDefault = parsingStrategy.base { + fatalError(Self.conflictingDefaultsErrorMessage) + } + self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -437,6 +491,7 @@ extension Option { completion: CompletionKind? = nil, transform: @Sendable @escaping (String) throws -> Value ) { + // This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -482,6 +537,7 @@ extension Option { help: ArgumentHelp? = nil, completion: CompletionKind? = nil ) where T: ExpressibleByArgument, Value == T? { + // Note: This initializer uses nil as wrappedValue, so scanningForValueWithDefault is allowed self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -517,6 +573,13 @@ extension Option { help: ArgumentHelp? = nil, completion: CompletionKind? = nil ) where T: ExpressibleByArgument, Value == T? { + // Validate that we don't have conflicting default values (only if wrappedValue is not nil) + if _wrappedValue != nil, + case .scanningForValueWithDefault = parsingStrategy.base + { + fatalError(Self.conflictingDefaultsErrorMessage) + } + self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -560,6 +623,10 @@ extension Option { help: ArgumentHelp? = nil, completion: CompletionKind? = nil ) where T: ExpressibleByArgument, Value == T? { + // This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed + let defaultValue: T? = nil + let actualParsingStrategy = parsingStrategy.base + self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -573,8 +640,8 @@ extension Option { visibility: help?.visibility ?? .default, argumentType: T.self ), - parsingStrategy: parsingStrategy.base, - initial: nil, + parsingStrategy: actualParsingStrategy, + initial: defaultValue, completion: completion) return ArgumentSet(arg) @@ -615,6 +682,7 @@ extension Option { completion: CompletionKind? = nil, transform: @Sendable @escaping (String) throws -> T ) where Value == T? { + // Note: This initializer uses nil as wrappedValue, so scanningForValueWithDefault is allowed self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -647,6 +715,13 @@ extension Option { completion: CompletionKind? = nil, transform: @Sendable @escaping (String) throws -> T ) where Value == T? { + // Validate that we don't have conflicting default values (only if wrappedValue is not nil) + if _wrappedValue != nil, + case .scanningForValueWithDefault = parsingStrategy.base + { + fatalError(Self.conflictingDefaultsErrorMessage) + } + self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -691,6 +766,7 @@ extension Option { completion: CompletionKind? = nil, transform: @Sendable @escaping (String) throws -> T ) where Value == T? { + // This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -744,6 +820,7 @@ extension Option { help: ArgumentHelp? = nil, completion: CompletionKind? = nil ) where T: ExpressibleByArgument, Value == [T] { + // Note: Array parsing strategies don't include scanningForValueWithDefault, so no validation needed self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( @@ -851,6 +928,7 @@ extension Option { completion: CompletionKind? = nil, transform: @Sendable @escaping (String) throws -> T ) where Value == [T] { + // Note: Array parsing strategies don't include scanningForValueWithDefault, so no validation needed self.init( _parsedValue: .init { key in let arg = ArgumentDefinition( diff --git a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift index b59aa80a8..4f7d18996 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift @@ -80,12 +80,14 @@ struct ArgumentDefinition { /// This folds the public `ArrayParsingStrategy` and `SingleValueParsingStrategy` /// into a single enum. - enum ParsingStrategy { + enum ParsingStrategy: Hashable { /// Expect the next `SplitArguments.Element` to be a value and parse it. /// Will fail if the next input is an option. case `default` /// Parse the next `SplitArguments.Element.value` case scanningForValue + /// Parse the next `SplitArguments.Element.value` with a default value when not provided + case scanningForValueWithDefault(String?) /// Parse the next `SplitArguments.Element` as a value, regardless of its type. case unconditional /// Parse multiple `SplitArguments.Element.value` up to the next non-`.value` diff --git a/Sources/ArgumentParser/Parsing/ArgumentSet.swift b/Sources/ArgumentParser/Parsing/ArgumentSet.swift index 5f2728def..dc4a8b9f0 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentSet.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentSet.swift @@ -352,6 +352,36 @@ struct LenientParser { throw errorForMissingValue(originElement, parsed) } + case .scanningForValueWithDefault(let defaultValue): + // Same as scanningForValue, but uses default if no value found + if let value = parsed.value { + // This was `--foo=bar` style: + try update(origin, parsed.name, value, &result) + usedOrigins.formUnion(origin) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement( + at: originElement) + { + // Found a joined argument + let origins = origin.inserting(origin2) + try update(origins, parsed.name, String(value), &result) + usedOrigins.formUnion(origins) + } else if let (origin2, value) = inputArguments.popNextValue( + after: originElement) + { + // Use `popNext(after:)` to handle cases where short option + // labels are combined, but only if not followed by terminator + let origins = origin.inserting(origin2) + try update(origins, parsed.name, value, &result) + usedOrigins.formUnion(origins) + } else if let defaultValue = defaultValue { + // Use the default value when no value found or when terminator encountered + try update(origin, parsed.name, defaultValue, &result) + usedOrigins.formUnion(origin) + } else { + throw errorForMissingValue(originElement, parsed) + } + case .unconditional: // Use an attached value if it exists... if let value = parsed.value { diff --git a/Sources/ArgumentParser/Parsing/SplitArguments.swift b/Sources/ArgumentParser/Parsing/SplitArguments.swift index 5cf0d7aa7..ebbb33ce2 100644 --- a/Sources/ArgumentParser/Parsing/SplitArguments.swift +++ b/Sources/ArgumentParser/Parsing/SplitArguments.swift @@ -365,6 +365,18 @@ extension SplitArguments { after origin: InputOrigin.Element ) -> (InputOrigin.Element, String)? { guard let start = position(after: origin) else { return nil } + + // Check if there's a terminator before any value + if let terminatorIndex = elements[start...].firstIndex(where: { + $0.isTerminator + }), + let valueIndex = elements[start...].firstIndex(where: { $0.isValue }), + terminatorIndex < valueIndex + { + // There's a terminator before the next value, so don't consume the value + return nil + } + guard let resultIndex = elements[start...].firstIndex(where: { $0.isValue }) else { return nil } diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index 3ed2ac260..4e377d0a5 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -161,6 +161,8 @@ extension ArgumentInfoV0.ParsingStrategyV0 { self = .default case .scanningForValue: self = .scanningForValue + case .scanningForValueWithDefault(_): + self = .scanningForValue case .unconditional: self = .unconditional case .upToNextOption: diff --git a/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift b/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift index d365a4ab3..9844863bc 100644 --- a/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift +++ b/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift @@ -99,3 +99,81 @@ extension SingleValueParsingStrategyTests { } } } + +// MARK: Scanning for Value with Default + +private struct WithDefault: ParsableArguments { + @Option(parsing: .scanningForValue(default: "text")) + var showBinPath: String? +} + +private struct WithDefaultAndPositional: ParsableArguments { + @Option(parsing: .scanningForValue(default: "defaultPath")) + var showBinPath: String? + + @Argument var positionalArg: String +} + +extension SingleValueParsingStrategyTests { + func testParsingScanningForValueWithDefault() throws { + // Test when no option is provided - should use default + AssertParse(WithDefault.self, []) { result in + XCTAssertEqual(result.showBinPath, nil) + } + + // Test when option is provided - should override default + AssertParse(WithDefault.self, ["--show-bin-path"]) { result in + XCTAssertEqual(result.showBinPath, "text") + } + // Test when option is provided - should override default + AssertParse(WithDefault.self, ["--show-bin-path", "custom"]) { result in + XCTAssertEqual(result.showBinPath, "custom") + } + } + + func testParsingScanningForValueWithDefaultTerminatorHandling() throws { + // Test terminator handling: `cmd --show-bin-path -- json` + // --show-bin-path should use default value, json should be positional + AssertParse( + WithDefaultAndPositional.self, ["--show-bin-path", "--", "json"] + ) { result in + XCTAssertEqual( + result.showBinPath, "defaultPath", + "Option should use default value when followed by terminator") + XCTAssertEqual( + result.positionalArg, "json", + "Argument after terminator should be positional") + } + + // Test normal case for comparison: `cmd --show-bin-path customPath json` + AssertParse( + WithDefaultAndPositional.self, ["--show-bin-path", "customPath", "json"] + ) { result in + XCTAssertEqual( + result.showBinPath, "customPath", "Option should use provided value") + XCTAssertEqual( + result.positionalArg, "json", "Remaining argument should be positional") + } + + // Test normal case for comparison: `cmd --show-bin-path customPath json` + AssertParse( + WithDefaultAndPositional.self, + ["--show-bin-path", "customPath", "--", "json"] + ) { result in + XCTAssertEqual( + result.showBinPath, "customPath", "Option should use provided value") + XCTAssertEqual( + result.positionalArg, "json", "Remaining argument should be positional") + } + + // Test terminator without option: `cmd -- json` + AssertParse(WithDefaultAndPositional.self, ["--", "json"]) { result in + XCTAssertEqual( + result.showBinPath, nil, + "Option should be nil as it's not speficied on the command line") + XCTAssertEqual( + result.positionalArg, "json", + "Argument after terminator should be positional") + } + } +} diff --git a/Tests/ArgumentParserGenerateManualTests/GenerateManualTests.swift b/Tests/ArgumentParserGenerateManualTests/GenerateManualTests.swift index c2d6c583f..353c9b36c 100644 --- a/Tests/ArgumentParserGenerateManualTests/GenerateManualTests.swift +++ b/Tests/ArgumentParserGenerateManualTests/GenerateManualTests.swift @@ -56,4 +56,11 @@ final class GenerateManualTests: XCTestCase { func testRollMultiPageManual() throws { try assertGenerateManual(multiPage: true, command: "roll") } + + // Test if manpage generation works with our new scanningForValue(default:) functionality + // This verifies that the existing examples already work with defaults + func testMathSinglePageManualWithDefaults() throws { + // Math already has default values, so this should work fine + try assertGenerateManual(multiPage: false, command: "math") + } } diff --git a/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManualWithDefaults().mdoc b/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManualWithDefaults().mdoc new file mode 100644 index 000000000..1aa384530 --- /dev/null +++ b/Tests/ArgumentParserGenerateManualTests/Snapshots/testMathSinglePageManualWithDefaults().mdoc @@ -0,0 +1,112 @@ +.\" "Generated by swift-argument-parser" +.Dd May 12, 1996 +.Dt MATH 9 +.Os +.Sh NAME +.Nm math +.Nd "A utility for performing maths." +.Sh SYNOPSIS +.Nm +.Ar subcommand +.Op Fl -version +.Op Fl -help +.Sh DESCRIPTION +.Bl -tag -width 6n +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.It Em add +Print the sum of the values. +.Bl -tag -width 6n +.It Fl x , -hex-output +Use hexadecimal notation for the result. +.It Ar values... +A group of integers to operate on. +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.El +.It Em multiply +Print the product of the values. +.Bl -tag -width 6n +.It Fl x , -hex-output +Use hexadecimal notation for the result. +.It Ar values... +A group of integers to operate on. +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.El +.It Em stats +Calculate descriptive statistics. +.Bl -tag -width 6n +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.It Em average +Print the average of the values. +.Bl -tag -width 6n +.It Fl -kind Ar kind +The kind of average to provide. +.Pp +.It Ar values... +A group of floating-point values to operate on. +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.El +.It Em stdev +Print the standard deviation of the values. +.Bl -tag -width 6n +.It Ar values... +A group of floating-point values to operate on. +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.El +.It Em quantiles +Print the quantiles of the values (TBD). +.Bl -tag -width 6n +.It Ar one-of-four +.It Ar custom-arg +.It Ar custom-deprecated-arg +.It Ar values... +A group of floating-point values to operate on. +.It Fl -file Ar file +.It Fl -directory Ar directory +.It Fl -shell Ar shell +.It Fl -custom Ar custom +.It Fl -custom-deprecated Ar custom-deprecated +.It Fl -version +Show the version. +.It Fl h , -help +Show help information. +.El +.El +.It Em help +Show subcommand help information. +.Bl -tag -width 6n +.It Ar subcommands... +.It Fl -version +Show the version. +.El +.El +.Sh AUTHORS +The +.Nm +reference was written by +.An -nosplit +.An "Jane Appleseed" , +.Mt johnappleseed@apple.com , +and +.An -nosplit +.An "The Appleseeds" +.Ao +.Mt appleseeds@apple.com +.Ac . diff --git a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift index 2e25e31f4..c56360832 100644 --- a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift +++ b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift @@ -253,3 +253,35 @@ extension CompletionScriptTests { try assertCustomCompletions(shell: .zsh) } } + +// MARK: - Scanning for Value with Default Completion Tests +extension CompletionScriptTests { + struct WithDefaultsCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "defaults-test") + + @Option(parsing: .scanningForValue(default: "defaultText")) + var withDefault: String? + + @Option(parsing: .scanningForValue(default: "required")) + var requiredWithDefault: String + + @Option + var normalOption: String + } + + func testWithDefaultsBash() throws { + let script = WithDefaultsCommand.completionScript(for: .bash) + try assertSnapshot(actual: script, extension: "bash") + } + + func testWithDefaultsZsh() throws { + let script = WithDefaultsCommand.completionScript(for: .zsh) + try assertSnapshot(actual: script, extension: "zsh") + } + + func testWithDefaultsFish() throws { + let script = WithDefaultsCommand.completionScript(for: .fish) + try assertSnapshot(actual: script, extension: "fish") + } +} diff --git a/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift b/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift index 9a652479c..bbd4fa4b5 100644 --- a/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift +++ b/Tests/ArgumentParserUnitTests/DumpHelpGenerationTests.swift @@ -42,6 +42,10 @@ final class DumpHelpGenerationTests: XCTestCase { func testMathStatsDumpHelp() throws { try assertDumpHelp(command: "math stats") } + + public func testDDumpHelp() throws { + try assertDumpHelp(type: D.self) + } } extension DumpHelpGenerationTests { @@ -128,4 +132,15 @@ extension DumpHelpGenerationTests { @Option(help: .init(discussion: "A discussion.")) var discussion: String } + + struct D: ParsableCommand { + @Option(parsing: .scanningForValue(default: "defaultText")) + var withDefault: String? + + @Option(parsing: .scanningForValue(default: "required")) + var requiredWithDefault: String + + @Option + var normalOption: String + } } diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testDDumpHelp().json b/Tests/ArgumentParserUnitTests/Snapshots/testDDumpHelp().json new file mode 100644 index 000000000..282d3da70 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/Snapshots/testDDumpHelp().json @@ -0,0 +1,132 @@ +{ + "command" : { + "arguments" : [ + { + "isOptional" : true, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "long", + "name" : "with-default" + } + ], + "parsingStrategy" : "scanningForValue", + "preferredName" : { + "kind" : "long", + "name" : "with-default" + }, + "shouldDisplay" : true, + "valueName" : "with-default" + }, + { + "isOptional" : false, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "long", + "name" : "required-with-default" + } + ], + "parsingStrategy" : "scanningForValue", + "preferredName" : { + "kind" : "long", + "name" : "required-with-default" + }, + "shouldDisplay" : true, + "valueName" : "required-with-default" + }, + { + "isOptional" : false, + "isRepeating" : false, + "kind" : "option", + "names" : [ + { + "kind" : "long", + "name" : "normal-option" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "normal-option" + }, + "shouldDisplay" : true, + "valueName" : "normal-option" + }, + { + "abstract" : "Show help information.", + "isOptional" : true, + "isRepeating" : false, + "kind" : "flag", + "names" : [ + { + "kind" : "short", + "name" : "h" + }, + { + "kind" : "long", + "name" : "help" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "help" + }, + "shouldDisplay" : true, + "valueName" : "help" + } + ], + "commandName" : "d", + "shouldDisplay" : true, + "subcommands" : [ + { + "abstract" : "Show subcommand help information.", + "arguments" : [ + { + "isOptional" : true, + "isRepeating" : true, + "kind" : "positional", + "parsingStrategy" : "default", + "shouldDisplay" : true, + "valueName" : "subcommands" + }, + { + "isOptional" : true, + "isRepeating" : false, + "kind" : "flag", + "names" : [ + { + "kind" : "short", + "name" : "h" + }, + { + "kind" : "long", + "name" : "help" + }, + { + "kind" : "longWithSingleDash", + "name" : "help" + } + ], + "parsingStrategy" : "default", + "preferredName" : { + "kind" : "long", + "name" : "help" + }, + "shouldDisplay" : false, + "valueName" : "help" + } + ], + "commandName" : "help", + "shouldDisplay" : true, + "superCommands" : [ + "d" + ] + } + ] + }, + "serializationVersion" : 0 +} \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsBash().bash b/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsBash().bash new file mode 100644 index 000000000..9337afeaf --- /dev/null +++ b/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsBash().bash @@ -0,0 +1,198 @@ +#!/bin/bash + +__defaults-test_cursor_index_in_current_word() { + local remaining="${COMP_LINE}" + + local word + for word in "${COMP_WORDS[@]::COMP_CWORD}"; do + remaining="${remaining##*([[:space:]])"${word}"*([[:space:]])}" + done + + local -ir index="$((COMP_POINT - ${#COMP_LINE} + ${#remaining}))" + if [[ "${index}" -le 0 ]]; then + printf 0 + else + printf %s "${index}" + fi +} + +# positional arguments: +# +# - 1: the current (sub)command's count of positional arguments +# +# required variables: +# +# - flags: the flags that the current (sub)command can accept +# - options: the options that the current (sub)command can accept +# - positional_number: value ignored +# - unparsed_words: unparsed words from the current command line +# +# modified variables: +# +# - flags: remove flags for this (sub)command that are already on the command line +# - options: remove options for this (sub)command that are already on the command line +# - positional_number: set to the current positional number +# - unparsed_words: remove all flags, options, and option values for this (sub)command +__defaults-test_offer_flags_options() { + local -ir positional_count="${1}" + positional_number=0 + + local was_flag_option_terminator_seen=false + local is_parsing_option_value=false + + local -ar unparsed_word_indices=("${!unparsed_words[@]}") + local -i word_index + for word_index in "${unparsed_word_indices[@]}"; do + if "${is_parsing_option_value}"; then + # This word is an option value: + # Reset marker for next word iff not currently the last word + [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]] && is_parsing_option_value=false + unset "unparsed_words[${word_index}]" + # Do not process this word as a flag or an option + continue + fi + + local word="${unparsed_words["${word_index}"]}" + if ! "${was_flag_option_terminator_seen}"; then + case "${word}" in + --) + unset "unparsed_words[${word_index}]" + # by itself -- is a flag/option terminator, but if it is the last word, it is the start of a completion + if [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]]; then + was_flag_option_terminator_seen=true + fi + continue + ;; + -*) + # ${word} is a flag or an option + # If ${word} is an option, mark that the next word to be parsed is an option value + local option + for option in "${options[@]}"; do + [[ "${word}" = "${option}" ]] && is_parsing_option_value=true && break + done + + # Remove ${word} from ${flags} or ${options} so it isn't offered again + local not_found=true + local -i index + for index in "${!flags[@]}"; do + if [[ "${flags[${index}]}" = "${word}" ]]; then + unset "flags[${index}]" + flags=("${flags[@]}") + not_found=false + break + fi + done + if "${not_found}"; then + for index in "${!options[@]}"; do + if [[ "${options[${index}]}" = "${word}" ]]; then + unset "options[${index}]" + options=("${options[@]}") + break + fi + done + fi + unset "unparsed_words[${word_index}]" + continue + ;; + esac + fi + + # ${word} is neither a flag, nor an option, nor an option value + if [[ "${positional_number}" -lt "${positional_count}" ]]; then + # ${word} is a positional + ((positional_number++)) + unset "unparsed_words[${word_index}]" + else + if [[ -z "${word}" ]]; then + # Could be completing a flag, option, or subcommand + positional_number=-1 + else + # ${word} is a subcommand or invalid, so stop processing this (sub)command + positional_number=-2 + fi + break + fi + done + + unparsed_words=("${unparsed_words[@]}") + + if\ + ! "${was_flag_option_terminator_seen}"\ + && ! "${is_parsing_option_value}"\ + && [[ ("${cur}" = -* && "${positional_number}" -ge 0) || "${positional_number}" -eq -1 ]] + then + COMPREPLY+=($(compgen -W "${flags[*]} ${options[*]}" -- "${cur}")) + fi +} + +__defaults-test_add_completions() { + local completion + while IFS='' read -r completion; do + COMPREPLY+=("${completion}") + done < <(IFS=$'\n' compgen "${@}" -- "${cur}") +} + +__defaults-test_custom_complete() { + if [[ -n "${cur}" || -z ${COMP_WORDS[${COMP_CWORD}]} || "${COMP_LINE:${COMP_POINT}:1}" != ' ' ]]; then + local -ar words=("${COMP_WORDS[@]}") + else + local -ar words=("${COMP_WORDS[@]::${COMP_CWORD}}" '' "${COMP_WORDS[@]:${COMP_CWORD}}") + fi + + "${COMP_WORDS[0]}" "${@}" "${words[@]}" +} + +_defaults-test() { + trap "$(shopt -p);$(shopt -po)" RETURN + shopt -s extglob + set +o history +o posix + + local -xr SAP_SHELL=bash + local -x SAP_SHELL_VERSION + SAP_SHELL_VERSION="$(IFS='.';printf %s "${BASH_VERSINFO[*]}")" + local -r SAP_SHELL_VERSION + + local -r cur="${2}" + local -r prev="${3}" + + local -i positional_number + local -a unparsed_words=("${COMP_WORDS[@]:1:${COMP_CWORD}}") + + local -a flags=(-h --help) + local -a options=(--with-default --required-with-default --normal-option) + __defaults-test_offer_flags_options 0 + + # Offer option value completions + case "${prev}" in + '--with-default') + return + ;; + '--required-with-default') + return + ;; + '--normal-option') + return + ;; + esac + + # Offer subcommand / subcommand argument completions + local -r subcommand="${unparsed_words[0]}" + unset 'unparsed_words[0]' + unparsed_words=("${unparsed_words[@]}") + case "${subcommand}" in + help) + # Offer subcommand argument completions + "_defaults-test_${subcommand}" + ;; + *) + # Offer subcommand completions + COMPREPLY+=($(compgen -W 'help' -- "${cur}")) + ;; + esac +} + +_defaults-test_help() { + : +} + +complete -o filenames -F _defaults-test defaults-test \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsFish().fish b/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsFish().fish new file mode 100644 index 000000000..4c54602e6 --- /dev/null +++ b/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsFish().fish @@ -0,0 +1,71 @@ +function __defaults-test_should_offer_completions_for -a expected_commands -a expected_positional_index + set -l unparsed_tokens (__defaults-test_tokens -pc) + set -l positional_index 0 + set -l commands + + switch $unparsed_tokens[1] + case 'defaults-test' + __defaults-test_parse_subcommand 0 'with-default=' 'required-with-default=' 'normal-option=' 'h/help' + switch $unparsed_tokens[1] + case 'help' + __defaults-test_parse_subcommand -r 1 + end + end + + test "$commands" = "$expected_commands" -a \( -z "$expected_positional_index" -o "$expected_positional_index" -eq "$positional_index" \) +end + +function __defaults-test_tokens + if test (string split -m 1 -f 1 -- . "$FISH_VERSION") -gt 3 + commandline --tokens-raw $argv + else + commandline -o $argv + end +end + +function __defaults-test_parse_subcommand -S + argparse -s r -- $argv + set -l positional_count $argv[1] + set -l option_specs $argv[2..] + + set -a commands $unparsed_tokens[1] + set -e unparsed_tokens[1] + + set positional_index 0 + + while true + argparse -sn "$commands" $option_specs -- $unparsed_tokens 2> /dev/null + set unparsed_tokens $argv + set positional_index (math $positional_index + 1) + if test (count $unparsed_tokens) -eq 0 -o \( -z "$_flag_r" -a "$positional_index" -gt "$positional_count" \) + return 0 + end + set -e unparsed_tokens[1] + end +end + +function __defaults-test_complete_directories + set -l token (commandline -t) + string match -- '*/' $token + set -l subdirs $token*/ + printf '%s\n' $subdirs +end + +function __defaults-test_custom_completion + set -x SAP_SHELL fish + set -x SAP_SHELL_VERSION $FISH_VERSION + + set -l tokens (__defaults-test_tokens -p) + if test -z (__defaults-test_tokens -t) + set -l index (count (__defaults-test_tokens -pc)) + set tokens $tokens[..$index] \'\' $tokens[(math $index + 1)..] + end + command $tokens[1] $argv $tokens +end + +complete -c 'defaults-test' -f +complete -c 'defaults-test' -n '__defaults-test_should_offer_completions_for "defaults-test"' -l 'with-default' -rfka '' +complete -c 'defaults-test' -n '__defaults-test_should_offer_completions_for "defaults-test"' -l 'required-with-default' -rfka '' +complete -c 'defaults-test' -n '__defaults-test_should_offer_completions_for "defaults-test"' -l 'normal-option' -rfka '' +complete -c 'defaults-test' -n '__defaults-test_should_offer_completions_for "defaults-test"' -s 'h' -l 'help' -d 'Show help information.' +complete -c 'defaults-test' -n '__defaults-test_should_offer_completions_for "defaults-test" 1' -fa 'help' -d 'Show subcommand help information.' \ No newline at end of file diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsZsh().zsh b/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsZsh().zsh new file mode 100644 index 000000000..9892e133c --- /dev/null +++ b/Tests/ArgumentParserUnitTests/Snapshots/testWithDefaultsZsh().zsh @@ -0,0 +1,81 @@ +#compdef defaults-test + +__defaults-test_complete() { + local -ar non_empty_completions=("${@:#(|:*)}") + local -ar empty_completions=("${(M)@:#(|:*)}") + _describe -V '' non_empty_completions -- empty_completions -P $'\'\'' +} + +__defaults-test_custom_complete() { + local -a completions + completions=("${(@f)"$("${command_name}" "${@}" "${command_line[@]}")"}") + if [[ "${#completions[@]}" -gt 1 ]]; then + __defaults-test_complete "${completions[@]:0:-1}" + fi +} + +__defaults-test_cursor_index_in_current_word() { + if [[ -z "${QIPREFIX}${IPREFIX}${PREFIX}" ]]; then + printf 0 + else + printf %s "${#${(z)LBUFFER}[-1]}" + fi +} + +_defaults-test() { + emulate -RL zsh -G + setopt extendedglob nullglob numericglobsort + unsetopt aliases banghist + + local -xr SAP_SHELL=zsh + local -x SAP_SHELL_VERSION + SAP_SHELL_VERSION="$(builtin emulate zsh -c 'printf %s "${ZSH_VERSION}"')" + local -r SAP_SHELL_VERSION + + local context state state_descr line + local -A opt_args + + local -r command_name="${words[1]}" + local -ar command_line=("${words[@]}") + local -ir current_word_index="$((CURRENT - 1))" + + local -i ret=1 + local -ar arg_specs=( + '--with-default:with-default:' + '--required-with-default:required-with-default:' + '--normal-option:normal-option:' + '(-h --help)'{-h,--help}'[Show help information.]' + '(-): :->command' + '(-)*:: :->arg' + ) + _arguments -w -s -S : "${arg_specs[@]}" && ret=0 + case "${state}" in + command) + local -ar subcommands=( + 'help:Show subcommand help information.' + ) + _describe -V subcommand subcommands + ;; + arg) + case "${words[1]}" in + help) + "_defaults-test_${words[1]}" + ;; + esac + ;; + esac + + return "${ret}" +} + +_defaults-test_help() { + local -i ret=1 + local -ar arg_specs=( + '*:subcommands:' + ) + _arguments -w -s -S : "${arg_specs[@]}" && ret=0 + + return "${ret}" +} + +_defaults-test \ No newline at end of file