# Bash Quick Reference

> Reference guide for Bash language constructs, parameter expansion, arrays, control flow, functions, and scripting discipline.

- Skill: `paulpas/bash-quick-reference` (Agent Skill)
- Install (CLI): `npx skillmds@latest add paulpas/bash-quick-reference`
- Raw SKILL.md: https://api.skillmd.com/api/skills/paulpas/bash-quick-reference/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: paulpas (https://skillmd.com/u/paulpas)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/paulpas/bash-quick-reference

---






# Bash — Quick Reference

Reference guide for the Bash language: data (variables, parameter expansion, arrays), control flow (if/case/for/while/until), functions, redirections, pipes, traps, signals, job control, debugging, and strict-mode discipline.

## When to Use

- Writing short-to-medium scripts that glue together UNIX commands on Linux/macOS hosts
- Creating one-shot wrappers around external commands with flags and defaults
- Writing Makefile recipes, Dockerfile RUN lines, CI steps, or systemd ExecStart entries
- File/process plumbing where bash is the shortest path to the solution
- Any place where a few lines of glue are exactly the right shape

## When NOT to Use

- You need rich data types (nested dicts, typed records, dataframes) — use Python instead
- The work needs unit tests with decent ergonomics — switch to a real language at this point
- You need cross-platform Windows support — use PowerShell instead
- The script handles untrusted input involving filenames, environment variables, or external command output — bash is hard to write securely
- You're hitting "if my script were 30% bigger I'd write it in Python" — just write it in Python

## Mental model

Hold these seven sentences in your head and the rest of bash
starts making sense:

1. **Bash is a command interpreter that happens to have a
   programming language attached.** Most of what bash "does" is
   parse a line of input, expand variables and globs, and execute
   the resulting command. The language constructs (`if`, `for`,
   functions) are layered on top of that.
2. **Everything is a string until proven otherwise.** Bash has no
   built-in concept of types beyond "string" and "the shell does
   integer arithmetic in `((...))`". Numbers are strings the shell
   coerces during arithmetic. Arrays are arrays of strings.
3. **Words are split by `IFS` after expansion.** When you type
   `cmd $var`, bash expands `$var`, then splits the result on
   whitespace (or whatever `IFS` is set to), and `cmd` receives
   the resulting words as separate arguments. The way to prevent
   splitting is to quote: `cmd "$var"`.
4. **Variables are global by default.** Inside a function, every
   assignment reaches into the parent scope unless you mark it
   `local`. This bites people from other languages constantly.
5. **A pipeline of `cmd1 | cmd2` runs each side in a subshell.**
   Variables set inside the right side don't propagate back to
   the parent shell. Process substitution (`< <(cmd)`) avoids
   this.
6. **The exit status, not the output, is bash's contract.**
   Programs return integers; scripts test those integers. `0` is
   success, anything else is failure (and conventionally has a
   meaning — `man sysexits` shows the table).
7. **Best bash scripts use the language sparingly and let small
   UNIX tools do the heavy lifting.** When you find yourself
   writing more than a few dozen lines of bash logic in a script,
   you've usually crossed the line where a real programming
   language would be a better fit.

The rest of this card is the long form of those seven sentences.

## A note on conventions

Examples in this card use:

- `→` for an interactive shell prompt, so the command is visually
  distinct from its output.
- `#!/usr/bin/env bash` as the canonical shebang for bash scripts
  (more portable than `#!/bin/bash` because it locates `bash`
  through `$PATH`).
