From 7c18ece05a56e67efbd6ebb9d463f55aa7453e8c Mon Sep 17 00:00:00 2001 From: Manav_ Date: Wed, 25 Mar 2026 20:04:33 +0530 Subject: [PATCH] Ordered Multi-Option Collection Support This feature enables the Swift Argument Parser to collect multiple different option names into a single ordered array while preserving the relative global ordering of those flags as they appear on the command line. This is achieved by introducing a new @Option initializer that passes both the used flag name and its value to a transform closure, allowing for precise mapping of interleaved options to a single property. 1. ArgumentDefinition.swift I added a new initializer that changes the internal parsing block to pass the Name (as a String) to the transformation logic: // New ArgumentDefinition init transform: @escaping (_ name: String, _ valueString: String) throws -> Container.Contained 2. Option.swift I added overloads to the @Option initializers that make this name available to the user-provided transform closure: swift // New @Option initializer for arrays public init( name: NameSpecification = .long, ..., transform: @escaping (String, String) throws -> T // name is now available here! ) where Value == [T] Existing Code: You had to create separate properties for different options (e.g., @Option var resize: [Int] and @Option var blur: [Int]). While each array was ordered, there was no way to know if the user typed --resize 10 --blur 5 or --blur 5 --resize 10. New Code: You can now define a single property that accepts multiple different flag names. Because the transform closure receives the name of the flag being parsed, you can collect different operations into one array while perfectly preserving the interleaved order of the command-line input. --- .../Parsable Properties/Option.swift | 118 ++++++++++++++++++ .../Parsing/ArgumentDefinition.swift | 30 +++++ .../OrderedOptionsEndToEndTests.swift | 73 +++++++++++ build_output.txt | Bin 0 -> 10084 bytes 4 files changed, 221 insertions(+) create mode 100644 Tests/ArgumentParserEndToEndTests/OrderedOptionsEndToEndTests.swift create mode 100644 build_output.txt diff --git a/Sources/ArgumentParser/Parsable Properties/Option.swift b/Sources/ArgumentParser/Parsable Properties/Option.swift index 024a0232a..972facc40 100644 --- a/Sources/ArgumentParser/Parsable Properties/Option.swift +++ b/Sources/ArgumentParser/Parsable Properties/Option.swift @@ -408,6 +408,51 @@ extension Option { }) } + /// Creates a required property that reads its value from a labeled option, + /// parsing with the given closure. + /// + /// This initializer is used when you declare an `@Option`-attributed property + /// with a transform closure and without a default value: + /// + /// ```swift + /// @Option(transform: { $0.first ?? " " }) + /// var char: Character + /// ``` + /// + /// - Parameters: + /// - name: A specification for what names are allowed for this option. + /// - parsingStrategy: The behavior to use when looking for this option's + /// value. + /// - help: Information about how to use this option. + /// - completion: The type of command-line completion provided for this + /// option. + /// - transform: A closure that converts a string into this property's + /// type, or else throws an error. + @preconcurrency + public init( + wrappedValue: Value, + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transform: @Sendable @escaping (_ name: String, _ value: String) throws -> Value + ) { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Bare.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: parsingStrategy.base, + transform: transform, + initial: wrappedValue, + completion: completion) + + return ArgumentSet(arg) + }) + } + /// Creates a required property that reads its value from a labeled option, /// parsing with the given closure. /// @@ -452,6 +497,30 @@ extension Option { return ArgumentSet(arg) }) } + + @preconcurrency + public init( + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transform: @Sendable @escaping (_ name: String, _ value: String) throws -> Value + ) { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Bare.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: parsingStrategy.base, + transform: transform, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } } // MARK: - @Option Optional Initializers @@ -867,6 +936,31 @@ extension Option { }) } + @preconcurrency + public init( + wrappedValue: [T], + name: NameSpecification = .long, + parsing parsingStrategy: ArrayParsingStrategy = .singleValue, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transform: @Sendable @escaping (_ name: String, _ value: String) throws -> T + ) where Value == [T] { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Array.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: parsingStrategy.base, + transform: transform, + initial: wrappedValue, + completion: completion) + + return ArgumentSet(arg) + }) + } + /// Creates a required array property that reads its values from zero or /// more labeled options, parsing each element with the given closure. /// @@ -910,4 +1004,28 @@ extension Option { return ArgumentSet(arg) }) } + + @preconcurrency + public init( + name: NameSpecification = .long, + parsing parsingStrategy: ArrayParsingStrategy = .singleValue, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transform: @Sendable @escaping (_ name: String, _ value: String) throws -> T + ) where Value == [T] { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Array.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: parsingStrategy.base, + transform: transform, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } } diff --git a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift index b59aa80a8..1d74bf09c 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift @@ -296,6 +296,36 @@ extension ArgumentDefinition { completion: completion) } + init( + container: Container.Type, + key: InputKey, + kind: ArgumentDefinition.Kind, + help: ArgumentHelp?, + parsingStrategy: ParsingStrategy, + transform: @escaping (_ name: String, _ valueString: String) throws -> Container.Contained, + initial: Container.Initial?, + completion: CompletionKind? + ) where Container: ArgumentDefinitionContainer { + self.init( + container: Container.self, + key: key, + kind: kind, + allValueStrings: [], + help: help, + defaultValueDescription: nil, + parsingStrategy: parsingStrategy, + parser: { (key, origin, name, valueString) -> Container.Contained in + do { + return try transform(name?.valueString ?? "", valueString) + } catch { + throw ParserError.unableToParseValue( + origin, name, valueString, forKey: key, originalError: error) + } + }, + initial: initial, + completion: completion) + } + private init( container: Container.Type, key: InputKey, diff --git a/Tests/ArgumentParserEndToEndTests/OrderedOptionsEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/OrderedOptionsEndToEndTests.swift new file mode 100644 index 000000000..bd29f0a94 --- /dev/null +++ b/Tests/ArgumentParserEndToEndTests/OrderedOptionsEndToEndTests.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Argument Parser open source project +// +// Copyright (c) 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ArgumentParserTestHelpers +import XCTest + +final class OrderedOptionsEndToEndTests: XCTestCase {} + +private enum Operation: Equatable { + case resize(String) + case blur(Double) + case crop(String) + case rotate(Double) +} + +private struct ImageTool: ParsableArguments { + @Option( + name: [.customLong("resize"), .customLong("blur"), .customLong("crop"), .customLong("rotate")], + transform: { optionName, value in + switch optionName { + case "resize": return .resize(value) + case "blur": return .blur(Double(value)!) + case "crop": return .crop(value) + case "rotate": return .rotate(Double(value)!) + default: fatalError("Unexpected option name: \(optionName)") + } + } + ) var operations: [Operation] = [] +} + +extension OrderedOptionsEndToEndTests { + func testOrderedOptions() throws { + AssertParse(ImageTool.self, [ + "--resize", "50%", + "--blur", "3", + "--crop", "100x100", + "--rotate", "90", + "--blur", "1" + ]) { tool in + XCTAssertEqual(tool.operations, [ + .resize("50%"), + .blur(3.0), + .crop("100x100"), + .rotate(90.0), + .blur(1.0) + ]) + } + } + + func testMixedShortAndLongNames() throws { + struct MixedTool: ParsableArguments { + @Option( + name: [.customShort("r"), .customLong("resize")], + transform: { name, value in + return "\(name):\(value)" + } + ) var ops: [String] = [] + } + + AssertParse(MixedTool.self, ["-r", "1", "--resize", "2", "-r", "3"]) { tool in + XCTAssertEqual(tool.ops, ["r:1", "resize:2", "r:3"]) + } + } +} diff --git a/build_output.txt b/build_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..d9c3ce1bdba13ae0268a72a451f0345b6a825114 GIT binary patch literal 10084 zcmeI1>uXy_5XJX%q5p&MhbASJ$h%3PrN(Z+G!1E!l0Y1SBFl=2Bnw-9-BO39#R$FbJ$LTR+1c6IbN~F~IGlxpupgG87ar+74o7+&>dn7vA)Ws+d=dI# zs56sr5@tfqbyYts;<4j+<+XSf$Q_+M)tRZR@5$0kzvuBzW7*_xpst6J?lqH*10lx| zi9gHmK3aMmHo}&E?MC!&_)XX1;jxbFhM%L&q3*-KO<7rlZ5>-i^e`TMBKxE02bgCu zwuwH6x~o}v9?{MMs295CQ2vbM#ZcbN3*`MU2)8485blN>y5q54__Hh!C;C2$_k9i+t(c}w&XdUbR9eM z1pmsOH;>&}^u#iF{n!m6dfnKOv+rV@#&@0@3&qNPEcdvt<@2U;3-r!K?0s?aOx%pZ z2YpV2+m6_gI6cq^fL`j?q4LZ5>Wk4m;r+km{^g8(8-EAU6Dk04J(JhWp#3=ZK1VF` z$$HPKm-2irUk_D3$bv=XI&2XyQIgYBAs70#+3{TGe$sWIa!bbdpkRTs&ID{SDx=~E z$PJCcc7{mYC*Ewfm|g6(*NjvzK(DJADJMX8;msli-Gw*f40O)hlgI(Hm3c`#uT&qI z)6`VZ{g98WeQ-~zytZ*llqrALa8M6ypmX?=3u zUl{IQR(V#<3DEG;WyKst7U}l8%?WI;?y~a^8+3D*-Ndc?y{c17af7x!OzPA6VwS4p zotk%PHh-F!-3a%z9zD?e<^{W3k(2c^;;HU+R_p}mx)@iV(fNp$cV75i-F+GNdB^!f zQ7=KWGW9R~OqSDZJ}l)^QT9{pEcJB!|qwF5p^ zhUD0>BJc{~F>oG!9&0qI)E~ny`ekZ}$Ce`Q$*M=V6Z6XWf!l*v$+73Qx8%vJ$nCxC zN;_|nx;5-rc7M<>(^`xj&)$7`Ro7n7fzEZ@bsDauD)r(Fxvp54V?Aa5sXoyO%s-BD z96nEVlwQfXu^ESdwszT$Zm!)fUY5rg6!(NZJe}(|zSv%fu>x^TEF9}T)aj*0-c|$C zpt!<)IuYW%{=qyd6LyY@vwJ#LRy(ll@5}Sl>iS%Kpq#N!`JBCVUKgK?*^%O>I!_u# z-!qn+uraJ=Zd=weZYgtDXDwLE-1S*o&dzGp$rVwv+N6q78*@bzWU5qFov3yeYp#fb zqyw+3qD{RvX!5MCiZ-pzpxbwfzMJ)44Ro_An)hq4g(3=?bvtPgu8QVsxhtZeT@_ug zh=N|a8o`>~tcX6;TF2^jC!!z5I8DP{t%zc~YiDH|;Xl<; zeC(>@h9_y?vRcia#wcACP2CWq+tg=c8$}9LH1*nTiYVx6ubsNmsv-*daz?(M_t@^L zLtXAZv#N;VNmAtMiYVuqCANyVD%z%q8oF!VfnL?omw5y2Pg2UdN0X8aI%OUC^D=e| z%}O%pBh5%~>-XoN>#As*vIKg!;1B!dtgb2^pm|;c=P9Gx7zNGh)~yPgCuLUK83nyc zpD!~Cx~_`0F>0Rh