Fifteen Borrowings, Nine Bugs: What Reading Gleam's Compiler Gave Mere
I read Gleam's compiler next to my own — Mere, an ML-family language with five backends — five times over, and took fifteen things from it. Eleven were wires to answers my compiler already computed; only two were language features. The borrowings were not the return. What paid was that copying forced one small feature through all five backends and made me write a gate for every item, and that is how nine defects came out — including one that had shipped.
I spent a few days with Gleam’s compiler open beside my own. Gleam is a statically typed language on the BEAM with a JavaScript target; mine — Mere — is an ML-family language with five backends and no garbage collector. They have almost nothing in common at the level of runtime. That is exactly what made the exercise worth doing: whatever transfers between two compilers that disagree about everything underneath is likely to be about the part a person touches.
I went through it five times, asking the same question each time: what should I take?
Before the first pass I wrote down a rule, because without one this kind of reading turns into a wish list.
Take only what wires up an answer the compiler already computes.
By the end I had taken fifteen things. Eleven of them were wires. Two were small new analyses. Two were language features. The fifth pass turned up no new features at all — by then the upstream changelog’s compiler section was almost entirely about the quality of error messages, which is its own finding and I will come back to it.
The fifteen are not what this is about. What this is about is that the copying found nine bugs in my compiler, and almost none of them were in the things I copied.
What the rule produced
A worked example of “wires” is worth more than the definition. My language server already knew how to compute a missing match arm — the exhaustiveness checker names the missing constructor in its error message. It had never offered that as a code action, because nothing had ever asked it to. Gleam’s server builds forty-nine code actions. Adding three of mine was an afternoon: the answer existed, the protocol field did not.
The same shape repeated. Hover knew the type but not the doc comment sitting above the
definition. --decls printed a declaration report that nothing could parse, because it was
text. The prelude’s assert computed a stack that named the user’s line, and then reported
the position of the prelude’s own line instead. In each case the compiler had the answer and
was throwing it away at the exit.
That is a comfortable finding, and it would have been a dull essay. The uncomfortable part started when I began carrying these things through the whole system.
1. One feature, five backends, four bugs
echo x prints a value to stderr with the line it was written on. It is a debugging aid, and
about as small as a feature gets: two prelude definitions and one scope-aware rewrite pass.
Then I ran it on all five backends and compared the stderr byte for byte.
The WebAssembly host did not provide print_err. The emitter had been importing it for
two hundred and forty-three releases, and any program that called it failed to
instantiate — not a wrong answer, a module that would not load. Nobody had called it, because
until echo there was no reason to.
The RISC-V backend’s print_err did not write a trailing newline, while the other four did.
Same program, same input, different stderr depending on which backend you compiled with. This
is the class of defect a parity suite exists to catch, and the parity suite had been
comparing stdout.
Then the line numbers. On the RISC-V path the prelude is prepended to the user’s source as
text, so the second line of a program is line 1925 as far as the lexer is concerned.
Diagnostics were already compensating for that at the exit — every error message subtracted
the offset. Nothing compensated for a position going into the program, which is exactly
what echo does when it bakes “line 1925” into the string it will print at runtime. The same
uncorrected path made the unused-binding warning report bindings inside the prelude as though
the user had written them.
Four defects, one of them latent for two hundred and forty-three releases, and all four came from taking one small feature seriously enough to run it everywhere.
2. The formatter ate the sugar I had just added
echo is lowered while parsing: echo x becomes a call that carries the source position.
Every stage after the parser sees the lowered form, which is the point.
The formatter is a stage after the parser.
So mere fmt on a file containing echo x printed echo_at "line 2" x. I had built a
formatter that rewrote the program it was formatting, and I had done it the same afternoon I
added the sugar. It needed a parse mode that keeps the sugar, used by exactly one caller.
I mention it because of how it was found: by running mere fmt by hand on a scratch file.
No gate caught it. The formatter’s own gate compares comment counts, and the round-trip gate
runs on a corpus that has no echo in it — the feature was one day old.
3. The same complaint, drawn twice
Declaring a type twice produced a well-written message and no position at all. Fixing it was ten lines: the parser’s table already records where every type name was declared, and a name declared twice has two entries in it.
I fixed it, rebuilt, and the command-line output did not change.
mere check — the editor path — reaches that complaint through one function. The compiler
reaches it by raising an exception that is rendered somewhere else entirely. One question,
two answers, and I had improved the answer nobody was reading. The fix is not interesting;
the habit it forced is: before saying a diagnostic is fixed, count the paths that reach
it. I have now needed that habit three times in one week.
4. Three of my four repositories were empty
The last pass added a catalogue of hints for spellings borrowed from other languages —
var x = 1;, def f(n):, a and b — because my syntax layer had zero help: lines while
the semantic layers had twenty-six between them. The table is keyed on the token where the
parse failed.
Some of those words are real identifiers in my own code. var, case, val and mut are
all bound somewhere across my repositories, so the table asks a question before it answers:
does this file bind that name? If it does, the word is an identifier and the table stays
quiet.
To decide which words needed the guard I measured the corpus: four repositories, 889 files.
The number was wrong. Three of the four paths pointed at directories that do not exist, so
they contributed zero files each, and the total was non-zero, so nothing looked broken. The
real corpus is 989 files, and re-measuring moved of from 424 uses to 943 and var from 4
to 29.
The extra uses were not noise. Every one of them was a tuple pattern binder —
let (val, j) = scan_octal s (i + 1) 0 0 in, let (classes, var, prefix, r2) = parse_rescue_head r in — and my guard only recognised let NAME =. On those exact lines,
in real code, the compiler was telling the author about another language’s keyword.
A corpus measurement is worth what its reach is worth. Printing the file count per repository would have shown three zeroes at a glance, and I now do.
5. The experiment I threw away found a hole that had shipped
The fourth pass turned up something larger than a wire: in Gleam, the order of definitions in
a module does not matter, because the compiler builds a call graph and type-checks the
strongly connected components in dependency order. In Mere, a function cannot call one
defined below it, and the workaround is to put everything in one let rec ... and ... group.
In my Ruby subset’s main file that group is six hundred and fifty definitions long.
I built it as an experiment. It worked — a backward reference compiled and ran — and then I measured what it bought, and the answer was almost nothing: of 2,858 declarations, four became more general, and all four were accidental generality nobody would ever use. I threw the experiment away and wrote down why.
On the way out it left something behind. One of its steps grouped adjacent bindings, and with that step in place a regression test stopped refusing a program it is supposed to refuse.
The experiment had not broken the check. It had routed ordinary bindings into a hole that was already there.
Mere’s library mode gives every exported call its own memory region, freed when the call
returns, and refuses a program that stores a call-built container in module state — the
pointer would outlive its arena. That refusal is applied where a let binding is typed. A
let rec binding is typed by a different arm of the same match, written by hand, in four
separate declaration loops. None of the four had the check.
Nor did they have the value restriction. let store = vec_new (); is monomorphic on purpose;
spelled let rec store = vec_new (); it was generalised, and one vector could then hold an
integer and a string with neither use complaining. That one was shipped, in a released
compiler, reachable by changing one word.
Both now go through a single entry point that the four loops share, the way they already
shared the let one. The gate I wrote is not about either rule: it runs each property in all
five spellings of a top-level binding — let, let rec, a member of a group, and both of
those inside a module — and fails when the spellings disagree, whichever one is right.
Run against the previous build it goes red in exactly six places.
The shape
Reading another compiler is usually described as a way to find features. That is not what it did. Of fifteen borrowings, two were features, and the fifth pass found none at all — Gleam’s own recent work is overwhelmingly about the quality of its error messages, which tells you something about what having users does to a compiler’s priorities.
What the copying actually did was make me move. Carrying one debugging aid across five backends found four defects. Writing one gate per borrowed item — eleven of them, each with a poison run that proves it can go red — found the rest. I have written before that a guardrail protects one program and a gate protects a question; this is the cheapest way I know to end up with eleven more questions worth protecting. The formatter bug, the two-paths bug, the corpus bug and the shipped soundness hole were all found by the work around the borrowing, not by the borrowing.
There is a version of this essay that ends with fifteen features and a tidy table. The honest ending is smaller and better: the features were mostly already there, waiting on a wire. The imitation was not a shortcut. It was an instrument, and what it measured was my own compiler.
Counts, for the record: tests 2,767 → 2,817; eleven new gate scripts, each with a poison; eleven new CI jobs; the syntax layer from zero explanatory lines to a catalogue of eighteen, with all 149 places that raise a parse error left untouched.