Skip to content
VLSI Mentor

Linux & Bash · Part I · Module 16 · Conditionals and Pattern Branches

case: Branching on a Pattern

When one value has to be checked against six possibilities, no amount of elif makes it readable. Bash has a construct built for exactly that shape, and it brings something no chain of string comparisons can offer: the patterns are real glob patterns, the same ones you have been typing at the shell since Chapter 3.5.

Intermediate21 min readLinuxPart IBashConditionalsPatternsGlobbingScripting

Part I · Module 16 · Chapter 16.6 · case: Branching on a Pattern

Here is a dispatch written with the tools you have:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if [ "$tool" = "vcs" ]; then
  vendor="synopsys"
elif [ "$tool" = "xrun" ] || [ "$tool" = "irun" ]; then
  vendor="cadence"
elif [ "$tool" = "questa" ] || [ "$tool" = "vsim" ]; then
  vendor="siemens"
else
  vendor="unknown"
fi

It works. It also repeats $tool six times, and adding one more spelling of one more tool means adding another full [ ... ]. Here is the same thing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case "$tool" in
  vcs)           vendor="synopsys" ;;
  xrun|irun)     vendor="cadence"  ;;
  questa|vsim)   vendor="siemens"  ;;
  *)             vendor="unknown"  ;;
esac

The value is named once. Each arm carries its own patterns. The shape of the code is the shape of the decision.

A chain of tests against a pattern branch

Why a pattern branch beats a chain of tests when one value is being matched against many possibilities. A chain repeats the value on every line and compares it exactly, so adding an alternative spelling means adding another whole test. A pattern branch names the value once and lets each arm carry its own pattern, including alternatives and wildcards.A chain of testsone test per possibilityThe value is repeatedevery timeand each comparison is exactHard to read, easy tomistypeand an extra spelling meansanother whole testA pattern branchthe value is named once at thetopEach arm carries itsown patternwildcards and alternativesincludedThe shape shows thechoiceand a catch-all arm handlesthe rest12
When one value is matched against many possibilities, a chain repeats the value on every line and compares it exactly, so an extra spelling means an extra whole test. A pattern branch names the value once and lets each arm carry its own pattern — alternatives and wildcards included — with a single catch-all arm for everything that was not listed.

1. The Syntax

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case WORD in
  PATTERN)            commands ;;
  PATTERN|PATTERN)    commands ;;
  *)                  commands ;;
esac

Four things to take in, and then the punctuation stops being strange:

  • esac is case backwards, in the same joke as if/fi.
  • ) closes the pattern. There is no opening parenthesis — though you may write one, and some style guides do: (vcs) is legal and identical.
  • ;; ends an arm. It is not optional in the middle, and it is what stops the next arm from running.
  • *) is not special syntax. It is the glob *, which matches anything, placed last so that it catches whatever the earlier arms did not.

Running it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ for t in vcs xrun irun questa ""; do
>   case "$t" in
>     vcs)         echo "synopsys" ;;
>     xrun|irun)   echo "cadence"  ;;
>     questa)      echo "siemens"  ;;
>     *)           echo "unknown [$t]" ;;
>   esac
> done
synopsys
cadence
cadence
siemens
unknown []

An arm may hold as many commands as you like, separated normally:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ case a in
>   a) echo "one"; echo "two" ;;
> esac
one
two

And ;; may be omitted on the last arm, as long as a newline separates it from esac:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case "$1" in
  a) echo "is a" ;;
  b) echo "is b"
esac

That is legal and runs correctly. Write the ;; anyway — it costs two characters and it means adding a new arm below never breaks the one above it.

2. These Are Patterns, Not Strings

This is the whole reason case is more than a tidier elif. The left of each ) is a glob pattern, with exactly the syntax from Chapter 3.5:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ for f in run.log top.sv top.v notes.txt .hidden; do
>   printf '%-10s ' "$f"
>   case "$f" in
>     *.log)      echo "log"    ;;
>     *.sv|*.v)   echo "hdl"    ;;
>     .*)         echo "hidden" ;;
>     *)          echo "other"  ;;
>   esac
> done
run.log    log
top.sv     hdl
top.v      hdl
notes.txt  other
.hidden    hidden

