Skip to content

Commit b319a92

Browse files
committed
Augment Option to support default value
There is a need to have an option argument to behave like a flag in some cases. In order to prevent conflicting values between property initialization and parsing strategy, a fatalError is raised with an apppropriate message. Ideally, we would have raised a compile-time error, as `SingleValueParsingStrategy` values are determined at runtime. This change augments `Option` to support this. Fixes: #829
1 parent 1fb5308 commit b319a92

File tree

15 files changed

+910
-12
lines changed

15 files changed

+910
-12
lines changed

Sources/ArgumentParser/Documentation.docc/Articles/DeclaringArguments.md

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ struct Lucky: ParsableCommand {
8181
```
8282

8383
```
84-
% lucky
84+
% lucky
8585
Your lucky numbers are:
8686
7 14 21
8787
% lucky 1 2 3
@@ -376,6 +376,54 @@ The `.scanningForValue` strategy, on the other hand, looks ahead in the list of
376376
Verbose: true, name: Tomás, file: none
377377
```
378378

379+
#### Parsing strategies with default values
380+
381+
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:
382+
383+
```swift
384+
struct BuildTool: ParsableCommand {
385+
@Option(parsing: .scanningForValue(default: "release"))
386+
var configuration: String?
387+
388+
@Flag var verbose = false
389+
@Argument var target: String
390+
391+
mutating func run() throws {
392+
let config = configuration ?? "default"
393+
print("Building \(target) in \(config) mode")
394+
if verbose {
395+
print("Verbose output enabled")
396+
}
397+
}
398+
}
399+
```
400+
401+
With this approach:
402+
- When `--configuration` is not provided: `configuration` is `nil`
403+
- When `--configuration` is provided without a value: `configuration` uses the default `"release"`
404+
- When `--configuration` is provided with a value: `configuration` uses that explicit value
405+
406+
```
407+
% build-tool MyApp
408+
Building MyApp in default mode
409+
% build-tool MyApp --configuration
410+
Building MyApp in release mode
411+
% build-tool MyApp --configuration debug
412+
Building MyApp in debug mode
413+
% build-tool MyApp --configuration debug --verbose
414+
Building MyApp in debug mode
415+
Verbose output enabled
416+
```
417+
418+
The `.scanningForValue(default:)` strategy respects argument terminators (`--`), ensuring that values after the terminator are treated as positional arguments rather than option values:
419+
420+
```
421+
% build-tool MyApp --configuration -- debug
422+
Building MyApp in release mode
423+
```
424+
425+
In this example, `debug` is treated as a positional argument rather than the value for `--configuration`, so the default value `"release"` is used instead.
426+
379427
#### Alternative array parsing strategies
380428

381429
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
479527
```swift
480528
struct Example: ParsableCommand {
481529
@Flag var verbose = false
482-
530+
483531
@Argument(parsing: .allUnrecognized)
484532
var unknowns: [String] = []
485533

Sources/ArgumentParser/Parsable Properties/Option.swift

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,28 @@
2020
/// their default value. Options that are neither declared as `Optional` nor
2121
/// given a default value are required for users of your command-line tool.
2222
///
23-
/// For example, the following program defines three options:
23+
/// ## Specifying Default Values
24+
///
25+
/// There are multiple ways to specify default values for options:
26+
///
27+
/// 1. **Property initialization**: Assign a default value when declaring the property.
28+
/// 2. **Parsing strategy with default**: Use `.scanningForValue(default:)` to specify
29+
/// a default value that will be used when the option is not provided.
30+
///
31+
/// For example, the following program defines options with different default approaches:
2432
///
2533
/// ```swift
2634
/// @main
2735
/// struct Greet: ParsableCommand {
28-
/// @Option var greeting = "Hello"
36+
/// @Option var greeting = "Hello" // Property initialization
2937
/// @Option var age: Int? = nil
3038
/// @Option var name: String
39+
/// @Option(parsing: .scanningForValue(default: "Guest"))
40+
/// var fallbackName: String? // Parsing strategy default
3141
///
3242
/// mutating func run() {
33-
/// print("\(greeting) \(name)!")
43+
/// let actualName = name.isEmpty ? fallbackName : name
44+
/// print("\(greeting) \(actualName)!")
3445
/// if let age {
3546
/// print("Congrats on making it to the ripe old age of \(age)!")
3647
/// }
@@ -41,17 +52,29 @@
4152
/// `greeting` has a default value of `"Hello"`, which can be overridden by
4253
/// providing a different string as an argument, while `age` defaults to `nil`.
4354
/// `name` is a required option because it is non-`nil` and has no default
44-
/// value.
55+
/// value. `fallbackName` uses a parsing strategy default and will be set to
56+
/// `"Guest"` when the option is not provided.
4557
///
4658
/// $ greet --name Alicia
4759
/// Hello Alicia!
4860
/// $ greet --age 28 --name Seungchin --greeting Hi
4961
/// Hi Seungchin!
5062
/// Congrats on making it to the ripe old age of 28!
63+
/// $ greet --fallback-name Bob --name ""
64+
/// Hello Bob!
5165
@propertyWrapper
5266
public struct Option<Value>: Decodable, ParsedWrapper {
5367
internal var _parsedValue: Parsed<Value>
5468

69+
/// Error message for conflicting default values between property initialization and parsing strategy.
70+
private static var conflictingDefaultsErrorMessage: String {
71+
[
72+
"Cannot specify both a wrapped value (property default) and a parsing strategy default with .scanningForValue(default:).",
73+
"Use either property initialization (var name: String = \"defaultValue\") or parsing strategy default",
74+
"(@Option(parsing: .scanningForValue(default: \"defaultValue\")) var name: String), but not both.",
75+
].joined(separator: " ")
76+
}
77+
5578
internal init(_parsedValue: Parsed<Value>) {
5679
self._parsedValue = _parsedValue
5780
}
@@ -159,6 +182,18 @@ public struct SingleValueParsingStrategy: Hashable {
159182
public static var scanningForValue: SingleValueParsingStrategy {
160183
self.init(base: .scanningForValue)
161184
}
185+
186+
/// Parse the next input, as long as that input can't be interpreted as
187+
/// an option or flag, with a default value when the option is not provided.
188+
///
189+
/// - Parameter defaultValue: The default value to use when the option is not provided.
190+
/// - Returns: A parsing strategy that scans for value with a default.
191+
public static func scanningForValue<T>(default defaultValue: T)
192+
-> SingleValueParsingStrategy
193+
where T: ExpressibleByArgument {
194+
self.init(
195+
base: .scanningForValueWithDefault(defaultValue.defaultValueDescription))
196+
}
162197
}
163198

164199
extension SingleValueParsingStrategy: Sendable {}
@@ -273,6 +308,11 @@ extension Option where Value: ExpressibleByArgument {
273308
help: ArgumentHelp? = nil,
274309
completion: CompletionKind? = nil
275310
) {
311+
// Validate that we don't have conflicting default values
312+
if case .scanningForValueWithDefault = parsingStrategy.base {
313+
fatalError(Self.conflictingDefaultsErrorMessage)
314+
}
315+
276316
self.init(
277317
_parsedValue: .init { key in
278318
let arg = ArgumentDefinition(
@@ -307,6 +347,11 @@ extension Option where Value: ExpressibleByArgument {
307347
completion: CompletionKind?,
308348
help: ArgumentHelp?
309349
) {
350+
// Validate that we don't have conflicting default values
351+
if case .scanningForValueWithDefault = parsingStrategy.base {
352+
fatalError(Self.conflictingDefaultsErrorMessage)
353+
}
354+
310355
self.init(
311356
wrappedValue: _wrappedValue,
312357
name: name,
@@ -336,7 +381,11 @@ extension Option where Value: ExpressibleByArgument {
336381
parsing parsingStrategy: SingleValueParsingStrategy = .next,
337382
help: ArgumentHelp? = nil,
338383
completion: CompletionKind? = nil
339-
) {
384+
) where Value: ExpressibleByArgument {
385+
// This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed
386+
let defaultValue: Value? = nil
387+
let actualParsingStrategy = parsingStrategy.base
388+
340389
self.init(
341390
_parsedValue: .init { key in
342391
let arg = ArgumentDefinition(
@@ -350,8 +399,8 @@ extension Option where Value: ExpressibleByArgument {
350399
visibility: help?.visibility ?? .default,
351400
argumentType: Value.self
352401
),
353-
parsingStrategy: parsingStrategy.base,
354-
initial: nil,
402+
parsingStrategy: actualParsingStrategy,
403+
initial: defaultValue,
355404
completion: completion)
356405

357406
return ArgumentSet(arg)
@@ -392,6 +441,11 @@ extension Option {
392441
completion: CompletionKind? = nil,
393442
transform: @Sendable @escaping (String) throws -> Value
394443
) {
444+
// Validate that we don't have conflicting default values
445+
if case .scanningForValueWithDefault = parsingStrategy.base {
446+
fatalError(Self.conflictingDefaultsErrorMessage)
447+
}
448+
395449
self.init(
396450
_parsedValue: .init { key in
397451
let arg = ArgumentDefinition(
@@ -437,6 +491,7 @@ extension Option {
437491
completion: CompletionKind? = nil,
438492
transform: @Sendable @escaping (String) throws -> Value
439493
) {
494+
// This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed
440495
self.init(
441496
_parsedValue: .init { key in
442497
let arg = ArgumentDefinition(
@@ -482,6 +537,7 @@ extension Option {
482537
help: ArgumentHelp? = nil,
483538
completion: CompletionKind? = nil
484539
) where T: ExpressibleByArgument, Value == T? {
540+
// Note: This initializer uses nil as wrappedValue, so scanningForValueWithDefault is allowed
485541
self.init(
486542
_parsedValue: .init { key in
487543
let arg = ArgumentDefinition(
@@ -517,6 +573,13 @@ extension Option {
517573
help: ArgumentHelp? = nil,
518574
completion: CompletionKind? = nil
519575
) where T: ExpressibleByArgument, Value == T? {
576+
// Validate that we don't have conflicting default values (only if wrappedValue is not nil)
577+
if _wrappedValue != nil,
578+
case .scanningForValueWithDefault = parsingStrategy.base
579+
{
580+
fatalError(Self.conflictingDefaultsErrorMessage)
581+
}
582+
520583
self.init(
521584
_parsedValue: .init { key in
522585
let arg = ArgumentDefinition(
@@ -560,6 +623,10 @@ extension Option {
560623
help: ArgumentHelp? = nil,
561624
completion: CompletionKind? = nil
562625
) where T: ExpressibleByArgument, Value == T? {
626+
// This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed
627+
let defaultValue: T? = nil
628+
let actualParsingStrategy = parsingStrategy.base
629+
563630
self.init(
564631
_parsedValue: .init { key in
565632
let arg = ArgumentDefinition(
@@ -573,8 +640,8 @@ extension Option {
573640
visibility: help?.visibility ?? .default,
574641
argumentType: T.self
575642
),
576-
parsingStrategy: parsingStrategy.base,
577-
initial: nil,
643+
parsingStrategy: actualParsingStrategy,
644+
initial: defaultValue,
578645
completion: completion)
579646

580647
return ArgumentSet(arg)
@@ -615,6 +682,7 @@ extension Option {
615682
completion: CompletionKind? = nil,
616683
transform: @Sendable @escaping (String) throws -> T
617684
) where Value == T? {
685+
// Note: This initializer uses nil as wrappedValue, so scanningForValueWithDefault is allowed
618686
self.init(
619687
_parsedValue: .init { key in
620688
let arg = ArgumentDefinition(
@@ -647,6 +715,13 @@ extension Option {
647715
completion: CompletionKind? = nil,
648716
transform: @Sendable @escaping (String) throws -> T
649717
) where Value == T? {
718+
// Validate that we don't have conflicting default values (only if wrappedValue is not nil)
719+
if _wrappedValue != nil,
720+
case .scanningForValueWithDefault = parsingStrategy.base
721+
{
722+
fatalError(Self.conflictingDefaultsErrorMessage)
723+
}
724+
650725
self.init(
651726
_parsedValue: .init { key in
652727
let arg = ArgumentDefinition(
@@ -691,6 +766,7 @@ extension Option {
691766
completion: CompletionKind? = nil,
692767
transform: @Sendable @escaping (String) throws -> T
693768
) where Value == T? {
769+
// This initializer doesn't have a wrapped value, so scanningForValueWithDefault is allowed
694770
self.init(
695771
_parsedValue: .init { key in
696772
let arg = ArgumentDefinition(
@@ -744,6 +820,7 @@ extension Option {
744820
help: ArgumentHelp? = nil,
745821
completion: CompletionKind? = nil
746822
) where T: ExpressibleByArgument, Value == [T] {
823+
// Note: Array parsing strategies don't include scanningForValueWithDefault, so no validation needed
747824
self.init(
748825
_parsedValue: .init { key in
749826
let arg = ArgumentDefinition(
@@ -851,6 +928,7 @@ extension Option {
851928
completion: CompletionKind? = nil,
852929
transform: @Sendable @escaping (String) throws -> T
853930
) where Value == [T] {
931+
// Note: Array parsing strategies don't include scanningForValueWithDefault, so no validation needed
854932
self.init(
855933
_parsedValue: .init { key in
856934
let arg = ArgumentDefinition(

Sources/ArgumentParser/Parsing/ArgumentDefinition.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,14 @@ struct ArgumentDefinition {
8080

8181
/// This folds the public `ArrayParsingStrategy` and `SingleValueParsingStrategy`
8282
/// into a single enum.
83-
enum ParsingStrategy {
83+
enum ParsingStrategy: Hashable {
8484
/// Expect the next `SplitArguments.Element` to be a value and parse it.
8585
/// Will fail if the next input is an option.
8686
case `default`
8787
/// Parse the next `SplitArguments.Element.value`
8888
case scanningForValue
89+
/// Parse the next `SplitArguments.Element.value` with a default value when not provided
90+
case scanningForValueWithDefault(String?)
8991
/// Parse the next `SplitArguments.Element` as a value, regardless of its type.
9092
case unconditional
9193
/// Parse multiple `SplitArguments.Element.value` up to the next non-`.value`

Sources/ArgumentParser/Parsing/ArgumentSet.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,36 @@ struct LenientParser {
352352
throw errorForMissingValue(originElement, parsed)
353353
}
354354

355+
case .scanningForValueWithDefault(let defaultValue):
356+
// Same as scanningForValue, but uses default if no value found
357+
if let value = parsed.value {
358+
// This was `--foo=bar` style:
359+
try update(origin, parsed.name, value, &result)
360+
usedOrigins.formUnion(origin)
361+
} else if argument.allowsJoinedValue,
362+
let (origin2, value) = inputArguments.extractJoinedElement(
363+
at: originElement)
364+
{
365+
// Found a joined argument
366+
let origins = origin.inserting(origin2)
367+
try update(origins, parsed.name, String(value), &result)
368+
usedOrigins.formUnion(origins)
369+
} else if let (origin2, value) = inputArguments.popNextValue(
370+
after: originElement)
371+
{
372+
// Use `popNext(after:)` to handle cases where short option
373+
// labels are combined, but only if not followed by terminator
374+
let origins = origin.inserting(origin2)
375+
try update(origins, parsed.name, value, &result)
376+
usedOrigins.formUnion(origins)
377+
} else if let defaultValue = defaultValue {
378+
// Use the default value when no value found or when terminator encountered
379+
try update(origin, parsed.name, defaultValue, &result)
380+
usedOrigins.formUnion(origin)
381+
} else {
382+
throw errorForMissingValue(originElement, parsed)
383+
}
384+
355385
case .unconditional:
356386
// Use an attached value if it exists...
357387
if let value = parsed.value {

Sources/ArgumentParser/Parsing/SplitArguments.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,18 @@ extension SplitArguments {
365365
after origin: InputOrigin.Element
366366
) -> (InputOrigin.Element, String)? {
367367
guard let start = position(after: origin) else { return nil }
368+
369+
// Check if there's a terminator before any value
370+
if let terminatorIndex = elements[start...].firstIndex(where: {
371+
$0.isTerminator
372+
}),
373+
let valueIndex = elements[start...].firstIndex(where: { $0.isValue }),
374+
terminatorIndex < valueIndex
375+
{
376+
// There's a terminator before the next value, so don't consume the value
377+
return nil
378+
}
379+
368380
guard let resultIndex = elements[start...].firstIndex(where: { $0.isValue })
369381
else { return nil }
370382

Sources/ArgumentParser/Usage/DumpHelpGenerator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ extension ArgumentInfoV0.ParsingStrategyV0 {
161161
self = .default
162162
case .scanningForValue:
163163
self = .scanningForValue
164+
case .scanningForValueWithDefault(_):
165+
self = .scanningForValue
164166
case .unconditional:
165167
self = .unconditional
166168
case .upToNextOption:

0 commit comments

Comments
 (0)