Linux & Bash · Part I · Module 21 · From Script to Reusable Tool
Interface First: Arguments, Options and a Usage Message
Module 20 made a script fail honestly. This module makes it something another person can run — and that is a different property. It starts with the part they actually touch: what the script takes, what it promises, and the one line that tells them both. Written first, that line is a specification; written last, it is a guess at what the code already does.
Advanced19 min readLinuxPart IBashInterfaceUsageScripting
Part I · Module 21 · Chapter 21.1 · Interface First: Arguments, Options and a Usage Message
Every script in the last five modules has been correct. None of them has been reusable, and the difference is not internal quality.
A reusable tool is one where someone who did not write it can work out how to call it, gets told clearly when they call it wrongly, and finds that it behaves the same way on their machine as on yours. Those are interface properties, and this module is about them.
1. Write the Usage Line First
Deciding the interface before writing the work
The habit is small and the effect is large. Before writing any of the work, write the line a user will see:
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>That one line forces four decisions you would otherwise make accidentally, one at a time, while writing the body:
- What is required and what is optional.
<testlist>in angle brackets is required; everything in square brackets is not. - What is a flag and what takes a value.
-vand-nare switches;-sand-otake arguments, and the placeholder names say what kind. - How many positional arguments there are. One. Not "one or more, and if it is more then something different happens" — a decision easily made by accident.
- And what the defaults must be, because every optional thing needs one.
A script whose usage line you cannot write in one line has an interface problem, and it is better to discover that before the body exists than after.
The conventions in that line are not arbitrary — they are the ones man pages use, so a reader already knows how to parse them:
| In a usage line | Means |
|---|---|
<name> or NAME | required |
[-v] | optional flag |
[-s SEEDS] | optional flag taking a value |
<file>... | one or more |
[file...] | zero or more |
{a|b} | required, one of these |
2. The Usage Function
usage() {
printf 'usage: %s [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>\n' "${0##*/}" >&2
}Three things in that are deliberate.
${0##*/} rather than a hardcoded name, so a renamed or symlinked script tells the truth — Chapter 8.3's basename, and Chapter 18.2 §2's point that $0 is the script even inside a function.
>&2, because a usage message printed in response to a mistake is a diagnostic. Chapter 20.7's rule.
And printf rather than echo, for Chapter 17.2's reason: a usage line containing a -n or a backslash is not hypothetical.
Then the rejection, which belongs before anything else happens:
[ $# -ge 1 ] || { usage; exit 2; }exit 2, from Chapter 15.6's convention: 2 means you called it wrongly, which is a different thing from 1 meaning the work failed. Chapter 21.4 makes that a documented contract rather than a habit.
3. --help Is Not an Error
This is the distinction most scripts get wrong, and it is measurable.
A user typing tool --help has not made a mistake. They are asking a question, and the answer is the output they asked for — so it goes on stdout with status 0:
case ${1:-} in
-h|--help) help_text; exit 0 ;;
esacCompare the two from the caller's side. The difference is invisible at a bare prompt — stderr still reaches your terminal — so it has to be measured by what a consumer receives:
$ ./right.sh --help 2>/dev/null | wc -l # lines through a pipe
3
$ ./wrong.sh --help 2>/dev/null | wc -l
0
$ o=$(./right.sh --help 2>/dev/null); echo "${#o}" # bytes captured
73
$ o=$(./wrong.sh --help 2>/dev/null); echo "${#o}"
0
$ ./right.sh --help >/dev/null 2>&1; echo $? # status
0
$ ./wrong.sh --help >/dev/null 2>&1; echo $?
2
$ ./right.sh --help >/dev/null 2>&1 && echo ok # usable in a chain
ok
$ ./wrong.sh --help >/dev/null 2>&1 && echo ok
$
$ ./right.sh --help > doc.txt 2>/dev/null; wc -c < doc.txt # redirected
74
$ ./wrong.sh --help > doc.txt 2>/dev/null; wc -c < doc.txt
0Five consumers, and the second script answers none of them. | less, | grep, $( ), && … and > doc.txt all get an empty stream, and the status says the user made a mistake by asking. On a bare terminal the two look identical, which is exactly why this survives into shipped tools.
So:
| Stream | Status | |
|---|---|---|
--help requested | stdout | 0 |
| a wrong invocation | stderr | 2 |
Both print usage. They differ in where and in what they claim, and the difference is what makes --help usable.
4. Positional Arguments: Decide the Count
$# checks are cheap and the choice of comparison is a design decision:
[ $# -eq 1 ] || { usage; exit 2; } # exactly one
[ $# -ge 1 ] || { usage; exit 2; } # one or more
[ $# -le 2 ] || { usage; exit 2; } # at most twoPrefer -eq unless an argument really is optional. A script that accepts extra arguments and ignores them accepts your typos:
$ ./loose.sh tests.txt tsets.txt
running tests from tests.txtThe second file was a mistyped retry that the script silently discarded. With -eq 1:
$ ./strict.sh tests.txt tsets.txt
usage: strict.sh [-v] [-s SEEDS] <testlist>
$ echo $?
2And when you do take several, say so with ... and handle them as a list — Chapter 19.3's "$@":
# usage: tool <file>...
[ $# -ge 1 ] || { usage; exit 2; }
for f in "$@"; do ...; done5. -- Ends the Options
A convention from Chapter 1.5 that a reusable tool must honour, because its arguments come from somewhere you do not control:
while [ $# -gt 0 ]; do
case $1 in
-v) verbose=1; shift ;;
--) shift; break ;; # everything after this is positional
-*) printf '%s: unknown option: %s\n' "${0##*/}" "$1" >&2; usage; exit 2 ;;
*) break ;;
esac
done$ ./tool.sh -- -weird.sv
processing [-weird.sv]
$ ./tool.sh -weird.sv
tool.sh: unknown option: -weird.sv
usage: tool.sh [-v] [--] <file>...
$ echo $?
2Without --, a file genuinely named -weird.sv cannot be passed at all. With it, the caller has a way — and the -*) arm means a mistyped option is reported rather than treated as a filename, which Chapter 16.6 §8 measured as the reason that arm must come before *).
And pass -- on to the commands you call, for Chapter 20.4's reason:
rm -rf -- "$dir"
grep -q -- "$pattern" "$f"6. A Complete Script
The interface, complete, with the body deliberately trivial so the shape is visible:
#!/usr/bin/env bash
# run-suite.sh — run every test in a testlist, once per seed
#
# usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
#
# exit: 0 all passed · 1 at least one failed · 2 bad invocation
# 4 the environment is not set up
set -o errexit
set -o nounset
set -o pipefail
note() { printf '%s: %s\n' "${0##*/}" "$*" >&2; }
die() { note "$*"; usage; exit 2; }
usage() {
printf 'usage: %s [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>\n' "${0##*/}" >&2
}
help_text() {
usage 2>&1
cat <<'EOF'
Run every test named in <testlist>, once per seed.
-v verbose: report each test as it starts
-n dry run: print what would run, then exit 0
-s SEEDS seeds per test (default 1, maximum 100)
-o OUTDIR where logs go (default ./results)
-h, --help this message, on stdout
<testlist> one test name per line; blanks and # comments ignored
Exit status:
0 every test passed
1 at least one test failed
2 bad invocation
4 the environment is not set up
EOF
}
# ---- interface -----------------------------------------------------------
verbose=0
dry_run=0
seeds=1
outdir=./results
case ${1:-} in
-h|--help) help_text; exit 0 ;;
esac
while [ $# -gt 0 ]; do
case $1 in
-v) verbose=1; shift ;;
-n) dry_run=1; shift ;;
-s) [ $# -ge 2 ] || die "-s needs a value"; seeds=$2; shift 2 ;;
-o) [ $# -ge 2 ] || die "-o needs a value"; outdir=$2; shift 2 ;;
--) shift; break ;;
-*) die "unknown option: $1" ;;
*) break ;;
esac
done
[ $# -eq 1 ] || { usage; exit 2; }
testlist=$1
[ "$seeds" -ge 1 ] 2>/dev/null || die "seeds must be a positive integer, got: $seeds"
[ "$seeds" -le 100 ] 2>/dev/null || die "seeds must be 100 or fewer, got: $seeds"
[ -r "$testlist" ] || die "cannot read testlist: $testlist"
# ---- the work (deliberately trivial) ------------------------------------
declare -a tests=()
while IFS= read -r line || [ -n "$line" ]; do
case $line in ''|\#*) continue ;; esac
tests+=("$line")
done < "$testlist"
[ "${#tests[@]}" -gt 0 ] || die "no tests in $testlist"
if [ "$dry_run" -eq 1 ]; then
printf 'would run %d test(s) x %d seed(s) into %s\n' \
"${#tests[@]}" "$seeds" "$outdir"
for t in "${tests[@]}"; do
for (( s = 1; s <= seeds; s++ )); do
printf ' %s seed %d\n' "$t" "$s"
done
done
exit 0
fi
mkdir -p -- "$outdir" || die "cannot create $outdir"
failed=0
for t in "${tests[@]}"; do
for (( s = 1; s <= seeds; s++ )); do
[ "$verbose" -eq 0 ] || note "starting $t seed $s"
if [ "$t" = known_bad ]; then
printf 'FAIL %-16s seed %d\n' "$t" "$s"
failed=$(( failed + 1 ))
else
printf 'pass %-16s seed %d\n' "$t" "$s"
fi
done
done
printf -- '-- %d test(s) x %d seed(s), %d failure(s)\n' \
"${#tests[@]}" "$seeds" "$failed"
[ "$failed" -eq 0 ]$ printf '# the suite\n\nsmoke\ncache coherency\nknown_bad\n' > tests.txt
$ ./run-suite.sh --help | head -4
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
Run every test named in <testlist>, once per seed.
$ ./run-suite.sh --help > /dev/null; echo "rc=$?"
rc=0
$ ./run-suite.sh -n tests.txt
would run 3 test(s) x 1 seed(s) into ./results
smoke seed 1
cache coherency seed 1
known_bad seed 1
$ echo $?
0
$ ./run-suite.sh -s 2 tests.txt
pass smoke seed 1
pass smoke seed 2
pass cache coherency seed 1
pass cache coherency seed 2
FAIL known_bad seed 1
FAIL known_bad seed 2
-- 3 test(s) x 2 seed(s), 2 failure(s)
$ echo $?
1
$ ./run-suite.sh
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2
$ ./run-suite.sh tests.txt extra.txt
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2
$ ./run-suite.sh -s tests.txt
run-suite.sh: -s needs a value
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2
$ ./run-suite.sh -z tests.txt
run-suite.sh: unknown option: -z
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2
$ ./run-suite.sh -s 500 tests.txt
run-suite.sh: seeds must be 100 or fewer, got: 500
usage: run-suite.sh [-v] [-n] [-s SEEDS] [-o OUTDIR] <testlist>
$ echo $?
2Six decisions, all interface rather than implementation:
The header comment is the usage line plus the exit statuses, so head -6 run-suite.sh is documentation. That is cheaper than a README and cannot get out of sync with the usage() below it as easily.
--help goes to stdout and exits 0; every wrong call goes to stderr and exits 2. Measured above: --help | head -4 works and --help > /dev/null returns 0.
die prints the specific problem and then the usage line. -s needs a value alone leaves the user guessing at the syntax; the usage line alone leaves them guessing at which argument was wrong. Both is two lines and no guessing.
-eq 1 on the positional count, so tests.txt extra.txt is rejected rather than silently half-used.
The hand-rolled option loop handles -s VALUE with an explicit [ $# -ge 2 ] check — because shift 2 on a one-element list is a failure, and Chapter 15.4 measured that. Chapter 21.2 replaces this whole loop with four lines.
Validation is ordered: options, then count, then the values, then the world, then — only at mkdir -p — anything that changes the system. Chapter 20.3 §3's rule, which is also why -n can exit before the mkdir.
Practice
- Take a script of your own and write its usage line in one line, using the §1 conventions. If you cannot, describe the interface problem that makes it impossible.
- Write
usage()with${0##*/}and>&2. Then copy the script under a different name and confirm the message changes. - Write a script whose
--helpgoes to stderr and exits 2. Run./s.sh --help | head -3and./s.sh --help && echo ok. Then fix it and re-run both. - Build the
help_textfunction that reusesusage 2>&1. Confirm the one-line form appears identically in both outputs, then changeusage()and confirm both changed. - Write the same check with
-eq 1and-ge 1. Call each with two arguments and describe which typo each one catches. - Create a file named
-weird.sv. Pass it to a script with and without--. Then add the-*)arm and confirm a mistyped option is reported instead. - In
run-suite.sh, remove the[ $# -ge 2 ]check from the-sarm and run./run-suite.sh -s. What happens, and which chapter measured it? - Run
head -6 run-suite.sh. Is that adequate documentation for someone else? Add whatever is missing to the comment rather than to a README. - Add a
-j JOBSoption torun-suite.sh, usage line first. Count how many places you had to change, and whether the usage line made any of them obvious.
Check Your Understanding
- Why write the usage line before the body?
- What do angle brackets and square brackets mean in a usage line?
- Where does
--helpoutput go, and with what status? Why? - What is the measurable consequence of sending
--helpto stderr? - Why prefer
-eqto-geon a positional count? - What does
--buy a caller, and what does the-*)arm buy? - Why should
dieprint both the specific problem and the usage line?
7. Remember
- A script large enough to have stages wants its stages separated by files, not functions — Chapter 32.2 measures why, on a driver whose cheapest stage is the one most likely to be wrong.
- The interface is the part other people touch, so decide it before writing the work.
- Write the usage line first. It forces the four decisions you would otherwise make by accident, and if it will not fit on one line you have found a design problem.
- Use the
man-page conventions:<required>,[optional],[-s VALUE],<file>.... usage()uses${0##*/},printf, and>&2.--helpgoes to stdout with status 0. A help request is not an error, and stderr makes it unusable through a pipe or a redirect.- Let
help_textreuseusage 2>&1so the two cannot drift. - Document the exit statuses in the help text. It is the part an automated caller needs most.
- Prefer
-eqon the positional count — a script that ignores extra arguments accepts typos. - Honour
--, and put the-*)arm before*)so a mistyped option is reported. dieprints the specific problem and then the usage line.- Put the usage line and exit statuses in the header comment, so
headis documentation.
Chapter 21.2 replaces the hand-rolled option loop. Everything above stays — the usage line, the statuses, --help — but the fifteen lines of case/shift become four, and the two mistakes a caller can make get reported with the offending letter.
- A usage message belongs inside the script, as a quoted heredoc. Chapter 35.6 measures a README whose documented
--helpran the flow instead of describing it — four false claims in ten.
Where this fits
Part of the Linux & Bash curriculum.