- The "strict mode" preamble (`set -Eeuo pipefail; IFS=$'\n\t'`)
  in any non-trivial script — see the [Strict mode](#strict-mode-and-script-discipline) section
  for the full discussion.
- `[[ ... ]]` rather than `[ ... ]` for tests — the double-bracket
  form is a bash keyword that doesn't word-split or glob-expand
  its arguments, eliminating a whole class of bugs.
- `$(...)` rather than backticks for command substitution — the
  modern form nests cleanly.

When something works only in bash 4+ or only in bash 5+, the card
calls it out. The default `bash` on macOS is 3.2 (the last
GPL-2-licensed release), so portability between Linux's
modern bash and macOS's 3.2 bash is a real concern; install a
modern bash via Homebrew (`brew install bash`) if you'll be
writing serious scripts on macOS.

---

## Invoking bash

A bash process can come to exist in several different ways, and
the *kind* of bash determines which startup files it reads, which
features it enables, and how it interprets its arguments. Knowing
the rules saves you from "this works when I type it but not when
I run it as a script" surprises.

### Interactive vs. non-interactive

A bash process is *interactive* if its standard input is a
terminal (or if `-i` was passed). Interactive shells display a
prompt, enable command-line editing (readline), enable job
control by default, and read your interactive startup files.
Non-interactive shells (a script, a `bash -c '...'`, a remote ssh
command) skip almost all of that.

You can detect mode in a script:

```bash
case $- in
  *i*) echo "interactive" ;;
  *)   echo "non-interactive" ;;
esac
```

`$-` is the set of currently-active option flags; `i` is set when
the shell is interactive.

### Login vs. non-login

A bash process is a *login shell* if it was started in one of
several specific ways:

- The first character of `argv[0]` is `-` (which `login(1)` does).
- It was invoked as `bash -l` or `bash --login`.
- It's the shell SSH ran for an interactive session.

Non-login shells are everything else — typically the shells you
get inside a terminal emulator after the first one, or any
sub-shell.

The distinction matters because login and non-login shells read
different startup files. That, in turn, matters because the wrong
file getting your `PATH` change means "but I added it to my
.bashrc!" mysteries.

### Startup files (the canonical order)

For an interactive **login** shell, bash reads:

1. `/etc/profile`
2. The first of `~/.bash_profile`, `~/.bash_login`, `~/.profile`
   that exists.

When the login shell exits, it reads `~/.bash_logout`.

For an interactive **non-login** shell:

1. `/etc/bash.bashrc` (some distributions)
2. `~/.bashrc`

For a **non-interactive** shell (a script):

- Neither, unless `BASH_ENV` is set, in which case bash reads the
  file named by it.

The conventional pattern: put environment variables and `PATH`
modifications in `~/.bash_profile` (so login shells get them);
put shell aliases, functions, completion, and prompt theming in
`~/.bashrc`. Then source `~/.bashrc` from `~/.bash_profile` so the
distinction matters less:

```bash
# in ~/.bash_profile
[[ -f ~/.bashrc ]] && . ~/.bashrc

# environment-only stuff goes here:
export PATH="$HOME/bin:$PATH"
export EDITOR=vim
```

### Invoking the shell — common flags

```
bash                           # interactive non-login shell
bash --login                   # interactive login shell
bash -c 'cmd'                  # run cmd, then exit
bash -c 'cmd' name arg1 arg2   # run cmd with $0=name, $1=arg1, $2=arg2
bash file.sh                   # run a script
bash -x file.sh                # run with xtrace (echo each command before running)
bash -n file.sh                # syntax check; don't run
bash -v file.sh                # verbose; print each line as read
bash -l                        # login mode (--login)
bash -i                        # interactive mode
bash -p                        # privileged mode: don't read $BASH_ENV / $ENV; ignore $SHELLOPTS
bash --noprofile               # skip /etc/profile and ~/.bash_profile etc.
bash --norc                    # skip ~/.bashrc
bash --version                 # show version
bash --help                    # short flag summary
```

Useful in scripts:

- `bash -n script.sh` — syntax check only. The single best thing
  you can run in a pre-commit hook for shell scripts (`shellcheck`
  is the second).
- `bash -x script.sh` — execution trace. Each command is printed
  to stderr before being run, with `+` prefixes indicating the
  nesting depth.

### Shebangs

The first line of a script is the *shebang*; the kernel reads it
to decide which interpreter to invoke. The two reasonable forms:

```
#!/usr/bin/env bash
```

— uses `env` to find `bash` in `$PATH`. More portable, especially
on systems where bash isn't at `/bin/bash` (some BSDs, macOS when
using a Homebrew bash).

```
#!/bin/bash
```

— hard-codes the path. Slightly faster (one less `exec`), and
deterministic. Fine for scripts that you know are running on
systems with bash at the canonical location.

A useful disclaimer: `#!/bin/sh` is **not** the same as
`#!/bin/bash`. On Debian/Ubuntu systems, `/bin/sh` is `dash`, a
much smaller POSIX-only shell. A script written for bash and
shebanged with `/bin/sh` will fail in subtle ways: arrays don't
work, `[[ ]]` doesn't exist, `$'string'` ANSI-C quoting doesn't
exist, parameter expansion is more limited, and so on.

### Exit status and the meaning of zero

Every command returns an integer status to its parent. By
convention:

- `0` — success.
- Non-zero — failure of some kind. Programs sometimes use specific
  numbers to mean specific things (`grep` returns `1` for "no
  matches", `2` for "error"; `diff` returns `0` for "files match",
  `1` for "differ", `2` for "error").
- `126` — command found but not executable.
- `127` — command not found.
- `128 + N` — terminated by signal N (so `Ctrl-C` is `130`,
  `SIGSEGV` is `139`, `SIGTERM` is `143`).

The shell exposes the previous command's exit status in `$?`:

```bash
ls /etc/passwd
echo $?           # 0
ls /no/such/file
echo $?           # 2
```

Inside a pipeline, `$?` is the last command's exit status.
`$PIPESTATUS` is an array of the exit statuses of every command
in the pipeline:

```bash
foo | bar | baz
echo "${PIPESTATUS[@]}"   # exit statuses of foo, bar, and baz
```

