From b82d5d6596fbf2600d735869289cd32879b0a84c Mon Sep 17 00:00:00 2001 From: sean0407 <22557931+sean0407@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:23:27 +0800 Subject: [PATCH] Fix shared options after nested positional arguments --- .../Parsable Properties/OptionGroup.swift | 31 ++++++++++---- .../Parsing/ArgumentDecoder.swift | 17 ++++++-- .../Parsing/CommandParser.swift | 11 ++++- .../DefaultSubcommandEndToEndTests.swift | 40 +++++++++++++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift index 0a0d82cc1..19d94591c 100644 --- a/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift +++ b/Sources/ArgumentParser/Parsable Properties/OptionGroup.swift @@ -48,15 +48,32 @@ public struct OptionGroup: Decodable, ParsedWrapper { } public init(from _decoder: Decoder) throws { - if let d = _decoder as? SingleValueDecoder, - let value = try? d.previousValue(Value.self) - { - self.init(_parsedValue: .value(value)) + if let d = _decoder as? SingleValueDecoder { + let previousValue = try? d.previousValue(Value.self) + if let previousValue, !d.underlying.allowsOptionGroupOverrides { + self.init(_parsedValue: .value(previousValue)) + } else { + let previousOrigins = d.underlying.usedOrigins + do { + try self.init(_decoder: _decoder) + } catch { + guard + let previousValue, + d.underlying.usedOrigins == previousOrigins + else { throw error } + self.init(_parsedValue: .value(previousValue)) + } + + if d.underlying.usedOrigins != previousOrigins { + d.saveValue(wrappedValue) + } else if let previousValue { + self = OptionGroup(_parsedValue: .value(previousValue)) + } else { + d.saveValue(wrappedValue) + } + } } else { try self.init(_decoder: _decoder) - if let d = _decoder as? SingleValueDecoder { - d.saveValue(wrappedValue) - } } do { diff --git a/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift b/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift index 9a0395e42..8984b38c6 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentDecoder.swift @@ -29,9 +29,13 @@ struct DecodedArguments { /// A decoder that decodes from parsed command-line arguments. final class ArgumentDecoder: Decoder { - init(values: ParsedValues, previouslyDecoded: [DecodedArguments] = []) { + init( + values: ParsedValues, previouslyDecoded: [DecodedArguments] = [], + allowsOptionGroupOverrides: Bool = true + ) { self.values = values self.previouslyDecoded = previouslyDecoded + self.allowsOptionGroupOverrides = allowsOptionGroupOverrides self.usedOrigins = InputOrigin() } @@ -39,6 +43,7 @@ final class ArgumentDecoder: Decoder { var usedOrigins: InputOrigin var nextCommandIndex = 0 var previouslyDecoded: [DecodedArguments] = [] + let allowsOptionGroupOverrides: Bool var codingPath: [CodingKey] = [] @@ -217,8 +222,14 @@ struct SingleValueDecoder: Decoder { } func saveValue(_ value: T, type: T.Type = T.self) { - underlying.previouslyDecoded.append( - DecodedArguments(type: type, value: value)) + if let index = underlying.previouslyDecoded.firstIndex(where: { + $0.type == type + }) { + underlying.previouslyDecoded[index].value = value + } else { + underlying.previouslyDecoded.append( + DecodedArguments(type: type, value: value)) + } } struct SingleValueContainer: SingleValueDecodingContainer { diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index 46368b2f3..b28cea1a7 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -215,7 +215,9 @@ extension CommandParser { // Decode the values from ParsedValues into the ParsableCommand: let decoder = ArgumentDecoder( values: values, - previouslyDecoded: decodedArguments + previouslyDecoded: decodedArguments, + allowsOptionGroupOverrides: + !currentNode.element.includesPassthroughArguments ) var decodedResult: ParsableCommand do { @@ -232,6 +234,13 @@ extension CommandParser { split.removeAll(in: decoder.usedOrigins) // Save the decoded results to add to the next command. + for previous in decoder.previouslyDecoded { + if let index = decodedArguments.firstIndex(where: { + $0.type == previous.type + }) { + decodedArguments[index].value = previous.value + } + } let newDecodedValues = decoder.previouslyDecoded .filter { prev in !decodedArguments.contains(where: { $0.type == prev.type }) diff --git a/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift index 8376501a1..c06d6a142 100644 --- a/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift +++ b/Tests/ArgumentParserEndToEndTests/DefaultSubcommandEndToEndTests.swift @@ -336,6 +336,46 @@ extension DefaultSubcommandEndToEndTests { } } +extension DefaultSubcommandEndToEndTests { + struct RootWithSharedOptionAndPassthroughDefault: ParsableCommand { + static let configuration = CommandConfiguration( + subcommands: [Image.self, PassthroughPlugin.self], + defaultSubcommand: PassthroughPlugin.self + ) + + @OptionGroup var global: SharedDebugOption + } + + struct SharedDebugOption: ParsableArguments { + @Flag var debug = false + } + + struct Image: ParsableCommand { + static let configuration = CommandConfiguration(subcommands: [Tag.self]) + + @OptionGroup var global: SharedDebugOption + + struct Tag: ParsableCommand { + @OptionGroup var global: SharedDebugOption + @Argument var name: String + } + } + + struct PassthroughPlugin: ParsableCommand { + @Argument(parsing: .captureForPassthrough) var arguments: [String] = [] + } + + @Test func sharedOptionAfterNestedPositionalArgument() { + expectParseCommand( + RootWithSharedOptionAndPassthroughDefault.self, + Image.Tag.self, + ["image", "tag", "item", "--debug"] + ) { tag in + #expect(tag.global.debug) + } + } +} + extension DefaultSubcommandEndToEndTests { struct NestedDefaultSubcommandHelp: ParsableCommand { static let configuration = CommandConfiguration(