PBS 145 of X — Shell Conditionals
In the previous instalment we learned that exit codes are the shell’s answer to booleans, and that they are the key to conditionals and loops. So, how do we convert logical expressions to exit codes? The answer is the
test command, and it’s more advanced descendant, the
We’ll start this instalment by learning how to express the conditions we’ll be using in our conditional statements and loops, then we’ll look at two different constructs Bash supports for conditional commands —
Matching Podcast Episode
Listen along to this instalment on episode 759 of the Chit Chat Across the Pond Podcast.
You can also Download the MP3
Read an unedited, auto-generated transcript: CCATP_2023_02_18
- The instalment ZIP file — pbs145.zip
In the Beginning there was the
To understand why we’ll do things the way we will, it’s important to start from the beginning, and work forward to how Bash made things easier for us scripters.
The original shell did not provide any kind of built-in construct for doing any kind of tests of comparisons; it simply relied on a command named
test which evaluated a simple expression and returned an exit code of
1 depending on whether or not the expression evaluated to
The test command supported a number of operators, and since it was a terminal command, it used regular command line arguments for input. Most operators worked on two values, but some worked on a single value so the test command accepted arguments in these two forms:
# operators that work on two values test value1 -operator value2 # operators that work on single values test -operator value
For backwards compatibility, the
test command still exists in most modern shells, including Bash and Zsh, so we can demonstrate the
test command with two simple examples.
First, let’s use an operator that works on two values,
= to check if our current directory equals our home directory:
test "$PWD" = ~ && echo "You're home\!"
A few things to note here — firstly, the environment variable
$PWD contains your present working directory, and
~ gets expanded by Bash to the path to your home directory. Secondly, note that the spaces around the
= operator really matter.
= is the second argument to the
test command, so it needs those spaces on either side as argument separators. Also, if we cuddled the
= we’d be doing a variable assignment, which is not at all the same as a comparison! Thirdly, note that we quoted the variable
$PWD in double quotes. This is needed or the command will fail to work correctly if the path has a space in it.
Finally, let’s demonstrate the use of an operator that works on a single value: the
-e operator. We’ll check if a path exists:
test -e ~/.bash_history && echo "You have a Bash History file\!"
Not only is the
test command rather verbose, it’s very hard to tell that a comparison is being made at a glance. To make comparisons stand out more, shells started to alias the symbol
[ to the
test command, and the
test command was updated to ignore an optional final argument of
]. This meant you could use
[ instead of
test, and mark the end of our test with a purely decorative
]. This makes a huge difference in terms of readability:
# first example re-written [ "$PWD" = ~ ] && echo "You're home\!" # second example re-written [ -e ~/.bash_history ] && echo "You have a Bash History file\!"
Notice that because we’re still just calling a command, even if it is an oddly named one, we still need to quote our our variables in case they have spaces in their values, and the shell will still expand
~ to be the path to our home directory. Finally, I want to draw your attention to how important it is not to cuddle the square brackets — the
[ is a command, so it must be separated from its first argument by a space, and the
] is the final argument, so it must be separated from the previous argument by a space.
Next Generation Testing with
This hackery with aliases, and with using commands instead of native keywords lead to messy code and subtle bugs as scripters got caught out by forgetting to quote their variables and/or unexpected shell expansions from file globbing like
* getting expanded into a list of file names.
For this reason, when Bash was invented as an improvement over the traditional
sh shell, an actual keyword was added to the language, and that keyword has been retained in Zsh. If everyone had gotten used to
[, how could you denote an improved alternative? By making the keyword
[[ and requiring a closing
]] at the end of the expression!
For the most part,
[[ behaves just like
test, supporting the same operators, and expecting its arguments in the same order, but it’s different in two important ways — firstly, file globbing is not supported, so
* does not expand to a list of files, and secondly, variables do not need to be escaped. So, we can re-write our examples as:
# first example re-re-written [[ $PWD = ~ ]] && echo "You're home\!" # second example re-re-written [[ -e ~/.bash_history ]] && echo "You have a Bash History file\!"
Note that you still can’t cuddle the
To avoid weird bugs always use
]] in Bash and Zsh, never use
Available Test Operators
I won’t duplicate the Bash documentation here with a complete list of all operators, but I will list some of the most commonly used ones in various categories.
All of these file check commands are unary, i.e. they work on a single value.
||Something exists at the given path (could be a file, a folder, a symlink, a pipe, a socket …)|
||A file exists at the given path|
||A directory (folder) exists at the given path|
||A readable file exists at the given path|
||A writeable file exists at the given path|
||An executable file exists at the given path|
||A non-empty file exists at the given path|
Most of the string operators compare two strings, so expect two values, but some only expect one (are unary).
||yes||Is an empty string|
||yes||Is not an empty string|
||Both strings are the same|
||The strings are not the same|
||The first string is alphabetically before the second|
||The first string is alphabetically after the second|
Bash thinks in terms of strings, so rather annoyingly, none of the symoblic operators work as you would expect with numbers, for example
0 = 0.0 is considered false, as is
4 < 10! When you want Bash to think numerically, you need to use a different set of operators, all of which expect two values to compare:
||The two numbers are equal|
||The two numbers are not equal|
||The first number is less than the second|
||The first number is greater than the second|
||The first number is less than or equal to the second|
||The first number is greater than or equal to the second|
Other Useful Operators
There’s one other unary operator I think is worth mentioning,
-v checks if a variable with the given exists (has a value).
A Very Different Way of Thinking
Shell scripting is as different to these languages as fungi are to flowers. We’re using to thinking in terms of statements and code blocks, but in shell, it’s all just commands. There is no if statement, there is a series of commands that put the shell in different states, and hence start and end distinct mini lists of commands, but it’s all commands. When we’re writing scripts we choose to indent to make our code legible, but each line remains a separate command.
Now that we know how to test expressions, and now that we’re forearmed about how weird shell scripts are, let’s look at the two collections of commands for implementing conditionals — the
if command and its collection of related commands, and the
case command with its different collection of related commands.
Basic Conditionals with
if statement with the commands
fi, and the optional extra commands
if command expects as its first argument a command to execute to generate an exit code, and any subsequent arguments to the
if command get passed to the command that was the first argument. Basically, it works like
sudo, it’s a command that takes a command plus that command’s arguments. as its arguments.
then command puts the shell into a new mode where all commands entered only get executed if the preceding
if command received a successful exit code when it executed its arguments as a command. The
then command doesn’t need any arguments, but for convenience, it can accept any number of arguments that it simple treats as a command with arguments and executes if appropriate.
Once you run a
then command all execution is conditional until you tell the shell to flip to another mode. You can simply end the conditional completely with the
fi command, of you can move on to another condition with
else command is basically the
then command’s inverse, it flips the shell into a mode where commands only get executed if the preceding
if command got a failure exit code when it executed its arguments as a command. For convenience it can also take a command with arguments as its arguments. The shell will continue to only execute commands if the
if failed until you enter the
That just leaves the
elif command which you can think of as simply an alias for
else; if. (Remember you can use
; as a command separator when you want multiple commands on a single line.)
That’s hard to describe, so let’s look at some examples, all of which you’ll find in the instalment’s ZIP file.
Let’s start with a simple conditional, we have some code that only executes when some condition is true. This script expects to find a name as the first argument, but if it doesn’t, it prompts the user for one, then it greets the user. You’ll find the code in
pbs145a-simpleIf.sh and you can execute it with:
#!/usr/bin/env bash # store the first arg as the name name=$1 # test if we got a name, and if not, ask for one if [[ -z $name ]] then read -p "What's your name? " name fi # greet the user echo "Well hello there $name 🙂"
We can see the conditional in action by executing the script with an without an argument:
bartsmacstudio:pbs145 bart$ ./pbs145a-simpleIf.sh Bart Well hello there Bart 🙂 bartsmacstudio:pbs145 bart$ ./pbs145a-simpleIf.sh What's your name? Bart Well hello there Bart 🙂 bartsmacstudio:pbs145 bart$
Now, let’s look at a typical either/or scenario by adding an
else command into the mix. The script
pbs145b-ifElse.sh will print a different message depending on whether or not you have a Bash profile file:
#!/usr/bin/env bash # check for a bash profile if [[ -e ~/.bash_profile ]] then echo "You've customised Bash with a profile"'!' else echo "You haven't customised your Bash profile yet" fi
Finally, let’s add multiple conditions with
elif — in this example we print out what today’s day is named for. You’ll find the code in
#!/usr/bin/env bash # get the day of the week as a 3-letter abbreviation with the `date` command dow=$(date '+%a') # print the appropriate etymology if [[ $dow = 'Mon' ]] then echo 'Monday is named for the Moon' elif [[ $dow = 'Tue' ]] then echo 'Tuesday is named for the Norse god Týr' elif [[ $dow = 'Wed' ]] then echo 'Wednesday is named for the Norse god Odin' elif [[ $dow = 'Thur' ]] then echo 'Thursday is named for the Norse god Thor' elif [[ $dow = 'Fri' ]] then echo 'Friday is named for the Norse goddess Freya' elif [[ $dow = 'Sat' ]] then echo 'Saturday is named for Saturn' elif [[ $dow = 'Sun' ]] then echo 'Sunday is named for the Sun' else echo 'What calendar are you on???' fi
Notice how repetitive that code is? That’s where the
case command and its companions come in.
Multiple Conditions with
When you want to different things depending on a set of possible values for a single variable you can replace repetitive
elif structures with
case command is a very unusual beast, it in effect puts your shell into a new mode where it expects to be given a series of patterns followed by commands followed separated from each other by double semi-colons, and then the whole thing ends with an
case backwards) command.
case command expects a value as the first argument, and then the word
in as the second and final argument. Executing that command puts your shell into a mode where it will interpret the next line as one or more patterns separated by pipes, and terminated with a
). Next come arbitrarily many commands which will only execute if the value passed to the
case command matches one of the patterns. This continues until you either use the
;; command to signal ‘next pattern’ or the
esac command to end the special mode started by the
Note that the patterns are in BRE (Basic Regular Expression) format, i.e. the same format used by the
Also, it is generally considered good practice to end with a catch-all pattern which is simply
The exit code from the
esac command will be
1 (fail) if no patterns match, and hence, no commands get executed, or, the exit code from the last command to be executed within the matching pattern’s command list.
This all sounds very complicated, but when it’s actually quite straight forward when you see an example. Let’s re-write our days of the week with
case instead of
elif. You’ll find this script in the ZIP as
#!/usr/bin/env bash # get the day of the week as a 3-letter abbreviation with the `date` command dow=$(date '+%a') # print the appropriate etymology case $dow in Mon ) echo 'Monday is named for the Moon' ;; Tue ) echo 'Tuesday is named for the Norse god Týr' ;; Wed ) echo 'Wednesday is named for the Norse god Odin' ;; Thur ) echo 'Thursday is named for the Norse god Thor' ;; Fri ) echo 'Friday is named for the Norse goddess Freya' ;; Sat ) echo 'Saturday is named for Saturn' ;; Sun ) echo 'Sunday is named for the Sun' ;; * ) echo 'What calendar are you on???' esac
We now know how to express conditions, and to use those conditions to conditionally execute some code, the next logical step is to learn how to use conditions to power loops. That’s what we’ll start the next instalment with. But, we won’t be stopping there, what are loops more frequently used for? Processing lists or arrays, so that will form the second half of the next instalment.
Join the Community
Find us in the PBS channel on the Podfeet Slack.