Writing scripts

Writing a simple script is as easy as putting some commands in a file:

echo 'echo "Hello world!"' > my_script.sh

Executing scripts

There are 2 main ways to execute a script:

# execute explicitly using bash
bash ./my_script.sh

# execute as a command
# requires execute permissions on the file
chmod +x ./my_script.sh
./my_script.sh

A note on editing scripts

You can use any editor to author your scripts, but I would recommend an editor that supports useful plugins:

  • syntax highlighting
  • linters
  • LSPs

While writing simple scripts is easy, writing good scripts is hard. In this course we will focus on:

  • getting things to work locally
  • covering common features and constructs
  • giving you a glimpse into what it takes to write a polished script

Shebang

You can instruct the system to use a specific execution environment by adding a special instruction as the first line of your script.

This is often referred to as a shebang:

#!/bin/bash

# your actual script starts here

  • The shebang makes sure that your script is properly setup and reduces the knowledge and setup required from its user.

  • Shebangs are NOT limited to bash, you can use them with any language such as JavaScript or Python

A note on portability

In the above example, we are assuming that the /bin/bash file exists - this might not be the case.

To make the script a bit more portable, we can use the env command to dynamically find the actual location of the bash executable:

#!/bin/env bash

# your script starts here

Test

The test command allow us to assert many types of conditions:

  • string comparisons
  • number comparisons
  • file existence
  • file properties
  • variable properties
  • and more

Running tests

There are 2 ways to run the test command:

# execute explicitly
test <condition>

# using square brackets
[ <condition> ]

NOTE: the spaces around the condition when using brackets are required


To read the results of the test examine the its exit status:

test 'a' = 'a'
echo "$?" # prints 0 i.e. success

[ 'a' = 'b' ]
echo "$?" # prints 1 i.e. failure

Testing strings

# str1 matches str2
str1 = str2

# str1 does not match str2
str1 != str2

# str1 is less than str2 (alphabetically)
str1 < str2

# str1 is greater than str2 (alphabetically)
str1 > str2

Test: numbers

1 -lt 2   # less than

3 -le 4   # less than or equal

5 -eq 6   # equal

7 -ge 8   # greater than or equal

9 -gt 10   # greater than

11 -ne 12   # not equal

Test: file properties

-e file   # file exists
-d file   # directory exists
-f file   # file exists and is a regular file
# (not a directory or other special type of file)

-s file   # file exists and is not empty
-N file   # file was modified since it was last read

file1 -nt file2 # file1 is newer than file2
file1 -ot file2 # file1 is older than file2

Test: variables

-v var    # variable is set

-n $var   # variable value is NOT empty

-z $var   # variable is unset or value is empty

Test: logical operators

# cond1 AND cond2
[ condition1 -a condition2 ]

# cond1 OR cond2
[ condition1 -o condition2 ]

# NOT condition
[ ! condition ]

Bash specific testing

Bash extends the capabilities of the POSIX testing utility.

To use bash-specific features use double square brackets:

[[ <condition> ]]

Some useful extensions only available in bash are:

# using globs in tests
[[ "hello.txt" == *.txt ]]

# use character classes in tests
[[ "$input" == [0-9]* ]]

# use regular expressions in tests
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]

Logical operators outside test

You can use short-circuiting logical AND and logical OR when chaining commands

# only execute cmd2 if cmd1 succeeds
cmd1 && cmd2

# only execute cmd2 if cmd1  fails
cmd1 || cmd2

Conditionals

You do NOT need to check the result of a test through the exit status.


if

if condition; then
  statements
elif condition; then
  statements
else
  statements
fi

case

Case patterns can contain globs like *, ?, [a-z]:

case expression in
  pattern1 )
    statements ;;
  pattern2 )
    statements ;;
esac

Loops

Loops can use globs as well:

