Bash: re-using code

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.


You'll only receive email when they publish something new.

More from Dario Hamidi
All posts