Session 3 · Computer Systems

Shell scripts: variables, conditions, loops and regular expressions

How a bash script is built and run: variables and quoting, arguments, if and the test command, loops, functions, regular expressions and redirection. Ends with a robust script skeleton – check the input, send errors to stderr, exit with a non-zero code.

22 min readRunning a script: ./, bash, sourceQuoting and expansionArguments: $1, $#, "$@"if, test and exit codesRegular expressions with grepstdout, stderr and redirection

A shell script is a text file holding the commands you would otherwise type into the terminal. Bash reads it top to bottom and runs one line after the other. That is the whole idea – and it is enough to turn a task you repeat every day into one command that takes parameters, checks its input and can be reused by other scripts.

This session covers the language: variables, arguments, conditions, loops, functions, regular expressions and redirection. The next one, Shell scripting in practice, combines all of it into real tasks, and your shell-script assignment is built from exactly these pieces.

The one thing to remember: bash is a language of commands. A condition is a command whose exit code is 0 or not, a function returns a status and prints its result, and before any command runs, the shell rewrites the line you wrote. Most surprising behaviour follows from these three facts.

Your first script

hello.sh

#!/bin/bash
# A comment: everything after # is ignored
echo "Hello world"
#!/bin/bash
The shebang. Always the very first line: it tells the system which interpreter runs the file.
# …
A comment. The shell ignores everything after the # on that line.
echo
Prints its arguments to the standard output – the terminal, unless redirected.

A new file is not executable. Give it the x (execute) permission once, then run it. After chmod +x, ls -l hello.sh shows -rwxr-xr-x in the first column – the permission bits from Session 1.

chmod +x hello.sh     # once per file: give it the execute (x) permission
./hello.sh            # run it from the current directory

Three ways to run a script

./hello.sh            # needs x; new process, interpreter from the shebang
bash hello.sh         # no x needed; new process, bash chosen by you
source hello.sh       # no x needed; runs in YOUR shell (same as: . hello.sh)

The first two start a new process; source (or its short form, a single dot) runs the lines inside the shell you are typing into (Fig. 1). That difference shows up as soon as a script sets a variable or changes directory: in a child process the change disappears when the script ends, with source it stays.

your interactive shell./hello.shbash hello.shsource hello.sha new child processbash, from the shebangbash, chosen by youits variables vanish when it exitsneeds x permissionno x neededruns right herevariables stay
Fig. 1 · ./ and bash both start a child process – whatever the script changes dies with it. source runs the lines in your own shell.

Variables and quoting

name="student"           # no spaces around =, no $ on the left
echo "$name"             # $ to read the value
echo "${name}_backup"    # braces mark where the name ends
today=$(date +%F)        # command substitution: the output becomes the value
sum=$(( 3 + 4 ))         # arithmetic
unset name               # from here on, "$name" is an empty string
  • No spaces around =. name = "x" runs a command called name with two arguments.
  • No $ when assigning, $ when reading.
  • Names use letters, digits and _, and do not start with a digit.
  • An undefined variable is silently an empty string – no error. A typo in a name is a bug bash will not report.

What the shell does to a line before running it

This is the key to quoting. When you press Enter, the command does not get your text. The shell first substitutes variables, then command substitutions, then splits the line into words at spaces, then expands patterns like *.txt into matching file names (Fig. 2). Only the finished words reach the command.

what you typeecho "Hi $USER" $(date +%F) *.txt1 · variablesecho "Hi student" $(date +%F) *.txt2 · command substitutionecho "Hi student" YYYY-MM-DD *.txt3 · word splittingechoHi studentYYYY-MM-DD*.txt4 · globbingechoHi studentYYYY-MM-DDa.txtb.txtecho starts only now – with 5 words: echo, Hi student, YYYY-MM-DD, a.txt, b.txt"double quotes" let steps 1–2 happen but stop 3–4 · 'single quotes' stop all four
Fig. 2 · Four rewrites happen before echo starts. Quotes decide which ones apply: double quotes allow substitution but keep the result one word.
echo "Hello $USER"       # Hello student
echo 'Hello $USER'       # Hello $USER
echo "Today: $(date +%F)"  # Today: YYYY-MM-DD
FormWhat it does
"…"Variables and $( ) are substituted, but the result stays one word and is not globbed.
'…'Everything is literal. Nothing is substituted.
$(…)Command substitution: runs the command and puts its output in its place.

