diff --git a/bash_completion b/bash_completion index 38578f4bd0d..77f8ea303b2 100644 --- a/bash_completion +++ b/bash_completion @@ -2156,46 +2156,101 @@ _comp_realcommand() fi } -# This function returns the first argument, excluding options -# @var[out] ret First argument before current being completed if any, or -# otherwise an empty string +# This function returns the position of the first argument, excluding options +# +# Options: +# -a GLOB Pattern of options that take an option argument +# +# @var[out] ret Position of the first argument before the current one being +# completed if any, or otherwise an empty string # @return True (0) if any argument is found, False (> 0) otherwise. # @since 2.12 -_comp_get_first_arg() +_comp_locate_first_arg() { - local i + local has_optarg="" + local OPTIND=1 OPTARG="" OPTERR=0 _opt + while getopts ':a:' _opt "$@"; do + case $_opt in + a) has_optarg=$OPTARG ;; + *) + echo "bash_completion: $FUNCNAME: usage error" >&2 + return 2 + ;; + esac + done + shift "$((OPTIND - 1))" + local i ret= for ((i = 1; i < cword; i++)); do - if [[ ${words[i]} != -?* ]]; then - ret=${words[i]} + # shellcheck disable=SC2053 + if [[ $has_optarg && ${words[i]} == $has_optarg ]]; then + ((i++)) + elif [[ ${words[i]} != -?* ]]; then + ret=$i return 0 elif [[ ${words[i]} == -- ]]; then - ((i + 1 < cword)) && ret=${words[i + 1]} && return 0 + ((i + 1 < cword)) && ret=$((i + 1)) && return 0 break fi done return 1 } +# This function returns the first argument, excluding options +# +# Options: +# -a GLOB Pattern of options that take an option argument +# +# @var[out] ret First argument before the current one being completed if any, +# or otherwise an empty string +# @return True (0) if any argument is found, False (> 0) otherwise. +# @since 2.12 +_comp_get_first_arg() +{ + _comp_locate_first_arg "$@" && ret=${words[ret]} +} + # This function counts the number of args, excluding options -# @param $1 chars Characters out of $COMP_WORDBREAKS which should -# NOT be considered word breaks. See _comp__reassemble_words. -# @param $2 glob Options whose following argument should not be counted -# @param $3 glob Options that should be counted as args +# +# Options: +# -n CHARS Characters out of $COMP_WORDBREAKS which should +# NOT be considered word breaks. See +# _comp__reassemble_words. +# -a GLOB Options whose following argument should not be counted +# -i GLOB Options that should be counted as args +# # @var[out] ret Return the number of arguments # @since 2.12 _comp_count_args() { - local i cword words - _comp__reassemble_words "${1-}" words cword + local has_optarg="" has_exclude="" exclude="" glob_include="" + local OPTIND=1 OPTARG="" OPTERR=0 _opt + while getopts ':a:n:i:' _opt "$@"; do + case $_opt in + a) has_optarg=$OPTARG ;; + n) has_exclude=set exclude+=$OPTARG ;; + i) glob_include=$OPTARG ;; + *) + echo "bash_completion: $FUNCNAME: usage error" >&2 + return 2 + ;; + esac + done + shift "$((OPTIND - 1))" + if [[ $has_exclude ]]; then + local cword words + _comp__reassemble_words "$exclude<>&" words cword + fi + + local i ret=1 for ((i = 1; i < cword; i++)); do # shellcheck disable=SC2053 - if [[ ${2-} && ${words[i]} == ${2-} ]]; then + if [[ $has_optarg && ${words[i]} == $has_optarg ]]; then ((i++)) - elif [[ ${words[i]} != -?* || ${3-} && ${words[i]} == ${3-} ]]; then + elif [[ ${words[i]} != -?* || $glob_include && ${words[i]} == $glob_include ]]; then ((ret++)) elif [[ ${words[i]} == -- ]]; then ((ret += cword - i - 1)) diff --git a/completions/7z b/completions/7z index 87eca50a4aa..b44e3f5675e 100644 --- a/completions/7z +++ b/completions/7z @@ -85,7 +85,7 @@ _comp_cmd_7z() fi local ret - _comp_count_args "=" + _comp_count_args if ((ret == 2)); then _filedir_xspec unzip "${@:2}" # TODO: parsing 7z i output? diff --git a/completions/arp b/completions/arp index 3e653516fd0..cd83a6eb6ae 100644 --- a/completions/arp +++ b/completions/arp @@ -34,7 +34,7 @@ _comp_cmd_arp() fi local ret - _comp_count_args "" "@(--device|--protocol|--file|--hw-type|-${noargopts}[iApfHt])" + _comp_count_args -a "@(--device|--protocol|--file|--hw-type|-${noargopts}[iApfHt])" case $ret in 1) local ips=$("$1" -an | command sed -ne \ diff --git a/completions/chmod b/completions/chmod index 440750f89f5..9e66ac0465c 100644 --- a/completions/chmod +++ b/completions/chmod @@ -28,7 +28,7 @@ _comp_cmd_chmod() fi local ret - _comp_count_args "" "" "$modearg" + _comp_count_args -i "$modearg" case $ret in 1) ;; # mode diff --git a/completions/chown b/completions/chown index 986dec79cc4..00c81e98c7f 100644 --- a/completions/chown +++ b/completions/chown @@ -32,7 +32,7 @@ _comp_cmd_chown() local ret # The first argument is a usergroup; the rest are filedir. - _comp_count_args : + _comp_count_args if ((ret == 1)); then _comp_compgen_usergroup -u diff --git a/completions/cryptsetup b/completions/cryptsetup index f7e6675c9fd..e6b2c5abe4f 100644 --- a/completions/cryptsetup +++ b/completions/cryptsetup @@ -37,7 +37,7 @@ _comp_cmd_cryptsetup() local ret if _comp_get_first_arg; then local arg=$ret - _comp_count_args "" "-${noargopts}[chslSbopitTdM]" + _comp_count_args -a "-${noargopts}[chslSbopitTdM]" local args=$ret case $arg in open | create | luksOpen | loopaesOpen | tcryptOpen) diff --git a/completions/gpgv b/completions/gpgv index 2d4aba65215..78a8b9d842b 100644 --- a/completions/gpgv +++ b/completions/gpgv @@ -20,7 +20,7 @@ _comp_cmd_gpgv() esac local ret - _comp_count_args "" "--@(weak-digest|*-fd|keyring|homedir)" + _comp_count_args -a "--@(weak-digest|*-fd|keyring|homedir)" local args=$ret if [[ $cur == -* && $args -eq 1 ]]; then diff --git a/completions/ifup b/completions/ifup index 58c642f0916..b14c24b2310 100644 --- a/completions/ifup +++ b/completions/ifup @@ -29,7 +29,7 @@ _comp_cmd_ifupdown() fi local ret - _comp_count_args "" "@(--allow|-i|--interfaces|--state-dir|-X|--exclude|-o)" + _comp_count_args -a "@(--allow|-i|--interfaces|--state-dir|-X|--exclude|-o)" if ((ret == 1)); then _comp_compgen_configured_interfaces diff --git a/completions/jq b/completions/jq index 537b8f5a572..a12a16e4ef1 100644 --- a/completions/jq +++ b/completions/jq @@ -66,7 +66,7 @@ _comp_cmd_jq() local ret # TODO: DTRT with args taking 2 options # -f|--from-file are not counted here because they supply the filter - _comp_count_args "" "@(--arg|--arg?(json|file)|--slurpfile|--indent|--run-tests|-${noargopts}L)" + _comp_count_args -a "@(--arg|--arg?(json|file)|--slurpfile|--indent|--run-tests|-${noargopts}L)" # 1st arg is filter ((ret == 1)) && return diff --git a/completions/jsonschema b/completions/jsonschema index 2b7c49d0e94..a150deeb34a 100644 --- a/completions/jsonschema +++ b/completions/jsonschema @@ -21,7 +21,7 @@ _comp_cmd_jsonschema() fi local ret - _comp_count_args "" "-*" + _comp_count_args -a "-*" ((ret == 1)) || return _comp_compgen_filedir '@(json|schema)' } && diff --git a/completions/nc b/completions/nc index bb44418b14f..c4a3e4d4cd8 100644 --- a/completions/nc +++ b/completions/nc @@ -39,7 +39,7 @@ _comp_cmd_nc() # Complete 1st non-option arg only local ret - _comp_count_args "" "-*[IiMmOPpqsTVWwXx]" + _comp_count_args -n "" -a "-*[IiMmOPpqsTVWwXx]" ((ret == 1)) || return _known_hosts_real -- "$cur" diff --git a/completions/nslookup b/completions/nslookup index 13b029f23d6..553e4001e3e 100644 --- a/completions/nslookup +++ b/completions/nslookup @@ -53,7 +53,7 @@ _comp_cmd_nslookup() fi local ret - _comp_count_args "=" + _comp_count_args if ((ret <= 2)); then _known_hosts_real -- "$cur" [[ $ret -eq 1 && $cur == @(|-) ]] && COMPREPLY+=(-) @@ -90,7 +90,7 @@ _comp_cmd_host() fi local ret - _comp_count_args "" "-*[ctmNRW]" + _comp_count_args -a "-*[ctmNRW]" if ((ret == 1)); then _known_hosts_real -- "$cur" elif ((ret == 2)); then diff --git a/completions/sh b/completions/sh index ee680d67b0b..9c4e704c965 100644 --- a/completions/sh +++ b/completions/sh @@ -26,7 +26,7 @@ _comp_cmd_sh() fi local ret ext= - _comp_count_args "" "@(-c|[-+]o)" + _comp_count_args -a "@(-c|[-+]o)" ((ret == 1)) && ext="sh" _comp_compgen_filedir $ext } && diff --git a/completions/ssh b/completions/ssh index d67c35d9f50..f219c8592a2 100644 --- a/completions/ssh +++ b/completions/ssh @@ -375,7 +375,7 @@ _comp_cmd_ssh() else local ret # Keep glob sort in sync with cases above - _comp_count_args "=" "-*[BbcDeLpRWEFSIiJlmOoQw]" + _comp_count_args -n "=" -a "-*[BbcDeLpRWEFSIiJlmOoQw]" if ((ret > 1)); then compopt -o filenames _comp_compgen_commands diff --git a/test/t/unit/test_unit_count_args.py b/test/t/unit/test_unit_count_args.py index 064041bf615..1f91554b2a6 100644 --- a/test/t/unit/test_unit_count_args.py +++ b/test/t/unit/test_unit_count_args.py @@ -4,50 +4,59 @@ @pytest.mark.bashcomp( - cmd=None, ignore_env=r"^[+-](ret|COMP_(WORDS|CWORD|LINE|POINT))=" + cmd=None, + ignore_env=r"^[+-](ret|cword|words|COMP_(WORDS|CWORD|LINE|POINT))=", ) class TestUnitCountArgs(TestUnitBase): - def _test(self, *args, **kwargs): - return self._test_unit( - '_comp_count_args %s; echo "$ret"', *args, **kwargs + @pytest.fixture + def functions(self, bash): + assert_bash_exec( + bash, + '_comp__test_unit() { local -a words=(); local cword ret=""; _comp__reassemble_words "<>&" words cword; _comp_count_args "$@"; echo "$ret"; }', ) + def _test(self, *args, **kwargs): + return self._test_unit("_comp__test_unit %s", *args, **kwargs) + def test_1(self, bash): - assert_bash_exec(bash, "COMP_CWORD= _comp_count_args") + assert_bash_exec( + bash, + 'COMP_LINE= COMP_POINT=0 COMP_WORDS=() COMP_CWORD=; _comp_count_args -n ""', + ) - def test_2(self, bash): + def test_2(self, bash, functions): """a b| should set args to 1""" output = self._test(bash, "(a b)", 1, "a b", 3) assert output == "1" - def test_3(self, bash): + def test_3(self, bash, functions): """a b|c should set args to 1""" output = self._test(bash, "(a bc)", 1, "a bc", 3) assert output == "1" - def test_4(self, bash): + def test_4(self, bash, functions): """a b c| should set args to 2""" output = self._test(bash, "(a b c)", 2, "a b c", 4) assert output == "2" - def test_5(self, bash): + def test_5(self, bash, functions): """a b| c should set args to 1""" output = self._test(bash, "(a b c)", 1, "a b c", 3) assert output == "1" - def test_6(self, bash): + def test_6(self, bash, functions): """a b -c| d should set args to 2""" output = self._test(bash, "(a b -c d)", 2, "a b -c d", 6) assert output == "2" - def test_7(self, bash): + def test_7(self, bash, functions): """a b -c d e| with -c arg excluded should set args to 2""" output = self._test( - bash, "(a b -c d e)", 4, "a b -c d e", 10, arg='"" "@(-c|--foo)"' + bash, "(a b -c d e)", 4, "a b -c d e", 10, arg='-a "@(-c|--foo)"' ) assert output == "2" - def test_8(self, bash): + def test_8(self, bash, functions): """a -b -c d e| with -c arg excluded and -b included should set args to 1""" output = self._test( @@ -56,14 +65,14 @@ def test_8(self, bash): 4, "a -b -c d e", 11, - arg='"" "@(-c|--foo)" "-[b]"', + arg='-a "@(-c|--foo)" -i "-[b]"', ) assert output == "2" - def test_9(self, bash): + def test_9(self, bash, functions): """a -b -c d e| with -b included should set args to 3""" output = self._test( - bash, "(a -b -c d e)", 4, "a -b -c d e", 11, arg='"" "" "-b"' + bash, "(a -b -c d e)", 4, "a -b -c d e", 11, arg='-i "-b"' ) assert output == "3" @@ -75,7 +84,7 @@ def test_10_single_hyphen_1(self, bash): def test_10_single_hyphen_2(self, bash): """- in an option argument should be skipped""" output = self._test( - bash, "(a -b - c - e)", 5, "a -b - c - e", 11, arg='"" "-b"' + bash, "(a -b - c - e)", 5, "a -b - c - e", 11, arg='-a "-b"' ) assert output == "3" @@ -94,35 +103,45 @@ def test_11_double_hyphen_2(self, bash): def test_12_exclude_optarg_1(self, bash): """an option argument should be skipped even if it matches the argument pattern""" output = self._test( - bash, "(a -o -x b c)", 4, "a -o -x b c", 10, arg='"" "-o" "-x"' + bash, "(a -o -x b c)", 4, "a -o -x b c", 10, arg='-a "-o" -i "-x"' ) assert output == "2" def test_12_exclude_optarg_2(self, bash): """an option argument should be skipped even if it matches the argument pattern""" output = self._test( - bash, "(a -o -x -x c)", 4, "a -o -x -x c", 11, arg='"" "-o" "-x"' + bash, + "(a -o -x -x c)", + 4, + "a -o -x -x c", + 11, + arg='-a "-o" -i "-x"', ) assert output == "2" def test_12_exclude_optarg_3(self, bash): """an option argument should be skipped even if it matches the argument pattern""" output = self._test( - bash, "(a -o -x -y c)", 4, "a -o -x -y c", 11, arg='"" "-o" "-x"' + bash, + "(a -o -x -y c)", + 4, + "a -o -x -y c", + 11, + arg='-a "-o" -i "-x"', ) assert output == "1" def test_13_plus_option_optarg(self, bash): """When +o is specified to be an option taking an option argument, it should not be counted as an argument""" output = self._test( - bash, "(a +o b c)", 3, "a +o b c", 7, arg='"" "+o"' + bash, "(a +o b c)", 3, "a +o b c", 7, arg='-a "+o"' ) assert output == "1" def test_14_no_optarg_chain_1(self, bash): """an option argument should not take another option argument""" output = self._test( - bash, "(a -o -o -o -o c)", 5, "a -o -o -o -o c", 14, arg='"" "-o"' + bash, "(a -o -o -o -o c)", 5, "a -o -o -o -o c", 14, arg='-a "-o"' ) assert output == "1" @@ -141,7 +160,7 @@ def test_14_no_optarg_chain_2(self, bash): def test_15_double_hyphen_optarg(self, bash): """-- should lose its meaning when it is an option argument""" output = self._test( - bash, "(a -o -- -b -c d)", 5, "a -o -- -b -c d", 14, arg='"" "-o"' + bash, "(a -o -- -b -c d)", 5, "a -o -- -b -c d", 14, arg='-a "-o"' ) assert output == "1" diff --git a/test/t/unit/test_unit_get_first_arg.py b/test/t/unit/test_unit_get_first_arg.py index 78186c4a6c7..4e451116f86 100644 --- a/test/t/unit/test_unit_get_first_arg.py +++ b/test/t/unit/test_unit_get_first_arg.py @@ -12,60 +12,79 @@ def functions(self, bash): '_comp__test_unit() { local -a "words=$1"; local cword=$2 ret=; shift 2; _comp_get_first_arg "$@" && printf "%s\\n" "$ret"; return 0; }', ) + def _test(self, bash, words, cword, args=""): + return assert_bash_exec( + bash, + '_comp__test_unit "%s" %d %s' % (words, cword, args), + want_output=None, + ).strip() + def test_1(self, bash, functions): assert_bash_exec(bash, "_comp__test_unit '()' 0") def test_2(self, bash, functions): - output = assert_bash_exec( - bash, '_comp__test_unit "(a b)" 2', want_output=None - ).strip() + output = self._test(bash, "(a b)", 2) assert output == "b" def test_3(self, bash, functions): - output = assert_bash_exec( - bash, '_comp__test_unit "(a bc)" 2', want_output=None - ).strip() + output = self._test(bash, "(a bc)", 2) assert output == "bc" def test_4(self, bash, functions): - output = assert_bash_exec( - bash, '_comp__test_unit "(a b c)" 2', want_output=None - ).strip() + output = self._test(bash, "(a b c)", 2) assert output == "b" def test_5(self, bash, functions): """Neither of the current word and the command name should be picked as the first argument""" - output = assert_bash_exec( - bash, '_comp__test_unit "(a b c)" 1', want_output=None - ).strip() + output = self._test(bash, "(a b c)", 1) assert output == "" def test_6(self, bash, functions): """Options starting with - should not be picked as the first argument""" - output = assert_bash_exec( - bash, '_comp__test_unit "(a -b -c d e)" 4', want_output=None - ).strip() + output = self._test(bash, "(a -b -c d e)", 4) assert output == "d" def test_7_single_hyphen(self, bash, functions): """- should be counted as an argument representing stdout/stdin""" - output = assert_bash_exec( - bash, '_comp__test_unit "(a -b - c -d e)" 5', want_output=None - ).strip() + output = self._test(bash, "(a -b - c -d e)", 5) assert output == "-" def test_8_double_hyphen_1(self, bash, functions): """any word after -- should be picked""" - output = assert_bash_exec( - bash, '_comp__test_unit "(a -b -- -c -d e)" 5', want_output=None - ).strip() + output = self._test(bash, "(a -b -- -c -d e)", 5) assert output == "-c" def test_8_double_hyphen_2(self, bash, functions): """any word after -- should be picked only without any preceding argument""" - output = assert_bash_exec( - bash, '_comp__test_unit "(a b -- -c -d e)" 5', want_output=None - ).strip() + output = self._test(bash, "(a b -- -c -d e)", 5) assert output == "b" + + def test_9_skip_optarg_1(self, bash, functions): + output = self._test(bash, "(a -b -c d e f)", 5, '-a "@(-c|--foo)"') + assert output == "e" + + def test_9_skip_optarg_2(self, bash, functions): + output = self._test(bash, "(a -b --foo d e f)", 5, '-a "@(-c|--foo)"') + assert output == "e" + + def test_9_skip_optarg_3(self, bash): + output = self._test(bash, "(a -b - c d e)", 5, '-a "-b"') + assert output == "c" + + def test_9_skip_optarg_4(self, bash): + output = self._test(bash, "(a -b -c d e f)", 5, '-a "-[bc]"') + assert output == "d" + + def test_9_skip_optarg_5(self, bash): + output = self._test(bash, "(a +o b c d)", 4, '-a "+o"') + assert output == "c" + + def test_9_skip_optarg_6(self, bash): + output = self._test(bash, "(a -o -o -o -o b c)", 6, '-a "-o"') + assert output == "b" + + def test_9_skip_optarg_7(self, bash): + output = self._test(bash, "(a -o -- -b -c d e)", 6, '-a "-o"') + assert output == "d"