boop

Object-oriented programming for bash 4.3+ — real classes, objects, inheritance, and a standard library, in pure bash.


Project maintained by ydbxmhc Hosted on GitHub Pages

Bash Style Guide

Compiled from Google’s Shell Style Guide and Dave Eddy’s YSAP Bash Style Guide, with project-specific preferences noted. Content was rephrased for compliance with licensing restrictions.


Shebang

Every executable script starts with a shebang line.

#!/bin/bash

Use #!/usr/bin/env bash if portability across systems where bash may not be at /bin/bash matters. For controlled environments where you know the path, #!/bin/bash is fine and avoids env lookup.

boop perspective

Most files in the boop system are meant to be sourced. Attempting to execute them should have useful output such as help screens when possible.


Shell Options

For standalone scripts, consider:

set -euo pipefail
  • set -e — exit on error. Controversial: can mask failures in conditionals and compound commands. Know the edge cases.
  • set -u — treat unset variables as errors.
  • set -o pipefail — pipeline fails if any command in the pipe fails.

For library/framework code that others source: never set shell options. Save and restore if you must change one temporarily.

boop perspective

All of boop is effectively a library/framework intended to be sourced. It should virtually never set shell options unless explicitly requested by the user; if some options become important, they mush be PROMINANTLY listed in the documentation. This applies to anything that in ANY way significantly affects the user’s environment, including set, shopt, any edits to system variables such as PATH, etc.


Formatting

Indentation

Two spaces. No tabs. Stay consistent with existing files.
Exception: <<- heredocs require tabs for indentation of the body.

Pipelines

If a pipeline fits on one line, keep it on one line. Otherwise, end each line with | and indent the continuation. The parser knows a trailing pipe means more is coming — no \ needed.

If you line up the trailing pipes they catch the eye to make a continuous vertical line through the pipeline, establishing logical integration, and it isn’t fragile about any trailing whitespace - it even allows comments.

# Short — one line
command1 | command2

# Long — pipe at end of line, indent continuation
command1   |
  command2 |
  command3

# Bonus: you can comment each stage
date          | # current date string
  grep -o Mar | # extract month
  cat           # (or whatever)

Never use \ continuation for pipelines. A single trailing space after \ silently breaks the continuation, and the failure is invisible. Trailing | is self-continuing and robust.

The same applies to && and || — end the line with the operator so the parser knows more is coming.


Quoting

  • Always quote strings containing variables, command substitutions, spaces, or shell metacharacters unless there is an explicit reason not to do so - if so, comment it.
  • Use single quotes for literal strings with no expansion.
  • Use double quotes when variable expansion or command substitution is needed.
  • "$@" almost always, $* almost never - almost.
foo='literal string, no expansion'
bar="Hello, ${USER}"
myFunc "$arg1" ${maybe:-} # unquoted empty vanishes from the call, 
                          # but value vulnerable to word splitting

Inside [[ ... ]], variables don’t undergo word splitting, so unquoted is technically safe — but quoting is still preferred for consistency and readability.

Braces (${var}) are not a substitute for quotes. ${f} without quotes still word-splits. Use "${f}" or "$f".


Variable Naming

Local Variables

Lowercase with underscores: target_file, line_count.

All variables inside functions should be declared local, or explicitly as a global with declare -g to leave no doubt.

Globals should only be used for good reason, and care must be taken to prevent accidentally destroying any previous data when/if initializing.

my_func() {
  local name="$1"
  local result
  result="$(some_command)"
  printf '%s\n' "$result"
}

Separate declaration from assignment when the value comes from a command substitution — local swallows the exit code:

# Bad — $? is always 0 (exit code of local, not some_command)
local val="$(some_command)"

# Good — preserves exit code
local val
val="$(some_command)"

Constants and Exported Variables

UPPER_CASE with underscores. Declare at the top of the file. Use readonly or declare -r to prevent modification.

readonly CONFIG_DIR='/etc/myapp'
declare -xr APP_ENV='production'

Loop Variables

Name them meaningfully — match what you’re iterating over.

for zone in "${zones[@]}"; do deploy_to "$zone"; done

Functions

Declaration

Use the name() { } form. The function keyword is optional and adds nothing when parentheses are present.

Exception: function name { } (without parentheses) is required when the function name contains operator characters like +, -, *, /, etc. The name() {} parser rejects those because it tries to interpret the special characters. The function keyword form is more permissive about what constitutes a valid name. This comes up with eval‘d symbol aliases:

# name() {} form — parser chokes on the *
eval 'Math.*() { Math.multiply "$@"; }'   # FAILS

# function keyword form — works
eval 'function Math.* { Math.multiply "$@"; }'  # OK

Opening brace on the same line as the function name.

Naming

Lowercase with underscores for standalone functions: process_file, check_status.

Location

