Introduction

A script is basically a program written using a scripting language. There are many of them out there, and Bash is one of those.

Inside a script, you can use all Bash internal commands plus all system commands as long as the executing shell is able to find them (a PATH related matter).

As a programming language, Bash proposes the basic control structures: tests and loops.

It also comes with some data structures: the single dimensional array (indexed or associative).

A script is usually stored in a file so you can reuse it as many times as you want and, therefore, may be considered a command on its own. However, to make a Bash script actually behave like a command, you have to fulfill those three requirements:

  1. the script file must begin with the special comment (called a sha bang or a she bang) #!/bin/bash;

  2. the script file must be executable (the x bit must be set on it);

  3. the script file must be placed somewhere on your file system where the interpreter is able to find it (inside a directory declared in the PATH variable).

The name may be whatever you chose. Usually, script files end with the .sh extension, however, this is not mandatory. The file command considers a file an executable script as soon as it begins with a she bang.

A Bash script has no entry point (like the main function in most compiled programming languages). Every instruction is executed the moment it is parsed. However, you may define functions inside a script and decide one is the entry point.

Scripts and functions may be given parameters, then available through the $1 …​ $n variables.

Goals

  • Create simple shell scripts

  • Know how to make any script executable

  • Learn the basics of shell programming with Bash

Warming up

To get used to Bash scripting language, write small scripts to answer the following questions.