For scripts where you want any failure in a pipeline to count,
`set -o pipefail` makes the pipeline's exit status the leftmost
non-zero. See the [Strict mode](#strict-mode-and-script-discipline)
section.

### Conventional exit codes from sysexits.h

The historical `<sysexits.h>` defines a small vocabulary of exit
codes worth knowing:

| Code | Name | Meaning |
| --- | --- | --- |
| 0 | EX_OK | Success |
| 64 | EX_USAGE | Command-line usage error |
| 65 | EX_DATAERR | Data-format error in user input |
| 66 | EX_NOINPUT | Cannot open input |
| 67 | EX_NOUSER | Addressee unknown |
| 68 | EX_NOHOST | Host name unknown |
| 69 | EX_UNAVAILABLE | Service unavailable |
| 70 | EX_SOFTWARE | Internal software error |
| 71 | EX_OSERR | OS error (e.g. fork failed) |
| 72 | EX_OSFILE | System file missing |
| 73 | EX_CANTCREAT | Cannot create output file |
| 74 | EX_IOERR | I/O error |
| 75 | EX_TEMPFAIL | Temporary failure; retry |
| 76 | EX_PROTOCOL | Remote protocol error |
| 77 | EX_NOPERM | Permission denied |
| 78 | EX_CONFIG | Configuration error |

Most scripts get away with 0/1/2, but for tools that other
scripts will consume, picking specific codes from this table makes
debugging dramatically easier.

---

## Quoting

The shell parses every line you type by walking through it
left-to-right and applying rules about which characters mean
what. Most "weird bash bugs" come down to misunderstanding which
characters need to be quoted in which contexts. The good news is
that the rules are simple and exhaustive: there are exactly three
quoting mechanisms.

### The three quoting mechanisms

#### Backslash

A backslash before a character makes that character literal —
strips its special meaning to the shell.

```bash
echo \$HOME       # prints $HOME literally
echo \"           # prints "
echo a\ b         # echoes "a b" as TWO arguments? No — as ONE: "a b". The space is escaped, not a separator.
echo \\           # prints a single backslash
```

A backslash at the end of a line escapes the newline, continuing
the command on the next line:

```bash
ls -l --color=auto \
   --group-directories-first \
   /etc
```

This is the standard way to wrap a long command across several
lines.

#### Single quotes

Inside `'...'`, every character is literal. **Even backslashes.**
**Even dollar signs.** **Even backticks.** The only character that
can't appear inside single quotes is another single quote — there's
no way to escape it. To include a literal single quote, end the
quoted string, escape a quote, and start a new quoted string:

```bash
echo 'it'''s a trap'   # prints: it's a trap
```

Single quotes are the safest choice for any string with shell
metacharacters. When you don't need variable interpolation or
command substitution, single-quote.

#### Double quotes

Inside `"..."`, most characters are literal, but the following
remain active:

- `$` — variable expansion (`$VAR`, `${VAR}`), parameter expansion
  (`${VAR:-default}`), command substitution (`$(...)`),
  arithmetic expansion (`$((...))`).
- `\`` — command substitution (legacy form).
- `\` — escape character. Inside double quotes, backslash is
  special **only before** `$`, `\``, `"`, `\`, and newline.
  `"\d"` is the literal two-character sequence `\d`, but `"\$"`
  is a literal `$`.

```bash
echo "Hello, $USER"          # interpolated
echo "Today is $(date +%F)"  # command substitution works
echo "Cost: \$5.00"          # literal dollar sign
echo "Path: $HOME"           # variable expansion
echo "He said \"hi\""        # escaped double quote
```

Double quotes do **not** prevent word splitting *between* quoted
strings:

```bash
var="a b c"
echo "$var"        # one argument: "a b c"
echo $var          # three arguments: "a", "b", "c" — word-split
echo "${arr[@]}"   # one quoted argument per array element (the way you almost always want)
echo "${arr[*]}"   # one argument; elements joined by IFS[0]
```

The "always quote your variables" discipline is real:
`"$VAR"` for a single variable, `"${arr[@]}"` for an array,
`"$@"` for "all positional arguments preserving boundaries".

### ANSI-C quoting (`$'...'`)

A bash extension. `$'...'` is like single-quoting but with
backslash escapes interpreted:

```bash
echo $'hello\tworld'         # tab between
echo $'line1\nline2'         # newline embedded
echo $'\u00e9'               # Unicode é (bash 4.2+)
IFS=$'\n\t'                  # set IFS to newline and tab — strict-mode preamble
```

The supported escapes: `\a`, `\b`, `\e`, `\f`, `\n`, `\r`, `\t`,
`\v`, `\\`, `'`, `\"`, `\?`, `\nnn` (octal), `\xHH` (hex),
`\uHHHH` (Unicode), `\UHHHHHHHH` (32-bit Unicode), `\cX`
(control-X).

### Locale-aware quoting (`$"..."`)

`$"..."` is like double-quoting but the string is looked up in
the current locale's translation catalog. Used for
internationalisation. You'll almost never need this in
infrastructure code.

### Quoting in scripts (a checklist)

A short procedure for "did I quote this right?":

1. **Variables expanded for use as one argument**: `"$var"`.
2. **Variables expanded for use as a list of arguments** (rarely
   what you want): bare `$var` with `IFS` set appropriately.
3. **Arrays expanded as separate arguments**: `"${arr[@]}"`.
4. **Literal strings with no metacharacters needed**: single
   quotes.
5. **Strings that interpolate variables but don't have shell
   metacharacters**: double quotes around the whole string.
6. **Strings that contain backslash escapes**: `$'...'` (ANSI-C).
7. **Strings that contain both literal `$` and interpolated
   variables**: mix quoted regions:
   ```bash
   echo 'Cost: $5 for '"$USER"
   ```

### Common quoting mistakes

```bash
files=*.log
for f in $files; do ...; done   # WRONG: $files is a string "*.log"; it's the for that does the glob

for f in *.log; do ...; done    # CORRECT: glob expansion happens in the for itself

if [ $var = "value" ]; then     # WRONG-ish: if $var is empty, this becomes [ = value ], a syntax error
if [ "$var" = "value" ]; then   # better: empty $var becomes [ "" = value ], well-formed
if [[ $var = "value" ]]; then   # BEST: [[...]] doesn't word-split, doesn't glob

echo "$(rm -rf /)"              # the $(...) actually runs even though the result will be quoted

ls "$(echo $files)"             # double-evaluation: $files word-splits inside echo, then the result is one arg to ls
```

### Quoting and the command line

When you type interactively, the shell parses your line, expands
everything, and runs the command. When you put a script in a
file, the same parsing happens — but the file's bytes are read by
bash, not your terminal. Bash neither knows nor cares about your
terminal's encoding when reading a script. If you have `\303\251`
in your script source, bash sees it as those two literal bytes
(which form `é` in UTF-8). The terminal just affects how bash
*displays* its output back to you.

The practical implication: your script's behavior is determined
by its source bytes, not by your terminal. If you copy a script
from a webpage and pasting introduces "smart quotes" (`\u201c`
and `\u201d`), bash will see those as literal Unicode characters
that don't have any special meaning, and you'll get cryptic
syntax errors. Always paste through a tool that doesn't do
auto-correct (`vim`, `cat > file <<'EOF'`, etc.) when bringing
shell snippets into a file.

---

## Variables

A variable in bash is a name bound to a string. By default, every
variable is global — accessible from anywhere in the same shell
process. Functions can declare variables `local` to confine them
to the function's scope.

### Assignment

```bash
name=value           # assign — NO spaces around =
name="value"         # quoted; useful when value has whitespace
name='value'         # single-quoted; no expansion in the value
unset name           # remove the variable

readonly name        # make immutable; further assignments fail
declare -r name      # same
```

The "no spaces around `=`" rule is critical. `name = value` parses
as a command named `name` with two arguments — almost never what
you want. The error message is helpful: `name: command not found`.

Multiple assignments on one line:

```bash
a=1 b=2 c=3
```

These are evaluated left-to-right, all at the same shell-prefix
level. They're equivalent to three separate assignment statements.

You can also pass variable assignments as a *prefix* to a command,
which sets them only for the command's environment:

```bash
PATH=/usr/local/bin:$PATH mycommand    # only mycommand sees the modified PATH
LC_ALL=C sort file                      # the canonical "sort in byte order" invocation
NODE_ENV=production npm start
```

### Reading a variable

```bash
echo $var            # expand var
echo ${var}          # same, with explicit braces
echo "$var"          # quoted (recommended)
echo "${var}lovelace"   # braces let you butt text up against the var
```

The braces are optional unless you need to butt the variable
against text that could be confused for part of the name. `$varX`
expands the variable `varX` (an underscore-letter-digit sequence is
a valid name), but `${var}X` expands `var` followed by a literal
`X`.

### Variable scope

By default, every assignment creates or modifies a *shell variable*
visible to the current shell only. To make a variable available
to *child processes* (programs the shell exec's), export it:

```bash
export PATH=/usr/local/bin:$PATH    # commonly seen
PATH=/usr/local/bin:$PATH; export PATH   # same effect
declare -x PATH=/usr/local/bin:$PATH     # same
```

`export -p` shows everything currently exported. `printenv` and
`env` show the same.

To remove a variable from the environment without unsetting it
locally:

```bash
export -n VAR
```

Variables set without `export` are visible only to the current
shell, not to commands it runs:

```bash
local_var=hello
bash -c 'echo $local_var'    # prints empty line — the child shell didn't inherit it
```

### Inside functions: `local`

Inside a function, `local` declares a variable scoped to the
function (and the functions it calls). Without `local`, you'd
modify the parent shell's `i` or `name`, which is almost certainly
a bug.

```bash
greet() {
  local name="$1"            # local to greet
  echo "Hello, $name"
}

count_files() {
  local i count=0
  for ((i=0; i<$#; i++)); do
    [[ -f "${!i}" ]] && ((count++))
  done
  echo "$count"
}
```

`local` is itself a builtin command and can take any of the
declaration flags that `declare` does:

```bash
local -i n=42                 # integer
local -a arr=(a b c)          # indexed array
local -A map=([k1]=v1)        # associative array
local -r CONST=immutable      # readonly within the function
```

`local` only works inside functions; using it at the top level of
a script is a syntax error.

### Predefined and special variables

Bash maintains a set of automatic variables. The most useful:

| Variable | Meaning |
| --- | --- |
| `$0` | Script name (or `bash` for an interactive shell) |
| `$1`, `$2`, … | Positional parameters (script or function arguments) |
| `${10}`, `${11}`, … | Tenth and beyond — must use braces |
| `$#` | Number of positional parameters |
| `$@` | All positional parameters as separate words. Use `"$@"` to preserve word boundaries. |
| `$*` | All positional parameters as one string, joined by IFS[0]. |
| `$?` | Exit status of the last foreground command. |
| `$$` | PID of the current shell. |
| `$!` | PID of the most recent background command. |
| `$_` | Last argument of the previous command (interactive). |
| `$-` | Currently active option flags (e.g. `himBHs`). |
| `$BASH` | Path to the bash binary that's running. |
| `$BASH_VERSION` | Version string. |
| `$BASH_VERSINFO` | Array of version components: `[major, minor, patch, build, release, machine]`. |
| `$BASH_SOURCE` | Array of source filenames; `${BASH_SOURCE[0]}` is the current file. |
| `$LINENO` | Current line number in the script. |
| `$FUNCNAME` | Array; `${FUNCNAME[0]}` is the current function. |
| `$RANDOM` | A random integer 0..32767, fresh each read. |
| `$EPOCHSECONDS`, `$EPOCHREALTIME` | Bash 5+: seconds since epoch / fractional seconds. |
| `$SECONDS` | Number of seconds since the shell started. Assigning to it resets the counter. |
| `$PIPESTATUS` | Array of exit statuses of each command in the most recent pipeline. |
| `$IFS` | Input Field Separator — controls word splitting. Default: space, tab, newline. |
| `$PATH` | Colon-separated directories searched for executables. |
| `$HOME` | Current user's home directory. |
| `$USER`, `$LOGNAME` | Username. |
| `$SHELL` | The user's preferred shell (NOT necessarily the running shell). |
| `$PWD` | Current working directory. |
| `$OLDPWD` | Previous working directory (used by `cd -`). |
| `$HOSTNAME` | Machine hostname. |
| `$HOSTTYPE` | Architecture (e.g. `x86_64`). |
| `$OSTYPE` | OS name (e.g. `linux-gnu`, `darwin22.0`). |
| `$LANG`, `$LC_*` | Locale variables. |
| `$TZ` | Timezone. |
| `$TERM` | Terminal type. |
| `$EDITOR`, `$VISUAL` | Default editors. |
| `$PAGER` | Default pager. |

### The `declare` builtin

`declare` (synonym `typeset`) sets variable attributes:

```bash
declare -i n=42         # integer (arithmetic context for assignments)
declare -a arr          # indexed array
declare -A map          # associative array (bash 4+)
declare -r const=42     # readonly
declare -x VAR=42       # exported
declare -n ref=target   # nameref — alias for another variable (bash 4.3+)
declare -f              # show all defined functions
declare -F              # show function names only
declare -p              # print everything in re-importable form
declare -p VAR          # show one variable's full state
```

`declare` flags can combine: `declare -airx VAR` makes an integer
indexed array, readonly, exported (though such combinations are
rare).

`declare -i` is interesting: it puts the variable in
*integer mode*. Subsequent assignments are evaluated as
arithmetic expressions:

```bash
declare -i n
n="2 + 3"           # n becomes 5
n=5*2                # n becomes 10
n="hello"            # n becomes 0
```

This is occasionally useful but trips people up — declare a
variable as integer for clarity *and* be sure that's what you
want.

### Indirect references (namerefs and `${!var}`)

Sometimes you want one variable to *name* another. Two
mechanisms:

#### Indirect expansion

```bash
target=hello
ref=target
echo "${!ref}"       # prints: hello (the value of $target)
```

`${!var}` expands to the value of the variable whose name is in
`var`. Useful for one-off indirection.

#### Namerefs (bash 4.3+)

```bash
target=hello
declare -n ref=target
echo "$ref"          # prints: hello
ref="goodbye"        # modifies target
echo "$target"       # prints: goodbye
unset -n ref         # removes the nameref (without removing target)
```

A nameref is a permanent alias. Useful inside functions for "pass
a variable by reference":

```bash
populate() {
  local -n out=$1     # out is whatever variable name was passed
  out=("apple" "banana" "cherry")
}

populate fruits
echo "${fruits[@]}"   # apple banana cherry
```

This is the bash way to write functions that "return" arrays —
something you can't do directly through `return` (which is for
exit statuses) or through stdout (which would lose array
boundaries).

### Variable substitution and parameter expansion

This is one of bash's most powerful features. The general form:

```
${parameter}                     # plain expansion
${parameter:-default}            # use parameter if set+non-empty; else use default
${parameter-default}             # use parameter if set; else use default
${parameter:=default}            # assign default to parameter if unset+empty; then expand
${parameter=default}             # assign default if unset; then expand
${parameter:?error_message}      # if unset+empty, print error and exit (script) / abort (interactive)
${parameter?error_message}       # same but only if unset
${parameter:+alt_value}          # if set+non-empty, use alt_value; else empty
${parameter+alt_value}           # if set, use alt_value; else empty
```

Concrete examples:

```bash
echo "${USER:-anonymous}"         # use USER, or "anonymous" if unset/empty
: "${PORT:=8080}"                  # set PORT to 8080 if unset/empty (the leading : is a no-op command)
echo "${MUST:?must be set}"        # error and exit if MUST is unset/empty
echo "${ENABLED:+--enable}"        # if ENABLED is set+non-empty, expand to "--enable", otherwise empty
```

The `:` variants treat empty strings the same as unset. The
non-`:` variants distinguish them — useful when an empty string
is a valid value.

#### Length, substring, search/replace

```bash
${#parameter}                     # length of value
${parameter:offset}               # substring from offset to end
${parameter:offset:length}        # substring of given length
${parameter#pattern}              # strip shortest match of pattern from START
${parameter##pattern}             # strip longest match of pattern from START
${parameter%pattern}              # strip shortest match of pattern from END
${parameter%%pattern}             # strip longest match of pattern from END
${parameter/pattern/replacement}  # replace FIRST match
${parameter//pattern/replacement} # replace ALL matches
${parameter/#pattern/replacement} # match must be at START
${parameter/%pattern/replacement} # match must be at END
${parameter^}                     # uppercase first character (bash 4+)
${parameter^^}                    # uppercase all (bash 4+)
${parameter,}                     # lowercase first character (bash 4+)
${parameter,,}                    # lowercase all (bash 4+)
${parameter@operator}             # bash 4.4+: Q (quote), E (expand escapes), P (prompt expansion), A (assignment), a (attribute)
```

Concrete worked examples on the file `report.tar.gz`:

```bash
f="report.tar.gz"
echo "${#f}"          # 13 — length
echo "${f%.gz}"       # report.tar — strip shortest match of .gz from end
echo "${f%%.*}"       # report — strip longest match of .* from end
echo "${f#*.}"        # tar.gz — strip shortest match of *. from start
echo "${f##*.}"       # gz — strip longest match of *. from start (the file extension)
echo "${f/tar/TGZ}"   # repor.TGZ.gz — replace first match
echo "${f//[aeiou]/_}"# r_p_rt.t_r.gz — replace ALL vowels
echo "${f:0:6}"       # report — substring of length 6 from offset 0
echo "${f:7}"         # tar.gz — from offset 7 to end
echo "${f^^}"         # REPORT.TAR.GZ
```

Patterns inside `${var#pat}` and friends use *globs*, not regex.
The metacharacters are `*`, `?`, `[set]`, `[!set]`. Extended
globs (`@(...)`, `+(...)`, `*(...)`, `?(...)`, `!(...)`) work
when `shopt -s extglob` is set.

#### `basename` and `dirname` in pure bash

```bash
path="/var/log/syslog.1.gz"
filename="${path##*/}"          # syslog.1.gz — same as basename
dir="${path%/*}"                # /var/log — same as dirname
ext="${path##*.}"               # gz — file extension
stem="${filename%.*}"           # syslog.1 — strip last extension
```

Doing this in pure bash avoids the cost of forking `basename` and
`dirname`. For one-offs the cost is negligible; for tight loops
it matters.

#### Indirection through expansion

```bash
${!prefix*}                       # names of variables that start with prefix
${!prefix@}                       # same, with quoting context
```

So if you have `FOO_USER`, `FOO_PASS`, `FOO_PORT`, `${!FOO_*}`
expands to those names.

---

## Arrays

Bash supports two array types: *indexed arrays* (numeric keys
starting from 0) and *associative arrays* (string keys, like a
dict or hashmap). Indexed arrays work in any bash; associative
arrays require bash 4 or later. macOS ships bash 3.2, so a script
that relies on associative arrays will fail there unless you
install a modern bash via Homebrew.

### Indexed arrays

#### Creating

```bash
arr=(a b c)                       # literal
arr=("first" "second" "third")    # with quoted elements
arr=()                            # empty array
declare -a arr                    # declare without assigning
arr=([0]=a [3]=d [5]=f)           # explicit indices — sparse array
arr+=("d")                         # append
arr+=("e" "f" "g")                 # append several
mapfile -t lines < file            # bash 4+: read each line into arr (no trailing \n)
readarray -t lines < file          # synonym
```

`mapfile` (also called `readarray`) is the cleanest way to slurp a
file into an array, one line per element.

#### Accessing

```bash
echo "${arr[0]}"       # first element
echo "${arr[1]}"       # second element
echo "${arr[-1]}"      # last element (bash 4.3+)
echo "${arr[@]}"       # all elements as separate words
echo "${arr[*]}"       # all elements as one string, joined by IFS[0]
echo "${#arr[@]}"      # count
echo "${!arr[@]}"      # all defined indices
echo "${arr[@]:1:2}"   # slice: 2 elements starting at index 1
```

The crucial distinction: `"${arr[@]}"` (quoted) expands each
element as a separate quoted word — this is almost always what
you want. `"${arr[*]}"` joins everything with the first character
of `IFS` (a space by default) into one word.

Compare:

```bash
arr=("one" "two with space" "three")
for x in "${arr[@]}"; do echo "<$x>"; done
# <one>
# <two with space>
# <three>

for x in "${arr[*]}"; do echo "<$x>"; done
# <one two with space three>
```

#### Modifying

```bash
arr[1]="new value"        # replace element 1
arr[20]="far away"        # create a sparse element
unset arr[2]              # delete element 2 (leaves a hole; the array is sparse now)
arr=("${arr[@]}")         # collapse holes by re-creating
```

#### Iteration

```bash
for x in "${arr[@]}"; do
  echo "$x"
done

for i in "${!arr[@]}"; do
  echo "$i: ${arr[$i]}"
done

for ((i=0; i<${#arr[@]}; i++)); do
  echo "$i: ${arr[$i]}"
done
```

The first form iterates values; the second iterates indices and
gives you both index and value; the third uses C-style `for` and
is dense if you really need numeric iteration with arithmetic.

#### Common operations

```bash
# join with a delimiter (printf trick)
printf -v joined '%s,' "${arr[@]}"
joined="${joined%,}"     # strip trailing comma

# alternative join via IFS
IFS=, joined="${arr[*]}"

# split a string into an array on a delimiter
IFS=':' read -ra parts <<< "$PATH"
for p in "${parts[@]}"; do echo "$p"; done

# does the array contain a value?
contains() {
  local needle=$1; shift
  for x; do [[ $x == "$needle" ]] && return 0; done
  return 1
}
contains "two" "${arr[@]}" && echo found

# remove duplicates while preserving order
declare -A seen
unique=()
for x in "${arr[@]}"; do
  if [[ -z ${seen[$x]:-} ]]; then
    seen[$x]=1
    unique+=("$x")
  fi
done
```

### Associative arrays (bash 4+)

```bash
declare -A map                    # MUST declare first; can't auto-create
map=([key1]=value1 [key2]=value2)
map[name]="alice"
map[count]=42

echo "${map[name]}"               # access by key
echo "${map[@]}"                  # all values
echo "${!map[@]}"                 # all keys
echo "${#map[@]}"                 # number of entries

unset 'map[key1]'                 # remove (must quote — globbing happens)
unset map                         # remove the whole array

# iterate keys
for k in "${!map[@]}"; do
  echo "$k -> ${map[$k]}"
done

# test existence
if [[ -v map[name] ]]; then ...   # bash 4.2+
if [[ -n ${map[name]+set} ]]; then ...   # portable variant
```

Notes:

- `declare -A` is required. Auto-creating an associative array via
  `arr[key]=value` doesn't work — bash will treat it as an indexed
  array with a key of 0 (because the string `key` evaluates to 0
  in arithmetic context).
- Keys are arbitrary strings, including the empty string.
- Iteration order is unspecified. Sort the keys explicitly if you
  need ordered output.
- `unset 'map[key]'` — the quotes matter. Without them, if `key`
  contains a `*`, the shell will glob-expand it.

### Common array recipes

```bash
# read a CSV-like file into associative arrays keyed by id
declare -A user_email user_role
while IFS=, read -r id email role; do
  user_email[$id]=$email
  user_role[$id]=$role
done < users.csv

# sum a column from a file
total=0
while IFS=, read -r _ amount _; do
  total=$((total + amount))
done < orders.csv
echo "$total"

# count occurrences
declare -A counts
for word in "$@"; do
  counts[$word]=$(( ${counts[$word]:-0} + 1 ))
done
for k in "${!counts[@]}"; do
  printf '%5d  %s\n' "${counts[$k]}" "$k"
done | sort -rn
```

---

## Tests, conditions, and `[[ ... ]]`

A *condition* in bash is anything with an exit status. The shell
treats exit `0` as true and any non-zero as false. So `if cmd;
then ...` runs the `then` body if `cmd` exits zero. The `[[
... ]]` construct (and the older `[ ... ]` and `test`) are just
commands that exit with the appropriate status.

### `[[ ... ]]` is the modern form

`[[ ... ]]` is a bash-specific keyword (not a regular command).
Compared to the older `[ ... ]` (which is `/usr/bin/test`):

- It does **not** word-split unquoted variables, so
  `[[ $var = string ]]` works even if `$var` is empty.
- It does **not** glob-expand unquoted patterns on the right side
  of `=` and `==`.
- It supports `&&`, `||`, `<`, `>` directly without escaping.
- It has additional operators that `[ ]` doesn't: `=~` (regex
  match), `<` and `>` for string comparison.
