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.
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 directoryThree 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.
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 callednamewith 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.
echo "Hello $USER" # Hello student
echo 'Hello $USER' # Hello $USER
echo "Today: $(date +%F)" # Today: YYYY-MM-DD| Form | What 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
| Variable | Holds |
|---|---|
HOME | the absolute path of your home directory |
PWD | the absolute path of the current directory |
PATH | the directories searched for commands, separated by : |
USER | your user name |
SHELL | your default shell |
RANDOM | a new random number from 0 to 32767 on every read (a bash variable) |
PS1 | the look of your prompt (a bash variable) |
echo "User: $USER, home: $HOME"
echo "Dice: $(( RANDOM % 6 + 1 ))"
env # list every environment variableScript 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| Variable | Meaning |
|---|---|
$0 | the script name, as it was started |
$1 … $9 | the 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"
doneScript name: ./args.sh
First: Ada Lovelace
Count: 3
Argument: Ada Lovelace
Argument: 1815
Argument: LondonConditions: 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"
fiThe 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).
[ 5 -lt 10 ]; echo $? # 0 -> true
[ 5 -gt 10 ]; echo $? # 1 -> false
test 5 -lt 10; echo $? # 0 -> [ is just another name for testSo 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"
fiOperators
| Numbers | Strings | Files – 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?
| Syntax | Use it for | Example |
|---|---|---|
[ ] | 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"
fiIn 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 casefirst="ELTE "
second="IK"
both="${first}${second}" # ELTE IK
both+=" University" # ELTE IK UniversityBraces 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 noneLoops
| Loop | Repeats … |
|---|---|
while | as long as the condition is true |
until | as 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++))
doneuntil
count=1
until [ "$count" -gt 3 ]; do # runs while the condition is FALSE
echo "$count"
((count++))
donefor – 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
echobreak 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 5One 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.txtSession 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.
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"; fiRegular 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
| Pattern | Matches | Example |
|---|---|---|
. | any single character | a.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 |
\w | a 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
| Pattern | Meaning | Example |
|---|---|---|
^ | start of the line | ^apple → lines starting with apple |
$ | end of the line | ^apple$ → lines that are exactly apple |
\b | a word boundary | \bpear\b → pear as a whole word |
? | the previous item 0 or 1 times | -?[0-9] → 5, -5 |
* | 0 or more times | ab* → 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 appleReading 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"grep options you will use
| Option | Effect |
|---|---|
-E | extended regex: + ? | ( ) { } work without backslashes |
-c | print the number of matching lines instead of the lines |
-i | ignore case |
-v | print the lines that do NOT match |
-o | print only the matching part, one per line |
-n | prefix each match with its line number |
-x | the 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 caseIn 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"
fiStandard 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).
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 cleanThe 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
fidir_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"; fiExit 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 $? # 1Common mistakes and debugging
| Wrong | Right | Why |
|---|---|---|
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.txt | grep -E '^[0-9]+$' f.txt | the shell expands the unquoted pattern first |
cat f | grep x | wc -l | grep -c x f | grep reads files and counts by itself |
my_func() | my_func | no parentheses when calling a function |
echo "msg" >2 | echo "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 againbash -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
hiFor 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: 21Approach
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 "
doneTask 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
11Approach
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 noTask 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
doneSummary
- Start every script with
#!/bin/bash, make it executable once withchmod +x, run it with./.sourceruns 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"$@". ifruns a command and branches on its exit code;[is thetestcommand, so it needs spaces. 0 means true.- Functions print their result (capture it with
$( )) andreturnonly a status. - Quote regex patterns for grep in single quotes; leave the pattern after
=~unquoted. - Errors go to stderr with
>&2, followed byexit 1. Results go to stdout.