Skip to content

Commit a8b48bc

Browse files
authored
Add two new ArgumentArrayParsingStrategy options (#496)
This adds two new parsing options for argument arrays, and renames `.unconditionalRemaining` to `.captureForPassthrough`. - `.allUnrecognized` collects all the inputs that weren't used during parsing. This essentially suppresses all "unrecognized flag/option" and "unexpected argument" errors, and makes those extra inputs available to the client. - `.postTerminator` collects all inputs that follow the `--` terminator, before trying to parse any other positional arguments. This is a non-standard, but sometimes useful parsing strategy.
1 parent b80fb05 commit a8b48bc

File tree

8 files changed

+470
-144
lines changed

8 files changed

+470
-144
lines changed

Sources/ArgumentParser/Parsable Properties/Argument.swift

Lines changed: 163 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -104,51 +104,189 @@ public struct ArgumentArrayParsingStrategy: Hashable {
104104
/// Parse only unprefixed values from the command-line input, ignoring
105105
/// any inputs that have a dash prefix. This is the default strategy.
106106
///
107-
/// For example, for a parsable type defined as following:
107+
/// `remaining` is the default parsing strategy for argument arrays.
108108
///
109-
/// struct Options: ParsableArguments {
110-
/// @Flag var verbose: Bool
111-
/// @Argument(parsing: .remaining) var words: [String]
109+
/// For example, the `Example` command defined below has a `words` array that
110+
/// uses the `remaining` parsing strategy:
111+
///
112+
/// @main
113+
/// struct Example: ParsableCommand {
114+
/// @Flag var verbose = false
115+
///
116+
/// @Argument(parsing: .remaining)
117+
/// var words: [String]
118+
///
119+
/// func run() {
120+
/// print(words.joined(separator: "\n"))
121+
/// }
112122
/// }
113123
///
114-
/// Parsing the input `--verbose one two` or `one two --verbose` would result
115-
/// in `Options(verbose: true, words: ["one", "two"])`. Parsing the input
116-
/// `one two --other` would result in an unknown option error for `--other`.
124+
/// Any non-dash-prefixed inputs will be captured in the `words` array.
125+
///
126+
/// ```
127+
/// $ example --verbose one two
128+
/// one
129+
/// two
130+
/// $ example one two --verbose
131+
/// one
132+
/// two
133+
/// $ example one two --other
134+
/// Error: Unknown option '--other'
135+
/// ```
136+
///
137+
/// If a user uses the `--` terminator in their input, all following inputs
138+
/// will be captured in `words`.
117139
///
118-
/// This is the default strategy for parsing argument arrays.
140+
/// ```
141+
/// $ example one two -- --verbose --other
142+
/// one
143+
/// two
144+
/// --verbose
145+
/// --other
146+
/// ```
119147
public static var remaining: ArgumentArrayParsingStrategy {
120148
self.init(base: .default)
121149
}
122150

151+
/// After parsing, capture all unrecognized inputs in this argument array.
152+
///
153+
/// You can use the `allUnrecognized` parsing strategy to suppress
154+
/// "unexpected argument" errors or to capture unrecognized inputs for further
155+
/// processing.
156+
///
157+
/// For example, the `Example` command defined below has an `other` array that
158+
/// uses the `allUnrecognized` parsing strategy:
159+
///
160+
/// @main
161+
/// struct Example: ParsableCommand {
162+
/// @Flag var verbose = false
163+
/// @Argument var name: String
164+
///
165+
/// @Argument(parsing: .allUnrecognized)
166+
/// var other: [String]
167+
///
168+
/// func run() {
169+
/// print(other.joined(separator: "\n"))
170+
/// }
171+
/// }
172+
///
173+
/// After parsing the `--verbose` flag and `<name>` argument, any remaining
174+
/// input is captured in the `other` array.
175+
///
176+
/// ```
177+
/// $ example --verbose Negin one two
178+
/// one
179+
/// two
180+
/// $ example Asa --verbose --other -zzz
181+
/// --other
182+
/// -zzz
183+
/// ```
184+
public static var allUnrecognized: ArgumentArrayParsingStrategy {
185+
self.init(base: .allUnrecognized)
186+
}
187+
188+
/// Before parsing, capture all inputs that follow the `--` terminator in this
189+
/// argument array.
190+
///
191+
/// For example, the `Example` command defined below has a `words` array that
192+
/// uses the `postTerminator` parsing strategy:
193+
///
194+
/// @main
195+
/// struct Example: ParsableCommand {
196+
/// @Flag var verbose = false
197+
/// @Argument var name = ""
198+
///
199+
/// @Argument(parsing: .postTerminator)
200+
/// var words: [String]
201+
///
202+
/// func run() {
203+
/// print(words.joined(separator: "\n"))
204+
/// }
205+
/// }
206+
///
207+
/// Before looking for the `--verbose` flag and `<name>` argument, any inputs
208+
/// after the `--` terminator are captured into the `words` array.
209+
///
210+
/// ```
211+
/// $ example --verbose Asa -- one two --other
212+
/// one
213+
/// two
214+
/// --other
215+
/// $ example Asa Extra -- one two --other
216+
/// Error: Unexpected argument 'Extra'
217+
/// ```
218+
///
219+
/// - Note: This parsing strategy can be surprising for users, since it
220+
/// changes the behavior of the `--` terminator. Prefer ``remaining``
221+
/// whenever possible.
222+
public static var postTerminator: ArgumentArrayParsingStrategy {
223+
self.init(base: .postTerminator)
224+
}
225+
123226
/// Parse all remaining inputs after parsing any known options or flags,
124227
/// including dash-prefixed inputs and the `--` terminator.
125228
///
126-
/// When you use the `unconditionalRemaining` parsing strategy, the parser
127-
/// stops parsing flags and options as soon as it encounters a positional
128-
/// argument or an unrecognized flag. For example, for a parsable type
129-
/// defined as following:
229+
/// You can use the `captureForPassthrough` parsing strategy if you need to
230+
/// capture a user's input to manually pass it unchanged to another command.
130231
///
131-
/// struct Options: ParsableArguments {
132-
/// @Flag
133-
/// var verbose: Bool = false
232+
/// When you use this parsing strategy, the parser stops parsing flags and
233+
/// options as soon as it encounters a positional argument or an unrecognized
234+
/// flag, and captures all remaining inputs in the array argument.
134235
///
135-
/// @Argument(parsing: .unconditionalRemaining)
236+
/// For example, the `Example` command defined below has an `words` array that
237+
/// uses the `captureForPassthrough` parsing strategy:
238+
///
239+
/// @main
240+
/// struct Example: ParsableCommand {
241+
/// @Flag var verbose = false
242+
///
243+
/// @Argument(parsing: .captureForPassthrough)
136244
/// var words: [String] = []
245+
///
246+
/// func run() {
247+
/// print(words.joined(separator: "\n"))
248+
/// }
137249
/// }
138250
///
139-
/// Parsing the input `--verbose one two --verbose` includes the second
140-
/// `--verbose` flag in `words`, resulting in
141-
/// `Options(verbose: true, words: ["one", "two", "--verbose"])`.
251+
/// Any values after the first unrecognized input are captured in the `words`
252+
/// array.
253+
///
254+
/// ```
255+
/// $ example --verbose one two --other
256+
/// one
257+
/// two
258+
/// --other
259+
/// $ example one two --verbose
260+
/// one
261+
/// two
262+
/// --verbose
263+
/// ```
264+
///
265+
/// With the `captureForPassthrough` parsing strategy, the `--` terminator
266+
/// is included in the captured values.
267+
///
268+
/// ```
269+
/// $ example --verbose one two -- --other
270+
/// one
271+
/// two
272+
/// --
273+
/// --other
274+
/// ```
142275
///
143276
/// - Note: This parsing strategy can be surprising for users, particularly
144-
/// when combined with options and flags. Prefer `remaining` whenever
145-
/// possible, since users can always terminate options and flags with
146-
/// the `--` terminator. With the `remaining` parsing strategy, the input
147-
/// `--verbose -- one two --verbose` would have the same result as the above
148-
/// example: `Options(verbose: true, words: ["one", "two", "--verbose"])`.
149-
public static var unconditionalRemaining: ArgumentArrayParsingStrategy {
277+
/// when combined with options and flags. Prefer ``remaining`` or
278+
/// ``allUnrecognized`` whenever possible, since users can always terminate
279+
/// options and flags with the `--` terminator. With the `remaining`
280+
/// parsing strategy, the input `--verbose -- one two --other` would have
281+
/// the same result as the first example above.
282+
public static var captureForPassthrough: ArgumentArrayParsingStrategy {
150283
self.init(base: .allRemainingInput)
151284
}
285+
286+
@available(*, deprecated, renamed: "captureForPassthrough")
287+
public static var unconditionalRemaining: ArgumentArrayParsingStrategy {
288+
.captureForPassthrough
289+
}
152290
}
153291

154292
// MARK: - @Argument T: ExpressibleByArgument Initializers

Sources/ArgumentParser/Parsing/ArgumentDefinition.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ struct ArgumentDefinition {
8080
/// This folds the public `ArrayParsingStrategy` and `SingleValueParsingStrategy`
8181
/// into a single enum.
8282
enum ParsingStrategy {
83-
/// Expect the next `SplitArguments.Element` to be a value and parse it. Will fail if the next
84-
/// input is an option.
83+
/// Expect the next `SplitArguments.Element` to be a value and parse it.
84+
/// Will fail if the next input is an option.
8585
case `default`
8686
/// Parse the next `SplitArguments.Element.value`
8787
case scanningForValue
@@ -91,6 +91,12 @@ struct ArgumentDefinition {
9191
case upToNextOption
9292
/// Parse all remaining `SplitArguments.Element` as values, regardless of its type.
9393
case allRemainingInput
94+
/// Collect all the elements after the terminator, preventing them from
95+
/// appearing in any other position.
96+
case postTerminator
97+
/// Collect all unused inputs once recognized arguments/options/flags have
98+
/// been parsed.
99+
case allUnrecognized
94100
}
95101

96102
var kind: Kind

Sources/ArgumentParser/Parsing/ArgumentSet.swift

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,10 @@ extension ArgumentSet {
316316
try update(origins, parsed.name, value, &result)
317317
usedOrigins.formUnion(origins)
318318
}
319+
320+
case .postTerminator, .allUnrecognized:
321+
// These parsing kinds are for arguments only.
322+
throw ParserError.invalidState
319323
}
320324
}
321325

@@ -442,49 +446,84 @@ extension ArgumentSet {
442446
from unusedInput: SplitArguments,
443447
into result: inout ParsedValues
444448
) throws {
445-
// Filter out the inputs that aren't "whole" arguments, like `-h` and `-i`
446-
// from the input `-hi`.
447-
var argumentStack = unusedInput.elements.filter {
448-
$0.index.subIndex == .complete
449-
}.map {
450-
(InputOrigin.Element.argumentIndex($0.index), $0)
451-
}[...]
452-
453-
guard !argumentStack.isEmpty else { return }
449+
var endOfInput = unusedInput.elements.endIndex
454450

455-
/// Pops arguments until reaching one that is a value (i.e., isn't dash-
456-
/// prefixed).
457-
func skipNonValues() {
458-
while argumentStack.first?.1.isValue == false {
459-
_ = argumentStack.popFirst()
451+
// If this argument set includes a definition that should collect all the
452+
// post-terminator inputs, capture them before trying to fill other
453+
// `@Argument` definitions.
454+
if let postTerminatorArg = self.first(where: { def in
455+
def.isRepeatingPositional && def.parsingStrategy == .postTerminator
456+
}),
457+
case let .unary(update) = postTerminatorArg.update,
458+
let terminatorIndex = unusedInput.elements.firstIndex(where: \.isTerminator)
459+
{
460+
for input in unusedInput.elements[(terminatorIndex + 1)...] {
461+
// Everything post-terminator is a value, force-unwrapping here is safe:
462+
let value = input.value.valueString!
463+
try update([.argumentIndex(input.index)], nil, value, &result)
460464
}
465+
466+
endOfInput = terminatorIndex
461467
}
462-
463-
/// Pops the origin of the next argument to use.
464-
///
465-
/// If `unconditional` is false, this skips over any non-"value" input.
466-
func next(unconditional: Bool) -> InputOrigin.Element? {
467-
if !unconditional {
468-
skipNonValues()
468+
469+
// Create a stack out of the remaining unused inputs that aren't "partial"
470+
// arguments (i.e. the individual components of a `-vix` grouped short
471+
// option input).
472+
var argumentStack = unusedInput.elements[..<endOfInput].filter {
473+
$0.index.subIndex == .complete
474+
}[...]
475+
guard !argumentStack.isEmpty else { return }
476+
477+
/// Pops arguments off the stack until the next valid value. Skips over
478+
/// dash-prefixed inputs unless `unconditional` is `true`.
479+
func next(unconditional: Bool) -> SplitArguments.Element? {
480+
while let arg = argumentStack.popFirst() {
481+
if arg.isValue || unconditional {
482+
return arg
483+
}
469484
}
470-
return argumentStack.popFirst()?.0
485+
486+
return nil
471487
}
472488

489+
// For all positional arguments, consume one or more inputs.
490+
var usedOrigins = InputOrigin()
473491
ArgumentLoop:
474492
for argumentDefinition in self {
475493
guard case .positional = argumentDefinition.kind else { continue }
494+
switch argumentDefinition.parsingStrategy {
495+
case .default, .allRemainingInput:
496+
break
497+
default:
498+
continue ArgumentLoop
499+
}
476500
guard case let .unary(update) = argumentDefinition.update else {
477501
preconditionFailure("Shouldn't see a nullary positional argument.")
478502
}
479503
let allowOptionsAsInput = argumentDefinition.parsingStrategy == .allRemainingInput
480504

481505
repeat {
482-
guard let origin = next(unconditional: allowOptionsAsInput) else {
506+
guard let arg = next(unconditional: allowOptionsAsInput) else {
483507
break ArgumentLoop
484508
}
509+
let origin: InputOrigin.Element = .argumentIndex(arg.index)
485510
let value = unusedInput.originalInput(at: origin)!
486511
try update([origin], nil, value, &result)
512+
usedOrigins.insert(origin)
487513
} while argumentDefinition.isRepeatingPositional
488514
}
515+
516+
// If there's an `.allUnrecognized` argument array, collect leftover args.
517+
if let allUnrecognizedArg = self.first(where: { def in
518+
def.isRepeatingPositional && def.parsingStrategy == .allUnrecognized
519+
}),
520+
case let .unary(update) = allUnrecognizedArg.update
521+
{
522+
while let arg = argumentStack.popFirst() {
523+
let origin: InputOrigin.Element = .argumentIndex(arg.index)
524+
let value = unusedInput.originalInput(at: origin)!
525+
try update([origin], nil, value, &result)
526+
}
527+
}
489528
}
490529
}

Sources/ArgumentParser/Parsing/SplitArguments.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,11 +500,13 @@ extension SplitArguments {
500500
return $0.index.inputIndex
501501
}
502502

503-
// Now return all elements that are either:
503+
// Now return all non-terminator elements that are either:
504504
// 1) `.complete`
505505
// 2) `.sub` but not in `completeIndexes`
506506

507507
let extraElements = elements.filter {
508+
if $0.isTerminator { return false }
509+
508510
switch $0.index.subIndex {
509511
case .complete:
510512
return true

Tests/ArgumentParserEndToEndTests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ add_library(EndToEndTests
1111
PositionalEndToEndTests.swift
1212
RawRepresentableEndToEndTests.swift
1313
RepeatingEndToEndTests.swift
14+
RepeatingEndToEndTests+ParsingStrategy.swift
1415
ShortNameEndToEndTests.swift
1516
SimpleEndToEndTests.swift
1617
SingleValueParsingStrategyTests.swift

Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,15 @@ extension DefaultSubcommandEndToEndTests {
9191
@OptionGroup var options: CommonOptions
9292
@Argument var pluginName: String
9393

94-
@Argument(parsing: .unconditionalRemaining)
94+
@Argument(parsing: .captureForPassthrough)
9595
var pluginArguments: [String] = []
9696
}
9797

9898
fileprivate struct NonDefault: ParsableCommand {
9999
@OptionGroup var options: CommonOptions
100100
@Argument var pluginName: String
101101

102-
@Argument(parsing: .unconditionalRemaining)
102+
@Argument(parsing: .captureForPassthrough)
103103
var pluginArguments: [String] = []
104104
}
105105

0 commit comments

Comments
 (0)