- Inside `[[ ... ]]`, the right side of `==` and `=` is a *glob
  pattern* (unless quoted), and `=~` is a *regex*.

The general rule: use `[[ ... ]]` in bash scripts. Use `[ ... ]`
or `test` only if you need POSIX portability (e.g. a script that
needs to work in `dash`).

### File tests

```bash
[[ -e path ]]     # path exists (any type)
[[ -f path ]]     # exists and is a regular file
[[ -d path ]]     # exists and is a directory
[[ -L path ]]     # exists and is a symbolic link (alternative: -h)
[[ -h path ]]     # same as -L
[[ -p path ]]     # named pipe (FIFO)
[[ -S path ]]     # socket
[[ -b path ]]     # block device
[[ -c path ]]     # character device

[[ -r path ]]     # readable by the current user
[[ -w path ]]     # writable
[[ -x path ]]     # executable
[[ -s path ]]     # exists and is non-empty
[[ -O path ]]     # owned by the current user
[[ -G path ]]     # group ownership matches the current user's effective group
[[ -k path ]]     # has the sticky bit
[[ -u path ]]     # has the setuid bit
[[ -g path ]]     # has the setgid bit
[[ -N path ]]     # has been modified since last read
[[ -t fd ]]       # file descriptor fd refers to a terminal

[[ a -nt b ]]     # file a is newer than b
[[ a -ot b ]]     # file a is older than b
[[ a -ef b ]]     # a and b refer to the same inode (same file)
```

