26

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
  • 242,166
  • 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

30

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
  • 47,233
  • 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
  • 58,310
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
  • 22,803
Gerald Schade
  • 495
  • 1
  • 6
  • 8
  • 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 (ifexit) 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
  • 22,803
  • 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) | 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