The rule that follows: almost always write variables in double quotes, "$file". Without them a file name with a space becomes two arguments, and an empty value disappears entirely – which turns [ $a == x ] into the syntax error [ == x ].

Environment variables

VariableHolds
HOMEthe absolute path of your home directory
PWDthe absolute path of the current directory
PATHthe directories searched for commands, separated by :
USERyour user name
SHELLyour default shell
RANDOMa new random number from 0 to 32767 on every read (a bash variable)
PS1the look of your prompt (a bash variable)
echo "User: $USER, home: $HOME"
echo "Dice: $(( RANDOM % 6 + 1 ))"
env                      # list every environment variable

Script arguments

Words after the script name arrive as numbered variables (Fig. 3). Quotes on the command line decide what counts as one argument – the same word splitting as before.

./args.sh "Ada Lovelace" 1815 London
what you type./args.sh$0the script itself"Ada Lovelace"$1Ada Lovelace1815$21815London$3Londonwhat the script sees$# → 3the script name does not count"$@" → 3 wordsAda Lovelace | 1815 | London"$*" → 1 stringAda Lovelace 1815 London
Fig. 3 · The quoted "Ada Lovelace" is one argument. $# counts only the arguments, not the script name.
VariableMeaning
$0the script name, as it was started
$1 … $9the first … ninth argument
${10}from the tenth on, braces are required ($10 is $1 followed by a 0)
$#the number of arguments
"$@"all arguments, each as a separate word – the one to loop over
$*all arguments glued into one string
$?the exit code of the last command (0 = success)
$$the PID of the running shell – handy for unique temporary file names

args.sh – the interesting lines

echo "Script name: $0"
echo "First: $1"
echo "Count: $#"
for arg in "$@"; do      # "$@" keeps every argument as one word
    echo "Argument: $arg"
done
Script name: ./args.sh
First: Ada Lovelace
Count: 3
Argument: Ada Lovelace
Argument: 1815
Argument: London

Conditions: if and test

if [ "$n" -lt 10 ]; then
    echo "one digit"
elif [ "$n" -lt 100 ]; then
    echo "two digits"
else
    echo "three or more digits"
fi

The spaces inside [ … ] are not style. [ is a command – another name for test – and everything up to ] are its arguments. Like every command it finishes with an exit code, and that code is all if looks at (Fig. 4).

if [ "$n" -lt 10 ]; then … else … fi[ "$n" -lt 10 ]a command: testexitsexit codealso stored in $?0 → then branchsuccess means true1–255 → else branchfailure means falseAny command can stand after if: grep -q word file, cp a b, a function of yours.0 = true is the opposite of C, Java and Python – it means "nothing went wrong".
Fig. 4 · if runs a command. Exit code 0 means success and picks then; anything else picks else.
[ 5 -lt 10 ]; echo $?     # 0  -> true
[ 5 -gt 10 ]; echo $?     # 1  -> false
test 5 -lt 10; echo $?    # 0  -> [ is just another name for test

So any command can be a condition. grep -q prints nothing and only reports through its exit code – exactly what an if needs:

if grep -q "error" log.txt; then
    echo "The log contains an error"
fi

Operators

NumbersStringsFiles – true if …
-eq equal== equal-e the path exists
-ne not equal!= different-f it is a regular file
-lt less than-z empty string-d it is a directory
-le less or equal-n not empty-s it is not empty
-gt greater than=~ matches a regex (only in [[ ]])-r -w -x readable, writable, executable
-ge greater or equal

Combine conditions with && (and), || (or) and ! (not) inside [[ ]], for example [[ ! -f "$file" ]] or [[ $ext == "jpg" || $ext == "png" ]].

Which brackets?

