Three more rounds on huck's declaration builtins: a refusal that fired when it should not have, one that never fired at all, and a builtin that kept introducing itself by the wrong name.
`declare -a` on a name that already exists asks bash to change a variable's shape, and bash has a whole table of answers — promote, refuse, or empty it out. huck had three different partial copies of that table, and one of them silently threw away an associative array.
In bash a variable can exist without having a value. huck had nowhere to put that, so every valueless declaration quietly invented an empty string or an empty array — which changed what `declare -p` printed, what `set -u` complained about, and what a child process inherited.
A lone `[` in a pattern is an ordinary character in bash. huck had five places that matched patterns, and four of them disagreed — one loudly, three by silently not matching. Now there is one, and the pattern it works on is written the way bash writes it.
huck tidied its job table after every command. bash looks at five specific moments — and what it says depends on whether it runs a script or a -c string. A job that died between could vanish from `jobs`, lose its `Killed` notice, or make `wait %` fail. Coprocesses and QUIT followed the same thread.
huck had three separate pieces of code for turning a parsed command back into text — one for declare -f, one for error messages, one for jobs. Each had drifted. Now there is one, with the single switch bash's own printer has: are we inside a function body, or not?
A pipeline stage that failed while being set up did not merely fail — it took the pipeline with it, leaked an internal error message, and left no trace of itself in PIPESTATUS. The cause was a question of when, not what: huck expanded a stage's words last, where bash expands them first.
The tail of a five-round bug hunt: an error message that pointed at the wrong line of your script, and a second message that described a file you never named. Neither changed what the shell did — which is exactly why they were worth fixing.
Under set -u, one unset variable in a redirection was enough to end a huck script on the spot — silently skipping every remaining line. bash reports the same error and keeps going, and the reason it can is that the failure happened somewhere else entirely.
huck's test suite was green on CI and broken on a Mac. Six fixes later, the interesting part wasn't any single bug — it was that the suite kept hiding how many there were, including from the command we used to count them.
A single non-UTF-8 byte anywhere in the environment — in a variable huck never even looked at — crashed the shell before it ran a line. Nothing a script could do would prevent it, because the environment arrives before the script does.
A broken operand inside [[ ]] used to abandon the whole test, so an || that should have rescued it never ran. Plus numeric literals: huck now reads them the way bash does, wrapping instead of refusing.
A readonly variable could be shadowed inside a function, and could not have an attribute added — huck had both halves of the rule backwards. Plus four diagnostics that now read the way bash writes them.
huck's `local` and `declare` wrote onto the variable they were supposed to shadow, so a function-local could inherit an outer variable's type and even its contents. Plus two arithmetic fixes: quoted operands no longer quietly evaluate.
Four fixes to huck's arithmetic. Quoting a value no longer stops it being evaluated, an unusable value in an integer variable is reported instead of silently becoming zero, and failed expressions now say which part failed.
A script with `[ -f x ] && do_it` inside an if statement quietly exited early under set -e. huck was judging the same failure twice — once where it happened, and once again on the way out.
huck now colours the command line as you type it: strings, variables, globs and comments each get their own colour, brackets match under the cursor — and the only loud signal is a command that will not run.
huck's new syntax highlighting stopped at the first line of a multi-line command — and so, it turned out, did tab completion. Both for the same reason: a continuation line does not mean anything on its own.
When a script ends in the middle of something, the shell tells you which bracket it was still waiting for. huck was naming the wrong one in 78 different situations — and sometimes inventing a line number that didn't contain any bracket at all.
Seven fixes about a shell that spotted your mistake and then sent you somewhere else to find it. A syntax error blamed the line a block started on, an unterminated quote blamed the end of the file, and set -x invented quotes you never typed.
Eleven fixes about a shell that was too willing to answer. set -u stopped catching typos the moment you asked for a length, a nonsense expansion returned a plausible value, and a transform quietly applied to only the first argument.
A cascade of eighteen bug-fix rounds, starting from a redirection that reported an error and then quietly wrote your data into the file it was supposed to close.
Ten more fixes, led by a closed file descriptor that reopened itself because the shell saved another one on top of it — and a trap that fired twice depending on how many exclamation marks you typed.
A flaky test turned out to be two real bugs. The interesting one sent a command's output to a completely different place than bash would — same exit status, no error message, nothing to notice.
A round of small fixes to what builtins do with their arguments. Each one was a rule huck had invented, or a rule of bash's it hadn't noticed — and two of them hid because they were half-right.
mapfile -u now works, and so does its callback option. Getting the callback right meant three separate wrong guesses about how bash calls it — none of which the documentation settles.
After giving every builtin one option parser, the interesting cases were the ones that couldn't use it. pushd -Q was reporting itself as cd, complete was rejecting arguments bash accepts, and a typo could take the shell down.
readonly -pa didn't work, but export -pn did. Not because anyone decided that — because every builtin parsed its own options, and half of them were written by someone having a different day.
We went looking for copy-pasted code and found a bug you can type. Four builtins reject bundled short options that bash accepts — including one that rejects the exact spelling its own usage message documents.
`cmd || handler` protected a failing command — unless you wrapped it in parentheses, and then huck died anyway. The exemption was reaching the child process correctly and being thrown away on arrival.
A script that guarded a failing step with `|| handler` died instead of running the handler. huck had one switch controlling two different ideas about when a failure should be ignored — and it was missing from the case people write most.
A round of DEBUG-trap fixes where every issue mis-described its own bug — and each real cause turned out to be simpler than the report, and fixed by deleting a check rather than adding one.
huck had two dozen places each deciding, alone, whether an error should kill the shell. The results weren't inconsistent so much as uncorrelated with bash — wrong in both directions, sometimes in adjacent lines.
huck had five separate mechanisms for 'abandon this command', each with its own storage and its own idea of what outranks what. Unifying them changed no behaviour at all — which is the entire point, and the reason it was worth doing.
`trap 'exit 1' ERR` is how a careful script aborts on error. huck ran the handler and kept going — in all five kinds of trap. Fixing it also fixed two bugs nobody had reported, both hiding in code that had been copy-pasted.
A substitution that silently did nothing: ${x/#/prefix} — the idiomatic way to prepend to a value, or to every element of an array — returned the value untouched. Found by rebuilding a three-month-old huck to write a blog post.
A round of fixes to huck's RETURN and ERR traps: per-function cleanup handlers that silently did nothing, an ERR handler that quietly overwrote $?, and inherited traps that fired one time too many.
Three months of design docs, commits, pull requests and issues, read as evidence rather than memory: the process inverted itself, the same two bug shapes keep recurring, and 'done' turned out to need a definition.
A routine 'run the tests' turned up three defects with nothing wrong in the shell itself: assertions that only held on Linux, a suite racing itself for file descriptors, and a test quietly writing into the repo. Plus a build cache that reported failures for code it never compiled.
Fixing how `kill` handles a negative process ID turned up a neighbour, which turned up two more. A day later ten pull requests had merged — and the interesting part is the rule that decided when to stop.
huck tracked output twice — the real file descriptors, and a software 'sink' the interpreter passed around. Every leak, loss and mis-ordering for months lived where the two disagreed. Deleting one of them ended the whole bug class.
bash ships a test suite. Pointing it at huck gave a scoreboard that can't be argued with — 5 categories passing at first run, 39 a month later — and forced fixes nobody would have thought to look for, like the order an associative array iterates in.
Every external command huck ran cost a tenth of a second doing nothing at all. Finding it meant admitting that five 'flaky timeouts' in the test suite were one performance bug wearing a disguise.
Thirty iterations replaced huck's lexer and parser with a parser-driven mode stack. The whole point was that you can't tell from the outside — and why a rewrite nobody notices was worth a month.
Two weeks that added the features scripts reach for when they get clever — and split huck into crates so the interpreter could be used as a library, with no terminal attached.
Control flow made huck a language. It still couldn't declare an array, scope a variable, or set a shell option — the unglamorous surface that every real script depends on. Two weeks of filling it in.
Two weeks in, huck could pipe, redirect, expand variables and substitute commands — and had never heard of a for loop. What the first forty iterations built, in the order they built it.