2

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
Kusalananda
  • 333,661

3 Answers3

4

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.

  • 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? – vatosarmat Sep 02 '22 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. – Stéphane Chazelas Sep 02 '22 at 07:58
  • 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. – QuartzCristal Sep 02 '22 at 10:47
3

The values in the case statement have to be separated with explicit |, as the | is part of the syntax. However, as the values in the case statement must be a pattern, an extended pattern could be used.

Generate string="*@($(IFS="|";echo "${choices[*]}"))*" and use it as an extended pattern as in:

#!/bin/bash

declare -ar choices=('foo' 'bar' 'baz')

string="@($(IFS="|";echo "${choices[]}"))" # value contains one option #string="@($(IFS="|";echo "${choices[]}"))" # value is exactly one option

shopt -s extglob function do_work { case "$1" in $string) echo 'yes!' ;; *) echo 'no!' ;; esac }

complete -W "${choices[*]}" do_work

But that is changing the option of extglob, we can avoid that by returning it to whatever value it had when starting the script before exiting. Note that the value of $string is expanded at the time of function definition, not at the time when the function is executed. But a simpler solution is to avoid setting (and possibly unsetting that shell option by using the fact that the right side of an = inside a [[...]] test works as if the extglob shell option were enabled (no, sorry, that doesn't work in [...]). So, we can reduce the case expression to [[ $1 = $string ]] and (for echo commands) this works:

[[ $1 = $string ]] && echo 'yes!' || echo 'no!'

But, in general, it is better to use functions, and, as functions are not guaranteed to return an exit code of 0, we actually should use:

if [[ $1 = $string ]]; then yesaction; else noaction; fi

As we are already solving the rough spots of this answer, we could as well make the completion able to use options with spaces as well. All that is needed is that the IFS splitting of options from ${complete_var} has each option inside quotes and separated by spaces.

#!/bin/bash

declare -a choices=('foo' 'foo bar' 'baz')

option_string="@($(printf -v var "%s|" "${choices[@]}"; printf '%s' "${var%?}" ))" complete_var="$( printf -v var "'"%s"' " "${choices[@]}"; printf '%s' "${var%?}" )"

#printf '%s\n' "${option_string}" "${complete_var}"

yesaction() { echo 'yes!'; } noaction () { echo 'no!' ; }

do_work () { if [[ $1 == $option_string ]] then yesaction else noaction fi
}

complete -W "${complete_var}" do_work

return 34; exit

That will set the values as fixed values inside the functions. No need to set choices to read only. And allows spaces inside the choices.

That is assuming that you will source (.) the script in your shell. Something like:

$ . ./script.sh
$ do_work <kbd>TAB</kbd>-<kbd>TAB</kbd>      # will show "foo" "bar" and "foo bar" as options.

The only glitch I can see is that the value of the completions will be presented inside double quotes: "foo", for example. But that is not a problem needing solution IMO.

1

You can also use =~ operator for checking if a value is in an array.

if [[ " ${choices[*]} " =~ " ${1} " ]]; then
    echo "yes!";
else
    echo "no!";
fi
  • 2
    Note that "${choices[*]}" joins the elements with the first character of $IFS (which only happens to be space in the initial value of $IFS), so you'd rather need: [[ "${IFS:0:1}${choices[*]}${IFS:0:1}" = *"${IFS:0:1}$1${IFS:0:1}"* ]]. Also beware that with the default value of $IFS, it would say yes! for $1 == "foo bar" – Stéphane Chazelas Sep 02 '22 at 07:45
  • 1
    Using [[ ... = *"$1"* ]] is usually better than [[ ... =~ "$1" ]], not only because it's more portable but also because most regexp libraries can't cope with non-text while bash's globs at least can (to some extent). – Stéphane Chazelas Sep 02 '22 at 08:02