Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ struct Lucky: ParsableCommand {
```

```
% lucky
% lucky
Your lucky numbers are:
7 14 21
% lucky 1 2 3
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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] = []

Expand Down
96 changes: 87 additions & 9 deletions Sources/ArgumentParser/Parsable Properties/Option.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)!")
/// }
Expand All @@ -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<Value>: Decodable, ParsedWrapper {
internal var _parsedValue: Parsed<Value>

/// 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<Value>) {
self._parsedValue = _parsedValue
}
Expand Down Expand Up @@ -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<T>(default defaultValue: T)
-> SingleValueParsingStrategy
where T: ExpressibleByArgument {
self.init(
base: .scanningForValueWithDefault(defaultValue.defaultValueDescription))
}
}

extension SingleValueParsingStrategy: Sendable {}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion Sources/ArgumentParser/Parsing/ArgumentDefinition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
30 changes: 30 additions & 0 deletions Sources/ArgumentParser/Parsing/ArgumentSet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions Sources/ArgumentParser/Parsing/SplitArguments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
2 changes: 2 additions & 0 deletions Sources/ArgumentParser/Usage/DumpHelpGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ extension ArgumentInfoV0.ParsingStrategyV0 {
self = .default
case .scanningForValue:
self = .scanningForValue
case .scanningForValueWithDefault(_):
self = .scanningForValue
case .unconditional:
self = .unconditional
case .upToNextOption:
Expand Down
Loading