### String tests

```bash
[[ -z "$s" ]]     # string is empty
[[ -n "$s" ]]     # string is non-empty
[[ "$a" = "$b" ]] # equal (= and == are equivalent in [[ ]])
[[ "$a" != "$b" ]]# not equal
[[ "$a" < "$b" ]] # lexicographic less-than (only inside [[ ]])
[[ "$a" > "$b" ]] # lexicographic greater-than

[[ "$file" = *.log ]]     # GLOB MATCH (right side is a glob pattern)
[[ "$file" == *.log ]]    # same
[[ "$file" != *.tmp ]]    # negated glob match
[[ "$line" =~ ^[0-9]+$ ]] # REGEX MATCH (extended regex)

# variable existence (bash 4.2+)
[[ -v VAR ]]              # VAR is set (even if empty)
[[ ! -v VAR ]]            # VAR is unset
```

A subtle gotcha: in `[[ "$file" == *.log ]]`, the right side must
be **unquoted** for glob behaviour. Quote it and the `=` becomes
literal-string equality:

```bash
[[ "$file" == *.log ]]    # glob: matches anything ending in .log
[[ "$file" == "*.log" ]]  # literal: matches the exact string "*.log"
```

The `=~` operator does extended regex matching. Capture groups
end up in `$BASH_REMATCH`:

