Some of my earlier memories of using computers involve typing a computer program into the computer. I remember hoping that I didn’t make a mistake and then finally running it. In this chapter, I hope to foster a sense of accomplishment that helps you on your journey.
We have been using Zsh as our shell in all the examples so far in this book. However, there is another shell we will use for scripting, called sh.
Unlike Zsh, sh has been bundled with macOS for many years and is a good default for maximum compatibility with any script you write. This is especially important if you want to use what you learn in this chapter on other computers.
Your First Script
The sh command lives in /bin/sh. Now you’ll write your first script. Actually, that’s a lie, this will be your second script. Your first was the .zshrc file.
Create a file called "~/welcome" with these contents:
echo "Welcome";
If you need help with this step, please consult Chapter 2. This is your script and you can run it like this:
% bash ~/welcome
Welcome
Anything you can type into your Terminal can be used in a script.
Running and Debugging Scripts
Previously, we created a welcome file that we ran using Bash.
% cat ~/welcome
echo "Welcome";
While in principle this is a script, we can make it more like the commands we have used. The first thing we need to do is fix how we run the command.
Now when we try to run the script again, the shell will be able to execute it.
% ~/welcome
Welcome
We can also tell the shell to look in the current directory for the script.
% cd ~
% ./welcome
Welcome
Running Scripts from the Current Directory
Before we go on, I’d like to explain why we don’t just use the filename, as with other commands.
% welcome
zsh: command not found: welcome
The reason is that all the other commands are located in the PATH variable. Your current directory is not in the list of directories. Therefore, the shell will not look for the script you are trying to run in your current directory.
There is one more thing we should do to make this official. We should specify an interpreter for this script. This is straightforward; we simply add #!/bin/... as the first line in the script. The contents of ~/welcome will now look like Listing 5-1.
#!/bin/sh
echo "Welcome"
Listing 5-1
The Finished Welcome Script
We are using /bin/sh, as this has existed for a long time on macOS. This means that when you look online, you will find more support for this interpreter. This is why we are using this interpreter, rather than the Zsh interpreter that we’ve been discussing in this book. In fact, /bin/sh is really the Bash interpreter. You can see this if you use the version option on both.
% /bin/sh --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin19)
Copyright (C) 2007 Free Software Foundation, Inc.
% /bin/bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin19)
Copyright (C) 2007 Free Software Foundation, Inc.
Text Modes
The macOS Terminal can have different font styles, as shown in Table 5-1, such as bold, underlined, or blinking.
Table 5-1
Different Text Modes in Bash
Mode
Text Style
0
Normal, remove all style
1
Bold
2
Dim
3
Italic
4
Underline
5
Blink
7
Inverted
8
Hidden
22
Remove bold/dim
23
Remove italic
24
Remove underline
25
Remove blink
27
Remove Inverted
28
Remove hidden
Note
Mode 21 is supposed to be used to remove bold, but it doesn’t work on the macOS Terminal.
By default, text that you output will not be styled.
% printf "Normal text"
Normal text
Let’s experiment with the different text modes. We will create a file called textmode and enter the code in Listing 5-2.
#!/bin/sh
printf "\e[0m"
printf "This text is \e[1mBOLD\e[22m.\n"
printf "This text is \e[2mDIM\e[22m.\n"
printf "This text is \e[3mITALIC\e[23m.\n"
printf "This text is \e[4mUNDERLINED\e[24m.\n"
printf "This text is \e[5mBLINKING\e[25m.\n"
printf "This text is \e[7mINVERTED\e[27m.\n"
printf "This text is \e[8mHIDDEN\e[28m.\n"
Listing 5-2
Filename: textmode—Script Showing the Different Text Modes
When we run the code in Listing 5-2, we get the following output.
% chmod +x textmode
% textmode
This text is BOLD.
This text is DIM.
This text is ITALIC.
This text is UNDERLINED.
This text is BLINKING.
This text is INVERTED.
This text is HIDDEN.
Note
The text will actually be blinking and the hidden text will show up when highlighted.
Clearing Text Modes
These text mode changes are persistent. If you don’t end them, they will carry on until you reset the style. The Zsh shell will clear the text modes before displaying a new prompt, but this is something that the Bash shell does not do.
Listing 5-3 shows what happens if you forget to turn off the textmode, in this case, bold.
% printf "This text is \e[1mBOLD.\n"
This text is BOLD.
% printf "This text is Normal.\n"
This text is Normal.
Listing 5-3
Commands Showing the Difference When Bold Is Accidentally Not Turned Off
In a script, that outcome is slightly different and the commands in Listing 5-4 illustrate this point.
#!/bin/sh
printf "This text is \e[1mBOLD.\n"
printf "This text is Normal.\n"
Listing 5-4
Filename: boldtext—Enabling Bold Without Ending It
When we run the boldtext command, we can see the bold text on the second line of text, where we didn’t end the bold mode.
% ./boldtext
This text is BOLD.
This text is Normal.
After this command has been run in Zsh, the text mode will be reset to normal.
Combining Text Modes
We can also combine these styles by starting multiple text modes without closing them.
% printf "This text is \e[1m\e[4mBOLD and UNDERLINED\e[22m\e[24m.\n"
This text is BOLD and UNDERLINED.
Text Color
Did you know you can change the text color? You can set the text color and the background color and can choose from a color palette of 256 colors. To help visualize all the different colors in your palette, use the code in Listing 5-5.
#!/bin/sh
# Original Script - https://askubuntu.com/a/681719
When you run this script, you will get output that looks like Figure 5-1, which is the ANSI color code for each color. However, it is likely that Figure 5-1 is shown in grayscale in the book. If this is the case, you will need to run this command yourself.
Figure 5-1
Output of the color command
Changing the text color is similar to how we changed the text mode in the previous section.
printf '\e[<fg_bg>;5;<ANSI_color_code>m'
fg_bg needs to be replaced with either 38 (foreground) or 48 (background). Then you have \e[0m, which is used to reset the colors back to their defaults.
For example, let’s say we wanted blue text on a red background.
printf '\e[38;5;12m'
printf '\e[48;5;9m'
printf 'blue text on a red background\n'
printf '\e[0m'
Note
This works only if they are entered at the same time, rather than typed out one by one.
Remember, Zsh resets the colors and styles after every command. All the commands could be combined into one, but I have used four to help with readability. If you want to run them as one command, all the text needs to be combined into a single printf command.
printf '\e[38;5;12m\e[48;5;9mblue \e[1mtext\e[22m on a red background\n\e[0m'
If you don’t include the mode reset, the colors will carry on until you change them again. You can also combine the text mode and color.
printf '\e[38;5;12m'
printf '\e[48;5;9m'
printf 'blue \e[1mtext\e[22m on a red background\n'
printf '\e[0m'
Variables
In Chapter 2, we covered creating environment variables. Variables in scripts work the same way as shell variables, with the main difference being that they will disappear when the script finishes running. A variable can be used inside a string, which can be output or used to create a new variable.
echo "My home directory is ${HOME}."
The braces around the variable name are optional, but they help define the bounds of the variable name. Without the braces, you wouldn’t be able to have a string directly follow the variable.
We can use this to combine existing variables to create a new variable.
STRING1="Hello"
STRING2="World"
STRING3="${STRING1} ${STRING2}"
echo $STRING3
Special Variables
There are a few special variables that we didn’t cover. When we’ve executed other commands, we have sometimes given them options or arguments.
% say "hello"
These special variables help us grab the text that started the program. The first variable is $# and it will tell us how many arguments we’ve passed in. Let’s create a quick script to demonstrate, as shown in Listing 5-6. I will use args as the filename.
#!/bin/sh
echo "You used $# arguments."
Listing 5-6
Output Command Arguments
Don’t forget to make it executable, like we discussed in Chapter 2.
% chmod +x args
Now we can run the program without arguments.
% ./args
You used 0 arguments.
We can run the command again and make up some arguments.
% ./args "hello" "world"
You used 2 arguments.
Using Arguments
It’s one thing to know that there are arguments, but another to use them. There are two main ways to access the arguments to the script with Bash—using $* and $@ or using $0, $1, $2, and so on.
$*
The $* argument will treat all the arguments as a single string, with a single space between them. Let’s change the args echo script to $*, as shown in Listing 5-7.
#!/bin/sh
echo $*
% ./args "hello" "world"
hello world
Listing 5-7
Arg Script Using $*
$@
The $@ variable works the same as $* when used with echo. However, you can use $@ as an array in a loop. Let’s change the script args to a loop, as shown in Listing 5-8.
#!/bin/sh
for arg in $@
do
echo "$arg"
done
Listing 5-8
Outputting Arguments with a Loop
Now, every argument passed to args will be displayed on a newline.
% ./args "hello" "world"
hello
world
$0, $1, $2, and So On
These variables represent the position of the argument on the shell when you ran the command. If you know the position of the argument, you can access it by using the offset.
$0 is the program name.
Every argument will be given to a new variable with an incremental number. Let’s change the script in Listing 5-9.
echo $0
echo $1
echo $2
% ./args "hello" "world"
./args
hello
world
Listing 5-9
Outputting the Arguments Based on Offset
The If Statement
Outputting variables is one thing, but what if we want to perform different tasks depending on some condition? We need to use a structure known as the if statement.
if [[ <condition> ]] ; then
<commands if condition is true>
elif [ <condition ]] ; then
<commands if this condition is true>
else
<commands if condition is false>
fi
The possibilities are endless with if statements. This information is only the beginning, but it will be enough to get you started.
There are lots of different tests you can run using the if statement. Table 5-2 shows a selection of conditions you are most likely to need.
Table 5-2
Different if Conditions That Can Be Used
Condition (Test)
Description
-e path
Does path point to a file?
-d path
Does path point to a directory?
string1 == string2
Does string1 equal string2?
string1 != string2
Are string1 and string2 different?
string1 < string2
Does string1 appear before string2 alphabetically?
string1 > string2
Does string2 appear before string1 alphabetically?
variable1 -eq variable2
Does variable1 equal variable2?
variable1 -ne variable2
Does variable1 not equal variable2?
variable1 -lt variable2
Is variable1 less than variable2?
variable1 -le variable2
Is variable1 less than or equal to variable2?
variable1 -gt variable2
Is variable1 greater than variable2?
variable1 -ge variable2
Is variable1 greater than or equal to variable2?
To run a test, we just surround the expression with double square brackets, as shown in Listing 5-10.
if [[ 10 -gt 5 ]] ; then
echo "10 is greater than 5";
else
echo "5 is greater than 10";
fi
Listing 5-10
Example of Conditional Checking if 10 Is Greater Than 5
Did you notice that the test in Listing 5-10 was impossible to evaluate as false? Just by changing the expression in the test, we can see if a file exists on the filesystem. This code is shown in Listing 5-11.
if [[ -e ~/testfile ]] ; then
echo "testfile exists";
else
echo "testfile doesn't exist";
fi
Listing 5-11
Testing if ~/testfile Exists
Try running the code before and after you create the file.
% touch ~/testfile
You can find much more about this topic in the Bash manual, by searching for “Evaluation.”
Arithmetic
There might be times when you want to work with numbers. By default, Bash will treat all input as strings, which would make doing arithmetic tricky.
% echo 1+3
1+3
We need to tell Bash it is dealing with numbers. There is an easy way to do this, by using arithmetic expansion. To use arithmetic expansion, you need to surround your expression with a dollar sign and double parentheses, as follows:
$((expression))
Inside the arithmetic expansion, we can use all the standard arithmetic operators, as shown in Table 5-3.
Table 5-3
Listing of the Different Arithmetic Operators
Operation
Example
Result
Addition
echo $((1+3))
4
Subtraction
echo $((1-3))
-2
Multiplication
echo $((1*3))
Division
echo $((7/3))
2
Modulo
echo $((7 % 3))
1
When you use division, you need to be aware that it only returns whole numbers. If there is a reminder, you can get it by using the modulo symbol.
Loops
Earlier in the section entitled “Text Color,” there was a script that displayed the ANSI color codes. You may not have noticed, but it used a loop to generate that table. This code is shown again in Listing 5-12.
# Original Script - https://askubuntu.com/a/681719
Since line 3 in Listing 5-13 is doing many different things, we should look at it more closely. In Table 5-4, we are going to step through how the line is interpreted. This should give you a good understanding of how it works.
Steps C and D could have been combined by using [ b -ne 0 ].
Break and Continue
A special note about the keywords continue and break. Let’s look at a simple loop that outputs the line numbers 0 to 9, as shown in Listing 5-14.
for((i=0; i<10; i++)); do
printf "Line: %d\n" $i;
done
Listing 5-14
Example of a Loop Printing the Value of i
The continue statement in a loop will cause the current iteration to end, and it will move to the next iteration. The break statement will end the loop before the end condition.
I’m going to modify Listing 5-14, in order to add break and continue statements. Consider the loop in Listing 5-15.
for((i=0; i<10; i++)); do
if [[ i -lt 4 ]] ; then
continue;
fi
printf "Line: %d\n" $i;
if [[ i -gt 6 ]] ; then
break;
fi
done
Listing 5-15
A Loop Using continue and break
This loop will output the following:
Line: 4
Line: 5
Line: 6
Line: 7
The continue will keep being called until i is at least 5, and the loop will end when i is greater than 6, but not before outputting line 7.
Exit Code
Have you ever wondered if the system knows if a script ran successfully or not? Maybe you would like to know the status of a command that you ran? There is shell variable that holds the last command’s exit status.
% echo $?
0
Let’s try that again, but with a command that fails.
% ./missing-command
zsh: no such file or directory: ./missing-command
% echo $?
127
Exit codes are useful for programmatically handling errors and each command will define its own list of error codes that it will emit. It is worth noting that the exit code will be a value between 0 and 255.
There is an easy way to understand them. If the exit code is 0, then the command ended successfully. If the exit code is greater than 0, then the command ended with an error.
If you want to know what the exit codes are for a particular command, their man page normally lists them.
% man curl
There are nearly a hundred exit codes for curl.
Exiting
When you write your own script, there might be times when you want your script to end early. It might be a success, or maybe the user didn’t provide all the arguments required to run the command successfully.
You can use the exit keyword to end the currently executing process, and you can see the effect of doing so in your Terminal window.
% exit
[Process completed]
Using exit within a script will cause it to exit in the same way as your shell. The full usage of exit is exit <error code>. If you don’t supply an exit code, it will default to the exit status of the last run command. Therefore, it is recommended to always supply an exit code when using exit.
What error code should you use? Well, that entirely depends on why you want to exit. However, for simplicity, I would stick with 0 if your script is exiting without error, or 1 if there was an error.
Reading Input
Much has been said about displaying output from a script by using echo and printf, but we haven’t covered anything about getting input into a script. What if, in the middle of a script, we have a question?
We’ve seen it before, when searching the manual and we get prompted to display a particular manual entry. How can we do this ourselves?
read <variable>
read takes all the input from the user until a newline and stores it in a variable. In this example, we type what goes in, which is stored in $input. Then the same text will be outputted.
% read input; echo $input
what goes in
what goes in
We can use echo to help create a prompt that allows the user to see what they are entering.
% echo -n "Input: "; read input; echo $input
Input: must come out
must come out
Note
If you use the -n option with echo, then echo will not output a newline.
Projects
It’s one thing being told how to use Bash. It’s another thing to try some projects that illustrate these principles, which you’ll do in this section.
Each project is split into three sections—“You Will Need,” “Expected Output,” and “Suggested Answer.”
The “You Will Need” section is about helping you identify what will be useful in completing the project. The “Expected Output” section is the code that your project will need to output for the given script. The script should work for all the given arguments. The “Suggested Answer” section explains how I would have tackled the project.
Remember, there are many ways to produce the answers for these projects. Don’t worry if you have an answer that doesn’t exactly match this book.
Project 5-1: Print Arguments
This project tests your ability to print certain arguments, as well as to print a message if no arguments were provided. Let’s create a file called args.
You Will Need
if
loop
$0
$#
Variable arithmetic
Expected Output
When your script is run without any arguments, I want you to display a helpful message that tells the user how to use the script.
% ./print-args
Usage: ./print-args message
The script will also need to output each argument in turn with its argument number.
% ./print-args "hello" "world" 1 2 3
Arg 1: hello
Arg 2: world
Arg 3: 1
Arg 4: 2
Arg 5: 3
How would you solve this?
Suggested Answer
This script should first check the number of arguments that have been passed in with $#. If the number is less than 1, (i.e., is 0), the script will print the usage instructions and exit. Otherwise, the script will loop over the arguments in $0 and print them. See Listing 5-16.
#!/bin/sh
if [ $# -lt 1 ]
then
echo "Usage: $0 message"
exit 1
fi
count=0
for arg in $@
do
count=$((count+1))
echo "Arg $count: $arg"
done
Listing 5-16
print-args Suggested Answer
Project 5-2: Quicker Say Command
In this project, let’s assume that you are frequently using the say command to save computer-generated speech with the Fiona voice to an .aiff file. The filename can be based on the current date.
Rather than having to type the same argument every time, you can create a script with the arguments hard-coded. The only thing that needs to be passed into this script is the message. Let’s create a file called saytofile.
You Will Need
if
$0
$#
$@ or $*
/bin/date
say
Expected Output
When your script runs without any arguments, I want you to display a helpful message that tells the user how to use the script.
% ./saytofile
Usage: ./saytofile message
Your script will take all the arguments and pass them to the say command. To be quicker for the user, say needs to save the output to the filesystem using a filename that contains the current date and time.
% ./saytofile "this is my message to the world"
Saved to speech-2020-01-04 12:11:54.aiff
How would you solve this?
Suggested Answer
The script first needs to see if the usage needs to be printed. Then the script creates a variable called filename, which is speech.aiff. It then uses /bin/date to format the current date and time to ensure the filename is unique, assuming that it doesn’t run more than once a second.
Then we add the say command, along with a specific voice and the filename. Finally, we use $@ to pass all the arguments to the script into the say command as the message. See Listing 5-17.
In this project, we will create a simple program in which you have to guess a random number that the computer picked between 1 and 10.
The script will give you hints as to whether the answer is higher or lower, but it will limit you to five guesses. Let’s create a file called higher-or-lower.
You Will Need
if
while
read
$RANDOM
Expected Output
This script will not take any arguments, so you will not need to display a help message. Instead you can go straight into the game.
% ./higher-or-lower
Higher or Lower
===============
Guess the number I am thinking of between 1 and 10.
You have 5 guesses.
Guess: 1
Higher!
Guess: 2
Higher!
Guess: 3
Higher!
Guess: 4
Higher!
Guess: 5
Higher!
Sorry, you didn't guess it correctly.
The answer was 6.
Suggested Answer
The first thing this script does in Listing 5-18 is generate a random number. It then outputs the rules of the game. Then the script loops as many times as there are guesses. During each iteration, the script asks the player for a guess and reads the next thing they type.
If the guess is incorrect, the script will tell the player that the answer is higher or lower. If the guess is correct, the script will tell the player and then exit with the success code. When the guesses are used up, the loop will exit. Then the player will be told that they didn’t win and what the answer is. See Listing 5-18.
#!/bin/sh
guess=0
randomNumber=$(( ($RANDOM % 10) + 1))
echo "Higher or Lower
===============
Guess the number I am thinking of between 1 and 10.
You have 5 guesses.
"
for((i=0; i<5; i++)); do
/bin/echo -n "Guess: ";
read guess;
if [[ $guess -lt $randomNumber ]]
then
echo "Higher!"
elif [[ $guess -gt $randomNumber ]]
then
echo "Lower!"
elif [[ $guess -eq $randomNumber ]]
then
echo "You guessed correctly!"
exit 0;
fi
done
echo "Sorry, you didn't guess it correctly.
The answer was $randomNumber.
";
Listing 5-18
higher-or-lower Suggested Answer
Project 5-4: Higher or Lower with Changeable Limits
In this project, we will take the simple program where you have to guess the random number the computer has picked between 1 and 10 and improve on it. This time we will take optional arguments to adjust the upper range of the guessing. As we increase the difficulty, it makes sense to make the number of guesses adjustable as well.
The other issue with the previous script was it treated any input as a number, but it was evaluated as 0. In this project, you should instead output a message to say when the input wasn’t understood to be a number. Extra credit if you add a help option to the script.
Let’s duplicate the higher-or-lower script and call it higher-or-lower2.
You Will Need
if
while
read
$RANDOM
Expected Output
The script will have built-in defaults, which means when you run the script without any arguments, it can start the game. This means you need another way to show the usage information. We’ve seen other commands use --help and this feels like a good solution.
% ./higher-or-lower2 --help
Higher or Lower Game
./higher-or-lower2 [upper limit=10] [guesses=5]
% ./higher-or-lower2 50 2
Higher or Lower
===============
Guess the number I am thinking of between 1 and 50.
You have 2 guesses.
Guess: a
I didn't understand that.
Guess: 2
Higher!
Sorry, you didn't guess it correctly.
The answer was 4.
I would like you to make the number of guesses optional. This means you can start the game by only specifying the upper limit, without specifying the number of guesses.
Suggested Answer
The first thing the script needs to do is check to see if --help has been provided as an argument. If it has, it will print the usage information and exit.
Next, I am defining upperLimit and totalGuesses so that they are always defined regardless of whether they have been provided. We can now check to see if the guesses and an upper limit have been provided. If they have been provided, we set them to the variables upperLimit and totalGuesses, respectively.
The last part of the setup is to check upperLimit and totalGuesses to see if they are less than 1. We could easily set the check to be equal to 0, but I wanted to ignore any variable that was set to a negative value. Now that the setup has been performed, the rules can be displayed.
We can now iterate for as many guesses as the player gets and read the player’s current guess (see Listing 5-19). Anything that the player types in that isn’t a number will be treated as 0. This is why we check to see if the player’s guess is lower than the answer. We are checking to see if it is not equal to 0. We have a check for a guess that’s higher than the answer, as well as when the answer matches the player’s guess. The last check determines if the player’s guess matches 0, and if it does, it outputs a message to say that it didn’t understand.
#!/bin/sh
if [[ $# -eq 1 && $1 == '--help' ]]
then
echo "Usage: $0 [upper limit=10] [guesses=5]";
exit 0
fi
upperLimit=0
totalGuesses=0
if [[ $# -gt 0 ]]
then
upperLimit=$1
if [[ $# -gt 1 ]]
then
totalGuesses=$2
fi
fi
if [[ $upperLimit -lt 1 ]]; then
upperLimit=5
fi
if [[ $totalGuesses -lt 1 ]]; then
totalGuesses=5
fi
guess=0
randomNumber=$(( ($RANDOM % upperLimit) + 1))
echo "Higher or Lower
===============
Guess the number I am thinking of between 1 and $upperLimit.
You have $totalGuesses guesses.
"
for((i=0; i<$totalGuesses; i++)); do
/bin/echo -n "Guess: ";
read guess;
if [[ $guess -lt $randomNumber && $guess -ne 0 ]]
then
echo "Higher!"
elif [[ $guess -gt $randomNumber ]]
then
echo "Lower!"
elif [[ $guess -eq $randomNumber ]]
then
echo "You guessed correctly!"
exit 0;
elif [[ $guess -eq 0 ]]
then
echo "I didn't understand that."
fi
echo ""
done
echo "Sorry, you didn't guess it correctly.
The answer was $randomNumber.
";
Listing 5-19
higher-or-lower2 Suggested Answer
Finally, if the loop exits, that means the player failed to guess the correct answer. It will then display the correct answer.
Summary
When you’re scripting, you can do just about anything. It’s worth starting small and adding extra functionality over time. In this chapter, we took a more relaxed approach and looked at sh, which is bundled with macOS and is a good default for maximum compatibility with any script you write.
In the next chapter, we take a closer look at another scripting language—PHP.