Group all functions together near the top of the file, after constants and sourced dependencies. Don’t scatter executable code between function definitions.

main Function

For scripts with multiple functions, wrap the entry point in a main() function called at the bottom of the file:

main() {
  parse_args "$@"
  do_work
}

main "$@"

Short linear scripts don’t need this.


Output

printf Over echo

echo behavior varies across platforms (backslash interpretation, -n flag handling). printf is predictable everywhere.

# Bad
echo "$value"
echo -n "no newline"

# Good
printf '%s\n' "$value"
printf '%s' "no newline"

STDOUT vs STDERR

Normal output goes to stdout. Error and diagnostic messages go to stderr.

printf 'Processing %s\n' "$file"           # stdout — normal output
printf 'Error: file not found\n' >&2       # stderr — error message

Command Substitution

Use $(command) — never backticks. Backticks don’t nest cleanly and are harder to read.

# Good
result="$(command "$(inner_command)")"

# Bad
result="`command \`inner_command\``"

Conditionals and Tests

[[ ]] Over [ ]

Always use [[ ... ]]. It doesn’t word-split or glob-expand, supports =~ for regex, and == for pattern matching.

# Good
if [[ -d "$dir" ]]; then ...
if [[ "$name" =~ ^[a-z]+$ ]]; then ...

# Bad
if [ -d "$dir" ]; then ...

String Testing

Use -z (empty) and -n (non-empty) explicitly. Don’t rely on implicit truthiness.

# Good
if [[ -z "$var" ]]; then ...
if [[ -n "$var" ]]; then ...

# Avoid
if [[ "$var" ]]; then ...

Use == for equality (not =, which looks like assignment).

Numeric Comparison

Use (( )) for arithmetic comparisons, not [[ ]] with -gt/-lt (which work but are less readable), and definitely not </> inside [[ ]] (those do lexicographic comparison).

# Good
if (( count > 10 )); then ...

# Acceptable
if [[ "$count" -gt 10 ]]; then ...

# Wrong — lexicographic, not numeric
if [[ "$count" > 10 ]]; then ...

Arithmetic

Use (( )) and $(( )). Never let, expr, or $[ ].

Inside $(( )), variables don’t need $ or ${}:

(( total = width * height ))
printf '%s\n' "$(( a + b ))"

Beware: (( )) as a standalone statement returns exit code 1 when the expression evaluates to 0. Under set -e, (( i++ )) starting from 0 will kill your script.


Arrays

Use arrays for lists. Never pack multiple items into a space-delimited string.

# Good
files=(foo.txt bar.txt baz.txt)
for f in "${files[@]}"; do
  process "$f"
done

# Bad
files='foo.txt bar.txt baz.txt'
for f in $files; do
  process "$f"
done

Use "${array[@]}" (quoted, @) to expand safely.


Parameter Expansion

Prefer bash builtins over forking external commands for string manipulation.

# Good — builtins, no fork
prog="${0##*/}"                    # basename
dir="${path%/*}"                   # dirname
clean="${name//[0-9]/}"            # strip digits
upper="${val^^}"                   # uppercase (bash 4+)

# Bad — forks external processes
prog="$(basename "$0")"
dir="$(dirname "$path")"
clean="$(echo "$name" | sed 's/[0-9]//g')"

Loops and Input

Don’t Parse ls

# Wrong
for f in $(ls); do ...

# Right
for f in *; do ...

Pipe to While — Beware Subshells

Piping to while creates a subshell. Variables set inside won’t propagate to the parent.

# Bug — last_line is always empty in the parent
your_command | while read -r line; do
  last_line="$line"
done
printf '%s\n' "$last_line"  # empty!

# Fix — process substitution
while read -r line; do
  last_line="$line"
done < <(your_command)
printf '%s\n' "$last_line"  # correct

Or use readarray (bash 4+):

readarray -t lines < <(your_command)
for line in "${lines[@]}"; do ...

Use read Builtin for Parsing

IFS=: read -r user _ <<< "$line"
IFS=. read -r host domain tld <<< "$fqdn"

Useless Use of Cat (UUoC)

If a command can read a file directly, don’t pipe cat into it.

# Wrong
cat file | grep pattern

# Right
grep pattern file
grep pattern < file

For reading file contents into a variable, use $(<file) — it’s a bash builtin, no fork:

contents="$(<config.txt)"

Error Handling

Check Return Values

Always check return values. Use if directly or || for inline handling.

# Direct check
if ! mv "${files[@]}" "$dest/"; then
  printf 'Failed to move files to %s\n' "$dest" >&2
  exit 1
fi

# Inline
cd /some/path || exit 1

PIPESTATUS

Check individual pipeline stages when it matters:

