PBS 145 of X: Shell Conditionals (Bash)
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 [[
keyword.
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 — if
and case
.
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
Episode Resources
- The instalment ZIP file — pbs145.zip
In the Beginning there was the test
Command
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 0
or 1
depending on whether or not the expression evaluated to true
or false
.
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\!"
From test
to [
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 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 [[
or ]]
keywords.
To avoid weird bugs always use [[
and ]]
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.
File-like Checks
All of these file check commands are unary, i.e. they work on a single value.
Operator | Meaning |
-e |
Something exists at the given path (could be a file, a folder, a symlink, a pipe, a socket …) |
-f |
A file exists at the given path |
-d |
A directory (folder) exists at the given path |
-r |
A readable file exists at the given path |
-w |
A writeable file exists at the given path |
-x |
An executable file exists at the given path |
-s |
A non-empty file exists at the given path |
String Operators
Most of the string operators compare two strings, so expect two values, but some only expect one (are unary).
Operator | Unary? | Meaning |
-z |
yes | Is an empty string |
-n |
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 |
Numeric Comparisons
Bash thinks in terms of strings, so rather annoyingly, none of the symbolic 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:
Operator | Meaning |
-eq |
The two numbers are equal |
-ne |
The two numbers are not equal |
-lt |
The first number is less than the second |
-gt |
The first number is greater than the second |
-le |
The first number is less than or equal to the second |
-ge |
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
So far, the only true programming language we’ve dived into in this long-running series is JavaScript, but one of the reasons it’s a decent first language is that it’s very like many other languages. If you know JavaScript I can show you some basic C, C++, Java, Perl, or PHP, and you’ll have a pretty good idea of what the code does.
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.
Bash Conditionals
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
In Bash, we implement the equivalent of a JavaScript if
statement with the commands if
, then
& fi
, and the optional extra commands else
& elif
.
The 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.
The 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 simply 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
or elif
.
The 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 fi
command.
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: ./pbs145a-simpleIf.sh
:
#!/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 pbs145c-elif.sh
:
#!/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 = 'Thu' ]]
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 case
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
.
The 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 esac
(case
backwards) command.
The 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 case
command.
Note that the patterns are in BRE (Basic Regular Expression) format, i.e. the same format used by the grep
command.
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 if
& elif
. You’ll find this script in the ZIP as pbs145d-case.sh
:
#!/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'
;;
Thu )
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
Final Thoughts
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.