Bash: re-using code
October 23, 2021•791 words
Changes are that you have a lot of bash scripts in your project(s) and that some of those scripts contain bits and pieces that you would like to re-use.
The prospect of making your shell scripts reusable might seem daunting, but Bash actually comes with a couple of mechanisms that make this easier than you might have thought.
Using the source
The easiest way to re-use a piece of Bash code is to load it into the current process. This is exactly what source
does.
Actually, you can think of source
as eval
, but applied to files instead of strings.
The source
builtin has a couple of features that make it interesting for the purpose of re-use:
- it searches through all the directories on
PATH
to find the file to source, - you can pass arguments to the file being sourced.
So for the purpose of treating source
-able files as "modules" like you know them from other programming languages, we even get an "advanced" feature: parametrized modules.
The most prominent example of parametrized modules is probably Ruby's ActiveRecord Data Migrations:
class AddPartNumberToProducts < ActiveRecord::Migration[7.0]
def change
end
end
The [7.0]
bit after Migration
is actually a method call on the ActiveRecord::Migration
object and returns a new class, from which AddPartNumberToProducts
is inheriting.
In Bash we can be similarly flexible by passing arguments to source
.
PATH
lookup
The critical thing to understand about how source
finds files to source is that it only looks for a single name, no slashes allowed. Basically source
performs the same kind of lookup as your shell does when finding commands.
Let's look at an example:
$ tree -L 2
.
├── bin
│ └── hello-world
└── modules
└── messages
Our main script is hello-world
and we would like to use a module called messages
in there to print out messages in a standardized format.
Here is hello-world
:
#!/usr/bin/env bash
source messages
main() {
say "hello, world"
}
main "$@"
We're loading the module messages
using source
and expect it to provide a function called say
.
Running this script as it is, we'll get an error:
$ bin/hello-world
bin/hello-world: line 3: messages: No such file or directory
bin/hello-world: line 6: say: command not found
Loading messages
failed and as a consequence say
isn't defined.
Fix 1: specifiying an absolute path
There are multiple ways to address this issue. One is to avoid path lookup by using an absolute path, leveraging the fact that $0
contains the path to the current script file:
#!/usr/bin/env bash
source $(realpath -m $0/../../modules/messages)
# ... rest unchanged
The call to realpath
is necessary in order to provide a fully resolved path to source
, otherwise source
errors.
While this works and our script indeed prints hello, world
now, it:
- is cumbersome to write and remember,
- sourcing more than one file like this means a lot of repetition
Fix 2: changing PATH
If we change PATH
to include the modules
directory, our script becomes much simpler:
$ head -3 bin/hello-world
#!/usr/bin/env bash
source messages
$ PATH=$PWD/modules:$PATH bin/hello-world
hello, world
Simpler code comes at the cost of managing your environment. In practice this doesn't appear to be a problem, because:
- developers already use tools to manage their environment variables,
- the environment in CI is explicitly managed as well,
- and so is the environment on deployment targets.
Additionally, the path-setting logic can be deduplicated in a simple wrapper script that sets up PATH
correctly before invoking the next script.
parametrizing modules
Now on to the exciting bit: adding parameters to our module. Let's say we want allow users of the messages
module to specify a style that is applied to all messages by default, for example making text appear in bold.
We want the usage of our module to look like this:
source messages style=bold
All the arguments after messages
are available in the messages
script as positional parameters, so $1
would be style=bold
.
Let's add simple argument parsing to our module and modify say
to store the style in a variable:
MESSAGES_STYLE=
case "$1" in
style=bold)
MESSAGES_STYLE=$'\033[1m';;
*)
printf "messages: unknown parameter: %s\n" "$1" >&2
exit 1;;
esac
say() {
if [[ -n "$MESSAGES_STYLE" ]]; then
printf "${MESSAGES_STYLE}%s\033[0m\n" "$*"
else
printf "%s\n" "$*"
fi
}
If the first parameter passed to our module is style=bold
we record the ANSI escape sequence for telling the terminal that text should be displayed in bold. In say
we check whether a style if set, and if it is, we output the necessary escape sequences.
In case any other argument is passed, we just print an error message and exit the program.