SyntaxUse it forExample
[ ]simple tests, numbers with -eq, -lt; works in every POSIX shell[ "$n" -lt 10 ]
[[ ]]strings, && and || inside, regex with =~; bash only, safer with unquoted variables[[ $a == x && $b != y ]]
(( ))arithmetic with the usual < > == operators, no $ needed(( n % 2 == 0 ))
( )not a test: runs commands in a subshell(cd /tmp && ls)
if (( n % 2 == 0 )); then     # inside (( )) no $ is needed and < > work
    echo "$n is even"
fi

In a #!/bin/bash script, [[ ]] is the comfortable default. Learn [ ] too – most existing scripts use it.

Reading input

read -r -p "Enter username: " username
read -r -s -p "Enter password: " password   # -s: typing is not shown
echo                                        # -s also hides the Enter, so break the line

-r keeps backslashes as typed (use it every time), -p prints a prompt, -s hides what is typed. Arguments are better for anything another script might pass in; read is for interactive questions.

Working with strings

Bash can cut and change strings itself, without calling another program. All of these are forms of ${…}:

str="ELTE IK University"
echo "${#str}"          # 18                  length
echo "${str:5:2}"       # IK                  2 characters from index 5
echo "${str:8}"         # University          from index 8 to the end
echo "${str: -10}"      # University          last 10 (the space before - matters)
echo "${str/IK/TTK}"    # ELTE TTK University first match replaced
echo "${str//E/e}"      # eLTe IK University  every match replaced
echo "${str^^}"         # ELTE IK UNIVERSITY  upper case
echo "${str,,}"         # elte ik university  lower case
str="ELTE IK University"E0L1T2E34I5K67U8n9i10v11e12r13s14i15t16y17${str:5:2}→ IK${str:8}→ University (to the end)Dashed cells are spaces – they count too.
Fig. 5 · Positions start at 0, and spaces count. ${str:start:length} – leave out the length to go to the end.
first="ELTE "
second="IK"
both="${first}${second}"   # ELTE IK
both+=" University"        # ELTE IK University

Braces matter when text follows the name: "$file_backup" reads a variable called file_backup, "${file}_backup" appends _backup to $file. The default-value form is the shortest way to make an argument optional:

n=${1:-10}        # the first argument, or 10 if there is none

Loops

LoopRepeats …
whileas long as the condition is true
untilas long as the condition is false
for … in …once for every item of a list: words, a range, files
for (( … ))with a counter, C style

while

count=1
while [ "$count" -le 5 ]; do
    echo "$count"
    ((count++))
done

until

count=1
until [ "$count" -gt 3 ]; do     # runs while the condition is FALSE
    echo "$count"
    ((count++))
done

for – list, range, files

for fruit in apple pear banana; do echo "$fruit"; done   # a list of words
for i in {1..5}; do echo "$i"; done                      # a range
for file in *.sh; do echo "Script: $file"; done          # files (globbing)

for – C style

for (( i=10; i>0; i-- )); do
    echo -n "$i "      # -n: no newline
done
echo

break and continue

for i in {1..10}; do
    if (( i == 3 )); then continue; fi   # skip the rest of this round
    if (( i == 6 )); then break; fi      # leave the loop
    echo -n "$i "
done                                     # prints: 1 2 4 5

One loop you will write in almost every task processes a file line by line. The redirection after done feeds the file into the loop, and each read takes the next line:

while read -r line; do
    echo "Line: $line"
done < numbers.txt

Session 4 takes this loop apart – splitting lines into fields, the last line without a newline, and why piping into it loses your variables.

Functions

rectangle_area() {
    local area=$(( $1 * $2 ))   # local: exists only inside the function
    echo "Area: $area"
}

rectangle_area 10 20            # call without parentheses: Area: 200
  • Define a function before the line that calls it.
  • Call it by name, without parentheses; arguments follow, separated by spaces.
  • Inside, the function reads its own arguments as $1, $2, $#, "$@" – the script's arguments are hidden while it runs.
  • Without local, every variable is global: the rest of the script sees it and can overwrite it.

The surprise is how a function gives something back. It has two separate channels (Fig. 6): what it prints, which the caller captures with $( ), and its exit status, a number from 0 to 255 that if can branch on.

