25

I am trying to use a variable consisting of different strings separated with a | as a case statement test. For example:

string="\"foo\"|\"bar\""
read choice
case $choice in
    $string)
        echo "You chose $choice";;
    *)
        echo "Bad choice!";;
esac

I want to be able to type foo or bar and execute the first part of the case statement. However, both foo and bar take me to the second:

$ foo.sh
foo
Bad choice!
$ foo.sh
bar
Bad choice!

Using "$string" instead of $string makes no difference. Neither does using string="foo|bar".

I know I can do it this way:

case $choice in
    "foo"|"bar")
        echo "You chose $choice";;
    *)
        echo "Bad choice!";;
esac

I can think of various workarounds but I would like to know if it's possible to use a variable as a case condition in bash. Is it possible and, if so, how?

terdon
  • 234,489
  • 66
  • 447
  • 667
  • 3
    I can't bring myself to suggest it as a real answer, but since no one else has mentioned it, you _could_ wrap the case statement in an `eval`, escaping $choice, the parens, asterisk, semicolons, and newlines. Ugly, but it "works". – Jeff Schaller Oct 07 '15 at 18:50
  • @JeffSchaller - it's not a bad idea a lot of times, and is maybe just the ticket in this case. i considered recommending it, too, but the `read` bit stopped me. in my opinion user input validation, which is what this appears to be, `case` patterns should *not* be at the top of the evaluation list, and should rather be pruned down to the `*` default pattern such that the only results that reach there are guaranteed acceptable. still, because the issue is parse/expansion order, then a second evaluation could be what's called for. – mikeserv Oct 08 '15 at 00:57
  • Also look into `dmenu`. – Vorac Jun 29 '20 at 03:41

7 Answers7

29

The bash manual states:

case word in [ [(] pattern [ | pattern ] ... ) list ;; ] ... esac

Each pattern examined is expanded using tilde expansion, parameter and variable expansion, arithmetic substitution, command substitution, and process substitution.

No «Pathname expansion»

Thus: a pattern is NOT expanded with «Pathname expansion».

Therefore: a pattern could NOT contain "|" inside. Only: two patterns could be joined with the "|".

This works:

s1="foo"; s2="bar"    # or even s1="*foo*"; s2="*bar*"

read choice
case $choice in
    $s1|$s2 )     echo "Two val choice $choice"; ;;  # not "$s1"|"$s2"
    * )           echo "A Bad  choice! $choice"; ;;
esac

Using « Extended Globbing »

However, word is matched with pattern using « Pathname Expansion » rules.
And « Extended Globbing » here, here and, here allows the use of alternating ("|") patterns.

This also work:

shopt -s extglob

string='@(foo|bar)'

read choice
    case $choice in
        $string )      printf 'String  choice %-20s' "$choice"; ;;&
        $s1|$s2 )      printf 'Two val choice %-20s' "$choice"; ;;
        *)             printf 'A Bad  choice! %-20s' "$choice"; ;;
    esac
echo

String content

The next test script shows that the pattern that match all lines that contain either foo or bar anywhere is '*$(foo|bar)*' or the two variables $s1=*foo* and $s2=*bar*


Testing script:

shopt -s extglob    # comment out this line to test unset extglob.
shopt -p extglob

s1="*foo*"; s2="*bar*"

string="*foo*"
string="*foo*|*bar*"
string='@(*foo*|*bar)'
string='*@(foo|bar)*'
printf "%s\n" "$string"

while IFS= read -r choice; do
    case $choice in
        "$s1"|"$s2" )   printf 'A first choice %-20s' "$choice"; ;;&
        $string )   printf 'String  choice %-20s' "$choice"; ;;&
        $s1|$s2 )   printf 'Two val choice %-20s' "$choice"; ;;
        *)      printf 'A Bad  choice! %-20s' "$choice"; ;;
    esac
    echo
done <<-\_several_strings_
f
b
foo
bar
*foo*
*foo*|*bar*
\"foo\"
"foo"
afooline
onebarvalue
now foo with spaces
_several_strings_
12

You can use the extglob option:

shopt -s extglob
string='@(foo|bar)'
choroba
  • 45,735
  • 7
  • 84
  • 110
  • Interesting; what makes `@(foo|bar)` special compared to `foo|bar`? Both are valid patterns that work the same when typed literally. – chepner Oct 06 '15 at 13:53
  • 6
    Ah, never mind. `|` isn't part of the pattern in `foo|bar`, it's part of the syntax of the `case` statement to allow multiple patterns in one clause. `|` *is* part of the extended pattern, though. – chepner Oct 06 '15 at 13:57
5

You need two variables for case because the or | pipe is parsed before the patterns are expanded.

v1=foo v2=bar

case foo in ("$v1"|"$v2") echo foo; esac

foo

Shell patterns in variables are handled differently when quoted or unquoted as well:

q=?

case a in
("$q") echo question mark;;
($q)   echo not a question mark
esac

not a question mark
mikeserv
  • 57,448
  • 9
  • 113
  • 229
2

Here is a POSIX solution using eval :

#!/bin/sh
string="foo|bar"
read choice
eval "
case \$choice in
    $string)
        echo \"You chose \$choice\";;
    *)
        echo \"Bad choice!\";;
esac
"

Explanation : the eval command makes the shell first expand the arguments so we are left with

case $choice in
    foo|bar)
        echo "You chose $choice";;
    *)
        echo "Bad choice!";;
esac

and then it interprets again the command and executes it.

0

If You want a dash-compatible work-around, You could write:

  string="foo|bar"
  read choice
  awk 'BEGIN{
   n=split("'$string'",p,"|");
   for(i=1;i<=n;i++)
    if(system("\
      case \"'$choice'\" in "p[i]")\
       echo \"You chose '$choice'\";\
       exit 1;;\
      esac"))
     exit;
   print "Bad choice"
  }'

Explanation:

  • awk is used to split string and test each part separately. If choice matches the currently tested part p[i], the awk-command will be ended with exit in line 11.
  • For the very test, the shell's case is used (within a system-call), as asked by @terdon. This keeps the possibility to modify the intended test-string for example to foo*|bar in order to match also for foooo ("search pattern"), as the shell's case allows.
  • If instead you would prefer regular expressions, you could omit the system-call and use awk's ~or match instead.
AdminBee
  • 21,637
  • 21
  • 47
  • 71
Gerald Schade
  • 495
  • 1
  • 6
  • 7
  • 2
    Dear downvoter, I could better learn how to avoid useless solution candidates if You would tell me the reason for Your downvote. – Gerald Schade Jun 17 '19 at 05:31
  • 1
    I didn’t downvote, but: (1) The OP said “I can think of various workarounds but I would like to know if it's possible to *use a variable as a `case` condition **in  bash**.*” (emphasis added).  This answer not only involves a huge, powerful tool, but *it invokes a new, separate shell process **for each string (pattern) value (for each input value)**.*  Suppose you call your mechanic and say “My car won’t start” and they say “No problem. Call a cab, have the cab take you to the bus station, and take the bus to wherever you want to go.”  It’s an overkill non-answer.  … (Cont’d) – G-Man Says 'Reinstate Monica' Aug 28 '22 at 06:08
  • (Cont’d) …  (2) Also, I believe that your answer has problems with quoting.  (It’s better to pass shell variables into awk with `-v`, so they become Awk variables.)  (3) You replaced the shell command `echo "Bad choice!"` with the Awk statement `print "Bad choice"`.  But the code in the question is obviously just a dummy / stub / placeholder / example.  If the OP wanted to do some non-trivial shell commands for non-matching inputs, they would have to extend your answer to replace the `print "Bad choice"` statement with another `system()` call.  (3a) Likewise, … (Cont’d) – G-Man Says 'Reinstate Monica' Aug 28 '22 at 06:08
  • (Cont’d) …  if somebody changed your answer to use Awk’s pattern matching capabilities, they would still need `system()` if they wanted to anything more complex than an `echo` if the input matches a pattern. (4) Your Awk code depends on the shell code in the `system()` call ‘’failing’’ if there’s a match and ‘’succeeding’’ if there’s no match. (4a) This is intuitively backwards. (4b) It is not explained. (4c) It’s not clear why you expect the shell code to exit with a status of 0 if it ‘’falls off the end’’ (i.e., reaches the `esac` statement without hitting the `exit`). … (Cont’d) – G-Man Says 'Reinstate Monica' Aug 28 '22 at 06:08
  • (Cont’d) …  (5) There’s no provision for including a literal vertical bar in a pattern with `\|` (e.g., `em\|go`).  (6) Your code is hard to read.  It has inconsistent and shallow indentation, and the ‘body’ of the `for` loop is a single statement (`if`…`exit`) that spans six lines, but isn’t delimited by curly braces (`{`…`}`).  … (Cont’d) – G-Man Says 'Reinstate Monica' Aug 28 '22 at 19:38
  • 1
    (Cont’d) …  (7) Your explanation (the bulleted text at the bottom) is confusing, because you talk about regular expressions, and you say that ``foo*`` would match `foooo`.  That’s true, but, since `case` statements use glob patterns, not regular expressions, `foo*` also matches things like `food` and `footstep`. (And, of course, the regular expression `foo*` would also match `fo`.) – G-Man Says 'Reinstate Monica' Aug 28 '22 at 19:38
