# Input Parameter Parsing

> Source: https://learn-bash.net/input-parameter-parsing/
> Part of Learn Bash, free to read.

Every script that does anything useful eventually needs options. Bash gives you three levels of answer, and the right one depends on whether you need long flags.

## The raw material

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

echo "script name : $0"
echo "first arg   : ${1:-}"     # :- so an unset $1 does not trip set -u
echo "count       : $#"
echo "all args    : $*"         # one string
echo "all args    : $@"         # separate words — almost always what you want
```

```bash
$ ./demo.sh alpha "two words"
script name : ./demo.sh
first arg   : alpha
count       : 2
```

:::warn `"$@"` and `"$*"` are not interchangeable
`"$@"` expands to one quoted word per argument. `"$*"` joins everything into a single word using the first character of `IFS`. Passing arguments through to another command is always `"$@"` — using `"$*"` there is how a filename with a space becomes two broken arguments.
:::

## shift

`shift` discards `$1` and moves everything down. It is what makes a parsing loop work.

```bash
while [[ $# -gt 0 ]]; do
  echo "handling: $1"
  shift
done
```

## Level 1: a manual loop

The most common approach, because it handles long options and `getopts` does not.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

usage() {
  cat <<'EOF'
Usage: backup.sh [options] <source> [<source>...]

Options:
  -d, --dest DIR      Destination directory (default: ./backups)
  -n, --dry-run       Print what would happen, change nothing
  -v, --verbose       More output (repeatable)
  -j, --jobs N        Parallel jobs (default: 4)
      --no-compress   Skip compression
  -h, --help          This message
EOF
}

dest="./backups"
dry_run=0
verbose=0
jobs=4
compress=1
sources=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    -d|--dest)
      [[ $# -ge 2 ]] || { echo "$1 requires a value" >&2; exit 2; }
      dest="$2"; shift 2 ;;
    --dest=*)      dest="${1#*=}"; shift ;;
    -j|--jobs)
      [[ $# -ge 2 ]] || { echo "$1 requires a value" >&2; exit 2; }
      jobs="$2"; shift 2 ;;
    --jobs=*)      jobs="${1#*=}"; shift ;;
    -n|--dry-run)  dry_run=1; shift ;;
    -v|--verbose)  verbose=$((verbose + 1)); shift ;;
    --no-compress) compress=0; shift ;;
    -h|--help)     usage; exit 0 ;;
    --)            shift; sources+=("$@"); break ;;
    -*)            echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
    *)             sources+=("$1"); shift ;;
  esac
done

# --- validate -------------------------------------------------------
[[ ${#sources[@]} -gt 0 ]] || { echo "no source given" >&2; usage >&2; exit 2; }
[[ "$jobs" =~ ^[0-9]+$ ]]  || { echo "--jobs must be a number, got: $jobs" >&2; exit 2; }
[[ -d "$dest" ]] || mkdir -p -- "$dest"

for s in "${sources[@]}"; do
  [[ -e "$s" ]] || { echo "no such path: $s" >&2; exit 1; }
done

(( verbose )) && printf 'dest=%s jobs=%s compress=%s sources=%d\n' \
  "$dest" "$jobs" "$compress" "${#sources[@]}" >&2

(( dry_run )) && { printf 'would back up: %s\n' "${sources[@]}"; exit 0; }
```

Four details in there are worth naming, because they are the ones generated scripts miss:

- **`--` ends option parsing.** Everything after it is positional, even if it starts with a dash. Without it you cannot back up a file called `-report.txt`.
- **`--dest=value` handled separately.** `${1#*=}` strips up to the first `=`. Users expect both forms.
- **Missing values checked.** `[[ $# -ge 2 ]]` before `shift 2`, otherwise `--dest` at the end of the line silently consumes nothing and `set -u` fires somewhere confusing.
- **Unknown options rejected.** The `-*)` case. Silently ignoring a typo'd flag is how a script "works" while doing the wrong thing.

## Level 2: getopts

Built in, POSIX, tidier — and short options only. Good for small scripts.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

dest="./backups"; dry_run=0; verbose=0

while getopts ":d:nvh" opt; do
  case "$opt" in
    d) dest="$OPTARG" ;;
    n) dry_run=1 ;;
    v) verbose=1 ;;
    h) usage; exit 0 ;;
    :)  echo "-$OPTARG requires a value" >&2; exit 2 ;;
    \?) echo "unknown option: -$OPTARG" >&2; exit 2 ;;
  esac
done
shift $((OPTIND - 1))     # drop the parsed options; "$@" is now positional
```

The optstring is the whole configuration: a letter takes no value, a letter followed by `:` requires one, and a **leading** `:` switches on silent error reporting so your `:` and `\?` cases run instead of getopts printing its own message.

`shift $((OPTIND - 1))` at the end is mandatory. Forget it and your positional arguments still have the options in front of them.

:::note Do not reach for `getopt` (no s)
The external `getopt` command does support long options, but the BSD version on macOS and the GNU version on Linux behave differently, which turns a portability problem into a debugging session. Use `getopts` for short-only, and the manual loop when you need long flags.
:::

## Defaults and environment overrides

```bash
dest="${BACKUP_DEST:-./backups}"      # env, then default
jobs="${JOBS:-4}"
: "${API_TOKEN:?API_TOKEN must be set}"   # required — fails with a clear message
```

That last form is worth memorising. `${VAR:?message}` exits with your message if the variable is unset or empty, which is exactly what you want for anything required — and it is the safe way to interpolate a variable into a destructive command:

```bash
rm -rf -- "${BUILD_DIR:?BUILD_DIR is not set}"/*
```

If `BUILD_DIR` is empty, the script exits instead of deleting from the root.

## Always add --dry-run

For any script that deletes, uploads, deploys or overwrites:

```bash
run() {
  if (( dry_run )); then
    printf 'would run:'; printf ' %q' "$@"; printf '\n'
  else
    "$@"
  fi
}

run rm -rf -- "$tmp"
run rsync -a --delete -- "$src/" "$dest/"
```

`%q` quotes the output so you can copy the printed line and run it verbatim. This one function makes a script safe to try once, which matters enormously when the script was written by an agent.

## Exercise

```bash
#!/bin/bash
# Parse these arguments and print the result.
# Support: -n/--name VALUE, -c/--count N (default 1), -q/--quiet, and
# positional arguments collected into an array.
# Print: name, count, quiet, then each positional on its own line.

set -euo pipefail
set -- --name Ada -c 3 -- report.txt "two words"

# write your code here
```

## Common questions

### getopts or a manual while loop?

`getopts` if short options are enough — it is shorter and handles bundling (`-nv`) for free. A manual loop the moment you want `--long-options`, which in practice is most scripts anyone else will run.

### Why does `--` matter?

It marks the end of options, so anything after it is treated as a positional argument even if it begins with a dash. Without it, a filename like `-report.txt` is read as flags. The same reasoning is why you write `rm -- "$f"`.

### How do I make an option repeatable?

Append to an array rather than assigning: `-e|--exclude) excludes+=("$2"); shift 2 ;;`. Then expand it with `"${excludes[@]}"`. Counting flags like `-vv` work the same way with `verbose=$((verbose + 1))`.