```bash
if [[ $line =~ ^([A-Z]+):([0-9]+)$ ]]; then
  echo "tag:    ${BASH_REMATCH[1]}"
  echo "number: ${BASH_REMATCH[2]}"
fi
```

`BASH_REMATCH[0]` is the whole match, `[1]` is the first capture
group, and so on.

### Numeric tests

Two equivalent options:

```bash
# inside [[ ]], use the dash-letter operators
[[ "$n" -eq 5 ]]   # equal
[[ "$n" -ne 5 ]]   # not equal
[[ "$n" -lt 5 ]]   # less than
[[ "$n" -le 5 ]]   # less than or equal
[[ "$n" -gt 5 ]]   # greater than
[[ "$n" -ge 5 ]]   # greater than or equal

# inside (( )), use familiar operators
(( n == 5 ))       # equal
(( n != 5 ))       # not equal
(( n < 5 ))        # less than
(( n <= 5 ))       # less than or equal
(( n > 5 ))        # greater than
(( n >= 5 ))       # greater than or equal
(( n > 0 && n < 100 ))  # combined
```

`(( ... ))` is *arithmetic context* — its contents are an
arithmetic expression. Variable expansion happens automatically
without `$`. Exit status is 0 if the expression is non-zero, 1
otherwise (yes, "non-zero is true" is reversed from the usual
shell convention — because in arithmetic, non-zero is the
mathematical "true").

