From eda9ea2dde3d37fb22508f0f12d656d3183137fd Mon Sep 17 00:00:00 2001 From: Kth Date: Mon, 25 Jul 2022 18:00:33 +0800 Subject: [PATCH] [GSoC] Conversation for required @Argument 2 (#457) * Fix array parsing as supplemental input * Add test for `ExpressibleByArgument` * Add options for `CaseIterable` * Add test for `@Argument(transform:)` --- .../Parsing/CommandParser.swift | 8 +- .../ArgumentParser/Parsing/Interactive.swift | 79 +++++++++--- .../ArgumentInteractiveTests.swift | 116 ++++++++++++++++++ 3 files changed, 182 insertions(+), 21 deletions(-) diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index 140aa8b0..18c59ccb 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -22,7 +22,7 @@ struct CommandParser { let commandTree: Tree var currentNode: Tree var decodedArguments: [DecodedArguments] = [] - var lineStack: [String]? + var lineStack: [String]? = nil var rootCommand: ParsableCommand.Type { commandTree.element @@ -37,7 +37,7 @@ struct CommandParser { } } - internal init(_ rootCommand: ParsableCommand.Type, lines: [String]?) { + internal init(_ rootCommand: ParsableCommand.Type, lines: [String]) { self.init(rootCommand) self.lineStack = lines lineStack?.reverse() @@ -158,7 +158,7 @@ extension CommandParser { defaultCapturesAll: currentNode.element.defaultIncludesUnconditionalArguments) } catch { // Try to fix error by interacting with the user - guard canInteract(error: error, split: &split) else { throw error } + guard try canInteract(error: error, split: &split) else { throw error } return try getValues() } } @@ -173,7 +173,7 @@ extension CommandParser { return (decoder, decodedResult) } catch { // Try to fix error by interacting with the user - if canInteract(error: error, arguments: commandArguments, values: &values) { + if try canInteract(error: error, arguments: commandArguments, values: &values) { return try getDecoderAndResult() } else { // If decoding this command failed, see if they were asking for diff --git a/Sources/ArgumentParser/Parsing/Interactive.swift b/Sources/ArgumentParser/Parsing/Interactive.swift index 9cda6ddc..38a35b31 100644 --- a/Sources/ArgumentParser/Parsing/Interactive.swift +++ b/Sources/ArgumentParser/Parsing/Interactive.swift @@ -15,16 +15,13 @@ extension CommandParser { /// - error: A parsing error thrown by `lenientParse(_:subcommands:defaultCapturesAll:)`. /// - split: A collection of parsed arguments which needs to be modified. /// - Returns: Whether the dialog resolve the error. - mutating func canInteract(error: Error, split: inout SplitArguments) -> Bool { + mutating func canInteract(error: Error, split: inout SplitArguments) throws -> Bool { guard lineStack == nil || !lineStack!.isEmpty else { return false } guard let error = error as? ParserError else { return false } guard case let .missingValueForOption(inputOrigin, name) = error else { return false } - var input = lineStack?.removeLast() - while input?.isEmpty ?? true { - print("? Please enter value for '\(name.synopsisString)': ", terminator: "") - input = readLine() ?? nil - } + print("? Please enter value for '\(name.synopsisString)': ", terminator: "") + let input = getInput() let inputIndex = inputOrigin.elements.first!.baseIndex! + 1 split._elements.insert(.init(value: .value(input!), @@ -46,7 +43,7 @@ extension CommandParser { /// - arguments: A nested tree of argument definitions which can provide modification method. /// - values: The resulting values after parsing the arguments which needs to be modified. /// - Returns: Whether the dialog resolve the error. - mutating func canInteract(error: Error, arguments: ArgumentSet, values: inout ParsedValues) -> Bool { + mutating func canInteract(error: Error, arguments: ArgumentSet, values: inout ParsedValues) throws -> Bool { guard lineStack == nil || !lineStack!.isEmpty else { return false } guard let error = error as? ParserError else { return false } @@ -55,22 +52,26 @@ extension CommandParser { let label = key.rawValue guard label != "generateCompletionScript" else { break } - // Extract the parameters used in the test. - var input = lineStack?.removeLast() - while input?.isEmpty ?? true { - print("? Please enter '\(label)': ", terminator: "") - input = readLine() ?? nil - } - // Retrieve the correct `ArgumentDefinition` for the required transformation // before storing the new value received from the user. guard let definition = arguments.content.first(where: { $0.valueName == label }) else { break } - let name = definition.names.first // (where: { $0.case == .long } ) guard case let .unary(update) = definition.update else { break } + let name = definition.names.first // (where: { $0.case == .long } ) + + let input: [String] + let allValues = definition.help.allValues + if allValues.isEmpty { + // Get normal value + input = getNormalValue(label: label) + } else { + allValues.enumerated().forEach { print("\($0 + 1). \($1)") } + // Get CaseIterable Enum + input = getCaseIterableEnum(label: label, allValues: allValues) + } // Split array input like "1 2 3". - for input in input!.components(separatedBy: " ") { - try! update(InputOrigin(elements: [.interactive]), name, input, &values) + for element in input { + try update(InputOrigin(elements: [.interactive]), name, element, &values) } return true @@ -80,4 +81,48 @@ extension CommandParser { return false } + + fileprivate mutating func getNormalValue(label: String) -> [String] { + print("? Please enter '\(label)': ", terminator: "") + return getInput()?.components(separatedBy: " ") ?? [""] + } + + fileprivate mutating func getCaseIterableEnum(label: String, allValues: [String]) -> [String] { + print("? Please select '\(label)': ", terminator: "") + let strs = getInput()?.components(separatedBy: " ") ?? [""] + + var nums: [String] = [] + let range = 1 ... allValues.count + for str in strs { + guard let index = Int(str) else { + print("Error: '\(str)' is not a serial number.\n") + return getCaseIterableEnum(label: label, allValues: allValues) + } + + guard range.contains(index) else { + print("Error: '\(index)' is not in the range of \(range) \n") + return getCaseIterableEnum(label: label, allValues: allValues) + } + + nums.append(allValues[index - 1]) + } + + if nums.count == 1 { + print("You select '\(nums[0])'.\n") + } else { + print("You select '\(nums.joined(separator: "', '"))'.\n") + } + + return nums + } + + fileprivate mutating func getInput() -> String? { + if lineStack != nil { + // Extract the parameters used in the test. + return lineStack!.removeLast() + } else { + // Get values from user input. + return readLine() + } + } } diff --git a/Tests/ArgumentParserInteractiveTests/ArgumentInteractiveTests.swift b/Tests/ArgumentParserInteractiveTests/ArgumentInteractiveTests.swift index ce9b0fc0..f982188e 100644 --- a/Tests/ArgumentParserInteractiveTests/ArgumentInteractiveTests.swift +++ b/Tests/ArgumentParserInteractiveTests/ArgumentInteractiveTests.swift @@ -49,6 +49,50 @@ extension ArgumentInteractiveTests { // MARK: - +private struct ExpressibleValue: ParsableCommand { + enum Mode: String, ExpressibleByArgument { + case foo, bar, baz + } + + @Argument var mode: Mode +} + +private struct TransformableValue: ParsableCommand { + enum Format: Equatable { + case text + case other(String) + + init(_ string: String) throws { + if string == "text" { + self = .text + } else { + self = .other(string) + } + } + } + + @Argument(transform: Format.init) var format: Format +} + +extension ArgumentInteractiveTests { + func testParsing_ExpressibleValue() throws { + AssertParseCommand(ExpressibleValue.self, ExpressibleValue.self, [], lines: ["foo"]) { value in + XCTAssertEqual(value.mode, .foo) + } + } + + func testParsing_TransformableValue() throws { + AssertParseCommand(TransformableValue.self, TransformableValue.self, [], lines: ["text"]) { value in + XCTAssertEqual(value.format, .text) + } + AssertParseCommand(TransformableValue.self, TransformableValue.self, [], lines: ["keynote"]) { value in + XCTAssertEqual(value.format, .other("keynote")) + } + } +} + +// MARK: - + private struct StringArray: ParsableCommand { @Argument var values: [String] } @@ -80,3 +124,75 @@ extension ArgumentInteractiveTests { } } } + +// MARK: - + +private struct PositionalArray1: ParsableCommand { + @Argument var values: [Int] + @Option var count: Int + @Flag var verbose = false +} + +private struct PositionalArray2: ParsableCommand { + @Option var count: Int + @Argument var values: [Int] + @Flag var verbose = false +} + +private struct PositionalArray3: ParsableCommand { + @Option var count: Int + @Flag var verbose = false + @Argument var values: [Int] +} + +extension ArgumentInteractiveTests { + func testParsing_PositionalArray() throws { + AssertParseCommand(PositionalArray1.self, PositionalArray1.self, ["--count", "3", "--verbose"], lines: ["1 2"]) { value in + XCTAssertEqual(value.count, 3) + XCTAssertEqual(value.verbose, true) + XCTAssertEqual(value.values, [1, 2]) + } + + AssertParseCommand(PositionalArray2.self, PositionalArray2.self, ["--count", "3", "--verbose"], lines: ["1 2"]) { value in + XCTAssertEqual(value.count, 3) + XCTAssertEqual(value.verbose, true) + XCTAssertEqual(value.values, [1, 2]) + } + + AssertParseCommand(PositionalArray3.self, PositionalArray3.self, ["--count", "3", "--verbose"], lines: ["1 2"]) { value in + XCTAssertEqual(value.count, 3) + XCTAssertEqual(value.verbose, true) + XCTAssertEqual(value.values, [1, 2]) + } + } +} + +// MARK: - + +private struct CaseIterableArgument: ParsableCommand { + enum Mode: String, CaseIterable, ExpressibleByArgument { + case foo, bar, baz + } + + @Argument var mode: Mode + @Argument var modes: [Mode] +} + +extension ArgumentInteractiveTests { + func testParsing_CaseIterableArgument() throws { + AssertParseCommand(CaseIterableArgument.self, CaseIterableArgument.self, ["foo"], lines: ["2 3"]) { value in + XCTAssertEqual(value.mode, .foo) + XCTAssertEqual(value.modes, [.bar, .baz]) + } + + AssertParseCommand(CaseIterableArgument.self, CaseIterableArgument.self, [], lines: ["1", "2 3"]) { value in + XCTAssertEqual(value.mode, .foo) + XCTAssertEqual(value.modes, [.bar, .baz]) + } + + AssertParseCommand(CaseIterableArgument.self, CaseIterableArgument.self, [], lines: ["foo", "0", "2 3", "1"]) { value in + XCTAssertEqual(value.mode, .baz) + XCTAssertEqual(value.modes, [.foo]) + } + } +}