diff --git a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift index 7cf57c0b9..be171ac5c 100644 --- a/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/ZshCompletionsGenerator.swift @@ -139,7 +139,7 @@ extension ArgumentDefinition { let inputs: String switch update { - case .unary: + case .unary, .tuplary: inputs = ":\(valueName):\(zshActionString(commands))" case .nullary: inputs = "" diff --git a/Sources/ArgumentParser/Parsable Properties/Option.swift b/Sources/ArgumentParser/Parsable Properties/Option.swift index 22fb6da6c..8cd3270b2 100644 --- a/Sources/ArgumentParser/Parsable Properties/Option.swift +++ b/Sources/ArgumentParser/Parsable Properties/Option.swift @@ -861,3 +861,145 @@ extension Option { }) } } + +// MARK: - @Option tuples + +extension Option { + private init< + First: ExpressibleByArgument, + Second: ExpressibleByArgument, + each Rest: ExpressibleByArgument + >( + wrappedValue: Value?, + name: NameSpecification, + parsing parsingStrategy: SingleValueParsingStrategy, + help: ArgumentHelp?, + completion: CompletionKind? + ) where Value == (First, Second, repeat each Rest) { + self.init(_parsedValue: .init { key in + let arg = ArgumentDefinition.tupleOption( + key: key, + name: name, + parsingStrategy: parsingStrategy, + help: help, + completion: completion, + valueCount: 2 + packCount(repeat (each Rest).self), + update: { origin, name, values, parsedValues in + switch initVariadicValues(First.self, Second.self, repeat (each Rest).self, with: values) { + case .success(let value): + parsedValues.set(value, forKey: key, inputOrigin: origin) + case .failure(let error): + throw ParserError.unableToParseValue( + origin, name, values[error.index], forKey: key, originalError: nil) + } + }, + initial: { origin, parsedValues in + parsedValues.set(wrappedValue, forKey: key, inputOrigin: origin) + }) + return ArgumentSet(arg) + }) + } + + /// Creates a labeled option with multiple values. + /// + /// Use this `@Option` property wrapper when you have a value that requires + /// more than one input for a key. For example, you could use this property + /// wrapper to capture the three dimensions for a package: + /// + /// struct Package: ParsableArguments { + /// @Option + /// var dimensions: (Double, Double, Double) + /// } + /// + /// A user would then specify the three dimensions after the `--dimensions` + /// key: + /// + /// $ package --dimensions 5 4 12 + /// + /// - 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. + public init< + First: ExpressibleByArgument, + Second: ExpressibleByArgument, + each Rest: ExpressibleByArgument + >( + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil + ) where Value == (First, Second, repeat each Rest) { + self.init( + wrappedValue: nil, + name: name, + parsing: parsingStrategy, + help: help, + completion: completion + ) + } + + public init< + First: ExpressibleByArgument, + Second: ExpressibleByArgument, + each Rest: ExpressibleByArgument + >( + wrappedValue: (First, Second, repeat each Rest), + name: NameSpecification = .long, + parsing parsingStrategy: SingleValueParsingStrategy = .next, + help: ArgumentHelp? = nil, + completion: CompletionKind? = nil + ) where Value == (First, Second, repeat each Rest) { + self.init( + // This version is okay: + wrappedValue: .some(wrappedValue), + // This version crashes: + // wrappedValue: wrappedValue, + name: name, + parsing: parsingStrategy, + help: help, + completion: completion + ) + } +} + +// MARK: Variadic tuple support + +fileprivate struct InitFailure: Error { + var index: Int +} + +/// Returns a variadic tuple, with values generated by zipping the provided +/// variadic type arguments with the array of strings. +/// +/// Each string in `arr` is used to generate one value, and the whole `each T` +/// group is either returned, or a failure is diagnosed by the first value +/// where `init(argument:)` fails. +fileprivate func initVariadicValues( + _ elem: repeat (each T).Type, with arr: [String] +) -> Result<(repeat each T), InitFailure> { + var arr = arr[...] + func pairYs(_ v: V.Type) throws -> V { + guard let value = V.init(argument: arr.popFirst()!) else { + throw InitFailure(index: arr.startIndex - 1) + } + return value + } + + do { + return .success(try (repeat pairYs(each elem))) + } catch { + return .failure(error as! InitFailure) + } +} + +/// Returns the number of elements in `each T`. +fileprivate func packCount(_ el: repeat each T) -> Int { + var count = 0 + func increment(_ u: U) { count += 1 } + repeat (increment(each el)) + return count +} diff --git a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift index 5b095311b..6d0e7c262 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentDefinition.swift @@ -15,12 +15,16 @@ struct ArgumentDefinition { enum Update { typealias Nullary = (InputOrigin, Name?, inout ParsedValues) throws -> Void typealias Unary = (InputOrigin, Name?, String, inout ParsedValues) throws -> Void - + typealias Tuplary = (InputOrigin, Name?, [String], inout ParsedValues) throws -> Void + /// An argument that gets its value solely from its presence. case nullary(Nullary) /// An argument that takes a string as its value. case unary(Unary) + + /// An argument that takes two or more strings to create its value. + case tuplary(Int, Tuplary) } typealias Initial = (InputOrigin, inout ParsedValues) throws -> Void @@ -43,6 +47,7 @@ struct ArgumentDefinition { static let isOptional = Options(rawValue: 1 << 0) static let isRepeating = Options(rawValue: 1 << 1) + static let isComposite = Options(rawValue: 1 << 2) } var options: Options @@ -113,14 +118,33 @@ struct ArgumentDefinition { } } + var preferredValueName: String { + names.preferredName?.valueString + ?? help.keys.first?.name.convertedToSnakeCase(separator: "-") + ?? "value" + } + var valueName: String { help.valueName.mapEmpty { - names.preferredName?.valueString - ?? help.keys.first?.name.convertedToSnakeCase(separator: "-") - ?? "value" + preferredValueName } } + var formattedValueName: String { + let defaultName = valueName + + switch update { + case .tuplary(let count, _): + let parts = defaultName.split(separator: " ").prefix(count) + let missingCount = count - parts.count + let missingParts: [Substring] = zip((parts.count + 1)..., repeatElement(preferredValueName[...], count: missingCount)) + .map { "\($1)-\($0)" } + return (parts + missingParts).map { "<\($0)>" }.joined(separator: " ") + default: + return "<\(defaultName)>" + } + } + init( kind: Kind, help: Help, @@ -149,13 +173,13 @@ extension ArgumentDefinition: CustomDebugStringConvertible { return names .map { $0.synopsisString } .joined(separator: ",") - case (.named(let names), .unary): + case (.named(let names), .unary), (.named(let names), .tuplary): return names .map { $0.synopsisString } .joined(separator: ",") - + " <\(valueName)>" + + " \(formattedValueName)" case (.positional, _): - return "<\(valueName)>" + return formattedValueName case (.default, _): return "" } @@ -334,6 +358,33 @@ extension ArgumentDefinition { values.set(initial, forKey: key, inputOrigin: inputOrigin) }) } + + static func tupleOption( + key: InputKey, + name: NameSpecification, + parsingStrategy: SingleValueParsingStrategy, + help: ArgumentHelp?, + completion: CompletionKind?, + valueCount: Int, + update: @escaping Update.Tuplary, + initial: @escaping Initial = { _, _ in } + ) -> Self { + var def = ArgumentDefinition( + kind: .name(key: key, specification: name), + help: .init( + allValueStrings: [], + options: [], + help: help, + defaultValue: nil, + key: key, + isComposite: false), + completion: completion ?? .default, + parsingStrategy: parsingStrategy.base, + update: .tuplary(valueCount, update), + initial: initial) + def.help.options.insert(.isComposite) + return def + } } // MARK: - Abstraction over T, Option, Array diff --git a/Sources/ArgumentParser/Parsing/ArgumentSet.swift b/Sources/ArgumentParser/Parsing/ArgumentSet.swift index 1289245ca..075244b22 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentSet.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentSet.swift @@ -374,6 +374,68 @@ struct LenientParser { } } + mutating func parseTuplaryValue( + _ argument: ArgumentDefinition, + _ parsed: ParsedArgument, + _ valueCount: Int, + _ originElement: InputOrigin.Element, + _ update: ArgumentDefinition.Update.Tuplary, + _ result: inout ParsedValues, + _ usedOrigins: inout InputOrigin + ) throws { + var origins = InputOrigin(elements: [originElement]) + var values: [String] = [] + + // Use an attached value if it exists... + if let value = parsed.value { + values.append(value) + origins.insert(originElement) + } else if argument.allowsJoinedValue, + let (origin2, value) = inputArguments.extractJoinedElement(at: originElement) { + // Found a joined argument + origins.insert(origin2) + values.append(String(value)) + } + + // ...and then consume the arguments until hitting an option + switch argument.parsingStrategy { + case .default: + while let (origin2, value) = inputArguments.popNextElementIfValue(), + values.count < valueCount + { + origins.insert(origin2) + values.append(value) + } + case .scanningForValue: + while let (origin2, value) = inputArguments.popNextValue(after: originElement), + values.count < valueCount + { + origins.insert(origin2) + values.append(value) + } + + case .unconditional: + while let (origin2, value) = inputArguments.popNextElementAsValue(after: originElement), + values.count < valueCount + { + origins.insert(origin2) + values.append(value) + } + + case .upToNextOption, .allRemainingInput, .postTerminator, .allUnrecognized: + fatalError() + } + + + guard valueCount == values.count else { + throw ParserError.insufficientValuesForOption( + origins, parsed.name, expected: valueCount, given: values.count) + } + + try update(origins, parsed.name, values, &result) + usedOrigins.formUnion(origins) + } + mutating func parsePositionalValues( from unusedInput: SplitArguments, into result: inout ParsedValues @@ -539,6 +601,8 @@ struct LenientParser { usedOrigins.insert(origin) case let .unary(update): try parseValue(argument, parsed, origin, update, &result, &usedOrigins) + case let .tuplary(count, update): + try parseTuplaryValue(argument, parsed, count, origin, update, &result, &usedOrigins) } case .terminator: // Ignore the terminator, it might get picked up as a positional value later. diff --git a/Sources/ArgumentParser/Parsing/ParserError.swift b/Sources/ArgumentParser/Parsing/ParserError.swift index 17a33ae46..80487d0e8 100644 --- a/Sources/ArgumentParser/Parsing/ParserError.swift +++ b/Sources/ArgumentParser/Parsing/ParserError.swift @@ -26,6 +26,7 @@ enum ParserError: Error { case nonAlphanumericShortOption(Character) /// The option was there, but its value is missing, e.g. `--name` but no value for the `name`. case missingValueForOption(InputOrigin, Name) + case insufficientValuesForOption(InputOrigin, Name, expected: Int, given: Int) case unexpectedValueForOption(InputOrigin.Element, Name, String) case unexpectedExtraValues([(InputOrigin, String)]) case duplicateExclusiveValues(previous: InputOrigin, duplicate: InputOrigin, originalInput: [String]) diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index e1b562434..b76d55940 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -171,7 +171,7 @@ fileprivate extension ArgumentInfoV0.KindV0 { switch argument.update { case .nullary: self = .flag - case .unary: + case .unary, .tuplary: self = .option } case .positional: diff --git a/Sources/ArgumentParser/Usage/UsageGenerator.swift b/Sources/ArgumentParser/Usage/UsageGenerator.swift index e34b94ee6..bad70a1e2 100644 --- a/Sources/ArgumentParser/Usage/UsageGenerator.swift +++ b/Sources/ArgumentParser/Usage/UsageGenerator.swift @@ -83,8 +83,8 @@ extension ArgumentDefinition { .joined(separator: ", ") switch update { - case .unary: - return "\(joinedSynopsisString) <\(valueName)>" + case .unary, .tuplary: + return "\(joinedSynopsisString) \(formattedValueName)" case .nullary: return joinedSynopsisString } @@ -103,13 +103,13 @@ extension ArgumentDefinition { } switch update { - case .unary: - return "\(name.synopsisString) <\(valueName)>" + case .unary, .tuplary: + return "\(name.synopsisString) \(formattedValueName)" case .nullary: return name.synopsisString } case .positional: - return "<\(valueName)>" + return formattedValueName case .default: return "" } @@ -187,6 +187,8 @@ extension ErrorMessageGenerator { return unknownOptionMessage(origin: o, name: n) case .missingValueForOption(let o, let n): return missingValueForOptionMessage(origin: o, name: n) + case .insufficientValuesForOption(let o, let n, let e, let g): + return insufficientValuesForOptionMessage(origin: o, name: n, expected: e, given: g) case .unexpectedValueForOption(let o, let n, let v): return unexpectedValueForOptionMessage(origin: o, name: n, value: v) case .unexpectedExtraValues(let v): @@ -316,6 +318,26 @@ extension ErrorMessageGenerator { } } + func insufficientValuesForOptionMessage( + origin: InputOrigin, + name: Name, + expected: Int, + given: Int + ) -> String { + let valueString = (expected - given) > 1 ? "values" : "value" + return if given > 0 { + """ + Missing \(valueString) for '\(name.synopsisString)'. \ + Expected \(expected) values, but only received \(given). + """ + } else { + """ + Missing \(valueString) for '\(name.synopsisString)'. + Expected \(expected) values after option. + """ + } + } + func unexpectedValueForOptionMessage(origin: InputOrigin.Element, name: Name, value: String) -> String? { return "The option '\(name.synopsisString)' does not take any value, but '\(value)' was specified." } @@ -372,13 +394,14 @@ extension ErrorMessageGenerator { func unableToParseHelpMessage(origin: InputOrigin, name: Name?, value: String, key: InputKey, error: Error?) -> String { guard let abstract = help(for: key)?.abstract else { return "" } - let valueName = arguments(for: key).first?.valueName + let argument = arguments(for: key).first + let valueName = argument?.formattedValueName switch (name, valueName) { case let (n?, v?): - return "\(n.synopsisString) <\(v)> \(abstract)" + return "\(n.synopsisString) \(v) \(abstract)" case let (_, v?): - return "<\(v)> \(abstract)" + return "\(v) \(abstract)" case (_, _): return "" } diff --git a/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift b/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift index 8014a7389..43aec8525 100644 --- a/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift +++ b/Tests/ArgumentParserEndToEndTests/SingleValueParsingStrategyTests.swift @@ -82,4 +82,161 @@ extension SingleValueParsingStrategyTests { XCTAssertEqual(bar.input, "-Baz") } } + + struct Tuple2: ParsableArguments { + @Option var count: (Int, Int) + } + + struct SignedTuple5: ParsableArguments { + @Option(parsing: .unconditional) + var count: (Int, Int, Int, Int, Int) + } + + struct Tuple9: ParsableArguments { + @Option var count: (Int, Int, Int, Int, Int, Int, Int, Int, Int) + + func check() -> Bool { + count.0 == 1 + && count.1 == 2 + && count.2 == 3 + && count.3 == 4 + && count.4 == 5 + && count.5 == 6 + && count.6 == 7 + && count.7 == 8 + && count.8 == 9 + } + } + + struct ScanningTuple12: ParsableArguments { + @Option(parsing: .scanningForValue) + var count: ( + Int, Int, Int, Int, Int, Int, + Int, Int, Int, Int, Int, Int) + + @Flag var verbose = false + + func check() -> Bool { + count.0 == 1 + && count.1 == 2 + && count.2 == 3 + && count.3 == 4 + && count.4 == 5 + && count.5 == 6 + && count.6 == 7 + && count.7 == 8 + && count.8 == 9 + && count.9 == 10 + && count.10 == 11 + && count.11 == 12 + } + } + + func testParsing_Tuple2() throws { + AssertParse(Tuple2.self, ["--count", "1", "2"]) { tuple2 in + XCTAssertEqual(tuple2.count.0, 1) + XCTAssertEqual(tuple2.count.1, 2) + } + XCTAssertThrowsError(try Tuple2.parse(["--count"])) + XCTAssertThrowsError(try Tuple2.parse(["--count", "1"])) + XCTAssertThrowsError(try Tuple2.parse(["--count", "1", "2", "3"])) + XCTAssertThrowsError(try Tuple2.parse(["--count", "ZZ", "2"])) + XCTAssertThrowsError(try Tuple2.parse(["--count", "1", "ZZ"])) + } + + func testParsing_Tuple9() throws { + for n in 0...11 { + let args = ["--count"] + (0.. 1 { + var args = args + args[i] = "zzz-\(i)" + do { + _ = try Tuple9.parse(args) + XCTFail("Didn't throw on invalid argument value") + } catch { + let errorString = Tuple9.message(for: error) + XCTAssert(errorString.contains("zzz-\(i)")) + } + } + } else { + // Incorrect number of arguments + XCTAssertThrowsError(try Tuple9.parse(args)) + } + } + } + + func testParsing_SignedTuple5() throws { + for n in 0...7 { + let args = ["--count"] + (0.. 1 { + var args = args + args[i] = "zzz-\(i)" + do { + _ = try SignedTuple5.parse(args) + XCTFail("Didn't throw on invalid argument value") + } catch { + let errorString = SignedTuple5.message(for: error) + XCTAssert(errorString.contains("zzz-\(i)")) + } + } + } else { + // Incorrect number of arguments + XCTAssertThrowsError(try SignedTuple5.parse(args)) + } + } + } + + func testParsing_ScanningTuple12() throws { + for n in 0...14 { + let args = ["--count"] + (0.. 1 { + var args = args + args[i] = "zzz-\(i)" + do { + _ = try ScanningTuple12.parse(args) + XCTFail("Didn't throw on invalid argument value") + } catch { + let errorString = ScanningTuple12.message(for: error) + XCTAssert(errorString.contains("zzz-\(i)")) + } + } + } else { + // Incorrect number of arguments + XCTAssertThrowsError(try ScanningTuple12.parse(args)) + } + } + } }