the RESULT – printed, then capturedsquare 7echo $(( $1 * $1 ))stdoutthe text 49$( … )captures the textresult=49the STATUS – success or failureis_even 4return 0 or 1exit status0–255, also in $?if is_even 4branches on itthen branchreturn 49 would not hand back the number 49 – only a status, and anything above 255 wraps around.
Fig. 6 · Results travel through stdout and $( ); return only sets a success/failure status.
square() {
    echo $(( $1 * $1 ))         # the RESULT goes to stdout
}
is_even() {
    (( $1 % 2 == 0 ))           # the STATUS: 0 if true, 1 if false
}

result=$(square 7)              # 49
if is_even 4; then echo "4 is even"; fi

Regular expressions

A regular expression describes the shape of text you are looking for. grep, sed, awk and bash's [[ … =~ … ]] all use them. Most characters match themselves; the special ones (. * [ ( ? + ^ $ \) need a backslash to be taken literally – \. is a real dot. Try patterns on regex101, which explains every piece.

Characters and classes

PatternMatchesExample
.any single charactera.a → aba, a1a
[abc]one of the listed characters[0-9] → one digit
[a-d]one character from a range[a-z] → one lowercase letter
[^abc]one character NOT in the list[^0-9] → not a digit
\wa letter, digit or _\w+ → a word
[[:digit:]]a digit (POSIX class); also [[:alpha:]], [[:space:]]same as [0-9]

Inside [ ] most special characters lose their meaning: [.,] is a dot or a comma, no backslash needed. grep -E does not know \d from other languages – write [0-9].

Anchors and repetition

PatternMeaningExample
^start of the line^apple → lines starting with apple
$end of the line^apple$ → lines that are exactly apple
\ba word boundary\bpear\b → pear as a whole word
?the previous item 0 or 1 times-?[0-9] → 5, -5
*0 or more timesab* → a, ab, abbb
+1 or more times[0-9]+ → 7, 2026
{n}exactly n times[0-9]{4} → a four-digit number
{n,m}n to m times; {n,} at least n, {,m} at most m[0-9]{2,4} → 12, 1234

Groups and alternatives

Parentheses make a group, so a repetition applies to all of it: (ab)+ matches ab, abab. The bar chooses: (apple|pear|plum). A group also remembers what it matched, and sed can reuse it as \1, \2 – here to swap two words:

echo "apple pear" | sed -E 's/(\w+) (\w+)/\2 \1/'    # pear apple

Reading a real pattern

The example script line_counter.sh counts the lines of a file that hold a single number ending in 1, with an optional sign and an optional decimal part. Read the pattern left to right, one piece at a time (Fig. 7).

grep -cE '^[+-]?([0-9]+[.,])?[0-9]*1$' "$1"
^start ofthe line[+-]?optionalsign([0-9]+[.,])?optional whole partand a . or ,[0-9]*zero or moredigits1the last digitmust be 1$end ofthe linelines of numbers.txt – blue matches, red does not12.1-13.32132111123asd1.15Without ^ and $ a match anywhere inside the line would count – the anchors force the whole line to fit.
Fig. 7 · Six pieces, read left to right. The anchors ^ and $ are what make it a whole-line check.

grep options you will use

OptionEffect
-Eextended regex: + ? | ( ) { } work without backslashes
-cprint the number of matching lines instead of the lines
-iignore case
-vprint the lines that do NOT match
-oprint only the matching part, one per line
-nprefix each match with its line number
-xthe whole line must match
grep -E '^[0-9]+$' numbers.txt      # lines that are only digits
grep -cE '^[0-9]+$' numbers.txt     # how many such lines
grep -iv 'error' log.txt            # lines WITHOUT "error", any case

In bash's own test it is the other way round: the pattern after =~ must not be quoted, or bash compares it as plain text.

if [[ $times =~ ^[0-9]+$ ]]; then     # pattern NOT quoted
    echo "a non-negative integer"
fi

Standard streams and redirection

Every process starts with three open streams, each identified by a number, its file descriptor: 0 is stdin (input), 1 is stdout (normal output), 2 is stderr (error messages). On a terminal 1 and 2 both end up on the screen, so they look the same – until you redirect one of them (Fig. 8).

