From b60353508eb470515f1063a1e1a75bdf1fda730f Mon Sep 17 00:00:00 2001 From: Koichi Murase Date: Mon, 7 Aug 2023 00:36:59 +0900 Subject: [PATCH] feat(_comp_compgen): support `-U var` to unlocal var When `-U var` is specified for a builtin `compgen` call, variable `var` is unlocalized by `_comp_unlocal` just before storing the result to the target array. When `-U var` is specified for a generator call, variable `var` is unlocalized by `_comp_unlocal` just before calling the generator function `_comp_compgen_G2` (where `G2` is the generator name).. A generator should basically define local variables with the names starting with `_`. However, a generator sometimes needs to use local variable names that do not start with `_`. When the child generator call with a variable name (such as `local var; _comp_compgen -v var`) is used within the generator, the local variable can unexpectedly mask a local variable of the upper call. For example, the following call fails to obtain the result of generator `mygen1` because the array `arr` is masked by the same name of a local variable in `_comp_compgen_mygen1`. # generator with a problem _comp_compgen_mygen1() { local -a arr=(1 2 3) _comp_compgen -av arr -- -W '4 5 6' _comp_compgen_set "${arr[@]/#p}" } _comp_compgen -v arr mygen1 # fails to get the result in array `arr` To avoid this, a generator that defines a local variable that does not start with `_` can use the option `-U var` to unlocalize the variable on assigning the final result. # properly designed generator _comp_compgen_mygen1() { local -a arr=(1 2 3) _comp_compgen -av arr -- -W '4 5 6' _comp_compgen -U arr set "${arr[@]/#p}" } --- bash_completion | 149 ++++++++++++++++++------------- doc/api-and-naming.md | 44 ++++++++- test/t/unit/test_unit_compgen.py | 12 +++ 3 files changed, 141 insertions(+), 64 deletions(-) diff --git a/bash_completion b/bash_completion index 0ec10c8b64c..00e3b6bf7b1 100644 --- a/bash_completion +++ b/bash_completion @@ -422,6 +422,20 @@ _comp_compgen__error_fallback() # The array name should not start with an underscores "_", which is # internally used. The array name should not be either "IFS" or # "OPT{IND,ARG,ERR}". +# -U var Unlocalize VAR before performing the assignments. This option can +# be specified multiple times to register multiple variables. This +# option is supposed to be used in implementing a generator (G1) when +# G1 defines a local variable name that does not start with `_`. In +# such a case, when the target variable specified to G1 by `-v VAR1` +# conflicts with the local variable, the assignment to the target +# variable fails to propagate outside G1. To avoid such a situation, +# G1 can call `_comp_compgen` with `-U VAR` to unlocalize `VAR` +# before accessing the target variable. For a builtin compgen call +# (i.e., _comp_compgen [options] -- options), VAR is unlocalized +# after calling the builtin `compgen` but before assigning results to +# the target array. For a generator call (i.e., _comp_compgen +# [options] G2 ...), VAR is unlocalized before calling the child +# generator function `_comp_compgen_G2`. # -c cur Set a word used as a prefix to filter the completions. The default # is ${cur-}. # -R The same as -c ''. Use raw outputs without filtering. @@ -512,6 +526,7 @@ _comp_compgen() local _dir="" local _ifs=$' \t\n' _has_ifs="" local _icmd="" _xcmd="" + local -a _upvars=() local _old_nocasematch="" if shopt -q nocasematch; then @@ -519,7 +534,7 @@ _comp_compgen() shopt -u nocasematch fi local OPTIND=1 OPTARG="" OPTERR=0 _opt - while getopts ':av:Rc:C:lF:i:x:' _opt "$@"; do + while getopts ':av:U:Rc:C:lF:i:x:' _opt "$@"; do case $_opt in a) _append=set ;; v) @@ -529,6 +544,16 @@ _comp_compgen() fi _var=$OPTARG ;; + U) + if [[ $OPTARG == @(*[^_a-zA-Z0-9]*|[0-9]*|'') ]]; then + printf 'bash_completion: %s: -U: invalid variable name `%s'\''\n' "$FUNCNAME" "$OPTARG" >&2 + return 2 + elif [[ $OPTARG == @(_*|IFS|OPTIND|OPTARG|OPTERR) ]]; then + printf 'bash_completion: %s: -U: unnecessary to mark `%s'\'' as upvar\n' "$FUNCNAME" "$OPTARG" >&2 + return 2 + fi + _upvars+=("$OPTARG") + ;; c) _cur=$OPTARG ;; R) _cur="" ;; C) @@ -588,6 +613,8 @@ _comp_compgen() return 2 fi + ((${#_upvars[@]})) && _comp_unlocal "${_upvars[@]}" + if [[ $_dir ]]; then local _original_pwd=$PWD local PWD=${PWD-} OLDPWD=${OLDPWD-} @@ -653,6 +680,7 @@ _comp_compgen() return } + ((${#_upvars[@]})) && _comp_unlocal "${_upvars[@]}" _comp_split -l ${_append:+-a} "$_var" "$_result" } @@ -711,8 +739,8 @@ _comp_compgen_split() return 2 fi - local _split_input=$1 IFS=$' \t\n' - _comp_compgen -F "$_ifs" -- ${_compgen_options[@]+"${_compgen_options[@]}"} -W '$_split_input' + local input=$1 IFS=$' \t\n' + _comp_compgen -F "$_ifs" -U input -- ${_compgen_options[@]+"${_compgen_options[@]}"} -W '$input' } # Check if the argument looks like a path. @@ -959,14 +987,14 @@ _comp_get_words() _comp_compgen_ltrim_colon() { (($#)) || return 0 - local -a tmp - tmp=("$@") + local -a _tmp + _tmp=("$@") if [[ $cur == *:* && $COMP_WORDBREAKS == *:* ]]; then # Remove colon-word prefix from items - local colon_word=${cur%"${cur##*:}"} - tmp=("${tmp[@]#"$colon_word"}") + local _colon_word=${cur%"${cur##*:}"} + _tmp=("${_tmp[@]#"$_colon_word"}") fi - _comp_compgen -R -- -W '"${tmp[@]}"' + _comp_compgen_set "${_tmp[@]}" } # If the word-to-complete contains a colon (:), left-trim COMPREPLY items with @@ -1078,9 +1106,7 @@ _comp_compgen_filedir() # Note: bash < 4.4 has a bug that all the elements are connected with # ${v+"${a[@]}"} when IFS does not contain whitespace. local IFS=$' \t\n' - local -a _tmp=(${toks[@]+"${toks[@]}"}) - _comp_unlocal toks - _comp_compgen_set ${_tmp[@]+"${_tmp[@]}"} + _comp_compgen -U toks set ${toks[@]+"${toks[@]}"} } # _comp_compgen_filedir() # This function splits $cur=--foo=bar into $prev=--foo, $cur=bar, making it @@ -1413,7 +1439,7 @@ _comp_compgen_help__get_help_lines() } # Helper function for _comp_compgen_help and _comp_compgen_usage. -# @var[in,out] _options Add options +# @var[in,out] options Add options # @return True (0) if an option was found, False (> 0) otherwise _comp_compgen_help__parse() { @@ -1441,12 +1467,12 @@ _comp_compgen_help__parse() if [[ $option =~ (\[((no|dont)-?)\]). ]]; then option2=${option/"${BASH_REMATCH[1]}"/} option2=${option2%%[<{().[]*} - _options+=("${option2/=*/=}") + options+=("${option2/=*/=}") option=${option/"${BASH_REMATCH[1]}"/"${BASH_REMATCH[2]}"} fi [[ $option =~ ^([^=<{().[]|\.[A-Za-z0-9])+=? ]] && - _options+=("$BASH_REMATCH") + options+=("$BASH_REMATCH") } # Parse GNU style help output of the given command and generate and store @@ -1466,7 +1492,7 @@ _comp_compgen_help() local -a _lines _comp_compgen_help__get_help_lines "$@" || return "$?" - local -a _options=() + local -a options=() local _line for _line in "${_lines[@]}"; do [[ $_line == *([[:blank:]])-* ]] || continue @@ -1476,9 +1502,9 @@ _comp_compgen_help() done _comp_compgen_help__parse "${_line// or /, }" done - ((${#_options[@]})) || return 1 + ((${#options[@]})) || return 1 - _comp_compgen -- -W '"${_options[@]}"' + _comp_compgen -U options -- -W '"${options[@]}"' return 0 } @@ -1498,7 +1524,7 @@ _comp_compgen_usage() local -a _lines _comp_compgen_help__get_help_lines "$@" || return "$?" - local -a _options=() + local -a options=() local _line _match _option _i _char for _line in "${_lines[@]}"; do while [[ $_line =~ \[[[:space:]]*(-[^]]+)[[:space:]]*\] ]]; do @@ -1509,7 +1535,7 @@ _comp_compgen_usage() # Treat as bundled short options for ((_i = 1; _i < ${#_option}; _i++)); do _char=${_option:_i:1} - [[ $_char != '[' ]] && _options+=("-$_char") + [[ $_char != '[' ]] && options+=("-$_char") done ;; *) @@ -1519,9 +1545,9 @@ _comp_compgen_usage() _line=${_line#*"$_match"} done done - ((${#_options[@]})) || return 1 + ((${#options[@]})) || return 1 - _comp_compgen -- -W '"${_options[@]}"' + _comp_compgen -U options -- -W '"${options[@]}"' return 0 } @@ -1533,7 +1559,7 @@ _comp_compgen_signals() { local -a sigs _comp_compgen -v sigs -c "SIG${cur#"${1-}"}" -- -P "${1-}" -A signal && - _comp_compgen_set "${sigs[@]/#${1-}SIG/${1-}}" + _comp_compgen -U sigs set "${sigs[@]/#${1-}SIG/${1-}}" } # This function completes on known mac addresses @@ -1541,7 +1567,7 @@ _comp_compgen_signals() # @since 2.12 _comp_compgen_mac_addresses() { - local re='\([A-Fa-f0-9]\{2\}:\)\{5\}[A-Fa-f0-9]\{2\}' + local _re='\([A-Fa-f0-9]\{2\}:\)\{5\}[A-Fa-f0-9]\{2\}' local PATH="$PATH:/sbin:/usr/sbin" local -a addresses @@ -1553,10 +1579,10 @@ _comp_compgen_mac_addresses() { LC_ALL=C ifconfig -a || ip -c=never link show || ip link show } 2>/dev/null | command sed -ne \ - "s/.*[[:space:]]HWaddr[[:space:]]\{1,\}\($re\)[[:space:]].*/\1/p" -ne \ - "s/.*[[:space:]]HWaddr[[:space:]]\{1,\}\($re\)[[:space:]]*$/\1/p" -ne \ - "s|.*[[:space:]]\(link/\)\{0,1\}ether[[:space:]]\{1,\}\($re\)[[:space:]].*|\2|p" -ne \ - "s|.*[[:space:]]\(link/\)\{0,1\}ether[[:space:]]\{1,\}\($re\)[[:space:]]*$|\2|p" + "s/.*[[:space:]]HWaddr[[:space:]]\{1,\}\($_re\)[[:space:]].*/\1/p" -ne \ + "s/.*[[:space:]]HWaddr[[:space:]]\{1,\}\($_re\)[[:space:]]*$/\1/p" -ne \ + "s|.*[[:space:]]\(link/\)\{0,1\}ether[[:space:]]\{1,\}\($_re\)[[:space:]].*|\2|p" -ne \ + "s|.*[[:space:]]\(link/\)\{0,1\}ether[[:space:]]\{1,\}\($_re\)[[:space:]]*$|\2|p" )" # ARP cache @@ -1564,15 +1590,15 @@ _comp_compgen_mac_addresses() { arp -an || ip -c=never neigh show || ip neigh show } 2>/dev/null | command sed -ne \ - "s/.*[[:space:]]\($re\)[[:space:]].*/\1/p" -ne \ - "s/.*[[:space:]]\($re\)[[:space:]]*$/\1/p" + "s/.*[[:space:]]\($_re\)[[:space:]].*/\1/p" -ne \ + "s/.*[[:space:]]\($_re\)[[:space:]]*$/\1/p" )" # /etc/ethers _comp_compgen -av addresses split -- "$(command sed -ne \ - "s/^[[:space:]]*\($re\)[[:space:]].*/\1/p" /etc/ethers 2>/dev/null)" + "s/^[[:space:]]*\($_re\)[[:space:]].*/\1/p" /etc/ethers 2>/dev/null)" - _comp_compgen_ltrim_colon "${addresses[@]}" + _comp_compgen -U addresses ltrim_colon "${addresses[@]}" } # This function completes on configured network interfaces @@ -1585,23 +1611,23 @@ _comp_compgen_configured_interfaces() # Debian system _comp_expand_glob files '/etc/network/interfaces /etc/network/interfaces.d/*' ((${#files[@]})) || return 0 - _comp_compgen_split -- "$(command sed -ne \ + _comp_compgen -U files split -- "$(command sed -ne \ 's|^iface \([^ ]\{1,\}\).*$|\1|p' "${files[@]}" 2>/dev/null)" elif [[ -f /etc/SuSE-release ]]; then # SuSE system _comp_expand_glob files '/etc/sysconfig/network/ifcfg-*' ((${#files[@]})) || return 0 - _comp_compgen_split -- "$(printf '%s\n' "${files[@]}" | + _comp_compgen -U files split -- "$(printf '%s\n' "${files[@]}" | command sed -ne 's|.*ifcfg-\([^*].*\)$|\1|p')" elif [[ -f /etc/pld-release ]]; then # PLD Linux - _comp_compgen_split -- "$(command ls -B /etc/sysconfig/interfaces | + _comp_compgen -U files split -- "$(command ls -B /etc/sysconfig/interfaces | command sed -ne 's|.*ifcfg-\([^*].*\)$|\1|p')" else # Assume Red Hat _comp_expand_glob files '/etc/sysconfig/network-scripts/ifcfg-*' ((${#files[@]})) || return 0 - _comp_compgen_split -- "$(printf '%s\n' "${files[@]}" | + _comp_compgen -U files split -- "$(printf '%s\n' "${files[@]}" | command sed -ne 's|.*ifcfg-\([^*].*\)$|\1|p')" fi } @@ -1616,11 +1642,11 @@ _comp_compgen_configured_interfaces() # @since 2.12 _comp_compgen_ip_addresses() { - local n + local _n case ${1-} in - -a) n='6\{0,1\}' ;; - -6) n='6' ;; - *) n= ;; + -a) _n='6\{0,1\}' ;; + -6) _n='6' ;; + *) _n= ;; esac local PATH=$PATH:/sbin local addrs @@ -1628,13 +1654,13 @@ _comp_compgen_ip_addresses() LC_ALL=C ifconfig -a || ip -c=never addr show || ip addr show } 2>/dev/null | command sed -e 's/[[:space:]]addr:/ /' -ne \ - "s|.*inet${n}[[:space:]]\{1,\}\([^[:space:]/]*\).*|\1|p")" || + "s|.*inet${_n}[[:space:]]\{1,\}\([^[:space:]/]*\).*|\1|p")" || return - if [[ ! $n ]]; then - _comp_compgen -R -- -W '"${addrs[@]}"' + if [[ ! $_n ]]; then + _comp_compgen -U addrs set "${addrs[@]}" else - _comp_compgen_ltrim_colon "${addrs[@]}" + _comp_compgen -U addrs ltrim_colon "${addrs[@]}" fi } @@ -1665,7 +1691,7 @@ _comp_compgen_available_interfaces() fi } 2>/dev/null | awk \ '/^[^ \t]/ { if ($1 ~ /^[0-9]+:/) { print $2 } else { print $1 } }')" && - _comp_compgen -R -- -W '"${generated[@]/%[[:punct:]]/}"' + _comp_compgen -U generated set "${generated[@]}" } # Echo number of CPUs, falling back to 1 on failure. @@ -1831,7 +1857,7 @@ else fi fi ((${#procs[@]})) && - _comp_compgen -- -X "" -W '"${procs[@]}"' + _comp_compgen -U procs -- -X "" -W '"${procs[@]}"' } fi @@ -1879,7 +1905,7 @@ _comp_compgen_xinetd_services() local -a svcs _comp_expand_glob svcs '$xinetddir/!($_comp_backup_glob)' if ((${#svcs[@]})); then - _comp_compgen -- -W '"${svcs[@]#$xinetddir/}"' + _comp_compgen -U svcs -U xinetddir -- -W '"${svcs[@]#$xinetddir/}"' fi fi } @@ -1957,9 +1983,8 @@ _comp__init_set_up_service_completions # @since 2.12 _comp_compgen_kernel_modules() { - local modpath - modpath=/lib/modules/$1 - _comp_compgen_split -- "$(command ls -RL "$modpath" 2>/dev/null | + local _modpath=/lib/modules/$1 + _comp_compgen_split -- "$(command ls -RL "$_modpath" 2>/dev/null | command sed -ne 's/^\(.*\)\.k\{0,1\}o\(\.[gx]z\)\{0,1\}$/\1/p' \ -e 's/^\(.*\)\.ko\.zst$/\1/p')" } @@ -1992,26 +2017,24 @@ _comp_compgen_usergroup() # Completing group after 'user\:gr'. # Reply with a list of groups prefixed with 'user:', readline will # escape to the colon. - local prefix - prefix=${cur%%*([^:])} - prefix=${prefix//\\/} - local mycur=${cur#*[:]} + local _prefix + _prefix=${cur%%*([^:])} + _prefix=${_prefix//\\/} if [[ ${1-} == -u ]]; then - _comp_compgen -c "$mycur" allowed_groups + _comp_compgen -c "${cur#*:}" allowed_groups else - _comp_compgen -c "$mycur" -- -g + _comp_compgen -c "${cur#*:}" -- -g fi ((${#COMPREPLY[@]})) && - COMPREPLY=("${COMPREPLY[@]/#/$prefix}") + COMPREPLY=("${COMPREPLY[@]/#/$_prefix}") elif [[ $cur == *:* ]]; then # Completing group after 'user:gr'. # Reply with a list of unprefixed groups since readline with split on : # and only replace the 'gr' part - local mycur=${cur#*:} if [[ ${1-} == -u ]]; then - _comp_compgen -c "$mycur" allowed_groups + _comp_compgen -c "${cur#*:}" allowed_groups else - _comp_compgen -c "$mycur" -- -g + _comp_compgen -c "${cur#*:}" -- -g fi else # Completing a partial 'usernam'. @@ -2071,15 +2094,15 @@ _shells() # @since 2.12 _comp_compgen_fstypes() { - local fss + local _fss if [[ -e /proc/filesystems ]]; then # Linux - fss="$(cut -d$'\t' -f2 /proc/filesystems) + _fss="$(cut -d$'\t' -f2 /proc/filesystems) $(awk '! /\*/ { print $NF }' /etc/filesystems 2>/dev/null)" else # Generic - fss="$(awk '/^[ \t]*[^#]/ { print $3 }' /etc/fstab 2>/dev/null) + _fss="$(awk '/^[ \t]*[^#]/ { print $3 }' /etc/fstab 2>/dev/null) $(awk '/^[ \t]*[^#]/ { print $3 }' /etc/mnttab 2>/dev/null) $(awk '/^[ \t]*[^#]/ { print $4 }' /etc/vfstab 2>/dev/null) $(awk '{ print $1 }' /etc/dfs/fstypes 2>/dev/null) @@ -2087,7 +2110,7 @@ _comp_compgen_fstypes() $([[ -d /etc/fs ]] && command ls /etc/fs)" fi - [[ $fss ]] && _comp_compgen -- -W "$fss" + [[ $_fss ]] && _comp_compgen -- -W "$_fss" } # Get absolute path to a file, with rudimentary canonicalization. diff --git a/doc/api-and-naming.md b/doc/api-and-naming.md index eee33df8355..a83bcfa313e 100644 --- a/doc/api-and-naming.md +++ b/doc/api-and-naming.md @@ -150,7 +150,7 @@ calling `_comp_compgen` or other generators. To avoid conflicts with the options specified to `_comp_compgen`, one should not directly modify or reference the target variable. When post-filtering is needed, store them in a local array, filter them, and finally append them by -`_comp_compgen -- -W '"${arr[@]}"'`. To split the output of commands and +`_comp_compgen -- -W '"${_arr[@]}"'`. To split the output of commands and append the results to the target variable, use `_comp_compgen_split -- "$(cmd ...)"` instead of using `_comp_split COMPREPLY "$(cmd ...)"`. @@ -180,3 +180,45 @@ Exported generators are defined with the names `_comp_xfunc_CMD_compgen_NAME` and called by `_comp_compgen [opts] -x CMD NAME args`. Internal generators are defined with the names `_comp_cmd_CMD__compgen_NAME` and called by `_comp_compgen [opts] -i CMD NAME args`. + +#### Local variables of generator and `_comp_compgen -U var` + +A generator should basically define local variables with the names starting +with `_`. However, a generator sometimes needs to use local variable names +that do not start with `_`. When the child generator call with a variable name +(such as `local var; _comp_compgen -v var`) is used within the generator, the +local variable can unexpectedly mask a local variable of the upper call. + +For example, the following call fails to obtain the result of generator +`mygen1` because the array `arr` is masked by the same name of a local variable +in `_comp_compgen_mygen1`. + +```bash +# generator with a problem +_comp_compgen_mygen1() +{ + local -a arr=(1 2 3) + _comp_compgen -av arr -- -W '4 5 6' + _comp_compgen_set "${arr[@]/#p}" +} + +_comp_compgen -v arr mygen1 # fails to get the result in array `arr` +``` + +To avoid this, a generator that defines a local variable with its name not +starting with `_` can use the option `-U var` to unlocalize the variable on +assigning the final result. + +```bash +# properly designed generator +_comp_compgen_mygen1() +{ + local -a arr=(1 2 3) + _comp_compgen -av arr -- -W '4 5 6' + _comp_compgen -U arr set "${arr[@]/#p}" +} +``` + +To avoid unexpected unlocalization of previous-scope variables, a generator +should specify `-U var` to a child generator (that attempts to store results to +the current target variable) at most once. diff --git a/test/t/unit/test_unit_compgen.py b/test/t/unit/test_unit_compgen.py index f70de303033..f28e9a2b135 100644 --- a/test/t/unit/test_unit_compgen.py +++ b/test/t/unit/test_unit_compgen.py @@ -37,6 +37,12 @@ def functions(self, bash): "complete -F _comp_cmd_fcd fcd", ) + # test_8_option_U + assert_bash_exec( + bash, + "_comp_compgen_gen8() { local -a arr=(x y z); _comp_compgen -U arr -- -W '\"${arr[@]}\"'; }", + ) + def test_1_basic(self, bash, functions): output = assert_bash_exec( bash, "_comp__test_words 12 34 56 ''", want_output=True @@ -146,3 +152,9 @@ def test_7_xcmd(self, bash, functions): completions = assert_complete(bash, "compgen-cmd2 '") assert completions == ["012", "123", "234", "5foo", "6bar", "7baz"] + + def test_8_option_U(self, bash, functions): + output = assert_bash_exec( + bash, "_comp__test_compgen gen8", want_output=True + ) + assert output.strip() == ""