tar -cf - ./* | gzip > archive.tar.gz
if (( PIPESTATUS[0] != 0 )); then
  printf 'tar failed\n' >&2
fi

Capture PIPESTATUS immediately — any subsequent command overwrites it.


eval

Avoid eval. It enables code injection and defeats static analysis. Almost every use case has a safer alternative: arrays, indirect expansion (${!var}), or declare/printf -v.


Wildcard Expansion

Use explicit paths with wildcards to avoid problems with filenames starting with -:

# Dangerous — "-f" looks like a flag to rm
rm -v *

# Safe
rm -v ./*

Aliases

Don’t use aliases in scripts. They evaluate at definition time, not at call time, and are fragile with quoting. Use functions instead.

# Bad
alias ll='ls -lh'

# Good
ll() { ls -lh "$@"; }

File Reading

Prefer $(<file) over cat file or $(cat file) for reading file contents into a variable. It’s a bash builtin — no fork, no external process.

# Good
data="$(<input.txt)"

# Bad
data="$(cat input.txt)"

Don’t Round-Trip Through Disk

If you have a string in memory and need to feed it to a function that reads a file, don’t write it to a tmpfile to call that function. Refactor the function: extract the read-loop into a helper that takes stdin, then have the file variant pipe in < "$file" and the string variant pipe in <<< "$str".

# Bad — writes string to disk just to reuse a file-reading function
fromString() {
  local tmp; tmp=$(mktemp)
  printf '%s\n' "$1" > "$tmp"
  loadFromFile "$tmp"      # reads the file we just wrote
  rm -f "$tmp"
}

# Good — extract the parser, share it between both forms
__parseLines() {
  local line
  while IFS= read -r line; do
    # ...parse $line into globals/state...
  done
}
loadFromFile()  { __parseLines < "$1"; }
fromString()    { __parseLines <<< "$1"; }

The file-routing version pays for mktemp, printf >, < "$file", and rm — plus a tmpfile leak window if the parse crashes mid-flight (no trap cleanup). The refactored version is shorter, faster, and crash-safe. See STANDARDS.md “Primitives Inward, Wrappers Outward”.


Builtins Over External Commands

When a bash builtin can do the job, prefer it over forking an external process. This matters in loops and hot paths.

# Good — printf builtin for timestamps
printf '%(%Y-%m-%d %H:%M:%S)T\n' -1

# Bad — forks date(1)
date '+%Y-%m-%d %H:%M:%S'
# Good — parameter expansion
if [[ "$string" == *pattern* ]]; then ...

# Bad — forks grep
if echo "$string" | grep -q pattern; then ...

Heredocs and Herestrings

TODO: Document heredoc patterns (<<EOF, <<'EOF' for no expansion, <<-EOF for tab-stripped indentation). Herestring (<<<) usage and gotchas (trailing newline added automatically). When to use each vs printf or variable assignment.


Trap Handling

TODO: Document trap for EXIT, ERR, INT, TERM. Stacking handlers (bash only allows one per signal — layering requires manual management). Interaction with set -e. Cleanup patterns. See also boop’s planned Signal Handler Class in TODO.md.


Namerefs (local -n)

TODO: Document nameref declaration, the name-collision problem (a nameref can’t reference a variable with the same name as itself or any variable in the calling scope with the same name — bash resolves by name, not by scope). The __ClassName_method_var prefix convention exists specifically to prevent this. Gotchas with namerefs inside loops.


local Type Modifiers

TODO: Document local -i (integer), local -l (lowercase value), local -u (uppercase value), local -n (nameref). Note: -l lowercases the value, not the variable name. -i enables arithmetic context on assignment. These compose: local -li gives you a lowercase integer (though that’s rarely useful). Note: local -I (inherited, bash 5.0 only) is deliberately not used in this framework — see docs/STANDARDS.md “Inherited Identity Variables”.


Process Substitution

TODO: Document <(command) and >(command) beyond the pipe-to-while case. Using <(...) for diff-ing command outputs, feeding multiple streams to a command, etc. Note that process substitution creates a subshell and a /dev/fd/N file descriptor — not available in all shells (bash and zsh, not POSIX sh).


printf -v

TODO: Document printf -v varname for assigning formatted output directly to a variable without a subshell. Comparison with var="$(printf ...)" (which forks). Useful for building strings in loops without accumulating subshell overhead.


mapfile / readarray

TODO: Expand coverage. readarray -t lines < <(command) for reading output into an array. -t strips trailing newlines. -d for custom delimiters (bash 4.4+). Comparison with while read loops — when each is appropriate. Memory considerations for large inputs.


PIPESTATUS (Advanced)

TODO: Deeper treatment for error handling classes. Capturing PIPESTATUS into a local array immediately after a pipeline. Checking individual stage exit codes. Interaction with set -o pipefail. Patterns for retry/recovery based on which stage failed.


Sources


↑ Site map