command line1 · stdout goes to2 · stderr goes tocmdterminalterminalcmd > out.txtout.txt (overwritten)terminalcmd >> out.txtout.txt (appended)terminalcmd 2> err.txtterminalerr.txtcmd 2>/dev/nullterminal/dev/null (discarded)cmd > all.txt 2>&1all.txtall.txt (copy of 1)echo "msg" >&2the terminal, as stderrterminalecho "msg" >2a file named 2terminalfd 0 (stdin) reads the keyboard unless you give it a file with < – sort < names.txt
Fig. 8 · Each redirection changes where one descriptor points. 2>&1 copies where 1 points right now – so it goes after > file.
ls > out.txt              # stdout to a file (overwrite)
ls >> out.txt             # stdout appended
sort < names.txt          # stdin from a file
ls nope 2> err.txt        # stderr to a file
ls nope 2>/dev/null       # stderr thrown away
ls . nope > all.txt 2>&1  # both into the same file (order matters)

Why error messages go to stderr

echo "File not found: $1" >&2   # an error message: stderr, not stdout
exit 1                          # and a non-zero exit code

>&2 means "send this echo's stdout to where descriptor 2 points". The & is essential: >2 would create a file called 2. The payoff shows when someone saves your script's output:

$ ./line_counter.sh nope.txt > result.txt
File not found: nope.txt        <- on the screen; result.txt stays clean

The same holds for pipes and $( ): count=$(./line_counter.sh nope.txt) captures only stdout, so an error message never ends up in count looking like a result. Pipes themselves – cmd1 | cmd2, stdout of one into stdin of the next – are covered in Session 2.

Putting it together: a robust script

The example scripts of this session share a shape worth copying: check everything at the top, stop at the first problem with a message on stderr and a non-zero exit code, then do the work knowing the input is valid. Here is the head of line_writer.sh, which prints a text a given number of times:

line_writer.sh – the checks

if [ "$#" -ne 2 ]; then
    echo "Usage: $0 <number of times> <text>" >&2
    exit 1
fi
times=$1
text=$2
if [[ ! $times =~ ^[0-9]+$ ]]; then
    echo "Error: '$times' is not a non-negative integer" >&2
    exit 1
fi

dir_tester.sh does the same with input from read: it refuses an empty name, then creates the directory only if it does not exist yet (mkdir -p also creates missing parent directories).

dir_tester.sh – the core

read -r -p "Enter directory name: " dir
if [ -z "$dir" ]; then
    echo "Directory name cannot be empty" >&2
    exit 1
fi
if [ -d "$dir" ]; then echo "Directory exists"; else mkdir -p "$dir"; fi

Exit codes

Every command and script ends with an exit code: 0 for success, 1–255 for some kind of failure. Your scripts should follow the same convention, because other scripts – and if – rely on it.

./line_writer.sh 1 test; echo $?    # 0
./line_writer.sh;        echo $?    # 1

Common mistakes and debugging