for f in ~/mydir/*.txt; do
  echo $f
done

for i in {1..5}; do
    echo "Number $i"
done

cat file.txt | while read line; do
  echo $line
done

Arrays

# declare an array
myarray=(1 "A" 2 "B")

# assign to an index in an array
myarray[0]="hi"

# print array element at index i
echo ${myarray[i]}

# print all elements in array
echo ${myarray[@]}

# print length of array element at index i
echo ${#myarray[i]}

# print length of array itself
echo ${#myarray[@]}

# append items to an array
myarray+=(7 "Z")

Looping over each element of an array:

colors=("red" "green" "yellow")
for color in "${colors[@]}"; do
  echo "The traffic light is $color"
done

Associative arrays

These act as dictionaries or hashmaps in other languages allowing you to store key - value pairs.

# define an associative array
declare -A PETS

PETS["Norwegian"]="cat"
PETS["Dane"]="horse"
PETS["Brit"]="bird"
PETS["German"]="?"

# Read the value for a given key
echo ${PETS['Brit']}

# Adding new values
PETS["Swede"]="dog"

# Alternative syntax for adding elements
PETS+=(["Bulgarian"]="goat")

Handling inputs

There are special variable that allow you to handle script arguments.

# print number of arguments passed to the script
echo "$#"

# an array containing all arguments
echo "$@"

# arguments by order
echo "$1 $2 $3"

# calling a script with arguments
my-script hello world

What is the value of $2 in the above script execution?

  • none / empty string
  • hello
  • world
  • 0

Prompting for user input

Use the read command to block execution and allow the user to provide input through stdin:

# program stops and waits for the user to input a line of text
read line
echo "$line"

If more than one variable is provided, the line will be split into words.

The -p option allows for adding a prompt:

read -p "Full Name: " first last
echo "My name is $last, $first $last"

Handling signals

Use the trap command to execute code as a response to a system signal:

# Delete temp file when receiving an EXIT signal
tempfile=/tmp/tmpdata
trap "rm -f $tempfile" EXIT

Functions

Functions can be used to group logical units and repetitive actions.

# declare a function
my_func() { echo "Hello $1"; }

# alternative syntax
function myfunc() {
  echo "same as above"
}

# calling a function is like calling a command
my_func "friend"

NOTE: In bash functions do NOT return values.


Use the declare built-in command to examine functions defined in your environment:

# Show function body
declare -f my_func

# List all function names
declare -F

The meaning of certain special variable changes inside a function:

# number of arguments to the function
myfunc() { echo "$#"; }`

# all arguments to the function
function myfunc() {
  echo "$@"
}

# function arguments by order
myfunc() { echo "$1 $2 $3"; }

Script vs function arguments

  • Create a script that takes 2 arguments
  • Print the string ‘From script:’
  • Print the values of both arguments
  • Add a function definition to the script that also takes 2 arguments
  • The function should print ‘From function:’ and then print its arguments
  • Call the function inside the script passing in the script arguments in reverse

Function local variables

By default all variables declared in a script will be visible anywhere within the script, even if they are declared inside a function.


To make a variable scoped to the specific function use the local keyword.

my_func() {
  greeting="ahoy"
  local hi="hello"
}

my_func
echo "global: $greeting"
echo "local: $hi"
# prints: ahoy

Doing math in bash

By default everything in the console is treated as a string. Use the ((<expression>)) syntax to perform math.

x=5
y=5
((z=x+y))

echo $z
# prints 10

Inside a math expression variables should NOT start with an $


Use the $(( EXPR )) syntax to get the return value of the math operation

echo "1 + 1 = $((1+1))"
# prints 1 + 1 = 2

# a random number between 1 and 10
echo "$((RANDOM % 10 + 1))"

x="$((5+5))"
echo $x
#prints 10

Linting with shellcheck

Shellcheck is a powerful tool that can find many mistakes and problems in your scripts.

It is an external command that needs to be installed.

shellcheck my-script
# produces a list of potential problems

Fun fact: there is a version of the popular containerization tool Docker written entirely in bash


Create a number guessing game

  1. Create a script called guess-num.sh
  2. Create a variable holding a random number between 0 and 100
  3. In a loop, allow the user to guess the number
  4. Alert the user if their guess was high or low
  5. Congratulate the user when they guess the number

Guessing game implementation example

This is an example of how the above exercise can be implemented.

NOTE: this is just an example, it is NOT in any way canonical. Feel free to implement the solution in a way that makes sense to you.

#!/bin/bash

# allow the user to provide upper bound as param
if [ -z "$1" ]; then
  upper=100
else
  upper=$1
fi

# generate the target number
((target = RANDOM % upper))

echo "Guess the number between 0 and $upper!"

# track the number of guesses
num_guesses=1

# taunt player if they quit
trap "echo -e '\ngiving up??'" EXIT

# loop forever (we'll break the loop manually)
while true; do
  read -rp "Guess [$num_guesses]: " guess

  # validate that the guess is a number
  if ! [[ "$guess" =~ ^[0-9]+$ ]]; then
    echo "please enter a number"
    continue
  fi

  # validate that the guess is within the bounds
  if [ "$guess" -lt 0 ]; then
    echo "guess should be higher than 0"
    continue
  elif [ "$guess" -gt "$upper" ]; then
    echo "guess should be lower than $upper"
    continue
  fi

  # give the user hints
  if [ "$guess" -lt "$target" ]; then
    echo "try higher!"
  elif [ "$guess" -gt "$target" ]; then
    echo "try lower!"
  else
    echo "You got it!"
    exit 0
  fi

  # increment number of guesses
  ((num_guesses = num_guesses + 1))
done

Interactive menu example

The code below demonstrates a more complex script that displays an interactive menu which allows the user to choose options using the arrow keys.

It uses arrays, variables, functions, loops, case conditionals and math.

#!/bin/bash

# Menu items
options=("Install Package" "Update System" "View Logs" "Configure Settings" "Exit")

# Currently selected index
selected=0

# Function to draw the menu
draw_menu() {
  clear
  echo "=== Main Menu ==="
  echo "Use ↑/↓ arrows and Enter to select"
  echo ""

  for i in "${!options[@]}"; do
    if [ $i -eq $selected ]; then
      echo "→ ${options[$i]}" # Selected item
    else
      echo "  ${options[$i]}"
    fi
  done
}

# Function to read arrow keys
read_input() {
  read -rsn1 key
  if [[ $key == $'\x1b' ]]; then
    read -rsn2 key
  fi
  echo "$key"
}

# Main loop
while true; do
  draw_menu

  key=$(read_input)

  case "$key" in
  '[A') # Up arrow
    ((selected--))
    if [ $selected -lt 0 ]; then
      selected=$((${#options[@]} - 1))
    fi
    ;;
  '[B') # Down arrow
    ((selected++))
    if [ $selected -ge ${#options[@]} ]; then
      selected=0
    fi
    ;;
  '') # Enter key
    clear
    echo "You selected: ${options[$selected]}"

    # Handle the selection
    case $selected in
    0) echo "Installing package..." ;;
    1) echo "Updating system..." ;;
    2) echo "Viewing logs..." ;;
    3) echo "Configuring settings..." ;;
    4)
      echo "Goodbye!"
      exit 0
      ;;
    esac

    echo ""
    echo "Press any key to return to menu..."
    read -n1
    ;;
  esac
done