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.