Skip to content

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.

NeverBecauseUse instead
${=var}zsh-only; bash rejects itfor x in $(cmd)
${var:t}zsh-only; bash silently returns the whole path$(basename "$var")
local -ANeeds bash 4; macOS ships 3.2A 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.

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”
Terminal window
cd "~/Projects" # a directory literally named ~
cd "$HOME/Projects" # what was meant

Any 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.

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.

testdata/golden holds the complete generated shell for both dialects on both platforms. Any catalog or template change shows up there.

Terminal window
make golden # re-record after an intended change

Review the diff, because that diff is what lands in every user’s shell.

Most bugs in this project were found by installing it and using it, not by the tests:

  • loadout env wrote its exports to stderr, so eval "$(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 its exit ended 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.

If you are contributing a shell function, the short version:

  • Write in the intersection of zsh and bash. When in doubt, POSIX.
  • Reset OPTIND before getopts.
  • Use $(basename "$x"), not ${x:t}.
  • No associative arrays.
  • Quote every expansion.
  • Interpolate paths with the shpath helper.
  • Run make golden and read the diff.

Then test the behaviour in both shells, not just that the file parses.