*, ?, [...] and | all work:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ for x in 5 a Z _ "" 12; do
>   printf '%-4s ' "[$x]"
>   case "$x" in
>     [0-9])  echo "one digit"  ;;
>     [a-z])  echo "lowercase"  ;;
>     [A-Z])  echo "uppercase"  ;;
>     "")     echo "empty"      ;;
>     *)      echo "other"      ;;
>   esac
> done
[5]  one digit
[a]  lowercase
[Z]  uppercase
[_]  other
[]   empty
[12] other

Two results in that table are worth noticing. _ falls to * because it is in none of the three ranges. And 12 does not match [0-9] — a bracket class matches exactly one character, so a two-digit number needs [0-9][0-9] or [0-9]*. That is the single commonest case pattern bug.

The empty arm is written ""), with quotes, because a bare ) would be a syntax error.

A pattern can come from a variable, and the same rule decides what happens:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ p="*.log"
$ case run.log in $p)   echo "unquoted — used as a pattern" ;; *) echo no ;; esac
unquoted — used as a pattern
$ case run.log in "$p") echo match ;; *) echo "quoted — used literally" ;; esac
quoted — used literally

3. Where case Differs From Filename Globbing

The syntax is identical. One behaviour is not, and it is the best demonstration in the chapter:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ls -a
.  ..  .hidden  visible

$ echo *
visible

$ case .hidden in *) echo "* MATCHED .hidden" ;; esac
* MATCHED .hidden

echo * does not list .hidden. case matches it with *.

Both used the same metacharacter, and they disagree. The reason is that Chapter 3.5's "a leading dot must be matched explicitly" rule is a property of filename expansion specifically — a deliberate decision so that rm * does not delete your .bashrc. case is not expanding filenames; it is matching one string against one pattern, with no filesystem involved and no dotfile rule to apply.

The practical consequence is a real guard you would otherwise write wrongly:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case "$f" in
  .*) echo "refusing to process a dotfile: $f" >&2; continue ;;
esac

That works, and you need it, because a $f that arrived from find or from a while read loop (Chapter 17.5) never went through filename expansion and so never had the dotfile rule applied to it.

4. Order Matters, and the First Match Wins

Exactly like elif:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ case "run.log" in
>   *)      echo "catch-all first" ;;
>   *.log)  echo "log" ;;
> esac
catch-all first

$ case "run.log" in
>   *.log)  echo "log first" ;;
>   *)      echo "catch-all" ;;
> esac
log first

A *) arm placed anywhere but last makes every arm below it dead code. Bash will not warn you — there is nothing invalid about it, and a pattern that can never match is not an error.

The rule is therefore: specific patterns first, *) last, always. Which also means ordering your real patterns from most to least specific:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case "$name" in
  run_golden.log)  ... ;;   # one exact file
  run_*.log)       ... ;;   # the family it belongs to
  *.log)           ... ;;   # any log
  *)               ... ;;   # anything else
esac

Reverse those four lines and only the first one ever runs.

5. No Match Is Not a Failure

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ case "zzz" in a) echo a ;; esac
$ echo $?
0

A case that matches nothing returns success. No output, no error, status 0.

That is consistent with Chapter 16.1 — an if with no taken branch also returns 0 — but it is a trap in a different way. If a case is the last thing in a script or a function, an unmatched value makes the script exit 0, reporting success for an input it did not understand.

So an *) arm is not a formality. It is the difference between a script that tells you about an unknown input and one that silently blesses it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case "$verdict" in
  PASSED)    exit 0 ;;
  FAILED)    exit 1 ;;
  TIMEOUT)   exit 1 ;;
  *)
    echo "${0##*/}: unrecognised verdict: $verdict" >&2
    exit 3
    ;;
esac

Always write an *) arm, even when you are certain the value is one of the listed ones. The day a tool adds a new verdict word is the day you find out whether you did.

6. case Is Often the Right Tool for $?

Chapter 16.3 classified a grep status with an if/elif chain of numeric comparisons. case does it in a third of the space:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ grep -q zz /etc/hosts
$ case $? in
>   0) echo "found"     ;;
>   1) echo "not found" ;;
>   *) echo "error"     ;;
> esac
not found