### Boolean composition

```bash
[[ -f file && -r file ]]              # both
[[ -f file || -L file ]]              # either
[[ ! -f file ]]                        # negation
[[ -f a && ( -r a || -w a ) ]]        # grouping with ()
```

These work inside `[[ ]]` and `(( ))`. Outside, you can chain
commands with `&&` and `||`:

```bash
[[ -f file ]] && cmd1                  # run cmd1 only if file exists
[[ ! -f file ]] || cmd1                # equivalent: run cmd1 if file exists
[[ -f file ]] && echo found || echo missing
```

The last form is the bash equivalent of a ternary, but with a
trap: if `echo found` itself fails (which it almost never does
but theoretically can), the `else` branch runs too. Prefer an
explicit `if/else` for non-trivial cases.

### Old-style `[ ... ]` and `test`

The older form, equivalent to invoking `test` (which is also
available as `/usr/bin/[`):

```bash
if [ "$var" = "value" ]; then ...
if test "$var" = "value"; then ...
[ -f file ] && cmd
```

Differences from `[[ ... ]]`:

- Word-splits and glob-expands unquoted variables — quoting is
  mandatory: `[ "$var" = "value" ]`, never `[ $var = "value" ]`.
- No `&&` / `||` (use `-a` and `-o`, but they're awkward and
  fragile — better to chain with shell `&&` and `||`).
- No `=~` regex.
- No `<` / `>` string comparison without escaping (`[ "$a"
  \< "$b" ]`).

In modern bash, `[[ .

…(truncated)
