Shell portability
Generated shell must behave identically in zsh and bash, on Linux and macOS. These rules are not style preferences — each one is a bug that happened.
The forbidden three
Section titled “The forbidden three”| Never | Because | Use instead |
|---|---|---|
${=var} | zsh-only; bash rejects it | for x in $(cmd) |
${var:t} | zsh-only; bash silently returns the whole path | $(basename "$var") |
local -A | Needs bash 4; macOS ships 3.2 | A case statement |
Tests forbid each of these in generated output.
The middle one is the instructive one. ${var:t} is not a syntax error in bash, so the file passes bash -n cleanly. It just returns something else — the whole path instead of the basename — which means a function that looked like it worked was producing a plausible wrong answer.
getopts needs local OPTIND=1
Section titled “getopts needs local OPTIND=1”zsh localises OPTIND per function. Bash does not.
Without an explicit reset, the second call to a function in a bash session silently ignores its flags and uses the defaults. No error, no warning. Every shipped function that parses flags resets it first.
A tilde does not expand inside double quotes
Section titled “A tilde does not expand inside double quotes”cd "~/Projects" # a directory literally named ~cd "$HOME/Projects" # what was meantAny path interpolated into generated shell must be in $HOME form. The shpath template helper does this, and TestNoUnexpandedTildeInQuotes fails on any "~/ in non-alias output.
Parsing is not behaviour
Section titled “Parsing is not behaviour”zsh -n and bash -n both pass on code that is wrong at runtime. Both of the silent failures above prove it.
There is a sharper example. A formatter once rewrote an associative array key from [a11-wb] to [a11 - wb]. The result parsed, defined cleanly, and silently failed to look anything up — a function that existed, ran, and returned nothing.
So tests compare what functions do, not whether they parse.
Golden files
Section titled “Golden files”testdata/golden holds the complete generated shell for both dialects on both platforms. Any catalog or template change shows up there.
make golden # re-record after an intended changeReview the diff, because that diff is what lands in every user’s shell.
The wider lesson
Section titled “The wider lesson”Most bugs in this project were found by installing it and using it, not by the tests:
loadout envwrote its exports to stderr, soeval "$(loadout env)"did nothing. It had never been invoked end to end.- The web interface served no CSS or JavaScript, because the guard required a token that a
<script>tag cannot send. The test passed one by hand with curl. - A formula generator’s guard could not abort: it ran inside
$( ), so itsexitended the subshell and the script carried on emitting a broken formula and exiting 0.
A check that cannot fail is worse than no check. When adding one, make it fail first.
Writing a function
Section titled “Writing a function”If you are contributing a shell function, the short version:
- Write in the intersection of zsh and bash. When in doubt, POSIX.
- Reset
OPTINDbeforegetopts. - Use
$(basename "$x"), not${x:t}. - No associative arrays.
- Quote every expansion.
- Interpolate paths with the
shpathhelper. - Run
make goldenand read the diff.
Then test the behaviour in both shells, not just that the file parses.