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.

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