Updates — building huck in the open

Notes on the design, the Rust, and what it's like building a shell almost entirely with Claude, one numbered iteration at a time.

Declaring things that cannot be

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.

  • #variables
  • #declare
  • #local
  • #typeset
  • #namerefs
  • #bugfix

Turning a variable into an array

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

  • #variables
  • #declare
  • #arrays
  • #bugfix

Declared, but not set

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.

  • #variables
  • #declare
  • #local
  • #export
  • #bugfix

One bracket, five opinions

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.

  • #patterns
  • #globbing
  • #case
  • #xtrace
  • #bugfix

Five places a shell looks at its jobs

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.

  • #jobs
  • #job-control
  • #wait
  • #bugfix

Three ways to say the same command

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?

  • #printer
  • #declare
  • #jobs
  • #diagnostics
  • #architecture
  • #bugfix

The stage that vanished from PIPESTATUS

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.

  • #bash-compat
  • #pipelines
  • #expansion

Two diagnostics that lied

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.

  • #bash-compat
  • #diagnostics
  • #pipelines

The redirect that took the whole script with it

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.

  • #bash-compat
  • #redirection
  • #set -u

The tests were passing by luck

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.

  • #testing
  • #portability
  • #tooling

One bad byte and the shell wouldn't start

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.

  • #robustness
  • #startup
  • #bash-compat

The test that gave up too early

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.

  • #bug-fix
  • #conditionals
  • #arithmetic
  • #bash-compat

What readonly actually protects

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.

  • #bug-fix
  • #variables
  • #diagnostics
  • #bash-compat

A local that wasn't a new variable

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.

  • #bug-fix
  • #variables
  • #arithmetic
  • #bash-compat

When the shell did the arithmetic anyway

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.

  • #bug-fix
  • #arithmetic
  • #bash-compat

The exit that should not have happened

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.

  • #errexit
  • #traps
  • #bash-compat

The line you are typing

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.

  • #interactive
  • #readline
  • #highlighting

The second line

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.

  • #interactive
  • #completion
  • #highlighting

Which bracket did you forget?

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.

  • #diagnostics
  • #parser
  • #bash-compat

The shell that pointed at the wrong line

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.

  • #bash-compat
  • #diagnostics
  • #syntax-errors
  • #set -x

Answers to questions nobody asked

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.

  • #bash-compat
  • #parameter-expansion
  • #set -u
  • #diagnostics

Eighteen small lies

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.

  • #bash-compat
  • #redirection
  • #builtins
  • #completion

The file descriptor that came back

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.

  • #bash-compat
  • #redirection
  • #traps
  • #xtrace

The plus sign that moved your output

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.

  • #redirection
  • #bash-compat
  • #flaky-tests

Five arguments nobody checked

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.

  • #builtins
  • #bash-compat

Reading from a file descriptor

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.

  • #builtins
  • #bash-compat

The builtins that don't take options

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.

  • #builtins
  • #bash-compat

Twenty-three ways to read a dash

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.

  • #builtins
  • #bash-compat
  • #quality

What a duplication audit found: readonly -pa

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.

  • #quality
  • #builtins
  • #ci

The guard that stopped at the parenthesis

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

  • #errexit
  • #traps
  • #subshell
  • #bugfix

The safety net that cut the rope

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.

  • #errexit
  • #traps
  • #bugfix

Three bug reports that were all wrong

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.

  • #traps
  • #debug
  • #extdebug
  • #bugfix

Who decides when a shell should die

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.

  • #errors
  • #posix
  • #architecture
  • #bugfix

Five ways to say stop

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.

  • #refactor
  • #architecture
  • #process

The traps that couldn't stop the shell

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

  • #traps
  • #bash-compat
  • #refactor

The prefix that went missing

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.

  • #expansion
  • #arrays
  • #bugfix

Traps that never fired, and traps that fired twice

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.

  • #traps
  • #bash-compat
  • #bugfix

What 350 iterations of logs actually say

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.

  • #process
  • #retrospective

When the tests were the bug

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.

  • #testing
  • #tooling
  • #macos

One bug, ten pull requests

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.

  • #process
  • #job-control
  • #bugfix

Two ideas about where output goes, and the bugs in between

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.

  • #history
  • #refactor
  • #file-descriptors

Scoring against bash's own test suite

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.

  • #history
  • #testing
  • #bash-compat

The shell that slept 100 milliseconds at a time

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.

  • #history
  • #performance
  • #file-descriptors

Hello, huck

Why build a bash-compatible shell in Rust — and build it as a long-running experiment in working with Claude.

  • #intro
  • #process

We rewrote the front end and nothing changed

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.

  • #history
  • #refactor
  • #parser