Introduction
As for any other language I develop with, I like to make use of tools and utilities to help me spot problems as soon as possible.
For that reason I like to have three things in all my projects:
- Formatter
- Linter (Static Analysis)
- Unit Test Framework
When developing in Bash
there is no difference in this regard. Some people would say
that Bash
is just for scripting. I mostly agree with that statement,
however, sometimes you have to write a lot of Bash
and for these cases I want
to be able to develop it in the same way I do for other languages.
As always, all the code used in this post is available in this repo.
Formatter
A formatter’s duty is to keep the code style the same across a whole project. For
Bash
I like to use shfmt as the formatter. It
is a Go
project and it can be installed running
go get -u mvdan.cc/sh/cmd/shfmt
The most basic usage for shfmt
is to format a file. By default it targets
bash
, but to target just POSIX
compatible shells the flag -p
can be used.
An example file before formatting:
#!/bin/bash
echo "Welcome to this example file"
echo "This is badly formatted"
VAR=$( pwd )
echo "This script is being ran from ${VAR}"
for item in $(seq 1 10)
do
echo "Number ${item}"
done
The previous file formatted:
$ shfmt mess.sh
#!/bin/bash
echo "Welcome to this example file"
echo "This is badly formatted"
VAR=$(pwd)
echo "This script is being ran from ${VAR}"
for item in $(seq 1 10); do
echo "Number ${item}"
done
Two interesting options I use quite often with shfmt
are -bn
and -ci
. They
are for binary operations like &&
and |
may start a line and switch
cases will be indented, respectively.
An example script with binary operations and a switch case:
#!/bin/bash
first_thing_to_be_run \
&& second_thing_to_be_run \
&& third_thing_to_be_run \
&& forth_and_last_thing_to_be_run
number=$RANDOM
case ${number} in
5)
echo "Lucky number"
;;
7)
echo "Lucky number"
;;
*)
echo "It may not be a lucky number"
;;
esac
shfmt
run with default arguments:
$ shfmt binary_ops_and_switch_cases.sh
#!/bin/bash
first_thing_to_be_run &&
second_thing_to_be_run &&
third_thing_to_be_run &&
forth_and_last_thing_to_be_run
number=$RANDOM
case ${number} in
5)
echo "Lucky number"
;;
7)
echo "Lucky number"
;;
*)
echo "It may not be a lucky number"
;;
esac
shfmt
run with -bn
and -ci
flags:
$ shfmt -bn -ci binary_ops_and_switch_cases.sh
#!/bin/bash
first_thing_to_be_run \
&& second_thing_to_be_run \
&& third_thing_to_be_run \
&& forth_and_last_thing_to_be_run
number=$RANDOM
case ${number} in
5)
echo "Lucky number"
;;
7)
echo "Lucky number"
;;
*)
echo "It may not be a lucky number"
;;
esac
Linter (Static Analysis)
Any good linter’s job is to point out all the errors that can found in your code. My favourite Bash
linter is ShellCheck
. It can be used from their website https://www.shellcheck.net/ or installing their command line application in your system.
Can you spot all the errors in the following snippet?
#!/bin/sh
for file in $(ls *.py); do
grep -qi wololo* $file \
&& echo -e 'The file $file contains words that start with wololo'
done
I am sure you have found several errors, but let’s have a look at the output of ShellCheck
$ shellcheck errors.sh
In errors.sh line 3:
for file in $(ls *.py); do
^-- SC2045: Iterating over ls output is fragile. Use globs.
^-- SC2035: Use ./*.py so names with dashes won't become options.
In errors.sh line 4:
grep -qi wololo* $file \
^-- SC2062: Quote the grep pattern so the shell won't interpret it.
^-- SC2022: Note that unlike globs, o* here matches 'ooo' but not 'oscar'.
^-- SC2086: Double quote to prevent globbing and word splitting.
In errors.sh line 5:
&& echo -e 'The file $file contains words that start with wololo'
^-- SC2039: In POSIX sh, echo flags are not supported.
^-- SC2016: Expressions don't expand in single quotes, use double quotes for that.
Finding errors like these by just looking at the code is hard. So, let a computer do it for you.
Unit Test Framework
Last but not least, a unit test framework for Bash
. My choice is Bats.
Given the following functions.sh
file:
sum() {
if [ ${#} -ne 2 ]; then
echo "Bad number of parameters"
echo "Usage: sum <number 1> <number 2>"
else
num1=${1}; shift
num2=${1}; shift
echo "${num1}+${num2}" | bc
fi
}
Test file test_functions.sh
for the previous file:
#!/usr/bin/env bats
source functions.sh
@test "The addition of 4 and 5 results in 9" {
run sum 4 5
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 1 ]
[ "${lines[0]}" = "9" ]
}
@test "The addition of -5 and 9 results in 4" {
run sum -5 9
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 1 ]
[ "${lines[0]}" = "4" ]
}
@test "Bad number of parameters (1)" {
run sum 10
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 2 ]
[ "${lines[0]}" = "Bad number of parameters" ]
[ "${lines[1]}" = "Usage: sum <number 1> <number 2>" ]
}
@test "Bad number of parameters (3)" {
run sum 10 11 12
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 2 ]
[ "${lines[0]}" = "Bad number of parameters" ]
[ "${lines[1]}" = "Usage: sum <number 1> <number 2>" ]
}
When that battery of test is run it outputs:
$ ./test_functions.sh
✓ The addition of 4 and 5 results in 9
✓ The addition of -5 and 9 results in 4
✓ Bad number of parameters (1)
✓ Bad number of parameters (3)
4 tests, 0 failures
Conclusion
Bash
is a great scripting language, but when you have to write more than a
simple script to perform a task it is worth spending the time in using a formatter,
a linter and unit test framework. It may be even useful to use some
documentation generation tool as well.