From cc330d825db1c2e29a54d4e214b74c7863c5b470 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:40:32 -0400 Subject: [PATCH 1/3] Fix preexisting typo. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- .../ParsableArgumentsValidationTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift index 589162927..4150ee350 100644 --- a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift +++ b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift @@ -399,7 +399,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { case second case other case forth - case fith + case fifth static func name(for value: ExampleEnum) -> NameSpecification { .short @@ -416,7 +416,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { case second case other case forth - case fith + case fifth } @Flag @@ -449,7 +449,7 @@ final class ParsableArgumentsValidationTests: XCTestCase { case second case other case forth - case fith + case fifth } @Flag From df97d27640049e80700cf9d184f2698414bb59e7 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Fri, 16 Jan 2026 06:20:11 -0500 Subject: [PATCH 2/3] Improve ParsableArguments#_errorLabel DocC. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/ArgumentParser/Parsable Types/ParsableArguments.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift index 9b5ebd617..c61fb8b90 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift @@ -26,7 +26,7 @@ public protocol ParsableArguments: Decodable, _SendableMetatype { /// The label to use for "Error: ..." messages from this type (experimental). /// - /// Can be ignored if `_errorPrefix`'s is changed. + /// `_errorLabel` will be ignored if `_errorPrefix` is changed to ignore `_errorLabel`. @available(*, deprecated, message: "Use _errorPrefix instead.") static var _errorLabel: String { get } From 8c0d24027a89ff8a6405af987340bdf5cffa4f22 Mon Sep 17 00:00:00 2001 From: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:27:19 -0500 Subject: [PATCH 3/3] Support async custom completion functions for AsyncParsableCommand via async/await. Signed-off-by: Ross Goldberg <484615+rgoldberg@users.noreply.github.com> --- Sources/ArgumentParser/CMakeLists.txt | 3 +- .../Parsable Properties/CompletionKind.swift | 15 -- .../Parsable Types/AsyncParsableCommand.swift | 30 ++- .../Parsable Types/ParsableArguments.swift | 8 +- .../Parsing/CommandParser.swift | 252 ++++++++++-------- .../Parsing/SplitArguments.swift | 8 +- .../AsyncCompletionsValidator.swift | 92 +++++++ .../ParsableArgumentsValidation.swift | 9 + .../CompletionScriptTests.swift | 69 +++-- .../ParsableArgumentsValidationTests.swift | 113 ++++++++ 10 files changed, 447 insertions(+), 152 deletions(-) create mode 100644 Sources/ArgumentParser/Validators/AsyncCompletionsValidator.swift diff --git a/Sources/ArgumentParser/CMakeLists.txt b/Sources/ArgumentParser/CMakeLists.txt index 77dc92e54..b19e7a8d1 100644 --- a/Sources/ArgumentParser/CMakeLists.txt +++ b/Sources/ArgumentParser/CMakeLists.txt @@ -49,7 +49,8 @@ add_library(ArgumentParser Utilities/StringExtensions.swift Utilities/SwiftExtensions.swift Utilities/Tree.swift - + + Validators/AsyncCompletionsValidator.swift Validators/CodingKeyValidator.swift Validators/NonsenseFlagsValidator.swift Validators/ParsableArgumentsValidation.swift diff --git a/Sources/ArgumentParser/Parsable Properties/CompletionKind.swift b/Sources/ArgumentParser/Parsable Properties/CompletionKind.swift index a7a069d77..82739618b 100644 --- a/Sources/ArgumentParser/Parsable Properties/CompletionKind.swift +++ b/Sources/ArgumentParser/Parsable Properties/CompletionKind.swift @@ -41,12 +41,7 @@ public struct CompletionKind { case directory case shellCommand(String) case custom(@Sendable ([String], Int, String) -> [String]) - #if !canImport(Dispatch) - @available(*, unavailable, message: "DispatchSemaphore is unavailable") case customAsync(@Sendable ([String], Int, String) async -> [String]) - #else - case customAsync(@Sendable ([String], Int, String) async -> [String]) - #endif case customDeprecated(@Sendable ([String]) -> [String]) } @@ -186,22 +181,12 @@ public struct CompletionKind { /// /// The same as `custom(@Sendable @escaping ([String], Int, String) -> [String])`, /// except that the closure is asynchronous. - #if !canImport(Dispatch) - @available(*, unavailable, message: "DispatchSemaphore is unavailable") - @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) - public static func custom( - _ completion: @Sendable @escaping ([String], Int, String) async -> [String] - ) -> CompletionKind { - fatalError("DispatchSemaphore is unavailable") - } - #else @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) public static func custom( _ completion: @Sendable @escaping ([String], Int, String) async -> [String] ) -> CompletionKind { CompletionKind(kind: .customAsync(completion)) } - #endif /// Deprecated; only kept for backwards compatibility. /// diff --git a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift index dd0a86e69..27696cf6d 100644 --- a/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift +++ b/Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift @@ -25,6 +25,34 @@ public protocol AsyncParsableCommand: ParsableCommand { @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) extension AsyncParsableCommand { + /// Parses a new instance of this type from command-line arguments. + /// + /// - Parameter arguments: An array of arguments to use for parsing. If + /// `arguments` is `nil`, this uses the program's command-line arguments. + /// - Returns: A new instance of this type. + /// - Throws: If parsing failed or arguments contains a help request. + public static func parse( + _ arguments: [String]? = nil + ) async throws -> Self { + try parse(try await parseAsRoot(arguments)) + } + + /// Parses an instance of this type, or one of its subcommands, from + /// command-line arguments. + /// + /// - Parameter arguments: An array of arguments to use for parsing. If + /// `arguments` is `nil`, this uses the program's command-line arguments. + /// - Returns: A new instance of this type, one of its subcommands, or a + /// command type internal to the `ArgumentParser` library. + /// - Throws: If parsing fails. + public static func parseAsRoot( + _ arguments: [String]? = nil + ) async throws -> ParsableCommand { + var parser = CommandParser(self) + let arguments = arguments ?? Array(CommandLine._staticArguments.dropFirst()) + return try await parser.parse(arguments: arguments) + } + /// Executes this command, or one of its subcommands, with the given arguments. /// /// This method parses an instance of this type, one of its subcommands, or @@ -36,7 +64,7 @@ extension AsyncParsableCommand { /// `arguments` is `nil`, this uses the program's command-line arguments. public static func main(_ arguments: [String]?) async { do { - var command = try parseAsRoot(arguments) + var command = try await parseAsRoot(arguments) if var asyncCommand = command as? AsyncParsableCommand { try await asyncCommand.run() } else { diff --git a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift index c61fb8b90..0bd652e5f 100644 --- a/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift +++ b/Sources/ArgumentParser/Parsable Types/ParsableArguments.swift @@ -88,9 +88,15 @@ extension ParsableArguments { /// - Throws: If parsing failed or arguments contains a help request. public static func parse( _ arguments: [String]? = nil + ) throws -> Self { + try parse(try self.asCommand.parseAsRoot(arguments)) + } + + internal static func parse( + _ command: ParsableCommand ) throws -> Self { // Parse the command and unwrap the result if necessary. - switch try self.asCommand.parseAsRoot(arguments) { + switch command { case let helpCommand as HelpCommand: throw ParserError.helpRequested(visibility: helpCommand.visibility) case let result as _WrappedParsableCommand: diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index 8dbc7fbec..08530ad0e 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -9,10 +9,6 @@ // //===----------------------------------------------------------------------===// -#if canImport(Dispatch) -@preconcurrency private import class Dispatch.DispatchSemaphore -#endif - struct CommandError: Error { var commandStack: [ParsableCommand.Type] var parserError: ParserError @@ -49,15 +45,15 @@ struct CommandParser { init(_ rootCommand: ParsableCommand.Type) { do { self.commandTree = try Tree(root: rootCommand) - } catch Tree.InitializationError.recursiveSubcommand( - let command) + } catch Tree.InitializationError + .recursiveSubcommand(let command) { configurationFailure( """ The command \"\(command)\" can't have itself as its own subcommand. """.wrapped(to: 70)) - } catch Tree - .InitializationError.aliasMatchingCommand(let command) + } catch Tree.InitializationError + .aliasMatchingCommand(let command) { configurationFailure( """ @@ -86,9 +82,9 @@ extension CommandParser { /// /// - Returns: A node for the matched subcommand if one was found; /// otherwise, `nil`. - fileprivate func consumeNextCommand(split: inout SplitArguments) -> Tree< - ParsableCommand.Type - >? { + fileprivate func consumeNextCommand( + split: inout SplitArguments + ) -> Tree? { guard let (origin, element) = split.peekNext(), element.isValue, let value = split.originalInput(at: origin), @@ -132,7 +128,8 @@ extension CommandParser { // Look for help flags guard !split.contains( - anyOf: self.commandStack.getHelpNames(visibility: .default)) + anyOf: self.commandStack.getHelpNames(visibility: .default) + ) else { throw HelpRequested(visibility: .default) } @@ -140,7 +137,8 @@ extension CommandParser { // Look for help-hidden flags guard !split.contains( - anyOf: self.commandStack.getHelpNames(visibility: .hidden)) + anyOf: self.commandStack.getHelpNames(visibility: .hidden) + ) else { throw HelpRequested(visibility: .hidden) } @@ -148,14 +146,18 @@ extension CommandParser { // Look for dump-help flag guard !split.contains(Name.long("experimental-dump-help")) else { throw CommandError( - commandStack: commandStack, parserError: .dumpHelpRequested) + commandStack: commandStack, + parserError: .dumpHelpRequested + ) } // Look for a version flag if any commands in the stack define a version if commandStack.contains(where: { !$0.configuration.version.isEmpty }) { guard !split.contains(Name.long("version")) else { throw CommandError( - commandStack: commandStack, parserError: .versionRequested) + commandStack: commandStack, + parserError: .versionRequested + ) } } } @@ -164,9 +166,9 @@ extension CommandParser { /// /// If there are remaining arguments or if no commands have been parsed, /// this throws an error. - fileprivate func extractLastParsedValue(_ split: SplitArguments) throws - -> ParsableCommand - { + fileprivate func extractLastParsedValue( + _ split: SplitArguments + ) throws -> ParsableCommand { try checkForBuiltInFlags(split) // We should have used up all arguments at this point: @@ -175,7 +177,9 @@ extension CommandParser { for element in split.elements { if case .option(let argument) = element.value { throw ParserError.unknownOption( - InputOrigin.Element.argumentIndex(element.index), argument.name) + InputOrigin.Element.argumentIndex(element.index), + argument.name + ) } } @@ -194,9 +198,9 @@ extension CommandParser { /// Extracts the current command from `split`, throwing if decoding isn't /// possible. - fileprivate mutating func parseCurrent(_ split: inout SplitArguments) throws - -> ParsableCommand - { + fileprivate mutating func parseCurrent( + _ split: inout SplitArguments + ) throws -> ParsableCommand { // Parse the arguments, ignoring anything unexpected var parser = LenientParser(currentNode.element, split) let values = try parser.parse() @@ -210,11 +214,13 @@ extension CommandParser { // Decode the values from ParsedValues into the ParsableCommand: let decoder = ArgumentDecoder( - values: values, previouslyDecoded: decodedArguments) + values: values, + previouslyDecoded: decodedArguments + ) var decodedResult: ParsableCommand do { decodedResult = try currentNode.element.init(from: decoder) - } catch let error { + } catch { // If decoding this command failed, see if they were asking for // help before propagating that parsing failure. try checkForBuiltInFlags(split) @@ -232,7 +238,8 @@ extension CommandParser { } decodedArguments.append(contentsOf: newDecodedValues) decodedArguments.append( - DecodedArguments(type: currentNode.element, value: decodedResult)) + DecodedArguments(type: currentNode.element, value: decodedResult) + ) return decodedResult } @@ -253,7 +260,8 @@ extension CommandParser { try checkForBuiltInFlags(split) throw CommandError( commandStack: commandStack, - parserError: ParserError.userValidationError(error)) + parserError: ParserError.userValidationError(error) + ) } // Look for next command in the argument list. @@ -266,12 +274,12 @@ extension CommandParser { try checkForBuiltInFlags(split, requireSoloArgument: true) // No command was found, so fall back to the default subcommand. - if let defaultSubcommand = currentNode.element.configuration - .defaultSubcommand + if let defaultSubcommand = + currentNode.element.configuration.defaultSubcommand { guard - let subcommandNode = currentNode.firstChild( - equalTo: defaultSubcommand) + let subcommandNode = + currentNode.firstChild(equalTo: defaultSubcommand) else { throw ParserError.invalidState } @@ -296,22 +304,79 @@ extension CommandParser { arguments: [String] ) throws(CommandError) -> ParsableCommand { do { - try handleCustomCompletion(arguments) + if let (argument, arguments) = + try parseCustomCompletion(from: arguments) + { + guard + let completions = + try customCompleteSync(argument, forArguments: arguments) + else { + throw ParserError.invalidState + } + try throwCompletionScriptCustomResponse(for: completions) + } } catch let error as ParserError { throw CommandError( commandStack: [commandTree.element], - parserError: error) + parserError: error + ) } catch { fatalError("Internal error: \(error)") } + return try parseBesidesCustomCompletion(arguments: arguments) + } + /// Returns the fully-parsed matching command for `arguments`, or throws + /// an appropriate error. + /// + /// - Parameter arguments: The array of arguments to parse. This should not + /// include the command name as the first argument. + /// + /// - Returns: The parsed command. + /// - Throws: A `CommandError` if an error is detected during parsing. + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + mutating func parse( + arguments: [String] + ) async throws(CommandError) -> ParsableCommand { + do { + if let (argument, arguments) = try parseCustomCompletion(from: arguments) + { + let completions: [String] + if let syncCompletions = + try customCompleteSync(argument, forArguments: arguments) + { + completions = syncCompletions + } else if case .customAsync(let complete) = argument.completion.kind { + let (arguments, index, prefix) = + try parseCustomCompletionArguments(from: arguments) + completions = await complete(arguments, index, prefix) + } else { + throw ParserError.invalidState + } + try throwCompletionScriptCustomResponse(for: completions) + } + } catch let error as ParserError { + throw CommandError( + commandStack: [commandTree.element], + parserError: error + ) + } catch { + fatalError("Internal error: \(error)") + } + return try parseBesidesCustomCompletion(arguments: arguments) + } + + private mutating func parseBesidesCustomCompletion( + arguments: [String] + ) throws(CommandError) -> ParsableCommand { var split: SplitArguments do { split = try SplitArguments(arguments: arguments) } catch { - let error = error as? ParserError ?? .invalidState throw CommandError( - commandStack: [commandTree.element], parserError: error) + commandStack: [commandTree.element], + parserError: error as? ParserError ?? .invalidState + ) } do { @@ -334,10 +399,13 @@ extension CommandParser { } catch let helpRequest as HelpRequested { return HelpCommand( commandStack: commandStack, - visibility: helpRequest.visibility) + visibility: helpRequest.visibility + ) } catch { throw CommandError( - commandStack: commandStack, parserError: .invalidState) + commandStack: commandStack, + parserError: .invalidState + ) } } } @@ -353,9 +421,9 @@ struct AutodetectedGenerateCompletions: ParsableCommand { } extension CommandParser { - func checkForCompletionScriptRequest(_ split: inout SplitArguments) - throws(CommandError) - { + func checkForCompletionScriptRequest( + _ split: inout SplitArguments + ) throws(CommandError) { // Pseudo-commands don't support `--generate-completion-script` flag guard rootCommand.configuration._superCommandName == nil else { return @@ -366,13 +434,15 @@ extension CommandParser { // First look for `--generate-completion-script ` var completionsParser = CommandParser(GenerateCompletions.self) - if let result = try? completionsParser.parseCurrent(&split) - as? GenerateCompletions + if let result = + try? completionsParser.parseCurrent(&split) as? GenerateCompletions { throw CommandError( commandStack: commandStack, parserError: .completionScriptRequested( - shell: result.generateCompletionScript)) + shell: result.generateCompletionScript + ) + ) } // Check for for `--generate-completion-script` without a value @@ -383,11 +453,15 @@ extension CommandParser { { throw CommandError( commandStack: commandStack, - parserError: .completionScriptRequested(shell: nil)) + parserError: .completionScriptRequested(shell: nil) + ) } } - func handleCustomCompletion(_ arguments: [String]) throws { + func parseCustomCompletion( + from arguments: [String] + ) throws(ParserError) -> (argument: ArgumentDefinition, arguments: [String])? + { // Completion functions use a custom format: // // ---completion [ ...] -- [ ...] @@ -399,8 +473,7 @@ extension CommandParser { // // The triple-dash prefix makes '---completion' invalid syntax for regular // arguments, so it's safe to use for this internal purpose. - guard arguments.first == "---completion" - else { return } + guard arguments.first == "---completion" else { return nil } var args = arguments.dropFirst() var current = commandTree @@ -426,10 +499,10 @@ extension CommandParser { // Look up the specified argument, then retrieve & run its custom completion function switch parsedArgument.value { case .option(let parsed): - guard let matchedArgument = argset.first(matching: parsed) else { + guard let argument = argset.first(matching: parsed) else { throw ParserError.invalidState } - try customComplete(matchedArgument, forArguments: Array(args)) + return (argument, Array(args)) case .value(let value): // Legacy completion script generators use internal key paths to identify @@ -440,19 +513,19 @@ extension CommandParser { if value.hasPrefix(toolInfoPrefix) { guard let index = Int(value.dropFirst(toolInfoPrefix.count)), - let matchedArgument = argset.positional(at: index) + let argument = argset.positional(at: index) else { throw ParserError.invalidState } - try customComplete(matchedArgument, forArguments: Array(args)) + return (argument, Array(args)) } else { guard let key = InputKey(fullPathString: value), - let matchedArgument = argset.firstPositional(withKey: key) + let argument = argset.firstPositional(withKey: key) else { throw ParserError.invalidState } - try customComplete(matchedArgument, forArguments: Array(args)) + return (argument, Array(args)) } case .terminator: @@ -460,10 +533,10 @@ extension CommandParser { } } - private func customComplete( + private func customCompleteSync( _ argument: ArgumentDefinition, forArguments args: [String] - ) throws { + ) throws(ParserError) -> [String]? { if let completionShellName = Platform.Environment[.shellName] { let shell = CompletionShell(rawValue: completionShellName) CompletionShell._requesting.withLock { $0 = shell } @@ -473,33 +546,27 @@ extension CommandParser { $0 = Platform.Environment[.shellVersion] } - let completions: [String] switch argument.completion.kind { case .custom(let complete): - let (args, completingArgumentIndex, completingPrefix) = - try parseCustomCompletionArguments(from: args) - completions = complete( - args, - completingArgumentIndex, - completingPrefix - ) - case .customAsync(let complete): - #if canImport(Dispatch) + let (args, index, prefix) = try parseCustomCompletionArguments(from: args) + return complete(args, index, prefix) + case .customAsync: if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) { - completions = try asyncCustomCompletions(from: args, complete: complete) + return nil } else { throw ParserError.invalidState } - #else - throw ParserError.invalidState - #endif case .customDeprecated(let complete): - completions = complete(args) + return complete(args) default: throw ParserError.invalidState } + } + private func throwCompletionScriptCustomResponse( + for completions: [String] + ) throws(ParserError) { // Parsing and retrieval successful! We don't want to continue with any // other parsing here, so after printing the result of the completion // function, exit with a success code. @@ -512,11 +579,11 @@ extension CommandParser { private func parseCustomCompletionArguments( from args: [String] -) throws -> ([String], Int, String) { +) throws(ParserError) -> ([String], Int, String) { var args = args.dropFirst(0) guard let s = args.popFirst(), - let completingArgumentIndex = Int(s) + let index = Int(s) else { throw ParserError.invalidState } @@ -528,54 +595,19 @@ private func parseCustomCompletionArguments( throw ParserError.invalidState } - let completingPrefix: String + let prefix: String if let completingArgument = args.last { - completingPrefix = String( + prefix = String( completingArgument.prefix(cursorIndexWithinCompletingArgument) ) } else if cursorIndexWithinCompletingArgument == 0 { - completingPrefix = "" + prefix = "" } else { throw ParserError.invalidState } - return (Array(args), completingArgumentIndex, completingPrefix) -} - -#if !canImport(Dispatch) -@available(*, unavailable, message: "DispatchSemaphore is unavailable") -@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) -private func asyncCustomCompletions( - from args: [String], - complete: @escaping @Sendable ([String], Int, String) async -> [String] -) throws -> [String] { - throw ParserError.invalidState -} -#else -@available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) -private func asyncCustomCompletions( - from args: [String], - complete: @escaping @Sendable ([String], Int, String) async -> [String] -) throws -> [String] { - let (args, completingArgumentIndex, completingPrefix) = - try parseCustomCompletionArguments(from: args) - - let completionsBox = Mutex<[String]>([]) - let semaphore = DispatchSemaphore(value: 0) - - Task { - let completion = await complete( - args, - completingArgumentIndex, - completingPrefix) - completionsBox.withLock { $0 = completion } - semaphore.signal() - } - - semaphore.wait() - return completionsBox.withLock { $0 } + return (Array(args), index, prefix) } -#endif // MARK: Building Command Stacks diff --git a/Sources/ArgumentParser/Parsing/SplitArguments.swift b/Sources/ArgumentParser/Parsing/SplitArguments.swift index bca85068f..d484668b9 100644 --- a/Sources/ArgumentParser/Parsing/SplitArguments.swift +++ b/Sources/ArgumentParser/Parsing/SplitArguments.swift @@ -626,7 +626,7 @@ extension SplitArguments { } } -func parseIndividualArg(_ arg: String, at position: Int) throws +func parseIndividualArg(_ arg: String, at position: Int) throws(ParserError) -> [SplitArguments.Element] { let index = SplitArguments.Index(inputIndex: .init(rawValue: position)) @@ -718,7 +718,9 @@ extension ParsedArgument { longArgRemainder: remainder, makeName: { Name.long(String($0)) }) } - fileprivate init(longArgWithSingleDashRemainder remainder: Substring) throws { + fileprivate init( + longArgWithSingleDashRemainder remainder: Substring + ) throws(ParserError) { try self.init( longArgRemainder: remainder, makeName: { @@ -737,7 +739,7 @@ extension ParsedArgument { fileprivate init( longArgRemainder remainder: Substring, makeName: (Substring) -> Name - ) throws { + ) throws(ParserError) { if let equalIdx = remainder.firstIndex(of: "=") { let name = remainder[remainder.startIndex.. ParsableArgumentsValidatorError? + { + guard + type is ParsableCommand.Type, + !(type is AsyncParsableCommand.Type) + else { return nil } + + let invalidAsyncCompletions = type.invalidAsyncCompletions( + parent: parent, + propertyPath: String(describing: type) + ) + + return invalidAsyncCompletions.isEmpty + ? nil + : Error(invalidAsyncCompletions: invalidAsyncCompletions) + } +} + +private protocol AnyOptionGroup { + static var wrappedType: ParsableArguments.Type { get } +} + +extension OptionGroup: AnyOptionGroup { + static var wrappedType: ParsableArguments.Type { Value.self } +} + +extension ParsableArguments { + static func invalidAsyncCompletions( + parent: InputKey?, + propertyPath: String + ) -> [String] { + Mirror(reflecting: self.init()) + .children + .flatMap { child in + child.label + .map { $0.hasPrefix("_") ? String($0.dropFirst()) : $0 } + .flatMap { label in + (type(of: child.value) as? AnyOptionGroup.Type)? + .wrappedType.invalidAsyncCompletions( + parent: InputKey(name: label, parent: parent), + propertyPath: "\(propertyPath).\(label)" + ) + ?? (child.value as? ArgumentSetProvider)? + .argumentSet(for: InputKey(name: label, parent: parent)) + .content + .compactMap { arg in + guard case .customAsync = arg.completion.kind else { + return nil + } + return "\(propertyPath).\(label)" + } + } + ?? [] + } + } +} diff --git a/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift b/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift index 3383c4302..9750088a8 100644 --- a/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift +++ b/Sources/ArgumentParser/Validators/ParsableArgumentsValidation.swift @@ -16,7 +16,16 @@ extension ParsableArguments { CodingKeyValidator.self, UniqueNamesValidator.self, NonsenseFlagsValidator.self, + { + if #available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, + *) { + AsyncCompletionsValidator.self + } else { + nil + } + }(), ] + .compactMap { $0 } let errors = validators.compactMap { validator in validator.validate(self, parent: parent) } diff --git a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift index 2e25e31f4..890d9b88b 100644 --- a/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift +++ b/Tests/ArgumentParserUnitTests/CompletionScriptTests.swift @@ -177,7 +177,9 @@ extension CompletionScriptTests { @Argument(completion: .custom { _, _, _ in candidates(prefix: "h") }) var four: String } + } + struct CustomAsync: AsyncParsableCommand { @Argument( completion: .custom { _, _, _ in await candidatesAsync(prefix: "j") } ) @@ -189,13 +191,18 @@ extension CompletionScriptTests { shell: CompletionShell, prefix: String = "", file: StaticString = #filePath, - line: UInt = #line - ) throws { + line: UInt = #line, + command: any ParsableCommand.Type = Custom.self + ) async throws { #if !os(Windows) && !os(WASI) do { Platform.Environment[.shellName, as: CompletionShell.self] = shell defer { Platform.Environment[.shellName] = nil } - _ = try Custom.parse(["---completion", "--", arg, "0", "0"]) + if let command = command as? AsyncParsableCommand.Type { + _ = try await command.parse(["---completion", "--", arg, "0", "0"]) + } else { + _ = try command.parse(["---completion", "--", arg, "0", "0"]) + } XCTFail("Didn't error as expected", file: file, line: line) } catch let error as CommandError { guard case .completionScriptCustomResponse(let output) = error.parserError @@ -219,37 +226,57 @@ extension CompletionScriptTests { shell: CompletionShell, file: StaticString = #filePath, line: UInt = #line - ) throws { + ) async throws { #if !os(Windows) && !os(WASI) - try assertCustomCompletion( + try await assertCustomCompletion( "-o", shell: shell, prefix: "e", file: file, line: line) - try assertCustomCompletion( + try await assertCustomCompletion( "--one", shell: shell, prefix: "e", file: file, line: line) - try assertCustomCompletion( + try await assertCustomCompletion( "two", shell: shell, prefix: "f", file: file, line: line) - try assertCustomCompletion( + try await assertCustomCompletion( "-z", shell: shell, prefix: "g", file: file, line: line) - try assertCustomCompletion( + try await assertCustomCompletion( "nested.four", shell: shell, prefix: "h", file: file, line: line) - try assertCustomCompletion( - "five", shell: shell, prefix: "j", file: file, line: line) + try await assertCustomCompletion( + "five", shell: shell, prefix: "j", file: file, line: line, + command: CustomAsync.self + ) - XCTAssertThrowsError( - try assertCustomCompletion("--bad", shell: shell, file: file, line: line)) - XCTAssertThrowsError( - try assertCustomCompletion("four", shell: shell, file: file, line: line)) + do { + try await assertCustomCompletion( + "--bad", + shell: shell, + file: file, + line: line + ) + XCTFail("Didn't error as expected", file: file, line: line) + } catch { + // Expected + } + do { + try await assertCustomCompletion( + "four", + shell: shell, + file: file, + line: line + ) + XCTFail("Didn't error as expected", file: file, line: line) + } catch { + // Expected + } #endif } - func testBashCustomCompletions() throws { - try assertCustomCompletions(shell: .bash) + func testBashCustomCompletions() async throws { + try await assertCustomCompletions(shell: .bash) } - func testFishCustomCompletions() throws { - try assertCustomCompletions(shell: .fish) + func testFishCustomCompletions() async throws { + try await assertCustomCompletions(shell: .fish) } - func testZshCustomCompletions() throws { - try assertCustomCompletions(shell: .zsh) + func testZshCustomCompletions() async throws { + try await assertCustomCompletions(shell: .zsh) } } diff --git a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift index 4150ee350..3cdd9fdcc 100644 --- a/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift +++ b/Tests/ArgumentParserUnitTests/ParsableArgumentsValidationTests.swift @@ -150,6 +150,119 @@ final class ParsableArgumentsValidationTests: XCTestCase { } } + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + private struct AsyncCompletionOptionGroup: ParsableArguments { + @Sendable static func asyncCompletion( + _: [String], + _: Int, + _: String + ) async -> [String] { [] } + + @Sendable static func syncCompletion( + _: [String], + _: Int, + _: String + ) -> [String] { [] } + + @Option( + name: .customLong("opt"), + help: "O", + completion: .custom(asyncCompletion) + ) + var option: Bool = false + @Argument(help: "A", completion: .custom(asyncCompletion)) + var arg: String = "" + } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + private struct TypeWithInvalidAsyncCompletions: ParsableCommand { + @Option( + name: .customLong("opt"), + help: "O", + completion: .custom(AsyncCompletionOptionGroup.asyncCompletion) + ) + var option: Bool = false + @Argument( + help: "A", + completion: .custom(AsyncCompletionOptionGroup.asyncCompletion) + ) + var arg: String = "" + @OptionGroup + var og: AsyncCompletionOptionGroup + } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + func testAsyncCompletionsValidatorInvalidAsync() throws { + if let error = AsyncCompletionsValidator.validate( + TypeWithInvalidAsyncCompletions.self, + parent: InputKey(name: "foo", parent: nil) + ) as? AsyncCompletionsValidator.Error { + XCTAssertEqual( + error.invalidAsyncCompletions, + [ + "TypeWithInvalidAsyncCompletions.option", + "TypeWithInvalidAsyncCompletions.arg", + "TypeWithInvalidAsyncCompletions.og.option", + "TypeWithInvalidAsyncCompletions.og.arg", + ] + ) + } else { + XCTFail() + } + } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + private struct TypeWithValidAsyncCompletions: AsyncParsableCommand { + @Option( + name: .customLong("opt"), + help: "O", + completion: .custom(AsyncCompletionOptionGroup.asyncCompletion) + ) + var option: Bool = false + @Argument( + help: "A", + completion: .custom(AsyncCompletionOptionGroup.asyncCompletion) + ) + var arg: String = "" + @OptionGroup + var og: AsyncCompletionOptionGroup + } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + func testAsyncCompletionsValidatorValidAsync() throws { + XCTAssertNil( + AsyncCompletionsValidator.validate( + TypeWithValidAsyncCompletions.self, + parent: InputKey(name: "foo", parent: nil) + ) + ) + } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + private struct TypeWithValidSyncCompletions: ParsableCommand { + @Option( + name: .customLong("opt"), + help: "O", + completion: .custom(AsyncCompletionOptionGroup.syncCompletion) + ) + var option: Bool = false + @Argument( + help: "A", + completion: .custom(AsyncCompletionOptionGroup.syncCompletion) + ) + var arg: String = "" + } + + @available(macOS 10.15, macCatalyst 13, iOS 13, tvOS 13, watchOS 6, *) + func testAsyncCompletionsValidatorValidSync() throws { + XCTAssertNil( + AsyncCompletionsValidator.validate( + TypeWithValidSyncCompletions.self, + parent: InputKey(name: "foo", parent: nil) + ) + ) + } + private struct F: ParsableArguments { @Argument() var phrase: String