WrongRightWhy
var = "x"var="x"no spaces around =
$var="x"var="x"no $ when assigning
if [$n -lt 10]if [ "$n" -lt 10 ][ is a command: it needs spaces around it
[ $a == $b ][ "$a" == "$b" ]an empty or spaced value breaks the test
[ "$n" < 10 ][ "$n" -lt 10 ]inside [ ], < is a redirection, not a comparison
grep ^[0-9]+$ f.txtgrep -E '^[0-9]+$' f.txtthe shell expands the unquoted pattern first
cat f | grep x | wc -lgrep -c x fgrep reads files and counts by itself
my_func()my_funcno parentheses when calling a function
echo "msg" >2echo "msg" >&2>2 writes a file named 2

When a script misbehaves

bash -n script.sh      # syntax check only, nothing runs
bash -x script.sh      # print every command, with the values filled in
set -x                 # inside a script: tracing on ...
set +x                 # ... and off again

bash -x is the quickest way to see what the shell really ran – every line after expansion, prefixed with +:

$ bash -x line_writer.sh 2 hi
+ '[' 2 -ne 2 ']'
+ times=2
+ text=hi
+ [[ 2 =~ ^[0-9]+$ ]]
+ (( i=0 ))
+ (( i<times ))
+ echo hi
hi

For a second opinion, paste the script into ShellCheck. It finds missing quotes, unused variables and most of the table above, and explains each warning.

Practice

Write each one as a script with a shebang, run it with good and bad input, and check $? after a failure. Try first, open the hint when stuck.

Task 1

Greeting

The script takes a name as its argument and prints Hello <name>!. Without an argument it greets the logged-in user.

Approach

No if needed – a default value does it in one line:

name=${1:-$USER}

Task 2

Calculator

The script takes two integers and prints their sum, difference and product. With the wrong number of arguments, or a non-integer, it prints a usage message and exits with an error.

$ ./calc.sh 7 3
Sum:        10
Difference: 4
Product:    21
Approach

Two checks before any arithmetic: the count, then the format (an optional sign, then digits). The arithmetic itself is $(( a + b )) and friends.

if [ "$#" -ne 2 ]; then
    echo "Usage: $0 <number1> <number2>" >&2
    exit 1
fi
if [[ ! $1 =~ ^[+-]?[0-9]+$ || ! $2 =~ ^[+-]?[0-9]+$ ]]; then ...

Task 3

File, directory or nothing?

The script takes a path and reports whether it is a regular file, a directory, or does not exist.

Approach

An if … elif … else chain with file tests. Order matters: -e is true for files and directories alike, so put the specific tests first.

if [ -f "$path" ]; then ...
elif [ -d "$path" ]; then ...
else ...

Task 4

Even numbers

The script takes a number n and prints the even numbers from 1 to n on one line, e.g. 2 4 6 8 10 for 10.

Approach

Either loop over every number and keep those where (( i % 2 == 0 )), or start at 2 and step by two – then no condition is needed:

for (( i=2; i<=n; i+=2 )); do
    echo -n "$i "
done

Task 5

Factorial in a function

Write a function that computes the factorial of its argument, and call it with the script's first argument: ./factorial.sh 5 prints 5! = 120.

Approach

The function prints the result; the caller captures it with $( ). Keep the loop variables local. Bash integers are 64-bit, so 20! is the largest result that is still correct.

factorial() {
    local result=1 i
    for (( i=2; i<=$1; i++ )); do result=$(( result * i )); done
    echo "$result"
}
result=$(factorial "$1")

Task 6

Negative integers only

Add some lines to a numbers.txt and write a regular expression that matches only the lines holding a negative integer.

12.1
-13.321
123
321
asd
something
11
Approach

A required minus, at least one digit, and anchors at both ends so nothing else fits on the line:

grep -E '^-[0-9]+$' numbers.txt
-7          match
-42         match
-13.321     no – the dot is not a digit
-0.5        no
+5          no – the sign must be a minus
12          no

Task 7

Sum until zero

The script keeps asking for numbers until the user enters 0, then prints the sum. A non-number gets an error message and another chance; Ctrl+D must not cause an endless loop.

Approach

An endless while true loop that you leave with break; continue skips invalid input. A failed read (end of input) is a reason to leave, too:

while true; do
    if ! read -r -p "Number (0 to finish): " number; then break; fi
    if [[ ! $number =~ ^[+-]?[0-9]+$ ]]; then echo "Not an integer" >&2; continue; fi
    (( number == 0 )) && break
    # ... add the number to the running sum
done

Summary

  • Start every script with #!/bin/bash, make it executable once with chmod +x, run it with ./. source runs it in your own shell instead of a child process.
  • Before a command runs, the shell substitutes variables and $( ), splits words and expands globs. Double-quote your variables.
  • Arguments are $1 … $9, ${10}, their count is $#, all of them "$@".
  • if runs a command and branches on its exit code; [ is the test command, so it needs spaces. 0 means true.
  • Functions print their result (capture it with $( )) and return only a status.
  • Quote regex patterns for grep in single quotes; leave the pattern after =~ unquoted.
  • Errors go to stderr with >&2, followed by exit 1. Results go to stdout.