Unix & Linux
bash shell-script autocomplete case
Updated Thu, 06 Oct 2022 07:54:32 GMT

Bash, use case statement to check if the word is in the array


I am writing a script which must accept a word from a limited predefined list as an argument. I also would like it to have completion. I'm storing list in a variable to avoid duplication between complete and case. So I've written this, completion does work, but case statement doesn't. Why? One can't just make case statement parameters out of variables?

declare -ar choices=('foo' 'bar' 'baz')
function do_work {
  case "$1" in 
    "${choices[*]}")
      echo 'yes!'
      ;;
    *)
      echo 'no!'
  esac
}
complete -W "${choices[*]}" do_work



Solution

The list in complete -W list is interpreted as a $IFS delimited list, and it's the $IFS at the time of completion that is taken into account.

So if you have:

complete -W 'a b,c d' do_work

do_work completion will offer a, b,c and d when $IFS contains space and a b and c d when $IFS contains , and not space.

So it's mostly broken by design. It also doesn't allow offering arbitrary strings as completions.

With these limitations, the best you can do is assume $IFS will never be modified (so will always contain space, tab and newline, characters that as a result can't be used in the completion words), and do:

choices='foo bar baz'
do_work() {
  case "$1" in
    (*' '*) echo 'no!';;
    (*)
      case " $choices " in 
        (*" $1 "*) echo 'yes!';;
        (*) echo 'no!';;
      esac;;
  esac
}
complete -W "$choices" do_work

You could add a readonly IFS to make sure $IFS is never modified, but that's likely to break things especially considering that bash doesn't let you declare a local variable that has been declared readonly in a parent scope, so even functions that do local IFS=, would break.

As for the more generic question of how to check whether a string is found amongst the elements of an array, bash (contrary to zsh) doesn't have an operator for that but, you could easily implement it with a loop:

amongst() {
  local string needle="$1"
  shift
  for string do
    [[ $needle = "$string" ]] && return
  done
  false
}

And then:

do_work {
  if amongst "$1" "${choices[@]}"; then
    echo 'yes!'
  else
    echo 'no!'
  fi
}

The more appropriate structure to look-up strings is to use hash tables or associative arrays:

typeset -A choices=( [foo]=1 [bar]=1 [baz]=1 )
do_work() {
  if [[ -n ${choices[+$1]} ]]; then
    echo 'yes!'
  else
    echo 'no!'
  fi
}
complete -W "${!choices[*]}" do_work

Here with "${!choices[*]}" joining the keys of the associative array with whichever is the first character of $IFS at that point (or with no separator if it's set but empty).

Note that bash associative arrays can't have an empty key, so the empty string can't be one of the choices, but anyway complete -W wouldn't support that either and completing an empty strings is not very useful anyway except maybe for the completion listing showing the user it's one of the accepted values.





Comments (3)

  • +0 – Argh, I forgot about IFS! Will it work if I set IFS to pattern-separator | and reset it to default after case statement? So "${choices[*]}" will expand to foo|bar|baz which seems to be valid case-pattern? — Sep 02, 2022 at 07:53  
  • +1 – @vatosarmat No, in case a in (a | b) ... the | is not part of the pattern, it's syntax in the case statement. You could do something like IFS='|' pattern="@(${choices[*]})"; shopt -s extglob; case $1 in ($pattern)... (or the equivalent with regexps) but that means you can't have glob (or regexps if using =~ instead of =/case) operators in your choices. — Sep 02, 2022 at 07:58  
  • +0 – Your choice of case " $choices " in (*" $1 "*) .... only allows for exact match, a $1 string of foobar will not be matched, only foo or bar. That is not the normal use of case statements and may cause (some) surprise to users. — Sep 02, 2022 at 10:47