Remember, the files containing your scripts must include the shebang construct at their very beginning and be executable (using the chmod command).

  1. Create a script to write the number of parameters it gets on the command line as well as a list of these parameters if any, one per line.

    Answer
    params.sh
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    #! /bin/bash
    #
    # Affiche le nombre d'arguments passé au script et
    # la liste de ces derniers s'ils existent.
    ##
    
    echo "Nombre de paramètres : $#"
    
    if [ $# -eq 0 ] ; then exit ; fi
    
    n=1
    for i in $@
    do
      echo "Argument $n : $i"
      ((n++))
    done
    
  2. Create a script to write the sequence of the first 10 integers starting at 1. Its return status is 0.

    Answer
    poor_seq1.sh
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    #! /bin/bash
    #
    # Affiche la séquence des 10 premiers entiers non nuls.
    ##
    
    i=1
    while [ $i -le 10 ]
    do
      echo $i
      let "i+=1"
    done
    
  3. Update your script to make it write a sequence of integers between min and max given on the command line (min is the first argument, max the second one).

    Answer
    poor_seq2.sh
    #! /bin/bash
    #
    # Affiche la séquence des entiers compris entre $1 et $2
    ##
    
    i=$1
    while [ $i -le $2 ]
    do
      echo $i
      let "i+=1"
    done

    The problem is the script does not check its arguments for actually being integers…​

  4. Change your script to make it more robust:

    1. If only one parameter is given, the script outputs the sequence of integers from 1 to this value and the error code is 0 (meaning this is a expected behaviour).

    2. if no parameter is provided, the script simply outputs a sequence of integers from 1 to 10, but the error code is 1 instead of 0.

    3. If min is not lesser than max, an error message is issued, no sequence is displayed (obviously) and the error core is 2.

      Answer
      poor_seq3.sh
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      
      #! /bin/bash
      #
      # Affiche la liste des entiers compris entre $1 et $2
      # Les erreurs d'arguments sont détectées et un code de
      # retour approprié est renvoyé.
      ##
      
      if [ $# -lt 2 ]; then echo "Arguments manquants" ; exit 1 ; fi
      if [ $1 -gt $2 ]; then echo "min > max !" ; exit 2 ; fi
      
      i=$1
      while [ $i -le $2 ]
      do
        echo $i
        let "i+=1"
      done
      
    4. Add to your script a last check on its parameters to ensure they all are actual integers.

      Answer
      poor_seq.sh
       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      
      #! /bin/bash
      #
      # Affiche la liste des entiers compris entre $1 et $2
      # Les erreurs d'arguments sont détectées et un code de
      # retour approprié est renvoyé.
      ##
      
      # Une fonction utilitaire pour savoir si une variable est un nombre entier
      # Tiré des réponses à ce thread : https://stackoverflow.com/questions/2210349/test-whether-string-is-a-valid-integer
      function is_int() {
        [[ $# -eq 0 ]] && return 1
      
        # Test à l'aide d'expressions rationnelles
        [[ $1 =~ ^[+-]?[0-9]+$ ]] && return 0
      
        # Autre solution possible avec les tests de Bash
        #if [ $1 -eq $1 ] 2>/dev/null
        #then
        # return 0
        #fi
        return 1
      }
      
      # Y a-t-il effectivement des arguments ?
      if [[ $# -lt 1 ]]; then echo "Arguments manquants" ; exit 1 ; fi
      if [[ $# -lt 2 ]]
      then
        min=1
        max=$1
      else
        min=$1
        max=$2
      fi
      
      # Est-ce qu'ils sont bien entiers ?
      if ! is_int "$min" ; then echo "$min n'est pas un entier !"; exit 3 ; fi
      if ! is_int "$max" ; then echo "$max n'est pas un entier !"; exit 3 ; fi
      
      # Est-ce qu'ils sont bien ordonnés ?
      if [[ $min -gt $max ]]; then echo "$min > $max !" ; exit 2 ; fi
      
      i=$min
      while [[ $i -le $max ]]
      do
        echo $i
        let "i+=1"
      done
      
  5. Update your script to accept a third optional parameter, the step between to integers. For example, if the step is 3, the sequence from 1 to 10 would be 1 4 7 10.
    You may set a default step of 1 set by using the Assign Default Values mechanism found in Bash (see section EXPANSION / Parameter Expansion in the Bash manual page).

Congratulations, you just wrote your version of the seq command found on your system.

Task automation

  1. Write a script to fetch a text in the Gutenberg ebook using the link https://www.gutenberg.org/ebooks/PAGE.txt.utf-8, where PAGE should be replaced by the page number of the Gutenberg book passed as parameter to the script. Remove the header (text before *** START OF THE PROJECT GUTENBERG EBOOK XXXXXX ***, this text included) and footer (text after *** END OF THE PROJECT GUTENBERG EBOOK XXXXXX ***, this text included) of the text and save it to a file named after its title.
    The name must not contain any space though, so replace them with the _ (underscore) character.

  2. Update your script to output the number of sentences in the text and the number of words once the pruning from the previous question is done.

  3. Make a last addition to your script to create a histogram of the words in it (a representation of the number of occurrences of each word in the text).

Analysing scripts

Theses scripts are contributed scripts found in appendix A of the Advanced Bash Scripting Guide[1].

On the online version of these scripts, some questions asked here find answers. However, we strongly advise you to search these answers by yourself. You may even provide better ones, so feel free to work on your own and to be creative!

Game of Life

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
#!/bin/bash
# life.sh: "Life in the Slow Lane"
# Author: Mendel Cooper
# License: GPL3

# Version 0.2:   Patched by Daniel Albers
#+               to allow non-square grids as input.
# Version 0.2.1: Added 2-second delay between generations.

# ##################################################################### #
# This is the Bash script version of John Conway's "Game of Life".      #
# "Life" is a simple implementation of cellular automata.               #
# --------------------------------------------------------------------- #
# On a rectangular grid, let each "cell" be either "living" or "dead."  #
# Designate a living cell with a dot, and a dead one with a blank space.#
#      Begin with an arbitrarily drawn dot-and-blank grid,              #
#+     and let this be the starting generation: generation 0.           #
# Determine each successive generation by the following rules:          #
#   1) Each cell has 8 neighbors, the adjoining cells                   #
#+     left, right, top, bottom, and the 4 diagonals.                   #
#                                                                       #
#                       123                                             #
#                       4*5     The * is the cell under consideration.  #
#                       678                                             #
#                                                                       #
# 2) A living cell with either 2 or 3 living neighbors remains alive.   #
SURVIVE=2                                                               #
# 3) A dead cell with 3 living neighbors comes alive, a "birth."        #
BIRTH=3                                                                 #
# 4) All other cases result in a dead cell for the next generation.     #
# ##################################################################### #


startfile=gen0   # Read the starting generation from the file "gen0" ...
                 # Default, if no other file specified when invoking script.
                 #
if [ -n "$1" ]   # Specify another "generation 0" file.
then
    startfile="$1"
fi

############################################
#  Abort script if "startfile" not specified
#+ and
#+ default file "gen0" not present.

E_NOSTARTFILE=86

if [ ! -e "$startfile" ]
then
  echo "Startfile \""$startfile"\" missing!"
  exit $E_NOSTARTFILE
fi
############################################


ALIVE1=.
DEAD1=_
                 # Represent living and dead cells in the start-up file.

#  -----------------------------------------------------#
#  This script uses a 10 x 10 grid (may be increased,
#+ but a large grid will slow down execution).
ROWS=10
COLS=10
#  Change above two variables to match desired grid size.
#  -----------------------------------------------------#

GENERATIONS=10          #  How many generations to cycle through.
                        #  Adjust this upwards
                        #+ if you have time on your hands.

NONE_ALIVE=85           #  Exit status on premature bailout,
                        #+ if no cells left alive.
DELAY=2                 #  Pause between generations.
TRUE=0
FALSE=1
ALIVE=0
DEAD=1

avar=                   # Global; holds current generation.
generation=0            # Initialize generation count.

# =================================================================

let "cells = $ROWS * $COLS"   # How many cells.

# Arrays containing "cells."
declare -a initial
declare -a current

display ()
{
  alive=0                 # How many cells alive at any given time.
                          # Initially zero.

  declare -a arr
  arr=( `echo "$1"` )     # Convert passed arg to array.

  element_count=${#arr[*]}

  local i
  local rowcheck

  for ((i=0; i<$element_count; i++))
  do

    # Insert newline at end of each row.
    let "rowcheck = $i % COLS"
    if [ "$rowcheck" -eq 0 ]
    then
    echo                # Newline.
    echo -n "      "    # Indent.
    fi

    cell=${arr[i]}

    if [ "$cell" = . ]
    then
    let "alive += 1"
    fi

    echo -n "$cell" | sed -e 's/_/ /g'
    # Print out array, changing underscores to spaces.
  done

  return
}

IsValid ()                            # Test if cell coordinate valid.
{

  if [ -z "$1"  -o -z "$2" ]          # Mandatory arguments missing?
  then
    return $FALSE
  fi

  local row
  local lower_limit=0                   # Disallow negative coordinate.
  local upper_limit
  local left
  local right

  let "upper_limit = $ROWS * $COLS - 1" # Total number of cells.


  if [ "$1" -lt "$lower_limit" -o "$1" -gt "$upper_limit" ]
  then
    return $FALSE                       # Out of array bounds.
  fi

  row=$2
  let "left = $row * $COLS"             # Left limit.
  let "right = $left + $COLS - 1"       # Right limit.

  if [ "$1" -lt "$left" -o "$1" -gt "$right" ]
  then
    return $FALSE                       # Beyond row boundary.
  fi

  return $TRUE                          # Valid coordinate.

}


IsAlive ()              #  Test whether cell is alive.
                        #  Takes array, cell number, and
{                       #+ state of cell as arguments.
  GetCount "$1" $2      #  Get alive cell count in neighborhood.
  local nhbd=$?

  if [ "$nhbd" -eq "$BIRTH" ]  # Alive in any case.
  then
    return $ALIVE
  fi

  if [ "$3" = "." -a "$nhbd" -eq "$SURVIVE" ]
  then                  # Alive only if previously alive.
    return $ALIVE
  fi

  return $DEAD          # Defaults to dead.

}


GetCount () # Count live cells in passed cell's neighborhood.
            # Two arguments needed:
            # $1) variable holding array
            # $2) cell number
{
  local cell_number=$2
  local array
  local top
  local center
  local bottom
  local r
  local row
  local i
  local t_top
  local t_cen
  local t_bot
  local count=0
  local ROW_NHBD=3

  array=( `echo "$1"` )

  let "top = $cell_number - $COLS - 1"    # Set up cell neighborhood.
  let "center = $cell_number - 1"
  let "bottom = $cell_number + $COLS - 1"
  let "r = $cell_number / $COLS"

  for ((i=0; i<$ROW_NHBD; i++))           # Traverse from left to right.
  do
    let "t_top = $top + $i"
    let "t_cen = $center + $i"
    let "t_bot = $bottom + $i"


    let "row = $r"                        # Count center row.
    IsValid $t_cen $row                   # Valid cell position?
    if [ $? -eq "$TRUE" ]
    then
      if [ ${array[$t_cen]} = "$ALIVE1" ] # Is it alive?
      then                                # If yes, then ...
        let "count += 1"                  # Increment count.
      fi
    fi

    let "row = $r - 1"                    # Count top row.
    IsValid $t_top $row
    if [ $? -eq "$TRUE" ]
    then
      if [ ${array[$t_top]} = "$ALIVE1" ] # Redundancy here.
      then                                # Can it be optimized?
        let "count += 1"
      fi
    fi

    let "row = $r + 1"                    # Count bottom row.
    IsValid $t_bot $row
    if [ $? -eq "$TRUE" ]
    then
      if [ ${array[$t_bot]} = "$ALIVE1" ]
      then
        let "count += 1"
      fi
    fi

  done


  if [ ${array[$cell_number]} = "$ALIVE1" ]
  then
    let "count -= 1"        #  Make sure value of tested cell itself
  fi                        #+ is not counted.


  return $count

}

next_gen ()               # Update generation array.
{

  local array
  local i=0

  array=( `echo "$1"` )     # Convert passed arg to array.

  while [ "$i" -lt "$cells" ]
  do
    IsAlive "$1" $i ${array[$i]}   # Is the cell alive?
    if [ $? -eq "$ALIVE" ]
    then                           #  If alive, then
      array[$i]=.                  #+ represent the cell as a period.
    else
      array[$i]="_"                #  Otherwise underscore
     fi                            #+ (will later be converted to space).
    let "i += 1"
  done


  #    let "generation += 1"       # Increment generation count.
  ###  Why was the above line commented out?


  # Set variable to pass as parameter to "display" function.
  avar=`echo ${array[@]}`   # Convert array back to string variable.
  display "$avar"           # Display it.
  echo; echo
  echo "Generation $generation  -  $alive alive"

  if [ "$alive" -eq 0 ]
  then
    echo
    echo "Premature exit: no more cells alive!"
    exit $NONE_ALIVE        #  No point in continuing
  fi                        #+ if no live cells.

}


# =========================================================

# main ()
# {

# Load initial array with contents of startup file.
initial=( `cat "$startfile" | sed -e '/#/d' | tr -d '\n' |\
# Delete lines containing '#' comment character.
           sed -e 's/\./\. /g' -e 's/_/_ /g'` )
# Remove linefeeds and insert space between elements.

clear          # Clear screen.

echo #         Title
setterm -reverse on
echo "======================="
setterm -reverse off
echo "    $GENERATIONS generations"
echo "           of"
echo "\"Life in the Slow Lane\""
setterm -reverse on
echo "======================="
setterm -reverse off

sleep $DELAY   # Display "splash screen" for 2 seconds.


# -------- Display first generation. --------
Gen0=`echo ${initial[@]}`
display "$Gen0"           # Display only.
echo; echo
echo "Generation $generation  -  $alive alive"
sleep $DELAY
# -------------------------------------------


let "generation += 1"     # Bump generation count.
echo

# ------- Display second generation. -------
Cur=`echo ${initial[@]}`
next_gen "$Cur"          # Update & display.
sleep $DELAY
# ------------------------------------------

let "generation += 1"     # Increment generation count.

# ------ Main loop for displaying subsequent generations ------
while [ "$generation" -le "$GENERATIONS" ]
do
  Cur="$avar"
  next_gen "$Cur"
  let "generation += 1"
  sleep $DELAY
done
# ==============================================================

echo
# }

exit 0   # CEOF:EOF

Notes

  • The `…​` construct (with backquotes) is similar to the $(…​) construct.

  • The setterm command is used to change the behaviour of the terminal. It may or may not work on your terminal, depending on its capabilities.

Analysis

  1. Identify all functions in the scripts, giving their purpose and their arguments. Give the role of each argument and indicate if it is mandatory or not (support your claim).

  2. What is the let command for? Where did you find the answer?

  3. Answer the question line 285.

  4. Why some variables are used inside quotes (eg lines 156, 169, 172, …​) and some without quotes (eg lines 169, 179, 221, …​)? Try to find a global rule, don’t try to explain each line.

  5. Create a "gen0" file to seed this script. You should guess the format and syntax of this file from the reading procedure of this script.

Make the script better

  1. The grid in this script has a "boundary problem". Change the script to have the grid wrap around, so that the left and right sides will "touch", as will the top and bottom.

  2. Modify this script so that it can determine the grid size from the "gen0" file, and set any variables necessary for the script to run.

  3. Create a new "gen0" file to test your new script.

  4. Identify the sections in the script where redundant code may be optimized.


1. Advanced Bash Scripting Guide