No quoting needed on $? because it is always a bare integer, no comparison operators to get wrong, and the *) covers 2 and everything else at once. For any command with more than two meaningful exit codes — grep, diff, rsync, most EDA tools — this is the readable form.

7. Two Things About Other Bash Versions

Fall-through is Bash 4 only. Bash 4 added ;& (run the next arm's body unconditionally) and ;;& (keep testing later patterns). On Bash 3.2 they are not merely ignored — they are a parse error:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ case a in a) echo one ;;& b) echo two ;; esac
bash: -c: line 0: syntax error near unexpected token `&'

$ bash --version | head -1
GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)

macOS still ships Bash 3.2 as /bin/bash, so a script using ;;& will not run there at all — and a syntax error means the script fails before executing a single line, including any version check you put at the top. If a script must run on macOS's system Bash, these operators are off the table.

nocasematch applies to case too:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ shopt -s nocasematch
$ case VCS in vcs) echo "matched case-insensitively" ;; *) echo no ;; esac
matched case-insensitively

$ case VCS in vcs) echo match ;; *) echo "no match by default" ;; esac
no match by default

That is Chapter 10.6's shell option, and it affects [[ ]] and case alike. Useful for a tool name a user might type in any case; a surprise if something else in the script set it.

extglob also applies, with Chapter 16.2's caveat that it must be set on an earlier line than the pattern that uses it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
shopt -s extglob
case "$f" in
  *.@(sv|svh|v))  echo "an HDL source file" ;;
  *)              echo "not HDL" ;;
esac

8. A Complete Script

An argument parser, which is where most real case statements live:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#!/usr/bin/env bash
# classify.sh — classify each named file, with options
# usage: classify.sh [-v] [-q] [--] FILE...
#
# exit: 0 all classified · 2 bad usage · 3 an unknown file type was seen

set -o nounset

verbose=0
quiet=0

# option parsing: a case over $1, shifting as we go
while [ $# -gt 0 ]; do
  case $1 in
    -v|--verbose)  verbose=1 ; shift ;;
    -q|--quiet)    quiet=1   ; shift ;;
    -h|--help)
      echo "usage: ${0##*/} [-v] [-q] [--] FILE..."
      exit 0
      ;;
    --)            shift ; break ;;
    -*)
      echo "${0##*/}: unknown option: $1" >&2
      echo "usage: ${0##*/} [-v] [-q] [--] FILE..." >&2
      exit 2
      ;;
    *)             break ;;
  esac
done

if [ $# -eq 0 ]; then
  echo "usage: ${0##*/} [-v] [-q] [--] FILE..." >&2
  exit 2
fi

unknown=0

for f in "$@"; do
  case $f in
    .*)                      kind="dotfile (skipped)" ;;
    *.sv|*.svh)              kind="SystemVerilog"     ;;
    *.v|*.vh)                kind="Verilog"           ;;
    *.vhd|*.vhdl)            kind="VHDL"              ;;
    *.sdc|*.tcl)             kind="constraints or Tcl";;
    *.log|*.rpt)             kind="tool output"        ;;
    Makefile|*.mk|makefile)  kind="build file"         ;;
    *.f|*.filelist)          kind="filelist"           ;;
    *)                       kind="unknown"; unknown=1 ;;
  esac

  if [ "$quiet" -eq 0 ]; then
    printf '%-24s %s\n' "$f" "$kind"
  fi
  if [ "$verbose" -eq 1 ] && [ -f "$f" ]; then
    printf '%-24s   %s bytes\n' "" "$(wc -c < "$f" | tr -d ' ')"
  fi
done

exit $(( unknown ? 3 : 0 ))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./classify.sh top.sv tb.svh legacy.v design.vhd timing.sdc run.log Makefile files.f .cache notes.docx
top.sv                   SystemVerilog
tb.svh                   SystemVerilog
legacy.v                 Verilog
design.vhd               VHDL
timing.sdc               constraints or Tcl
run.log                  tool output
Makefile                 build file
files.f                  filelist
.cache                   dotfile (skipped)
notes.docx               unknown
$ echo $?
3

$ ./classify.sh -q top.sv; echo "status=$?"
status=0

$ ./classify.sh --bogus top.sv
classify.sh: unknown option: --bogus
usage: classify.sh [-v] [-q] [--] FILE...
$ echo $?
2

$ ./classify.sh -- -weird-name.sv
-weird-name.sv           SystemVerilog

Four things in there are the real lessons of this chapter:

Two case statements doing different jobs. The first dispatches on options, where the patterns are exact words and alternation gives each option its short and long spelling for free. The second dispatches on file types, where the patterns are real globs. Same construct, completely different character.

-*) before *). In the option loop, -*) catches anything that starts with a dash and is not a known option — so a typo is reported rather than silently treated as a filename. Below it, *) breaks the loop because the first non-option argument has been reached. Swap those two arms and every mistyped option is read as a filename.

--) to end option parsing. The last transcript shows why: a file genuinely called -weird-name.sv cannot be passed any other way, and -- is the universal convention Chapter 1.5 introduced for exactly this.

.*) placed first in the file loop. It must come before *.sv, because a file called .top.sv would otherwise be classified as SystemVerilog and processed — and Section 3 showed that case has no dotfile rule to stop it.

Practice

  1. Write a case that classifies 5, 42, a, Z and an empty string into digit / two-digit / lowercase / uppercase / empty / other. Note which pattern you need for 42 and why [0-9] is not it.
  2. Run case .hidden in *) echo matched ;; esac and then echo * in a directory containing .hidden. Explain the disagreement in one sentence.
  3. Put a *) arm first in a four-arm case and confirm that nothing below it runs. Does Bash warn you?
  4. Run a case that matches nothing and print $?. Then write a script ending in that case and show that an unrecognised input makes it exit 0.
  5. Convert the six-way elif chain at the top of this chapter back from case, then count the characters in each version and the number of places $tool appears.
  6. Classify a grep status with case: run it against a matching pattern, a non-matching one and a missing file, and confirm all three arms.
  7. Try case a in a) echo one ;;& b) echo two ;; esac on your system. If it is a syntax error, say what that implies for a script that must run on macOS.
  8. Add a -o FILE option to classify.sh that takes a value. You will need to shift twice in that arm — work out why, then check it against getopts.
  9. Remove the .*) arm from classify.sh's file loop and run it on .top.sv. Explain the result using Section 3.

Check Your Understanding

  1. What is the one-line difference between the patterns in a case and the patterns you type to match filenames?
  2. Why does [0-9]) not match 42?
  3. Why is "*.log") wrong?
  4. A case matches none of its arms. What is the exit status?
  5. What happens if you put *) first?
  6. Why must -*) come before *) in an option-parsing loop?
  7. ;;& is a syntax error on your Mac's /bin/bash. Why can a version check at the top of the script not save you?

Chapter 27.8 uses this construct as the seam between one interface and two genuinely different simulators, and shows what happens when one branch silently ignores an option.

9. Remember

  • case matches patterns, not strings. The arms use Chapter 3.5's glob syntax: *, ?, [...], and | for alternatives.
  • esac closes it, ) closes a pattern, ;; ends an arm. A leading ( on a pattern is legal; ;; is optional only on the last arm.
  • Quote the word, never the pattern. "$f" after case; a bare *.log in the arm.
  • A bracket class matches exactly one character. [0-9] does not match 42.
  • case has no dotfile rule. * matches .hidden here even though echo * would not list it — so a .*) guard has to be explicit, and has to come first.
  • First match wins, so order from specific to general and keep *) last. An early *) silently makes everything below it dead.
  • An unmatched case returns 0. Always write an *) arm, or a script will report success on input it did not understand.
  • case $? is the clean way to classify an exit status with more than two meaningful values.
  • ;& and ;;& are Bash 4 and a syntax error on macOS's Bash 3.2 — and a syntax error defeats any runtime version check.
  • nocasematch and extglob both affect case, and both must be set before the line that needs them.

Chapter 16.7 closes the module with the form that has been appearing in every script in it without explanation: a condition that is not a test at all, but a command. if grep -q, if command -v, if ! make — the idiomatic Bash that stops you capturing output you were never going to read.

Where this fits

Part of the Linux & Bash curriculum.