diff --git a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift index 0c635d399..f89c2ec1d 100644 --- a/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/BashCompletionsGenerator.swift @@ -9,257 +9,389 @@ // //===----------------------------------------------------------------------===// -struct BashCompletionsGenerator { +extension [ParsableCommand.Type] { /// Generates a Bash completion script for the given command. - static func generateCompletionScript(_ type: ParsableCommand.Type) -> String { + var bashCompletionScript: String { // TODO: Add a check to see if the command is installed where we expect? - let initialFunctionName = [type].completionFunctionName() - .makeSafeFunctionName + // swift-format-ignore: NeverForceUnwrap + // Preconditions: + // - first must be non-empty for a bash completion script to be of use. + // - first is guaranteed non-empty in the one place where this computed var is used. + let commandName = first!._commandName return """ #!/bin/bash - \(generateCompletionFunction([type])) + # positional arguments: + # + # - 1: the current (sub)command's count of positional arguments + # + # required variables: + # + # - flags: the flags that the current (sub)command can accept + # - options: the options that the current (sub)command can accept + # - positional_number: value ignored + # - unparsed_words: unparsed words from the current command line + # + # modified variables: + # + # - flags: remove flags for this (sub)command that are already on the command line + # - options: remove options for this (sub)command that are already on the command line + # - positional_number: set to the current positional number + # - unparsed_words: remove all flags, options, and option values for this (sub)command + \(offerFlagsOptionsFunctionName)() { + local -ir positional_count="${1}" + positional_number=0 + + local was_flag_option_terminator_seen=false + local is_parsing_option_value=false + + local -ar unparsed_word_indices=("${!unparsed_words[@]}") + local -i word_index + for word_index in "${unparsed_word_indices[@]}"; do + if "${is_parsing_option_value}"; then + # This word is an option value: + # Reset marker for next word iff not currently the last word + [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]] && is_parsing_option_value=false + unset "unparsed_words[${word_index}]" + # Do not process this word as a flag or an option + continue + fi + + local word="${unparsed_words["${word_index}"]}" + if ! "${was_flag_option_terminator_seen}"; then + case "${word}" in + --) + unset "unparsed_words[${word_index}]" + # by itself -- is a flag/option terminator, but if it is the last word, it is the start of a completion + if [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]]; then + was_flag_option_terminator_seen=true + fi + continue + ;; + -*) + # ${word} is a flag or an option + # If ${word} is an option, mark that the next word to be parsed is an option value + local option + for option in "${options[@]}"; do + [[ "${word}" = "${option}" ]] && is_parsing_option_value=true && break + done + + # Remove ${word} from ${flags} or ${options} so it isn't offered again + local not_found=true + local -i index + for index in "${!flags[@]}"; do + if [[ "${flags[${index}]}" = "${word}" ]]; then + unset "flags[${index}]" + flags=("${flags[@]}") + not_found=false + break + fi + done + if "${not_found}"; then + for index in "${!options[@]}"; do + if [[ "${options[${index}]}" = "${word}" ]]; then + unset "options[${index}]" + options=("${options[@]}") + break + fi + done + fi + unset "unparsed_words[${word_index}]" + continue + ;; + esac + fi + + # ${word} is neither a flag, nor an option, nor an option value + if [[ "${positional_number}" -lt "${positional_count}" ]]; then + # ${word} is a positional + ((positional_number++)) + unset "unparsed_words[${word_index}]" + else + if [[ -z "${word}" ]]; then + # Could be completing a flag, option, or subcommand + positional_number=-1 + else + # ${word} is a subcommand or invalid, so stop processing this (sub)command + positional_number=-2 + fi + break + fi + done + + unparsed_words=("${unparsed_words[@]}") + + if\\ + ! "${was_flag_option_terminator_seen}"\\ + && ! "${is_parsing_option_value}"\\ + && [[ ("${cur}" = -* && "${positional_number}" -ge 0) || "${positional_number}" -eq -1 ]] + then + COMPREPLY+=($(compgen -W "${flags[*]} ${options[*]}" -- "${cur}")) + fi + } + + \(addCompletionsFunctionName)() { + local completion + while IFS='' read -r completion; do + COMPREPLY+=("${completion}") + done < <(IFS=$'\\n' compgen "${@}" -- "${cur}") + } - complete -F \(initialFunctionName) \(type._commandName) + \(customCompleteFunctionName)() { + if [[ -n "${cur}" || -z ${COMP_WORDS[${COMP_CWORD}]} || "${COMP_LINE:${COMP_POINT}:1}" != ' ' ]]; then + local -ar words=("${COMP_WORDS[@]}") + else + local -ar words=("${COMP_WORDS[@]::${COMP_CWORD}}" '' "${COMP_WORDS[@]:${COMP_CWORD}}") + fi + + "${COMP_WORDS[0]}" "${@}" "${words[@]}" + } + + \(completionFunctions)\ + complete -o filenames -F \(completionFunctionName().shellEscapeForVariableName()) \(commandName) """ } /// Generates a Bash completion function for the last command in the given list. - fileprivate static func generateCompletionFunction( - _ commands: [ParsableCommand.Type] - ) -> String { - guard let type = commands.last else { + private var completionFunctions: String { + guard let type = last else { fatalError() } - let functionName = commands.completionFunctionName().makeSafeFunctionName - - // The root command gets a different treatment for the parsing index. - let isRootCommand = commands.count == 1 - 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) - } - - // 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 functionName = + completionFunctionName().shellEscapeForVariableName() - // Generate additional top-level completions — these are completion lists - // or custom function-based word lists from positional arguments. - let additionalCompletions = generateArgumentCompletions(commands) + var subcommands = + type.configuration.subcommands.filter { $0.configuration.shouldDisplay } // Start building the resulting function code. - var result = "\(functionName)() {\n" + var result = "" + + // Include initial setup iff the root command. + let declareTopLevelArray: String + if count == 1 { + subcommands.addHelpSubcommandIfMissing() - // The function that represents the root command has some additional setup - // that other command functions don't need. - if isRootCommand { result += """ - export \(CompletionShell.shellEnvironmentVariableName)=bash - \(CompletionShell.shellVersionEnvironmentVariableName)="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")" - export \(CompletionShell.shellVersionEnvironmentVariableName) - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - COMPREPLY=() - - """.indentingEachLine(by: 4) - } + trap "$(shopt -p);$(shopt -po)" RETURN + shopt -s extglob + set +o history +o posix + + local -xr \(CompletionShell.shellEnvironmentVariableName)=bash + local -x \(CompletionShell.shellVersionEnvironmentVariableName) + \(CompletionShell.shellVersionEnvironmentVariableName)="$(IFS='.';printf %s "${BASH_VERSINFO[*]}")" + local -r \(CompletionShell.shellVersionEnvironmentVariableName) - // 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" - for line in additionalCompletions { - result += " opts=\"$opts \(line)\"\n" + local -r cur="${2}" + local -r prev="${3}" + + local -i positional_number + local -a unparsed_words=("${COMP_WORDS[@]:1:${COMP_CWORD}}") + + + """ + + declareTopLevelArray = "local -a " + } else { + declareTopLevelArray = "" } - result += """ - if [[ $COMP_CWORD == "\(dollarOne)" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi + let positionalArguments = positionalArguments - """ + let flagCompletions = flagCompletions + let optionCompletions = optionCompletions + if !flagCompletions.isEmpty || !optionCompletions.isEmpty { + result += """ + \(declareTopLevelArray)flags=(\(flagCompletions.joined(separator: " "))) + \(declareTopLevelArray)options=(\(optionCompletions.joined(separator: " "))) + \(offerFlagsOptionsFunctionName) \(positionalArguments.count) + + """ + } // 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 = + ArgumentSet(type, 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: "|"))) + \(bashValueCompletion(arg).indentingEachLine(by: 8))\ + return + ;; + """ + } + .joined(separator: "\n") if !optionHandlers.isEmpty { result += """ - case $prev in - \(optionHandlers.indentingEachLine(by: 4)) - esac - """.indentingEachLine(by: 4) + "\n" + + # Offer option value completions + case "${prev}" in + \(optionHandlers) + esac + + """ } - // Build out completions for the subcommands. - if !subcommands.isEmpty { - // Subcommands have their own case statement that delegates out to - // the subcommand completion functions. - result += " case ${COMP_WORDS[\(dollarOne)]} in\n" - for subcommand in subcommands { - result += """ - (\(subcommand._commandName)) - \(functionName)_\(subcommand._commandName) \(subcommandArgument) - return - ;; + let positionalCases = + zip(1..., positionalArguments) + .compactMap { position, arg in + let completion = bashValueCompletion(arg) + return completion.isEmpty + ? nil + : """ + \(position)) + \(completion.indentingEachLine(by: 8))\ + return + ;; """ - .indentingEachLine(by: 8) } - result += " esac\n" + + if !positionalCases.isEmpty { + result += """ + + # Offer positional completions + case "${positional_number}" in + \(positionalCases.joined())\ + esac + + """ } - // Finish off the function. - result += """ - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - } + if !subcommands.isEmpty { + result += """ - """ + # Offer subcommand / subcommand argument completions + local -r subcommand="${unparsed_words[0]}" + unset 'unparsed_words[0]' + unparsed_words=("${unparsed_words[@]}") + case "${subcommand}" in + \(subcommands.map { $0._commandName }.joined(separator: "|"))) + # Offer subcommand argument completions + "\(functionName)_${subcommand}" + ;; + *) + # Offer subcommand completions + COMPREPLY+=($(compgen -W '\( + subcommands.map { $0._commandName.shellEscapeForSingleQuotedString() }.joined(separator: " ") + )' -- "${cur}")) + ;; + esac - return result - + subcommands - .map { generateCompletionFunction(commands + [$0]) } - .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() } - } + if result.isEmpty { + result = " :\n" + } - /// 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] { - guard let type = commands.last else { return [] } - return ArgumentSet(type, 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[@]}") - """ - } + return """ + \(functionName)() { + \(result)\ } - } - /// Returns the case-matching statements for supplying completions after an option or flag. - fileprivate static func generateOptionHandlers( - _ commands: [ParsableCommand.Type] - ) -> String { - guard let type = commands.last else { return "" } - return ArgumentSet(type, 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 } + \(subcommands.map { (self + [$0]).completionFunctions }.joined()) + """ + } - return """ - \(arg.bashCompletionWords().joined(separator: "|"))) - \(arg.bashValueCompletion(commands).indentingEachLine(by: 4)) - return - ;; - """ + /// Returns flag completions for the last command of the given array. + private var flagCompletions: [String] { + argumentsForHelp(visibility: .default).flatMap { + switch ($0.kind, $0.update) { + case (.named, .nullary): + return $0.bashCompletionWords + default: + return [] } - .joined(separator: "\n") + } } -} -extension ArgumentDefinition { - /// Returns the different completion names for this argument. - fileprivate func bashCompletionWords() -> [String] { - help.visibility.base == .default - ? names.map { $0.synopsisString } - : [] + /// Returns option completions for the last command of the given array. + private var optionCompletions: [String] { + argumentsForHelp(visibility: .default).flatMap { + switch ($0.kind, $0.update) { + case (.named, .unary): + return $0.bashCompletionWords + default: + return [] + } + } } - /// 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 { + /// Returns the bash completions that can follow the given argument's `--name`. + private func bashValueCompletion(_ arg: ArgumentDefinition) -> String { + switch arg.completion.kind { case .default: return "" case .file(let extensions) where extensions.isEmpty: return """ - if declare -F _filedir >/dev/null; then - _filedir - else - COMPREPLY=( $(compgen -f -- "$cur") ) - fi + \(addCompletionsFunctionName) -f + """ case .file(let extensions): - var safeExts = extensions.map { - String($0.flatMap { $0 == "'" ? ["\\", "'"] : [$0] }) - } - safeExts.append(contentsOf: safeExts.map { $0.uppercased() }) - + let exts = + extensions + .map { $0.shellEscapeForSingleQuotedString() }.joined(separator: "|") return """ - if declare -F _filedir >/dev/null; then - \(safeExts.map { "_filedir '\($0)'" }.joined(separator:"\n ")) - _filedir -d - else - COMPREPLY=( - \(safeExts.map { "$(compgen -f -X '!*.\($0)' -- \"$cur\")" }.joined(separator: "\n ")) - $(compgen -d -- "$cur") - ) - fi + \(addCompletionsFunctionName) -o plusdirs -fX '!*.@(\(exts))' + """ case .directory: return """ - if declare -F _filedir >/dev/null; then - _filedir -d - else - COMPREPLY=( $(compgen -d -- "$cur") ) - fi + \(addCompletionsFunctionName) -d + """ case .list(let list): - return - #"COMPREPLY=( $(compgen -W "\#(list.joined(separator: " "))" -- "$cur") )"# + return """ + \(addCompletionsFunctionName) -W\ + '\(list.map { $0.shellEscapeForSingleQuotedString() }.joined(separator: "'$'\\n''"))' + + """ case .shellCommand(let command): - return "COMPREPLY=( $(\(command)) )" + return """ + \(addCompletionsFunctionName) -W "$(eval '\(command.shellEscapeForSingleQuotedString())')" + + """ 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 """ + \(addCompletionsFunctionName) -W\ + "$(\(customCompleteFunctionName) \(arg.customCompletionCall(self)))" + + """ } } + + private var offerFlagsOptionsFunctionName: String { + "_\(prefix(1).completionFunctionName().shellEscapeForVariableName())_offer_flags_options" + } + + private var addCompletionsFunctionName: String { + "_\(prefix(1).completionFunctionName().shellEscapeForVariableName())_add_completions" + } + + private var customCompleteFunctionName: String { + "_\(prefix(1).completionFunctionName().shellEscapeForVariableName())_custom_complete" + } } -extension String { - var makeSafeFunctionName: String { - self.replacingOccurrences(of: "-", with: "_") +extension ArgumentDefinition { + /// Returns the different completion names for this argument. + fileprivate var bashCompletionWords: [String] { + help.visibility.base == .default + ? names.map(\.synopsisString) + : [] } } diff --git a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift index f725dd9d0..010a2235d 100644 --- a/Sources/ArgumentParser/Completions/CompletionsGenerator.swift +++ b/Sources/ArgumentParser/Completions/CompletionsGenerator.swift @@ -142,7 +142,7 @@ struct CompletionsGenerator { case .zsh: return [command].zshCompletionScript case .bash: - return BashCompletionsGenerator.generateCompletionScript(command) + return [command].bashCompletionScript case .fish: return FishCompletionsGenerator.generateCompletionScript(command) default: @@ -176,10 +176,18 @@ extension ParsableCommand { } extension [ParsableCommand.Type] { + var positionalArguments: [ArgumentDefinition] { + guard let command = last else { + return [] + } + return ArgumentSet(command, visibility: .default, parent: nil) + .filter(\.isPositional) + } + /// Include default 'help' subcommand in nonempty subcommand list if & only if /// no help subcommand already exists. mutating func addHelpSubcommandIfMissing() { - if !isEmpty && allSatisfy({ $0._commandName != "help" }) { + if !isEmpty && !contains(where: { $0._commandName == "help" }) { append(HelpCommand.self) } } diff --git a/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash b/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash index 815cbed80..b88219d2e 100644 --- a/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash +++ b/Tests/ArgumentParserExampleTests/Snapshots/testMathBashCompletionScript().bash @@ -1,151 +1,262 @@ #!/bin/bash -_math() { - export SAP_SHELL=bash - SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")" - export SAP_SHELL_VERSION - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD-1]}" - COMPREPLY=() - opts="--version -h --help add multiply stats help" - if [[ $COMP_CWORD == "1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return +# positional arguments: +# +# - 1: the current (sub)command's count of positional arguments +# +# required variables: +# +# - flags: the flags that the current (sub)command can accept +# - options: the options that the current (sub)command can accept +# - positional_number: value ignored +# - unparsed_words: unparsed words from the current command line +# +# modified variables: +# +# - flags: remove flags for this (sub)command that are already on the command line +# - options: remove options for this (sub)command that are already on the command line +# - positional_number: set to the current positional number +# - unparsed_words: remove all flags, options, and option values for this (sub)command +__math_offer_flags_options() { + local -ir positional_count="${1}" + positional_number=0 + + local was_flag_option_terminator_seen=false + local is_parsing_option_value=false + + local -ar unparsed_word_indices=("${!unparsed_words[@]}") + local -i word_index + for word_index in "${unparsed_word_indices[@]}"; do + if "${is_parsing_option_value}"; then + # This word is an option value: + # Reset marker for next word iff not currently the last word + [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]] && is_parsing_option_value=false + unset "unparsed_words[${word_index}]" + # Do not process this word as a flag or an option + continue + fi + + local word="${unparsed_words["${word_index}"]}" + if ! "${was_flag_option_terminator_seen}"; then + case "${word}" in + --) + unset "unparsed_words[${word_index}]" + # by itself -- is a flag/option terminator, but if it is the last word, it is the start of a completion + if [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]]; then + was_flag_option_terminator_seen=true + fi + continue + ;; + -*) + # ${word} is a flag or an option + # If ${word} is an option, mark that the next word to be parsed is an option value + local option + for option in "${options[@]}"; do + [[ "${word}" = "${option}" ]] && is_parsing_option_value=true && break + done + + # Remove ${word} from ${flags} or ${options} so it isn't offered again + local not_found=true + local -i index + for index in "${!flags[@]}"; do + if [[ "${flags[${index}]}" = "${word}" ]]; then + unset "flags[${index}]" + flags=("${flags[@]}") + not_found=false + break + fi + done + if "${not_found}"; then + for index in "${!options[@]}"; do + if [[ "${options[${index}]}" = "${word}" ]]; then + unset "options[${index}]" + options=("${options[@]}") + break + fi + done + fi + unset "unparsed_words[${word_index}]" + continue + ;; + esac + fi + + # ${word} is neither a flag, nor an option, nor an option value + if [[ "${positional_number}" -lt "${positional_count}" ]]; then + # ${word} is a positional + ((positional_number++)) + unset "unparsed_words[${word_index}]" + else + if [[ -z "${word}" ]]; then + # Could be completing a flag, option, or subcommand + positional_number=-1 + else + # ${word} is a subcommand or invalid, so stop processing this (sub)command + positional_number=-2 + fi + break + fi + done + + unparsed_words=("${unparsed_words[@]}") + + if\ + ! "${was_flag_option_terminator_seen}"\ + && ! "${is_parsing_option_value}"\ + && [[ ("${cur}" = -* && "${positional_number}" -ge 0) || "${positional_number}" -eq -1 ]] + then + COMPREPLY+=($(compgen -W "${flags[*]} ${options[*]}" -- "${cur}")) + fi +} + +__math_add_completions() { + local completion + while IFS='' read -r completion; do + COMPREPLY+=("${completion}") + done < <(IFS=$'\n' compgen "${@}" -- "${cur}") +} + +__math_custom_complete() { + if [[ -n "${cur}" || -z ${COMP_WORDS[${COMP_CWORD}]} || "${COMP_LINE:${COMP_POINT}:1}" != ' ' ]]; then + local -ar words=("${COMP_WORDS[@]}") + else + local -ar words=("${COMP_WORDS[@]::${COMP_CWORD}}" '' "${COMP_WORDS[@]:${COMP_CWORD}}") fi - case ${COMP_WORDS[1]} in - (add) - _math_add 2 - return - ;; - (multiply) - _math_multiply 2 - return - ;; - (stats) - _math_stats 2 - return - ;; - (help) - _math_help 2 - return - ;; + + "${COMP_WORDS[0]}" "${@}" "${words[@]}" +} + +_math() { + trap "$(shopt -p);$(shopt -po)" RETURN + shopt -s extglob + set +o history +o posix + + local -xr SAP_SHELL=bash + local -x SAP_SHELL_VERSION + SAP_SHELL_VERSION="$(IFS='.';printf %s "${BASH_VERSINFO[*]}")" + local -r SAP_SHELL_VERSION + + local -r cur="${2}" + local -r prev="${3}" + + local -i positional_number + local -a unparsed_words=("${COMP_WORDS[@]:1:${COMP_CWORD}}") + + local -a flags=(--version -h --help) + local -a options=() + __math_offer_flags_options 0 + + # Offer subcommand / subcommand argument completions + local -r subcommand="${unparsed_words[0]}" + unset 'unparsed_words[0]' + unparsed_words=("${unparsed_words[@]}") + case "${subcommand}" in + add|multiply|stats|help) + # Offer subcommand argument completions + "_math_${subcommand}" + ;; + *) + # Offer subcommand completions + COMPREPLY+=($(compgen -W 'add multiply stats help' -- "${cur}")) + ;; esac - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } + _math_add() { - opts="--hex-output -x --version -h --help" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(--hex-output -x --version -h --help) + options=() + __math_offer_flags_options 1 } + _math_multiply() { - opts="--hex-output -x --version -h --help" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(--hex-output -x --version -h --help) + options=() + __math_offer_flags_options 1 } + _math_stats() { - opts="--version -h --help average stdev quantiles" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi - case ${COMP_WORDS[$1]} in - (average) - _math_stats_average $(($1+1)) - return - ;; - (stdev) - _math_stats_stdev $(($1+1)) - return - ;; - (quantiles) - _math_stats_quantiles $(($1+1)) - return - ;; + flags=(--version -h --help) + options=() + __math_offer_flags_options 0 + + # Offer subcommand / subcommand argument completions + local -r subcommand="${unparsed_words[0]}" + unset 'unparsed_words[0]' + unparsed_words=("${unparsed_words[@]}") + case "${subcommand}" in + average|stdev|quantiles) + # Offer subcommand argument completions + "_math_stats_${subcommand}" + ;; + *) + # Offer subcommand completions + COMPREPLY+=($(compgen -W 'average stdev quantiles' -- "${cur}")) + ;; esac - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } + _math_stats_average() { - opts="--kind --version -h --help" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(--version -h --help) + options=(--kind) + __math_offer_flags_options 1 + + # Offer option value completions + case "${prev}" in + --kind) + __math_add_completions -W 'mean'$'\n''median'$'\n''mode' return - fi - case $prev in - --kind) - COMPREPLY=( $(compgen -W "mean median mode" -- "$cur") ) - return ;; esac - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } + _math_stats_stdev() { - opts="--version -h --help" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(--version -h --help) + options=() + __math_offer_flags_options 1 } + _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[@]}")" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(--version -h --help) + options=(--file --directory --shell --custom) + __math_offer_flags_options 3 + + # Offer option value completions + case "${prev}" in + --file) + __math_add_completions -o plusdirs -fX '!*.@(txt|md)' return - fi - case $prev in - --file) - if declare -F _filedir >/dev/null; then - _filedir 'txt' - _filedir 'md' - _filedir 'TXT' - _filedir 'MD' - _filedir -d - else - COMPREPLY=( - $(compgen -f -X '!*.txt' -- "$cur") - $(compgen -f -X '!*.md' -- "$cur") - $(compgen -f -X '!*.TXT' -- "$cur") - $(compgen -f -X '!*.MD' -- "$cur") - $(compgen -d -- "$cur") - ) - fi - return ;; - --directory) - if declare -F _filedir >/dev/null; then - _filedir -d - else - COMPREPLY=( $(compgen -d -- "$cur") ) - fi - return + --directory) + __math_add_completions -d + return ;; - --shell) - COMPREPLY=( $(head -100 /usr/share/dict/words | tail -50) ) - return + --shell) + __math_add_completions -W "$(eval 'head -100 /usr/share/dict/words | tail -50')" + return ;; - --custom) - COMPREPLY=( $(compgen -W "$("${COMP_WORDS[0]}" ---completion stats quantiles -- --custom "${COMP_WORDS[@]}")" -- "$cur") ) - return + --custom) + __math_add_completions -W "$(__math_custom_complete ---completion stats quantiles -- --custom)" + return ;; esac - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) -} -_math_help() { - opts="--version" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + + # Offer positional completions + case "${positional_number}" in + 1) + __math_add_completions -W 'alphabet'$'\n''alligator'$'\n''branch'$'\n''braggart' return - fi - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; + 2) + __math_add_completions -W "$(__math_custom_complete ---completion stats quantiles -- customArg)" + return + ;; + esac } +_math_help() { + flags=(--version) + options=() + __math_offer_flags_options 1 +} -complete -F _math math +complete -o filenames -F _math math diff --git a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash index 599a68f4f..8da5e1893 100644 --- a/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash +++ b/Tests/ArgumentParserUnitTests/Snapshots/testBase_Bash().bash @@ -1,108 +1,241 @@ #!/bin/bash -_base_test() { - export SAP_SHELL=bash - SAP_SHELL_VERSION="$(IFS='.'; printf %s "${BASH_VERSINFO[*]}")" - export SAP_SHELL_VERSION - cur="${COMP_WORDS[COMP_CWORD]}" - 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[@]}")" - if [[ $COMP_CWORD == "1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return +# positional arguments: +# +# - 1: the current (sub)command's count of positional arguments +# +# required variables: +# +# - flags: the flags that the current (sub)command can accept +# - options: the options that the current (sub)command can accept +# - positional_number: value ignored +# - unparsed_words: unparsed words from the current command line +# +# modified variables: +# +# - flags: remove flags for this (sub)command that are already on the command line +# - options: remove options for this (sub)command that are already on the command line +# - positional_number: set to the current positional number +# - unparsed_words: remove all flags, options, and option values for this (sub)command +__base_test_offer_flags_options() { + local -ir positional_count="${1}" + positional_number=0 + + local was_flag_option_terminator_seen=false + local is_parsing_option_value=false + + local -ar unparsed_word_indices=("${!unparsed_words[@]}") + local -i word_index + for word_index in "${unparsed_word_indices[@]}"; do + if "${is_parsing_option_value}"; then + # This word is an option value: + # Reset marker for next word iff not currently the last word + [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]] && is_parsing_option_value=false + unset "unparsed_words[${word_index}]" + # Do not process this word as a flag or an option + continue + fi + + local word="${unparsed_words["${word_index}"]}" + if ! "${was_flag_option_terminator_seen}"; then + case "${word}" in + --) + unset "unparsed_words[${word_index}]" + # by itself -- is a flag/option terminator, but if it is the last word, it is the start of a completion + if [[ "${word_index}" -ne "${unparsed_word_indices[${#unparsed_word_indices[@]} - 1]}" ]]; then + was_flag_option_terminator_seen=true + fi + continue + ;; + -*) + # ${word} is a flag or an option + # If ${word} is an option, mark that the next word to be parsed is an option value + local option + for option in "${options[@]}"; do + [[ "${word}" = "${option}" ]] && is_parsing_option_value=true && break + done + + # Remove ${word} from ${flags} or ${options} so it isn't offered again + local not_found=true + local -i index + for index in "${!flags[@]}"; do + if [[ "${flags[${index}]}" = "${word}" ]]; then + unset "flags[${index}]" + flags=("${flags[@]}") + not_found=false + break + fi + done + if "${not_found}"; then + for index in "${!options[@]}"; do + if [[ "${options[${index}]}" = "${word}" ]]; then + unset "options[${index}]" + options=("${options[@]}") + break + fi + done + fi + unset "unparsed_words[${word_index}]" + continue + ;; + esac + fi + + # ${word} is neither a flag, nor an option, nor an option value + if [[ "${positional_number}" -lt "${positional_count}" ]]; then + # ${word} is a positional + ((positional_number++)) + unset "unparsed_words[${word_index}]" + else + if [[ -z "${word}" ]]; then + # Could be completing a flag, option, or subcommand + positional_number=-1 + else + # ${word} is a subcommand or invalid, so stop processing this (sub)command + positional_number=-2 + fi + break + fi + done + + unparsed_words=("${unparsed_words[@]}") + + if\ + ! "${was_flag_option_terminator_seen}"\ + && ! "${is_parsing_option_value}"\ + && [[ ("${cur}" = -* && "${positional_number}" -ge 0) || "${positional_number}" -eq -1 ]] + then + COMPREPLY+=($(compgen -W "${flags[*]} ${options[*]}" -- "${cur}")) fi - case $prev in - --name) +} - return +__base_test_add_completions() { + local completion + while IFS='' read -r completion; do + COMPREPLY+=("${completion}") + done < <(IFS=$'\n' compgen "${@}" -- "${cur}") +} + +__base_test_custom_complete() { + if [[ -n "${cur}" || -z ${COMP_WORDS[${COMP_CWORD}]} || "${COMP_LINE:${COMP_POINT}:1}" != ' ' ]]; then + local -ar words=("${COMP_WORDS[@]}") + else + local -ar words=("${COMP_WORDS[@]::${COMP_CWORD}}" '' "${COMP_WORDS[@]:${COMP_CWORD}}") + fi + + "${COMP_WORDS[0]}" "${@}" "${words[@]}" +} + +_base_test() { + trap "$(shopt -p);$(shopt -po)" RETURN + shopt -s extglob + set +o history +o posix + + local -xr SAP_SHELL=bash + local -x SAP_SHELL_VERSION + SAP_SHELL_VERSION="$(IFS='.';printf %s "${BASH_VERSINFO[*]}")" + local -r SAP_SHELL_VERSION + + local -r cur="${2}" + local -r prev="${3}" + + local -i positional_number + local -a unparsed_words=("${COMP_WORDS[@]:1:${COMP_CWORD}}") + + local -a flags=(--one --two --three --kind-counter -h --help) + local -a options=(--name --kind --other-kind --path1 --path2 --path3 --rep1 -r --rep2) + __base_test_offer_flags_options 2 + + # Offer option value completions + case "${prev}" in + --name) + return ;; - --kind) - COMPREPLY=( $(compgen -W "one two custom-three" -- "$cur") ) - return + --kind) + __base_test_add_completions -W 'one'$'\n''two'$'\n''custom-three' + return ;; - --other-kind) - COMPREPLY=( $(compgen -W "b1_bash b2_bash b3_bash" -- "$cur") ) - return + --other-kind) + __base_test_add_completions -W 'b1_bash'$'\n''b2_bash'$'\n''b3_bash' + return ;; - --path1) - if declare -F _filedir >/dev/null; then - _filedir - else - COMPREPLY=( $(compgen -f -- "$cur") ) - fi - return + --path1) + __base_test_add_completions -f + return ;; - --path2) - if declare -F _filedir >/dev/null; then - _filedir - else - COMPREPLY=( $(compgen -f -- "$cur") ) - fi - return + --path2) + __base_test_add_completions -f + return ;; - --path3) - COMPREPLY=( $(compgen -W "c1_bash c2_bash c3_bash" -- "$cur") ) - return + --path3) + __base_test_add_completions -W 'c1_bash'$'\n''c2_bash'$'\n''c3_bash' + return ;; - --rep1) - - return + --rep1) + return ;; - -r|--rep2) + -r|--rep2) + return + ;; + esac - return + # Offer positional completions + case "${positional_number}" in + 1) + __base_test_add_completions -W "$(__base_test_custom_complete ---completion -- argument)" + return + ;; + 2) + __base_test_add_completions -W "$(__base_test_custom_complete ---completion -- nested.nestedArgument)" + return ;; esac - case ${COMP_WORDS[1]} in - (sub-command) - _base_test_sub-command 2 - return - ;; - (escaped-command) - _base_test_escaped-command 2 - return - ;; - (help) - _base_test_help 2 - return - ;; + + # Offer subcommand / subcommand argument completions + local -r subcommand="${unparsed_words[0]}" + unset 'unparsed_words[0]' + unparsed_words=("${unparsed_words[@]}") + case "${subcommand}" in + sub-command|escaped-command|help) + # Offer subcommand argument completions + "_base_test_${subcommand}" + ;; + *) + # Offer subcommand completions + COMPREPLY+=($(compgen -W 'sub-command escaped-command help' -- "${cur}")) + ;; esac - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } + _base_test_sub_command() { - opts="-h --help" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(-h --help) + options=() + __base_test_offer_flags_options 0 } + _base_test_escaped_command() { - opts="--one -h --help" - opts="$opts $("${COMP_WORDS[0]}" ---completion escaped-command -- two "${COMP_WORDS[@]}")" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + flags=(-h --help) + options=(--one) + __base_test_offer_flags_options 1 + + # Offer option value completions + case "${prev}" in + --one) return - fi - case $prev in - --one) + ;; + esac - return + # Offer positional completions + case "${positional_number}" in + 1) + __base_test_add_completions -W "$(__base_test_custom_complete ---completion escaped-command -- two)" + return ;; esac - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } + _base_test_help() { - opts="" - if [[ $COMP_CWORD == "$1" ]]; then - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) - return - fi - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + : } - -complete -F _base_test base-test \ No newline at end of file +complete -o filenames -F _base_test base-test \ No newline at end of file