From 73bff4b97a98ff10d9116ac867a44d4e1a6b1f07 Mon Sep 17 00:00:00 2001 From: Johannes Weiss Date: Sun, 1 Feb 2026 18:59:21 +0000 Subject: [PATCH] Support for ordered, heterogeneous repeating options --- .../Parsable Properties/Option.swift | 280 ++++++++++++++++++ .../Parsing/ArgumentDefinition.swift | 30 ++ .../TransformEndToEndTests.swift | 125 ++++++++ 3 files changed, 435 insertions(+) diff --git a/Sources/ArgumentParser/Parsable Properties/Option.swift b/Sources/ArgumentParser/Parsable Properties/Option.swift index 024a0232a..1683a8a85 100644 --- a/Sources/ArgumentParser/Parsable Properties/Option.swift +++ b/Sources/ArgumentParser/Parsable Properties/Option.swift @@ -454,6 +454,105 @@ extension Option { } } +// MARK: - @Option T Initializers (name-aware transform) +extension Option { + /// Creates a property with a default value that reads its value from a + /// labeled option, parsing with a closure that receives the matched option + /// name along with the value string. + /// + /// This initializer is useful when a single `@Option` property has multiple + /// names and the transform needs to know which name was used. For example: + /// + /// ```swift + /// @Option( + /// name: [.customLong("resize"), .customLong("crop")], + /// transformIncludingName: { name, value in + /// switch name { + /// case "resize": return .resize(value) + /// case "crop": return .crop(value) + /// default: fatalError() + /// } + /// } + /// ) var operations: [Operation] = [] + /// ``` + /// + /// - Parameters: + /// - wrappedValue: The default value to use for this property, provided + /// implicitly by the compiler during property wrapper initialization. + /// - 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. + /// - transformIncludingName: A closure that receives the matched option + /// name (without leading dashes) and the value string, and returns this + /// property's type or throws an error. + @preconcurrency + public init( + wrappedValue: Value, + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transformIncludingName: @Sendable @escaping (_ optionName: 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, + transformIncludingName: transformIncludingName, + initial: wrappedValue, + completion: completion) + + return ArgumentSet(arg) + }) + } + + /// Creates a required property that reads its value from a labeled option, + /// parsing with a closure that receives the matched option name along with + /// the value string. + /// + /// - 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. + /// - transformIncludingName: A closure that receives the matched option + /// name (without leading dashes) and the value string, and returns this + /// property's type or throws an error. + @preconcurrency + @_disfavoredOverload + public init( + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transformIncludingName: @Sendable @escaping (_ optionName: 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, + transformIncludingName: transformIncludingName, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } +} + // MARK: - @Option Optional Initializers extension Option { /// Creates an optional property that reads its value from a labeled option, @@ -911,3 +1010,184 @@ extension Option { }) } } + +// MARK: - @Option Optional Initializers (name-aware transform) +extension Option { + /// Creates an optional property that reads its value from a labeled option, + /// parsing with a closure that receives the matched option name, with an + /// explicit `nil` default. + /// + /// - Parameters: + /// - wrappedValue: A default value to use for this property, provided + /// implicitly by the compiler during property wrapper initialization. + /// - 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. + /// - transformIncludingName: A closure that receives the matched option + /// name (without leading dashes) and the value string, and returns this + /// property's type or throws an error. + @preconcurrency + public init( + wrappedValue: _OptionalNilComparisonType, + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transformIncludingName: @Sendable @escaping (_ optionName: String, _ value: String) throws -> T + ) where Value == T? { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Optional.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: parsingStrategy.base, + transformIncludingName: transformIncludingName, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } + + /// Creates an optional property that reads its value from a labeled option, + /// parsing with a closure that receives the matched option name. + /// + /// - 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. + /// - transformIncludingName: A closure that receives the matched option + /// name (without leading dashes) and the value string, and returns this + /// property's type or throws an error. + @preconcurrency + public init( + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transformIncludingName: @Sendable @escaping (_ optionName: String, _ value: String) throws -> T + ) where Value == T? { + self.init( + _parsedValue: .init { key in + let arg = ArgumentDefinition( + container: Optional.self, + key: key, + kind: .name(key: key, specification: name), + help: help, + parsingStrategy: parsingStrategy.base, + transformIncludingName: transformIncludingName, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } +} + +// MARK: - @Option Array Initializers (name-aware transform) +extension Option { + /// Creates an array property that reads its values from zero or more labeled + /// options, parsing each element with a closure that receives the matched + /// option name along with the value string. + /// + /// This is particularly useful when multiple option names map to a single + /// array property and the relative ordering of different options matters: + /// + /// ```swift + /// @Option( + /// name: [.customLong("resize"), .customLong("crop")], + /// transformIncludingName: { name, value in + /// switch name { + /// case "resize": return .resize(value) + /// case "crop": return .crop(value) + /// default: fatalError() + /// } + /// } + /// ) var operations: [Operation] = [] + /// ``` + /// + /// - Parameters: + /// - wrappedValue: A default value to use for this property, provided + /// implicitly by the compiler during property wrapper initialization. + /// If this initial value is non-empty, elements passed from the command + /// line are appended to the original contents. + /// - name: A specification for what names are allowed for this option. + /// - parsingStrategy: The behavior to use when parsing the elements for + /// this option. + /// - help: Information about how to use this option. + /// - completion: The type of command-line completion provided for this + /// option. + /// - transformIncludingName: A closure that receives the matched option + /// name (without leading dashes) and the value string, and returns the + /// element type or throws an error. + @preconcurrency + public init( + wrappedValue: [T], + name: NameSpecification = .long, + parsing parsingStrategy: ArrayParsingStrategy = .singleValue, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transformIncludingName: @Sendable @escaping (_ optionName: 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, + transformIncludingName: transformIncludingName, + 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 a closure that receives the + /// matched option name along with the value string. + /// + /// - Parameters: + /// - name: A specification for what names are allowed for this option. + /// - parsingStrategy: The behavior to use when parsing the elements for + /// this option. + /// - help: Information about how to use this option. + /// - completion: The type of command-line completion provided for this + /// option. + /// - transformIncludingName: A closure that receives the matched option + /// name (without leading dashes) and the value string, and returns the + /// element type or throws an error. + @preconcurrency + public init( + name: NameSpecification = .long, + parsing parsingStrategy: ArrayParsingStrategy = .singleValue, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil, + transformIncludingName: @Sendable @escaping (_ optionName: 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, + transformIncludingName: transformIncludingName, + initial: nil, + completion: completion) + + return ArgumentSet(arg) + }) + } +} diff --git a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift index b59aa80a8..279805adb 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, + transformIncludingName: @escaping (String, 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 transformIncludingName(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/TransformEndToEndTests.swift b/Tests/ArgumentParserEndToEndTests/TransformEndToEndTests.swift index 0d32a35c7..f2d9c4ada 100644 --- a/Tests/ArgumentParserEndToEndTests/TransformEndToEndTests.swift +++ b/Tests/ArgumentParserEndToEndTests/TransformEndToEndTests.swift @@ -203,3 +203,128 @@ extension TransformEndToEndTests { + BarArgument.help + BarArgument.usageString) } } + +// MARK: - Name-aware transform + +private enum Operation: Equatable { + case resize(String) + case blur(String) + case crop(String) +} + +private struct NameTransformArray: ParsableArguments { + @Option( + name: [.customLong("resize"), .customLong("blur"), .customLong("crop")], + transformIncludingName: { (optionName: String, value: String) -> Operation in + switch optionName { + case "resize": return .resize(value) + case "blur": return .blur(value) + case "crop": return .crop(value) + default: fatalError() + } + } + ) var operations: [Operation] = [] +} + +private struct NameTransformSingle: ParsableArguments { + @Option( + name: [.customLong("resize"), .customLong("crop")], + transformIncludingName: { (optionName: String, value: String) -> Operation in + switch optionName { + case "resize": return .resize(value) + case "crop": return .crop(value) + default: fatalError() + } + } + ) var operation: Operation +} + +extension TransformEndToEndTests { + func testNameTransformArray_Order() throws { + AssertParse(NameTransformArray.self, ["--resize", "50%", "--blur", "3", "--crop", "100x100"]) { + XCTAssertEqual($0.operations, [.resize("50%"), .blur("3"), .crop("100x100")]) + } + } + + func testNameTransformArray_ReverseOrder() throws { + AssertParse(NameTransformArray.self, ["--crop", "100x100", "--resize", "50%"]) { + XCTAssertEqual($0.operations, [.crop("100x100"), .resize("50%")]) + } + } + + func testNameTransformArray_Repeated() throws { + AssertParse(NameTransformArray.self, ["--blur", "3", "--blur", "1"]) { + XCTAssertEqual($0.operations, [.blur("3"), .blur("1")]) + } + } + + func testNameTransformArray_Empty() throws { + AssertParse(NameTransformArray.self, []) { + XCTAssertEqual($0.operations, []) + } + } + + func testNameTransformSingle() throws { + AssertParse(NameTransformSingle.self, ["--resize", "50%"]) { + XCTAssertEqual($0.operation, .resize("50%")) + } + AssertParse(NameTransformSingle.self, ["--crop", "100x100"]) { + XCTAssertEqual($0.operation, .crop("100x100")) + } + } + + func testNameTransformArray_InterleavedRepeats() throws { + AssertParse(NameTransformArray.self, [ + "--crop", "200x200", + "--resize", "50%", + "--crop", "100x100", + "--resize", "25%", + ]) { + XCTAssertEqual($0.operations, [ + .crop("200x200"), + .resize("50%"), + .crop("100x100"), + .resize("25%"), + ]) + } + } + + func testNameTransformArray_SameOptionManyTimes() throws { + AssertParse(NameTransformArray.self, [ + "--blur", "1", + "--blur", "5", + "--blur", "3", + "--blur", "7", + ]) { + XCTAssertEqual($0.operations, [ + .blur("1"), .blur("5"), .blur("3"), .blur("7"), + ]) + } + } + + func testNameTransformArray_AllThreeInterleaved() throws { + AssertParse(NameTransformArray.self, [ + "--resize", "50%", + "--blur", "3", + "--crop", "100x100", + "--blur", "1", + "--resize", "25%", + "--crop", "50x50", + ]) { + XCTAssertEqual($0.operations, [ + .resize("50%"), + .blur("3"), + .crop("100x100"), + .blur("1"), + .resize("25%"), + .crop("50x50"), + ]) + } + } + + func testNameTransformArray_SingleElement() throws { + AssertParse(NameTransformArray.self, ["--crop", "80x80"]) { + XCTAssertEqual($0.operations, [.crop("80x80")]) + } + } +}