From d66a07b0831ee177b3b57e0c43784fc503ddcfef Mon Sep 17 00:00:00 2001 From: Rauhul Varma Date: Wed, 5 Feb 2025 14:24:45 -0800 Subject: [PATCH] Refactor Bash completions to use ToolInfo Rebases the implementation of the BashCompletionGenerator to use ToolInfo from ArgumentParserToolInfo instead of digging through the command structure. This helps us decouple the implementation of Argument parsing from the generation of supplemental content such as docs, man-pages, completion scripts, help menus and more. --- Package.swift | 1 + Package@swift-5.8.swift | 1 + .../BashCompletionsGenerator.swift | 199 ++++++++++-------- .../Completions/CompletionsGenerator.swift | 40 ++++ .../ArgumentParser/Parsing/ArgumentSet.swift | 12 +- .../Parsing/CommandParser.swift | 35 ++- .../Usage/DumpHelpGenerator.swift | 2 +- .../testMathBashCompletionScript().bash | 4 +- .../Snapshots/testBase_Bash().bash | 24 +-- 9 files changed, 197 insertions(+), 121 deletions(-) diff --git a/Package.swift b/Package.swift index f1ca54417..49820ed66 100644 --- a/Package.swift +++ b/Package.swift @@ -95,6 +95,7 @@ var package = Package( .testTarget( name: "ArgumentParserExampleTests", dependencies: ["ArgumentParserTestHelpers"], + exclude: ["Snapshots"], resources: [.copy("CountLinesTest.txt")]), .testTarget( name: "ArgumentParserGenerateDoccReferenceTests", diff --git a/Package@swift-5.8.swift b/Package@swift-5.8.swift index f6cbe8f95..ee9f0c31f 100644 --- a/Package@swift-5.8.swift +++ b/Package@swift-5.8.swift @@ -96,6 +96,7 @@ var package = Package( .testTarget( name: "ArgumentParserExampleTests", dependencies: ["ArgumentParserTestHelpers"], + exclude: ["Snapshots"], resources: [.copy("CountLinesTest.txt")]), .testTarget( name: "ArgumentParserGenerateDoccReferenceTests", diff --git a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift index 974f0c03c..d81e389e5 100644 --- a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift @@ -9,47 +9,59 @@ // //===----------------------------------------------------------------------===// +import ArgumentParserToolInfo + struct BashCompletionsGenerator { /// Generates a Bash completion script for the given command. static func generateCompletionScript(_ type: ParsableCommand.Type) -> String { + return ToolInfoV0(commandStack: [type]).bashCompletionScript() + } +} + +extension ToolInfoV0 { + fileprivate func bashCompletionScript() -> String { // TODO: Add a check to see if the command is installed where we expect? - let initialFunctionName = [type].completionFunctionName().makeSafeFunctionName return """ - #!/bin/bash + #!/bin/bash + + \(self.command.bashCompletionFunction()) + + complete -F \(self.command.bashCompletionFunctionName()) \(self.command.commandName) + """ + } +} - \(generateCompletionFunction([type])) +extension CommandInfoV0 { + fileprivate func bashCommandContext() -> [String] { + return (self.superCommands ?? []) + [self.commandName] + } - complete -F \(initialFunctionName) \(type._commandName) - """ + fileprivate func bashCompletionFunctionName() -> String { + return "_" + self.bashCommandContext().joined(separator: "_").makeSafeFunctionName } - /// Generates a Bash completion function for the last command in the given list. - fileprivate static func generateCompletionFunction(_ commands: [ParsableCommand.Type]) -> String { - let type = commands.last! - let functionName = commands.completionFunctionName().makeSafeFunctionName - + /// Generates a Bash completion function. + fileprivate func bashCompletionFunction() -> String { + let functionName = self.bashCompletionFunctionName() + // The root command gets a different treatment for the parsing index. - let isRootCommand = commands.count == 1 + let isRootCommand = (self.superCommands ?? []).count == 0 let dollarOne = isRootCommand ? "1" : "$1" let subcommandArgument = isRootCommand ? "2" : "$(($1+1))" // Include 'help' in the list of subcommands for the root command. - var subcommands = type.configuration.subcommands - .filter { $0.configuration.shouldDisplay } - if !subcommands.isEmpty && isRootCommand { - subcommands.append(HelpCommand.self) - } + let subcommands = (self.subcommands ?? []) + .filter { $0.shouldDisplay } // Generate the words that are available at the "top level" of this // command — these are the dash-prefixed names of options and flags as well // as all the subcommand names. - let completionWords = generateArgumentWords(commands) - + subcommands.map { $0._commandName } - + let completionKeys = self.bashCompletionKeys() + subcommands.map { $0.commandName } + // Generate additional top-level completions — these are completion lists // or custom function-based word lists from positional arguments. - let additionalCompletions = generateArgumentCompletions(commands) - + let additionalCompletions = self.bashPositionalCompletions() + // Start building the resulting function code. var result = "\(functionName)() {\n" @@ -69,7 +81,7 @@ struct BashCompletionsGenerator { // Start by declaring a local var for the top-level completions. // Return immediately if the completion matching hasn't moved further. - result += " opts=\"\(completionWords.joined(separator: " "))\"\n" + result += " opts=\"\(completionKeys.joined(separator: " "))\"\n" for line in additionalCompletions { result += " opts=\"$opts \(line)\"\n" } @@ -84,7 +96,7 @@ struct BashCompletionsGenerator { // Generate the case pattern-matching statements for option values. // If there aren't any, skip the case block altogether. - let optionHandlers = generateOptionHandlers(commands) + let optionHandlers = self.bashOptionCompletions().joined(separator: "\n") if !optionHandlers.isEmpty { result += """ case $prev in @@ -100,8 +112,8 @@ struct BashCompletionsGenerator { result += " case ${COMP_WORDS[\(dollarOne)]} in\n" for subcommand in subcommands { result += """ - (\(subcommand._commandName)) - \(functionName)_\(subcommand._commandName) \(subcommandArgument) + (\(subcommand.commandName)) + \(functionName)_\(subcommand.commandName) \(subcommandArgument) return ;; @@ -120,77 +132,100 @@ struct BashCompletionsGenerator { return result + subcommands - .map { generateCompletionFunction(commands + [$0]) } - .joined() + .map { $0.bashCompletionFunction() } + .joined() } /// Returns the option and flag names that can be top-level completions. - fileprivate static func generateArgumentWords(_ commands: [ParsableCommand.Type]) -> [String] { - commands - .argumentsForHelp(visibility: .default) - .flatMap { $0.bashCompletionWords() } + fileprivate func bashCompletionKeys() -> [String] { + var result = [String]() + for argument in self.arguments ?? [] { + // Skip hidden arguments. + guard argument.shouldDisplay else { continue } + result.append(contentsOf: argument.bashCompletionKeys()) + } + return result } /// Returns additional top-level completions from positional arguments. /// /// These consist of completions that are defined as `.list` or `.custom`. - fileprivate static func generateArgumentCompletions(_ commands: [ParsableCommand.Type]) -> [String] { - ArgumentSet(commands.last!, visibility: .default, parent: nil) - .compactMap { arg -> String? in - guard arg.isPositional else { return nil } - - switch arg.completion.kind { - case .default, .file, .directory: - return nil - case .list(let list): - return list.joined(separator: " ") - case .shellCommand(let command): - return "$(\(command))" - case .custom: - return """ - $("${COMP_WORDS[0]}" \(arg.customCompletionCall(commands)) "${COMP_WORDS[@]}") - """ - } - } + fileprivate func bashPositionalCompletions() -> [String] { + var result = [String]() + for argument in self.arguments ?? [] { + // Skip hidden arguments. + guard argument.shouldDisplay else { continue } + // Only select positional arguments. + guard argument.kind == .positional else { continue } + // Skip if no completions. + guard let completionValues = argument.bashPositionalCompletionValues(command: self) else { continue } + result.append(completionValues) + } + return result } /// Returns the case-matching statements for supplying completions after an option or flag. - fileprivate static func generateOptionHandlers(_ commands: [ParsableCommand.Type]) -> String { - ArgumentSet(commands.last!, visibility: .default, parent: nil) - .compactMap { arg -> String? in - let words = arg.bashCompletionWords() - if words.isEmpty { return nil } - - // Flags don't take a value, so we don't provide follow-on completions. - if arg.isNullary { return nil } - - return """ - \(arg.bashCompletionWords().joined(separator: "|"))) - \(arg.bashValueCompletion(commands).indentingEachLine(by: 4)) + fileprivate func bashOptionCompletions() -> [String] { + var result = [String]() + for argument in self.arguments ?? [] { + // Skip hidden arguments. + guard argument.shouldDisplay else { continue } + // Flags don't take a value, so we don't provide follow-on completions. + guard argument.kind != .flag else { continue } + // Skip if no keys. + let keys = argument.bashCompletionKeys() + guard !keys.isEmpty else { continue } + // Skip if no completions. + guard let completionValues = argument.bashOptionCompletionValues(command: self) else { continue } + result.append(""" + \(keys.joined(separator: "|"))) + \(completionValues.indentingEachLine(by: 4)) return ;; - """ - } - .joined(separator: "\n") + """) + } + return result } } -extension ArgumentDefinition { +extension ArgumentInfoV0 { /// Returns the different completion names for this argument. - fileprivate func bashCompletionWords() -> [String] { - return help.visibility.base == .default - ? names.map { $0.synopsisString } - : [] + fileprivate func bashCompletionKeys() -> [String] { + return (self.names ?? []).map { $0.commonCompletionSynopsisString() } + } + + // FIXME: determine if this can be combined with bashOptionCompletionValues + fileprivate func bashPositionalCompletionValues( + command: CommandInfoV0 + ) -> String? { + precondition(self.kind == .positional) + + switch self.completionKind { + case .none, .file, .directory: + // FIXME: this doesn't work + return nil + case .list(let list): + return list.joined(separator: " ") + case .shellCommand(let command): + return "$(\(command))" + case .custom: + // Generate a call back into the command to retrieve a completions list + return #"$("${COMP_WORDS[0]}" \#(self.commonCustomCompletionCall(command: command)) "${COMP_WORDS[@]}")"# + } } /// Returns the bash completions that can follow this argument's `--name`. /// /// Uses bash-completion for file and directory values if available. - fileprivate func bashValueCompletion(_ commands: [ParsableCommand.Type]) -> String { - switch completion.kind { - case .default: - return "" - + fileprivate func bashOptionCompletionValues( + command: CommandInfoV0 + ) -> String? { + precondition(self.kind == .option) + + switch self.completionKind { + case .none: + return nil + case .file(let extensions) where extensions.isEmpty: return """ if declare -F _filedir >/dev/null; then @@ -203,7 +238,7 @@ extension ArgumentDefinition { case .file(let extensions): var safeExts = extensions.map { String($0.flatMap { $0 == "'" ? ["\\", "'"] : [$0] }) } safeExts.append(contentsOf: safeExts.map { $0.uppercased() }) - + return """ if declare -F _filedir >/dev/null; then \(safeExts.map { "_filedir '\($0)'" }.joined(separator:"\n ")) @@ -224,22 +259,16 @@ extension ArgumentDefinition { COMPREPLY=( $(compgen -d -- "$cur") ) fi """ - + case .list(let list): return #"COMPREPLY=( $(compgen -W "\#(list.joined(separator: " "))" -- "$cur") )"# - + case .shellCommand(let command): return "COMPREPLY=( $(\(command)) )" - + case .custom: // Generate a call back into the command to retrieve a completions list - return #"COMPREPLY=( $(compgen -W "$("${COMP_WORDS[0]}" \#(customCompletionCall(commands)) "${COMP_WORDS[@]}")" -- "$cur") )"# + return #"COMPREPLY=( $(compgen -W "$("${COMP_WORDS[0]}" \#(self.commonCustomCompletionCall(command: command)) "${COMP_WORDS[@]}")" -- "$cur") )"# } } } - -extension String { - var makeSafeFunctionName: String { - self.replacingOccurrences(of: "-", with: "_") - } -} diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index bfff62405..2940abb55 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -9,6 +9,8 @@ // //===----------------------------------------------------------------------===// +import ArgumentParserToolInfo + /// A shell for which the parser can generate a completion script. public struct CompletionShell: RawRepresentable, Hashable, CaseIterable { public var rawValue: String @@ -139,3 +141,41 @@ extension Sequence where Element == ParsableCommand.Type { .joined(separator: "_") } } + +extension String { + var makeSafeFunctionName: String { + self.replacingOccurrences(of: "-", with: "_") + } +} + +extension ArgumentInfoV0 { + /// Returns a string with the arguments for the callback to generate custom + /// completions for this argument. + func commonCustomCompletionCall(command: CommandInfoV0) -> String { + let commandContext = (command.superCommands ?? []) + [command.commandName] + let subcommandNames = commandContext.dropFirst().joined(separator: " ") + + let argumentName: String + switch self.kind { + case .positional: + let index = (command.arguments ?? []) + .filter { $0.kind == .positional } + .firstIndex(of: self)! + argumentName = "positional@\(index)" + default: + argumentName = self.preferredName!.commonCompletionSynopsisString() + } + return "---completion \(subcommandNames) -- \(argumentName)" + } +} + +extension ArgumentInfoV0.NameInfoV0 { + func commonCompletionSynopsisString() -> String { + switch self.kind { + case .long: + return "--\(self.name)" + case .short, .longWithSingleDash: + return "-\(self.name)" + } + } +} diff --git a/Sources/ArgumentParser/Parsing/ArgumentSet.swift b/Sources/ArgumentParser/Parsing/ArgumentSet.swift index 1289245ca..b7d43ee28 100644 --- a/Sources/ArgumentParser/Parsing/ArgumentSet.swift +++ b/Sources/ArgumentParser/Parsing/ArgumentSet.swift @@ -191,9 +191,17 @@ extension ArgumentSet { } func firstPositional( - withKey key: InputKey + withKey key: InputKey, ) -> ArgumentDefinition? { - first(where: { $0.help.keys.contains(key) }) + return first(where: { $0.help.keys.contains(key) }) + } + + func positional( + at index: Int + ) -> ArgumentDefinition? { + let arguments = self.content.filter { $0.isPositional } + guard arguments.count > index else { return nil } + return arguments[index] } } diff --git a/Sources/ArgumentParser/Parsing/CommandParser.swift b/Sources/ArgumentParser/Parsing/CommandParser.swift index d92b119ca..6c9b5cd9f 100644 --- a/Sources/ArgumentParser/Parsing/CommandParser.swift +++ b/Sources/ArgumentParser/Parsing/CommandParser.swift @@ -338,25 +338,40 @@ extension CommandParser { // Generate the argument set and parse the argument to find in the set let argset = ArgumentSet(current.element, visibility: .private, parent: nil) - let parsedArgument = try! parseIndividualArg(argToMatch, at: 0).first! - + guard let parsedArgument = try parseIndividualArg(argToMatch, at: 0).first + else { throw ParserError.invalidState } + // Look up the specified argument and retrieve its custom completion function let completionFunction: ([String]) -> [String] - + switch parsedArgument.value { case .option(let parsed): guard let matchedArgument = argset.first(matching: parsed), case .custom(let f) = matchedArgument.completion.kind - else { throw ParserError.invalidState } + else { throw ParserError.invalidState } completionFunction = f - case .value(let str): - guard let key = InputKey(fullPathString: str), - let matchedArgument = argset.firstPositional(withKey: key), - case .custom(let f) = matchedArgument.completion.kind + case .value(let value): + // Legacy completion script generators use internal key paths to identify + // positional args, e.g. optionGroupA.optionGroupB.property. Newer + // generators based on ToolInfo use the `positional@` syntax which + // avoids leaking implementation details of the tool. + let toolInfoPrefix = "positional@" + if value.hasPrefix(toolInfoPrefix) { + guard + let index = Int(value.dropFirst(toolInfoPrefix.count)), + let matchedArgument = argset.positional(at: index), + case .custom(let f) = matchedArgument.completion.kind else { throw ParserError.invalidState } - completionFunction = f - + completionFunction = f + } else { + guard + let key = InputKey(fullPathString: value), + let matchedArgument = argset.firstPositional(withKey: key), + case .custom(let f) = matchedArgument.completion.kind + else { throw ParserError.invalidState } + completionFunction = f + } case .terminator: throw ParserError.invalidState } diff --git a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift index f520fec4b..67cfa01c1 100644 --- a/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift +++ b/Sources/ArgumentParser/Usage/DumpHelpGenerator.swift @@ -86,7 +86,7 @@ fileprivate extension ArgumentSet { } } -fileprivate extension ToolInfoV0 { +extension ToolInfoV0 { init(commandStack: [ParsableCommand.Type]) { self.init(command: CommandInfoV0(commandStack: commandStack)) // FIXME: This is a hack to inject the help command into the tool info diff --git a/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash b/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash index 815cbed80..7fdd0a92b 100644 --- a/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash +++ b/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash @@ -95,7 +95,7 @@ _math_stats_stdev() { _math_stats_quantiles() { opts="--file --directory --shell --custom --version -h --help" opts="$opts alphabet alligator branch braggart" - opts="$opts $("${COMP_WORDS[0]}" ---completion stats quantiles -- customArg "${COMP_WORDS[@]}")" + opts="$opts $("${COMP_WORDS[0]}" ---completion stats quantiles -- positional@1 "${COMP_WORDS[@]}")" if [[ $COMP_CWORD == "$1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return @@ -139,7 +139,7 @@ _math_stats_quantiles() { COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } _math_help() { - opts="--version" + opts="" if [[ $COMP_CWORD == "$1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash index 64b7a47e8..1bf2841b8 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash @@ -8,17 +8,13 @@ _base_test() { prev="${COMP_WORDS[COMP_CWORD-1]}" COMPREPLY=() opts="--name --kind --other-kind --path1 --path2 --path3 --one --two --three --kind-counter --rep1 -r --rep2 -h --help sub-command escaped-command help" - opts="$opts $("${COMP_WORDS[0]}" ---completion -- argument "${COMP_WORDS[@]}")" - opts="$opts $("${COMP_WORDS[0]}" ---completion -- nested.nestedArgument "${COMP_WORDS[@]}")" + opts="$opts $("${COMP_WORDS[0]}" ---completion -- positional@0 "${COMP_WORDS[@]}")" + opts="$opts $("${COMP_WORDS[0]}" ---completion -- positional@1 "${COMP_WORDS[@]}")" if [[ $COMP_CWORD == "1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return fi case $prev in - --name) - - return - ;; --kind) COMPREPLY=( $(compgen -W "one two custom-three" -- "$cur") ) return @@ -47,14 +43,6 @@ _base_test() { COMPREPLY=( $(compgen -W "c1_bash c2_bash c3_bash" -- "$cur") ) return ;; - --rep1) - - return - ;; - -r|--rep2) - - return - ;; esac case ${COMP_WORDS[1]} in (sub-command) @@ -82,17 +70,11 @@ _base_test_sub_command() { } _base_test_escaped_command() { opts="--one -h --help" - opts="$opts $("${COMP_WORDS[0]}" ---completion escaped-command -- two "${COMP_WORDS[@]}")" + opts="$opts $("${COMP_WORDS[0]}" ---completion escaped-command -- positional@0 "${COMP_WORDS[@]}")" if [[ $COMP_CWORD == "$1" ]]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) return fi - case $prev in - --one) - - return - ;; - esac COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } _base_test_help() {