Writing Reusable Libraries
Chapter 10 — Writing Reusable Libraries
A script that works once is useful. A library of well-designed functions that any script can source is an asset. This chapter covers how to structure Bash libraries so they are safe to source, self-documenting, version-aware, and easy to distribute — turning a collection of helper functions into something you can genuinely depend on.
1 — The Source-vs-Execute Split
Every library file needs to answer two questions: Am I being sourced or run directly? and Has something already sourced me?
The guard pattern: source-once
#!/usr/bin/env bash # lib/logging.sh — safe to source multiple times # Guard: skip re-sourcing if already loaded [[ -n "${_LIB_LOGGING_LOADED:-}" ]] && return 0 readonly _LIB_LOGGING_LOADED=1 # … function definitions follow …
The return 0 exits the sourcing operation cleanly. If the file is executed directly (no sourcing parent), return would fail — that's actually what you want as a hard stop for pure libraries. For libraries that also have a self-test mode, use the BASH_SOURCE trick instead:
# At the bottom of the library file if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # Script is being run directly — run self-tests _lib_logging_self_test fi
BASH_SOURCE[0] is the path of the current file.
${0} is the name of the running script.
They are equal only when the file is executed directly — when sourced,
${0} remains the caller's name.
2 — Semantic Versioning Guards
When scripts depend on a specific version of a library, they should be able to assert compatibility at load time.
# lib/utils.sh [[ -n "${_LIB_UTILS_LOADED:-}" ]] && return 0 readonly _LIB_UTILS_LOADED=1 readonly LIB_UTILS_VERSION='2.4.1' readonly LIB_UTILS_VERSION_MAJOR=2 readonly LIB_UTILS_VERSION_MINOR=4 # Callers use this function to declare their minimum requirement lib_utils_require() { local req_major="${1:?major version required}" local req_minor="${2:-0}" if (( LIB_UTILS_VERSION_MAJOR != req_major )); then printf 'ERROR: lib/utils.sh major version mismatch (have %s, need %d.x)\n' \ "$LIB_UTILS_VERSION" "$req_major" >&2 return 1 fi if (( LIB_UTILS_VERSION_MINOR < req_minor )); then printf 'ERROR: lib/utils.sh too old (have %s, need %d.%d+)\n' \ "$LIB_UTILS_VERSION" "$req_major" "$req_minor" >&2 return 1 fi } # In a caller script: # source lib/utils.sh # lib_utils_require 2 3 || exit 1 # need at least 2.3
3 — Namespace Discipline
Bash has a single global namespace for functions and variables. Libraries must be deliberate about what they export to avoid collisions with caller scripts.
Naming conventions
| Symbol type | Convention | Example |
|---|---|---|
| Public function | libname_verb_noun | log_info, http_get |
| Private function | _libname_verb_noun | _log_format_msg |
| Public constant | LIBNAME_CONSTANT (readonly) | LOG_LEVEL_DEBUG |
| Private variable | _LIBNAME_VAR | _LOG_FD |
| Guard variable | _LIB_NAME_LOADED | _LIB_HTTP_LOADED |
Containing side effects with local
# Bad — _tmp leaks into the caller's scope bad_function() { _tmp="$(some_command)" # … } # Good — everything stays in local scope good_function() { local _tmp _tmp="$(some_command)" # … } # Returning values without subshells: use a nameref str_upper() { local -n _result_ref="${1:?nameref var required}" local _input="$2" _result_ref="${_input^^}" # Bash 4+ case conversion } # Caller: declare upper str_upper upper "hello world" echo "$upper" # HELLO WORLD — no subshell, no fork
_result_ref as the variable name, the nameref binds to itself.
Using a double-underscore prefix (__result) reduces collision probability.
4 — Auto-Documentation with Structured Comments
Bash has no built-in doc system, but a consistent comment format makes it easy to generate reference docs with grep or awk.
## ## log_info MESSAGE [CONTEXT...] ## Write an INFO-level log entry to stderr. ## MESSAGE — the primary log message ## CONTEXT — optional key=value pairs appended after the message ## ## Example: ## log_info "User logged in" user=alice ip=10.0.0.1 ## log_info() { _log_write 'INFO' "$@" }
Extracting docs at runtime
# Extract all ## doc blocks and their function names from a library lib_help() { local libfile="${1:?library file required}" awk '/^##/{ sub(/^## ?/, "") doc = doc $0 "\n" next } /^[a-zA-Z_][a-zA-Z0-9_]*\(\)/{ if (doc != "") { print "--- " $0; printf "%s", doc; print "" } doc = "" next } { doc = "" }' "$libfile" }
5 — declare -f Introspection
declare -f prints the definition of a loaded function. declare -F lists all defined function names. These are invaluable for debugging, mocking in tests, and building dynamic dispatch.
# List all functions defined by a library after sourcing it before=$(declare -F) source lib/http.sh after=$(declare -F) comm -13 <(sort <<<"$before") <(sort <<<"$after") # new functions # Print the source of a specific function declare -f http_get # Check if a function exists before calling it func_exists() { declare -f "$1" >/dev/null 2&1; } if func_exists http_retry; then http_retry "$url" else http_get "$url" fi
Dynamic dispatch via function name construction
# A plugin pattern — call handler_TYPE if it exists, else default dispatch() { local type="$1"; shift local handler="handle_${type}" if func_exists "$handler"; then "$handler" "$@" else printf 'No handler for type: %s\n' "$type" >&2 return 1 fi } handle_json() { echo "Handling JSON: $*"; } handle_csv() { echo "Handling CSV: $*"; } dispatch json data.json # → handle_json data.json dispatch xml data.xml # → No handler for type: xml
6 — A Complete Library Template
Here is a production-ready skeleton you can copy as a starting point for any new library:
#!/usr/bin/env bash # ============================================================================= # lib/logging.sh — structured log helpers # Version: 1.0.0 | Requires: bash >= 4.2 # ============================================================================= # Source guard [[ -n "${_LIB_LOGGING_LOADED:-}" ]] && return 0 readonly _LIB_LOGGING_LOADED=1 # Bash version check if (( BASH_VERSINFO[0] < 4 || ( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2 ) )); then printf 'ERROR: lib/logging.sh requires bash >= 4.2 (have %s)\n' \ "$BASH_VERSION" >&2 return 1 fi # --- Public constants -------------------------------------------------------- readonly LIB_LOGGING_VERSION='1.0.0' readonly LOG_LEVEL_DEBUG=0 readonly LOG_LEVEL_INFO=1 readonly LOG_LEVEL_WARN=2 readonly LOG_LEVEL_ERROR=3 # --- Private state ----------------------------------------------------------- _LOG_LEVEL="${LOG_LEVEL:-$LOG_LEVEL_INFO}" # caller may set LOG_LEVEL _LOG_FILE="${LOG_FILE:-}" # empty = stderr only _LOG_COLOR="${LOG_COLOR:-auto}" # auto | always | never # Resolve colour mode once at load time if [[ $_LOG_COLOR == 'auto' ]]; then [[ -t 2 ]] && _LOG_COLOR='always' || _LOG_COLOR='never' fi # ANSI colours if [[ $_LOG_COLOR == 'always' ]]; then _C_DEBUG='\e[0;36m' # cyan _C_INFO='\e[0;32m' # green _C_WARN='\e[0;33m' # yellow _C_ERROR='\e[0;31m' # red _C_RESET='\e[0m' else _C_DEBUG='' _C_INFO='' _C_WARN='' _C_ERROR='' _C_RESET='' fi # --- Private helpers --------------------------------------------------------- _log_write() { local level_name="$1"; shift local level_num="$1"; shift (( level_num < _LOG_LEVEL )) && return 0 local color_var="_C_${level_name}" local ts="$(date '+%Y-%m-%dT%H:%M:%S')" local line printf -v line '%s[%s] [%s] %s%s' \ "${!color_var}" "$ts" "$level_name" "$*" "$_C_RESET" printf '%s\n' "$line" >&2 [[ -n "$_LOG_FILE" ]] && printf '%s\n' "$line" >> "$_LOG_FILE" } # --- Public API -------------------------------------------------------------- ## ## log_debug MESSAGE ## Write a DEBUG entry (visible when LOG_LEVEL=0) ## log_debug() { _log_write DEBUG "$LOG_LEVEL_DEBUG" "$@"; } ## ## log_info MESSAGE ## log_info() { _log_write INFO "$LOG_LEVEL_INFO" "$@"; } ## ## log_warn MESSAGE ## log_warn() { _log_write WARN "$LOG_LEVEL_WARN" "$@"; } ## ## log_error MESSAGE ## Write an ERROR entry and return exit code 1 ## log_error() { _log_write ERROR "$LOG_LEVEL_ERROR" "$@"; return 1; } ## ## log_set_level LEVEL ## Set minimum log level: 0=DEBUG 1=INFO 2=WARN 3=ERROR ## log_set_level() { _LOG_LEVEL="${1:?level required}"; } ## ## log_set_file PATH ## Tee all log output to PATH in addition to stderr ## log_set_file() { _LOG_FILE="${1:?path required}"; } # --- Self-test --------------------------------------------------------------- _lib_logging_self_test() { echo "=== lib/logging.sh self-test ===" log_debug "This is DEBUG" log_info "This is INFO" log_warn "This is WARN" log_error "This is ERROR" || true log_set_level "$LOG_LEVEL_WARN" log_info "You should NOT see this" log_warn "You should see this" echo "=== PASS ===" } if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _lib_logging_self_test fi
7 — Distribution and Sourcing Strategies
Portable sourcing: finding the library relative to the script
#!/usr/bin/env bash # Works whether the script is on PATH, symlinked, or called by absolute path SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) source "$SCRIPT_DIR/../lib/logging.sh" source "$SCRIPT_DIR/../lib/utils.sh"
/usr/local/bin.
A loader function for larger projects
# lib/loader.sh — source multiple libraries with error checking _LIB_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) lib_load() { local lib for lib in "$@"; do local path="$_LIB_ROOT/${lib}.sh" if [[ ! -f "$path" ]]; then printf 'lib_load: library not found: %s\n' "$path" >&2 return 1 fi # shellcheck source=/dev/null source "$path" || { printf 'lib_load: failed to source %s\n' "$path" >&2; return 1; } done } # Usage in a script: # source /opt/myapp/lib/loader.sh # lib_load logging utils http # sources logging.sh, utils.sh, http.sh
Single-file distribution via bundling
# bundle.sh — concatenate all library files into one distributable # Strip duplicate shebangs and source guards, keeping functions { echo '#!/usr/bin/env bash' echo '# Generated bundle — do not edit manually' for lib in lib/*.sh; do echo; echo "# ── $lib ──" # Remove shebang lines from non-first files grep -v '^#!' "$lib" done } > dist/myapp_bundle.sh chmod +x dist/myapp_bundle.sh
8 — Testing Your Library
A library without tests is a library you'll break silently. Even a minimal inline self-test loop is better than nothing.
# Minimal test runner — no external dependencies _TESTS_RUN=0 _TESTS_FAILED=0 assert_eq() { local desc="$1" expected="$2" actual="$3" (( _TESTS_RUN++ )) if [[ "$expected" == "$actual" ]]; then printf ' PASS: %s\n' "$desc" else printf ' FAIL: %s\n expected: %q\n actual: %q\n' \ "$desc" "$expected" "$actual" (( _TESTS_FAILED++ )) fi } assert_exit() { local desc="$1" expected_code="$2" shift 2 (( _TESTS_RUN++ )) "$@"; local rc=$? if (( rc == expected_code )); then printf ' PASS: %s (exit %d)\n' "$desc" "$rc" else printf ' FAIL: %s (expected exit %d, got %d)\n' \ "$desc" "$expected_code" "$rc" (( _TESTS_FAILED++ )) fi } test_summary() { printf '\nResults: %d/%d passed\n' \ "$(( _TESTS_RUN - _TESTS_FAILED ))" "$_TESTS_RUN" (( _TESTS_FAILED == 0 )) # exit 0 on success } # Example usage assert_eq "str_upper converts hello" "HELLO" "$(str_upper <<< 'hello')" assert_exit "log_error returns 1" 1 log_error "test error" test_summary
Exercises
Exercise 1 — lib/validate.sh
Write a library lib/validate.sh with the following public functions, each returning exit 0 on success and printing an error message on failure:
validate_nonempty VAR_NAME VALUE— fails if VALUE is emptyvalidate_integer VAR_NAME VALUE— fails if VALUE is not a whole numbervalidate_file VAR_NAME PATH— fails if PATH does not exist as a readable filevalidate_dir VAR_NAME PATH— fails if PATH is not a directory
Include source guard, version constant, and a self-test that runs when executed directly.
#!/usr/bin/env bash # lib/validate.sh — input validation helpers v1.0.0 [[ -n "${_LIB_VALIDATE_LOADED:-}" ]] && return 0 readonly _LIB_VALIDATE_LOADED=1 readonly LIB_VALIDATE_VERSION='1.0.0' _validate_fail() { printf 'validate: %s: %s\n' "$1" "$2" >&2 return 1 } ## ## validate_nonempty VAR_NAME VALUE ## validate_nonempty() { [[ -n "${2:-}" ]] || _validate_fail "$1" "must not be empty" } ## ## validate_integer VAR_NAME VALUE ## validate_integer() { [[ "${2:-}" =~ ^[0-9]+$ ]] || \ _validate_fail "$1" "'${2:-}' is not a non-negative integer" } ## ## validate_file VAR_NAME PATH ## validate_file() { [[ -f "${2:-}" && -r "${2:-}" ]] || \ _validate_fail "$1" "'${2:-}' is not a readable file" } ## ## validate_dir VAR_NAME PATH ## validate_dir() { [[ -d "${2:-}" ]] || _validate_fail "$1" "'${2:-}' is not a directory" } _lib_validate_self_test() { local pass=0 fail=0 _chk() { if "$@" 2>/dev/null; then (( pass++ )); else (( fail++ )); fi } _nochk() { if ! "$@" 2>/dev/null; then (( pass++ )); else (( fail++ )); fi } _chk validate_nonempty X "hello" _nochk validate_nonempty X "" _chk validate_integer N "42" _nochk validate_integer N "abc" _chk validate_file F /etc/hostname _nochk validate_file F /no/such/file _chk validate_dir D /tmp _nochk validate_dir D /etc/hostname printf 'validate self-test: %d pass, %d fail\n' "$pass" "$fail" (( fail == 0 )) } [[ "${BASH_SOURCE[0]}" == "${0}" ]] && _lib_validate_self_test
Exercise 2 — Nameref return values
Write a library lib/strings.sh using nameref (local -n) for all return values — no subshells. Include:
str_trim RETVAR INPUT— strip leading and trailing whitespacestr_repeat RETVAR STR N— repeat STR N timesstr_join RETVAR SEP ARRAY_NAME— join array elements with SEP
#!/usr/bin/env bash # lib/strings.sh v1.0.0 [[ -n "${_LIB_STRINGS_LOADED:-}" ]] && return 0 readonly _LIB_STRINGS_LOADED=1 ## ## str_trim RETVAR INPUT ## str_trim() { local -n __str_trim_ret="$1" local __s="$2" __s="${__s#"${__s%%[![:space:]]*}"}" # strip leading __s="${__s%"${__s##*[![:space:]]}"}" # strip trailing __str_trim_ret="$__s" } ## ## str_repeat RETVAR STR N ## str_repeat() { local -n __str_repeat_ret="$1" local __str="$2" __n="$3" __acc='' local __i for (( __i=0; __i<__n; __i++ )); do __acc+="$__str" done __str_repeat_ret="$__acc" } ## ## str_join RETVAR SEP ARRAY_NAME ## ARRAY_NAME is the name of an existing array variable ## str_join() { local -n __str_join_ret="$1" local __sep="$2" local -n __arr="$3" local __out='' __elem for __elem in "${__arr[@]}"; do [[ -n $__out ]] && __out+="$__sep" __out+="$__elem" done __str_join_ret="$__out" } [[ "${BASH_SOURCE[0]}" == "${0}" ]] && { declare result str_trim result " hello world " echo "trim: '$result'" str_repeat result "ab" 4 echo "repeat: '$result'" words=( one two three ) str_join result ', ' words echo "join: '$result'" }
Exercise 3 — Auto-doc extractor
Write a script libdoc.sh LIBFILE that:
- Parses the
##doc-comment blocks from a library file (as shown in section 4) - Prints a clean reference page with each function's signature and description
- Also accepts
--listto print just the function names, one per line - Exits 1 if no documented functions are found
#!/usr/bin/env bash set -euo pipefail MODE='full' if [[ "${1:-}" == '--list' ]]; then MODE='list'; shift fi LIBFILE="${1:?usage: $0 [--list] LIBFILE}" [[ -f $LIBFILE ]] || { echo "Not found: $LIBFILE" >&2; exit 1; } count=0 awk -v mode="$MODE" ' BEGIN { doc = ""; in_doc = 0 } /^##/ { sub(/^## ?/, "") doc = doc $0 "\n" in_doc = 1 next } in_doc && /^[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(\)/ { match($0, /^[a-zA-Z_][a-zA-Z0-9_]*/) fname = substr($0, RSTART, RLENGTH) if (mode == "list") { print fname } else { print "\033[0;36m" fname "\033[0m" printf "%s", doc print "" } count++ doc = ""; in_doc = 0 next } { doc = ""; in_doc = 0 } END { exit (count == 0 ? 1 : 0) } ' "$LIBFILE"
Exercise 4 — Loader with dependency resolution
Extend the loader pattern so libraries can declare their own dependencies.
Write lib/loader.sh where each library file may include a line like:
# REQUIRES: utils logging
The loader should parse this line and automatically source the listed libraries first, detecting circular dependencies and aborting with a clear error if one is found.
#!/usr/bin/env bash # lib/loader.sh — dependency-aware library loader [[ -n "${_LIB_LOADER_LOADED:-}" ]] && return 0 readonly _LIB_LOADER_LOADED=1 _LIB_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) declare -gA _LIB_LOADING # currently in-flight (cycle detection) lib_load() { local lib for lib in "$@"; do local guard="_LIB_${lib^^}_LOADED" [[ -n "${!guard:-}" ]] && continue # already loaded if [[ -n "${_LIB_LOADING[$lib]:-}" ]]; then printf 'lib_load: circular dependency detected: %s\n' "$lib" >&2 return 1 fi local path="$_LIB_ROOT/$lib.sh" [[ -f $path ]] || { printf 'lib_load: not found: %s\n' "$path" >&2; return 1; } # Parse REQUIRES line (e.g. # REQUIRES: utils logging) local req_line req_line=$(grep -m1 '^# REQUIRES:' "$path" || true) if [[ -n $req_line ]]; then local -a deps # shellcheck disable=SC2206 deps=( ${req_line#*: } ) _LIB_LOADING[$lib]=1 lib_load "${deps[@]}" || return 1 unset '_LIB_LOADING[$lib]' fi # shellcheck source=/dev/null _LIB_LOADING[$lib]=1 source "$path" || return 1 unset '_LIB_LOADING[$lib]' done }