0

One can use AWK's syntax

if($field ~ /regex/)

as well as

if($i ~ var)

to compare variable to input (var and star-argument list $*)

parse_arg_exists() {
  [ $# -eq 1 ] && return
  [ $# -lt 2 ] && printf "%s\n" "Usage: ${FUNCNAME[*]} <match_case> list-or-\$*" \
  "Prints the argument index that's matched in the regex-case (~ patn|patn2)" && exit 1
  export arg_case=$1
  shift
  echo "$@" | awk 'BEGIN{FS=" "; ORS=" "; split(ENVIRON["arg_case"], a, "|")} {
    n=-1
    for(i in a) {
     for(f=1; f<=NF; f++) {
      if($f ~ a[i]) n=f
     }
    }
  }
  END {
    if(n >= 0) print "arg index " n "\n"
    }'
 unset arg_case
}
string="--dot|-d"
printf "testing %s\n" "$string"
args="--dot -b -c"; printf "%s\n" "$args"
parse_arg_exists "$string" "$args"
args="-b -o"; printf "%s\n" "$args"
parse_arg_exists "$string" "$args"
args="-b -d -a"; printf "%s\n" "$args"
parse_arg_exists "$string" "$args"

Prints out:

testing --dot|-d
--dot -b -c
arg index 1
 -b -o
-b -d -a
arg index 2
AdminBee
  • 21,637
  • 21
  • 47
  • 71
TRicks43
  • 1
  • 1
  • Thanks! However, as I said in the question, I can think of various workarounds, including using `=~` if on a shell that supports it such as newer versions of bash, but I was wondering specifically about using `case` with a variable. – terdon Jun 17 '20 at 16:59
0

If you don't want to assign multiple variables for each option and only have one variable containing all the choices in a single string, then a suggestion is by using =~ operator to treat "foo|bar" as a regular expression in an if statement. You can then expand the desired choices. "foo|bar|more|another-choice" This also supports inputs with a dash in the pattern.

References:

#!/bin/bash
string="^(foo|bar)$"
read choice
if [[ "${choice}" =~ ${string} ]]; then
  "You chose $choice"
else
  echo "Bad choice!"
fi
0xEBJC
  • 1
  • 1
  • (1) `|` is a vertical bar character.  Please don’t call it a “pipe” unless you’re using it to create a pipe; i.e., an inter-process data flow; e.g., `date | od -cb`. (2) The OP said “I can think of various workarounds, including using `=~` …, but I was wondering specifically about using `case` with a variable.” – G-Man Says 'Reinstate Monica' Aug 25 '22 at 18:00
  • note that the `[[ .. ]]` conditional isn't standard, so with the `/bin/sh` hashbang, that script won't work on a system that has a more limited shell as `sh`. Including most Debian and Ubuntu installations. Also the `\b` isn't part of standard regex syntax, so even with Bash, that might not work on all systems. It seems to work on Debian with the GNU tools, but e.g. not on Mac. – ilkkachu Aug 25 '22 at 20:15