The mark, and the shape of the repository
Milestone log, v0.0.69 – v0.0.71. The design these versions serve is in DESIGN.md; the whole log is indexed here.
Andre’s final b mark, the extension that had to be reinstalled to carry it, and the twenty-six stray binaries that showed burxt build needed an -o.
v0.0.69: the b mark
Andre replaced the artwork with a final mark, and this version swapped it in everywhere and removed what it replaced.
The mark is a lowercase b in ember #E8502A with a bold white X inside the
bowl — b + x = Burxt. The X keeps the flat, angled ends of the wordmark’s, and it
is filled white rather than knocked out, so the mark reads on a light ground, a dark
one, or none at all. That is why one file serves both editor themes now: the
previous copper mark needed the same argument made about a colour, and this one needs
no argument at all.
What changed, and nothing more:
assets/holds the new kit — the icon as SVG and PNG, favicons at 16/32/48/180/512, a multi-size.ico, and three lockups (light, dark, outline). The old set was deleted rather than left beside it: two marks in one directory is how the wrong one ends up shipped.- The extension’s two copies were re-cut from the new sizes and the
.vsixrebuilt, because a package that still carries the old tile is a package that installs it. - The README banner points at the new lockups directly. The old
-600.pngweb versions are gone: awidth="300"in the<img>does what they were for, and a generated size that has to be regenerated by hand is a file that will go stale. - The brand colour as text —
editors/README.md’s Linguist entry — moved from#b26436to#E8502A. Sampled out of the PNG pixel by pixel, the same way the last one was.
The palette, for anything that needs it in text: ember #E8502A, white #FFFFFF
inside the bowl, obsidian #232320 for the dark lockup’s ground.
And the swap was not done until it was installed. The repository had the new mark,
the rebuilt .vsix had it, and the installed extension still showed the old one —
because VS Code keys its upgrade on the version number, and a rebuilt package with the
same number installs as “already installed”. The version moved to 0.1.1, the package
was reinstalled, the orphaned 0.1.0 directory was removed, and a test now refuses the
number that shipped the stale artwork. The general rule, which is the same one this
project keeps relearning: a change that is only in the repository is not a change —
check the artifact a user actually runs.
The one new fact worth recording: this is the first Burxt artwork that exists as
vector. burxt-b-icon.svg is 445 bytes — a bar, a bowl, and two crossing polygons — so
every raster size in the kit can be regenerated from it, and a size nobody has cut yet
is not a blocker. The previous mark was pixels all the way down, which is why the kit
had to arrive as a set.
v0.0.71: the repository root, and why it filled up
Andre asked why the source tree looked like a dump of files. It did, and the reason is worth writing down because it was a tool defect, not untidiness.
burxt build writes a bare, extensionless executable into the working directory. A
compiler exercised by hand from its own root therefore accumulates them, one per program
compiled — by v0.0.70 the root held twenty-six: a, b, c, t, stage1,
checker, contracts, and so on. .gitignore caught them, so none reached a commit and
git status stayed clean, which is exactly why it went unnoticed for so long: the
repository was tidy and the working copy was not, and only one of those is what a
person opening the directory sees.
Two files had escaped anyway — emitted.ll and t.ll, added before the *.ll rule
existed, and an ignore rule does not apply to a file already tracked. And money.bx and
order.bx sat at the root while every other program lived in examples/.
Fixed at the source rather than swept up:
burxt build|run|emit-ir -o <path>says where the output goes. That is the durable fix: the litter existed because the compiler had no way to be told otherwise.emit-irno longer writes beside the source. Its.llis an intermediate — the text goes to stdout — so it is written into the temp directory and removed, unless-oasked for it.t.llandemitted.llwere that intermediate, committed.- The two
.llfiles are untracked and deleted;money.bxandorder.bxmoved toexamples/with every reference updated. .gitignorelost nineteen names it had accumulated listing individual outputs. The rule above them — ignore anything extensionless at the root, re-admit what belongs — already covered every one, and a list of names has to be maintained while a rule does not.- A test now asserts the root holds only what belongs there, from an allowlist rather than a pattern, so anything new is added deliberately. The failure mode it prevents is the one that just happened: growth nobody notices because nothing complains.
The layout, stated once so it can be checked: src/ the Rust compiler, examples/ Burxt
programs including the self-hosted compiler, tests/ the fixtures and the runner,
spec/ the design specifications, editors/ the LSP client and grammar, assets/ the
brand kit. The root holds the build files and the four documents a reader looks for
first, and nothing else.
v0.0.72: the log leaves DESIGN.md
DESIGN.md was 3,022 lines, and 2,500 of them were this log. The design a reader comes to
that file for — the thesis, the semantics principles, the OOP model, the signature grammar —
was buried under the history of how it got there, and finding an entry meant searching
rather than navigating.
Split by stretch of versions, not by ten. The boundaries are where the work changed
shape: the language running, aggregates and dispatch, memory and the first self-hosting,
tooling, allocates/pure/the mark, contracts and termination, the self-hosted front end,
and the mark and the tree. A reader looking for how regions were decided goes to one file
rather than one anchor in a hundred.
What the move preserved, and what it cost:
- Every line, verified. The split was done by a script that matched
### v0.0.Nheadings, and the non-blank lines were compared before and after: 2,030 either side, none missing. Nothing was rewritten while it moved — an entry that was wrong stays wrong, with its correction where the correction was written. DESIGN.mdis 557 lines: the design, the roadmap, the testing shape, and an index of the log with a one-line description per file.- The gap became visible. v0.0.59–v0.0.68 and v0.0.70 have no entries, because they were
ten consecutive versions of one milestone and were recorded in
spec/1.0/M4-SELF-HOSTING.mdnext to the plan their measurements were checked against. In a 3,000-line file nobody notices numbers skipping; in an index it is the first thing you see. The index says so plainly rather than leaving a reader to work it out. - Two tests hold the shape: every log file must be linked from the index, every link in
the index must resolve, and
DESIGN.mdmust stay under 1,200 lines. The last one is the only defence against the file growing back into what it just stopped being.
v0.0.73: Burxt compiles Burxt
The fixpoint. stage-0 builds stage-1 from src/burxt-compiler/main.bx; stage-1 emits 1,208,254
bytes of LLVM IR for its own source with nothing refused; that becomes stage-2; and
stage-2 emits the same 1,208,254 bytes. stage-3, built from those, matches stage-2’s
machine code, and stage-2 answers exactly what stage-1 answers for all 88 programs in the
pass suite. A test runs the whole chain on every cargo test.
Byte-identical output is the certificate, and it is a stronger claim than “the tests pass”:
a disagreement anywhere in 4,900 lines of lexer, parser, checker and backend would appear
as a different byte. What it does not claim is in spec/1.0/M4-SELF-HOSTING.md §3b — stage-1’s
backend still does not emit Decimals, match, tail or contracts, none of which its own
source uses, which is exactly why the certificate could arrive before them.
Two defects stood between v0.0.68 and here, and both were the same shape as the two before them: the second implementation guessing a semantic instead of matching it.
&& and || did not short-circuit. Burxt’s do; the backend evaluated both sides. Not
an optimisation — this compiler’s own symbol table is written
while keep > 0 && self.syms[keep - 1].depth >= self.depth { keep = keep - 1; }
and with both sides evaluated, keep = 0 indexes at −1. Fixed with one cell: store the
left value and overwrite it only if the right side runs, which is correct for both
operators without a phi node.
Finding it took making the failure talk. index outside the array became index -1 is
outside an array of 1 (at byte 118938), and byte 118938 was that exact line. The numbers
and the position are a better error for every Burxt program, so they stayed — and the test
that covers the bounds panic now asserts them rather than the vaguer text.
The emitted allocator had no limit. burxt.alloc bumped into a 256 MB mapping and never
checked, so stage-2 compiling 4,900 lines ran past it and died with SIGSEGV — the one
outcome this language exists to make impossible. It now reserves 1 GB like stage-0, checks
the bump, and refuses with a named error and exit 70. A bump allocator that does not check
its limit does not fail; it corrupts.
The count that mattered along the way: stage-2 agreed with stage-1 on 73 of 88 programs before the short-circuit fix and 88 of 88 after it — one bug, fifteen programs, and the byte-identical fixpoint behind it.
v0.0.74: a Run button, and the version the compiler prints
Andre asked how someone actually runs Burxt — in JavaScript you save a file and the
browser runs it; in most languages an editor has a play button. Burxt had neither. It had
burxt run x.bx in a terminal and an extension that highlighted, diagnosed and hovered but
could not run anything.
- ▶ in the editor title bar,
Ctrl+F5, andBurxt: Run File/Burxt: Build Filein the command palette. The file is saved first — running the version on disk while looking at a different one on screen is the most confusing outcome available — and the program runs in a terminal rather than an output channel, because a Burxt program writes to stdout and can read arguments, so it belongs somewhere a person can see and type. - It builds into the temp directory with
-o. Without that, running a file leaves an executable beside it; v0.0.71 was spent on twenty-six of those, and a Run button that re-creates the problem for every user would be worse than no Run button. - The manifest’s Run command, its menu entry, its keybinding, and the
-oin the extension’s code are all asserted by the test that already guards the manifest. The failure mode is silent: a lostmenusblock does not break anything, it just quietly stops being a button.
Also: Cargo.toml still said 0.0.58, so burxt printed that in its usage banner
sixteen versions later. The stale-version lesson from v0.0.70 was about the extension; the
compiler had the same defect and nobody looked. Fixed, and the README now has a Running a
Burxt program section that shows the three ways — terminal, editor, installed command —
and says plainly what is not possible yet: no browser, and no more than one file per
program.
v0.0.75: examples/negative/, because a folder can carry the promise
Andre ran examples/inputs/money.bx and got three errors, ran examples/inputs/sample.bx
and got a fourth, and asked — reasonably — whether the examples were broken. I had just
told him every example was clean, having checked examples/*.bx and not the directory he
was actually in. He was right; I had looked in the wrong place.
The files were not broken. They are data, read by the self-hosted examples one level
up, and two of them are wrong on purpose: examples/checker.bx reads a file with three
scale mistakes so the Burxt-written checker has something to catch, and examples/lexer.bx
reads a file containing @ so the Burxt-written lexer has a real diagnostic to build.
That was recorded in the files’ first comments and in a test’s doc comment, and it was
still a defect. A reader meets the filename before the comment, the editor squiggles the
file before either, and examples/inputs/ promises nothing about whether its contents
should compile.
My first fix encoded the promise in the filenames — money-with-mistakes.bx. Andre’s
answer was better and is now the design: if you want a real negative test, make a folder
and call it negative. A folder answers the question before anyone opens a file, and a
directory rule holds for files nobody has written yet, where a naming convention has to be
remembered and a list of exceptions rots. Same argument as the .gitignore rule in
v0.0.71, arrived at from the other end.
So:
examples/negative/holds the three inputs that must fail — the scale mistakes, the unknown byte, and a redeclaration — each with a one-line header naming the example that reads it.examples/inputs/keeps only valid Burxt.- Both directories have a README that says which is which, including the honest note that
your editor will squiggle the negative ones and is right to: the language server
checks every
.bxfile you open and cannot know a directory is negative by convention. - One test replaced the naming heuristic: everything in
negative/must be rejected, everything ininputs/must compile, and both directories must explain themselves. The promise is now checked rather than written down.
A side effect worth keeping: Andre had edited inputs/declarations.bx while trying the
language out, replacing the redeclaration with let mut and an assignment — which silently
cost examples/symbols.bx the thing it exists to demonstrate. The edit stayed (it is a
better scratch file for trying the language), and the redeclaration moved to
negative/declarations.bx, where the symbol table now reads it and reports
redeclared: qty was already declared at offset 304.
v0.0.76: examples that teach, and a guide
Phase 0 of the plan Andre approved: before the next stretch of compiler work, make the language learnable. There were eight examples, all of them either the tour or a piece of the self-hosted compiler, and no documentation between the README and a 16-file spec folder.
Six new examples, one idea each, every one compiling and each ending with a section that quotes the real refusals:
traits.bx— a trait, two impls, static dispatch,dynas a parameter and a binding, and the three ways an impl can fail to answer its traitenums.bx— payloads, exhaustivematch, and every arm error the compiler can giveregions.bx— building in a region,allocates, and each escape that is refusedcontracts.bx—requires/ensures/pure/decreases/old, including a conservation law on amut selfmethodffi.bx+ffi.c— the C boundary,as scaledcarrying$19.99across as1999, and the pointer wall written downinvoice.bx— the program someone would actually write: line items in a growable array, a method per line, a tax function with a contract, and an exact total. It is the acceptance test for every later phase: when modules and generics arrive, this file gets rewritten and must still print the same numbers.
A seven-page guide in docs/guide/: getting started, numbers and money, types, memory,
contracts, the C boundary, and a reference of every keyword, type, operator, builtin,
runtime failure and compile-time refusal. The reference was generated by reading the
compiler rather than from memory, and correcting it against src/rust-compiler/codegen.rs’s actual panic
strings found four messages the guide had wrong.
The wiring is checked, because all three failure modes here are silent: a guide page nobody
links to, an example nobody lists, and an example that stopped compiling.
the_guide_and_examples_are_linked_and_compile asserts every page is in the index, the
README points at both, every example is listed, and every example still typechecks.
One thing writing the examples taught, which no test would have: impl blocks spell the
receiver in full — fn (self: Book) price() — while a trait’s signature writes just
self. That reads oddly the first time and is right: the trait cannot name a type it does
not know yet, and the impl can.
v0.0.77: the Burxt backend learns money
Phase 1a of the plan. The self-hosted compiler could compile 31 of the 88 test programs before this; it now compiles 58 of them correctly — meaning the program it produces prints exactly what stage-0’s does, checked by running both.
What the backend gained, in the order it was needed:
- Decimal literals, which are simply their scaled integer:
$19.99IS1999, and the scale lives in the type. Nothing is converted, so nothing can be lost. - Printing a Decimal: sign, integer part, and the fraction zero-padded to the scale,
computed in i128 because negating the most negative i64 does not fit in an i64. Scale 0
prints no decimal point — showing
.0would display a digit the value does not have. to_stringof a Decimal, the same formatting into a region-allocated buffer.- Rounded multiplication and division. The exact product of two scaled integers has the SUM of their scales; landing it on the answer’s scale is a division by a power of ten, and the rounding contract in the type says which way it goes. Both modes are emitted as i128 helpers: HalfEven ties to the even quotient so repeated rounding does not drift upward, HalfUp is the one people expect from school. Division by zero is a named error, not a SIGFPE.
examples/invoice.bx — line items, a tax rate, an exact total — now compiles through the
Burxt compiler and prints exactly what stage-0 prints. That is the thesis running on
itself.
Three defects found, all by running rather than reading:
A user function named bump collided with the allocator’s global. Every runtime symbol
now lives under burxt. — a prefix rather than a reserved word, so the programmer keeps the
name. Stage-0 solves the same problem by refusing the name; this is the better answer and
stage-0 should adopt it.
A one-field struct was treated as a scalar. The emitter inferred “is this an aggregate”
from the cell count, and a struct with one field occupies one cell — so passing it to a
method loaded the field instead of the address, and segfaulted. Now recorded rather than
inferred. The mirror case is mut self, which is one cell holding someone else’s address
and therefore reads by loading: the flag means “the alloca IS the storage”, not “this is a
struct”, and getting that distinction wrong the first time cost a second debugging round.
The quadratic string builder finally bit, exactly where v0.0.68 said it would: appending to a growing buffer copies the whole buffer, and a function body reaching a hundred kilobytes appended to a hundred thousand times copies gigabytes. It exhausted a 1 GB region while emitting its own source. Both buffers are chunked now and the final join is pairwise — O(n log n) instead of O(n²). The real fix is still a byte buffer that grows in place, which needs a builtin to write one.
Two smaller things the tests caught, both about files landing where they should not: the
object file went to the working directory, so two tests building the compiler in parallel
collided on stage1.o; and a test ran a compiled program from the repository root, where
driver_primitives.bx dutifully wrote emitted.ll. Intermediates go to the temp directory
now, and programs under test run in scratch.
The coverage is a ratchet in the suite: 58 of 88, and it may only go up. The target is 88, at which point the Burxt compiler can build everything the Rust one can.
v0.0.78: enums, match, interpolation, C, and musttail
77 of the 88 test programs now compile through the Burxt compiler and print exactly what stage-0’s build prints, up from 58, with nothing refused outright — every program in the suite reaches the linker, and eleven still disagree about their output.
- Enums are a tag plus room for the widest variant’s payload. Every value of the type is the same size, which is what lets one variant be assigned where another was. The tag is the variant’s POSITION in the declaration, so the two compilers agree on it without recording anything: the declaration is the shared fact.
matchreads cell 0 and compares. A chain of comparisons rather than a jump table, because that is what a reader would write and LLVM turns a dense one into a switch anyway. Each arm’s bindings point into the payload cells and leave scope with the arm.- Interpolation, at last — and the fix was in the parser, not the backend.
"total: {amount}"becomes a LIST of pieces, literal runs and expressions, and nothing downstream needs to know it was ever one token. The fragments are lexed in place with a newrun_rangeand parsed with the ordinary expression parser, because an interpolated expression is an ordinary expression: a call, a field access, arithmetic. Anything less would be a second, worse grammar living inside string literals. extern fn: declarations with C’s real types — a String is aptr, aCIntis 32 bits and must be declared so or the sign is lost coming back, and a Decimal crosses as the exact scaled integer it already is, which is the whole ofas scaled: no conversion, because none is needed.return tailismusttail, the guarantee rather than a hope: LLVM either replaces the frame or refuses to compile. The checker had already proved the signature match LLVM requires.
Four defects, each one only findable by running:
The fragment tokens made the parser walk off the end of the program. Lexing {n} appends
tokens after the file’s, and the driver’s loop asks done() — which compared against the
token array’s length. It parsed n from inside a string as a top-level statement. The
program’s token count is now recorded where lexing finishes, and done() means the program
ended, not the array did.
A decimal literal was emitted with the lexer’s digits instead of the checker’s scale. A
literal takes the scale of its context when that loses nothing — $5 in a Decimal<2> is
the integer 500 — so $5 printed as 0.05. The emitter now reads the scale the checker
settled on, and narrows as well as widens: 42.0 in a Decimal<0> is 42, exactly.
strcmp was declared twice — once by the emitted runtime for String equality, once by a
program that reasonably wanted it. LLVM refuses a symbol declared twice, so the runtime’s
list is checked and the program’s declaration skipped. Stage-0 answers the same collision by
refusing the name; skipping is better, and the only cost is that a program declaring a
different signature for one of those symbols hears it from LLVM rather than the compiler.
What remains: dyn dispatch (five programs), two enum cases, and three others. Each has a
name, which is the point of a ratchet.
v0.0.79: 88 of 88 — the Burxt backend compiles the whole language
Every program in the test suite now compiles through the Burxt compiler and prints exactly what stage-0’s build prints. The ratchet went 31 → 58 → 77 → 88, and Phase 1 of the plan is done: there is no construct in the suite the Burxt backend refuses.
dyn dispatch was the last big piece, and it is a fat pointer: two cells, the value and the
table of the trait’s methods. Two decisions carry it:
- The slot order comes from the trait’s declaration, so every implementor agrees on which slot is which without negotiating — the same reason an enum’s tag is a variant’s position. The declaration is the shared fact, and neither compiler records anything.
- The coercion happens where a value is STORED — a binding, a field, an argument — and
nowhere else. One function,
emit_value_for, is the only place a concrete type becomes a trait object, because a coercion with two homes has two behaviours.
Four defects, and the shape of each is worth keeping:
Two allocas named %v3. Slot names came from the table’s length, and a match arm’s
bindings leave scope by truncating that table — so the next arm reused the number. Names now
come from a counter that only grows. Scope and identity are different questions, and one
counter cannot answer both.
Widening a product divided by one. 0.05 * 0.5 bound to a Decimal<6> is wider than
the exact product, so nothing rounds — widening is multiplication by a power of ten and it is
lossless. Sending it through the rounding helper divided by 10^0 and left the value at the
wrong scale: 0.000025 instead of 0.025000. The rounding path is now only for the
direction that actually rounds.
strcmp returning -1 came back as 4294967295. The checker maps an extern’s CInt return
to Int, because a Burxt caller receives an Int — but the call instruction must name the
width C actually returns, or the upper 32 bits are read as data. The emitter now reads the
return type from the declaration rather than from the checker’s view of it.
And one that was the harness, not the compiler: read_file.bx reads a file next to it,
and the measuring loop ran programs in a scratch directory without bringing the fixture
along. Worth recording because the first instinct was to look at the compiler, and the
compiler was right.
What this means, precisely: burxt the Rust program and main.bx the Burxt program now
produce the same behaviour for every program either can compile. Rust remains the trust
anchor and the differential test — that was never the thing to remove.
v0.0.80: the suite runs on Burxt
Phase 2. tests/runner.bx walks the same 299 fixtures tests/runner.rs walks — pass, fail
and panic — and reports the same verdict:
running the suite with ./target/debug/burxt
ran 299, passed 299, failed 0
all green
A second implementation of the harness, standing to the Rust one exactly as stage-1 stands to stage-0: not a replacement, a cross-check, so a fixture cannot quietly come to mean two different things. A Rust test asserts both runners see the same count and the same verdict.
And the loop closes: the runner, written in Burxt, compiled by the compiler written in Burxt, runs all 299 fixtures green — with stage-0 as the thing under test rather than the thing doing the testing. That assertion is in the same test, so it holds on every run.
It needed nothing new from the language, which is the interesting part. Three things a harness must do, and how each is done:
- List a directory. Burxt cannot:
opendirreturns a pointer, and the memory model has nothing to say about who owns it. So the shell lists it and the answer comes back through a file. An honest limit, worked around in the open — and the first thing the standard library will hide behindfs.list(path). - Run a program and capture what it said.
system(cmd + " > log 2>&1"), thenread_file. The exit code is the high byte of the wait status, whichdiv_floor(status, 256)recovers — the named division builtin earning its keep. - Compare. String equality for a pass fixture’s exact output; a
containswalk for a fail fixture, because those record the text an error must CONTAIN rather than the whole message, so rewording a diagnostic does not break a test that is really about a rule.
The runner was checked for the failure mode that matters most in a test harness — reporting green when nothing works. Pointed at a stub that exits 0 and prints nothing, it reports 299 failures with a reason for each. A harness that cannot fail is not a harness.
Which compiler to exercise is an argument, so this runs against stage-0 today and stage-1 tomorrow without being edited. That was the point of having two implementations.
v0.0.81: modules — use "path", and one buffer with a map
Phase 3 begins. The blocker for everyone who is not Andre: a program was one file, and
src/burxt-compiler/main.bx is 4,996 lines because it had no choice.
use "lexer.bx";
region r {
print(scan("hello"));
}
spec/1.0/M6-MODULES.md has the reasoning; the decisions worth repeating here:
- A path, not a name.
use lexer;needs a resolution rule — which directory, which extension, which precedence — and every language with one has a page of documentation about it. A path relative to the file that writes it is already unambiguous, and a directory of Burxt files can be moved without editing anything inside it. - No
pubyet, deferred with a trigger rather than forgotten. It doubles the annotation burden on every declaration, and its value is hiding — which matters when strangers depend on your module and not before. Name collisions are already an error, which is the half of the problem that bites today. - A module holds declarations, not statements. A file that runs when it is imported is the import side-effect problem, and every language that allows it grows a convention against it. Refused with the reason, naming the file.
- Cycles work.
ausesb,busesa, and both may call the other. That falls out of the two-pass checker — declarations are collected before any body — rather than being designed for, and it is a real advantage over#include.
The implementation decision is the one worth recording. The compiler concatenates the
sources into one buffer and keeps a map back to the files, and a Span stays what it was:
a byte range. Only the renderer consults the map, so an error inside a module reports that
module and its own line:
error: cannot + Decimal<2> and Decimal<4>: scales must match.
--> bad.bx:3:12
The alternative — a file index in every Span — would have touched every diagnostic in both
compilers, and every place a span is built or compared, in exchange for information only the
renderer needs.
And the use lines are resolved as a pre-pass over the text, then blanked out:
replaced by spaces of the same length, so every byte offset after them is unchanged and the
lexer, parser and typechecker learn nothing about modules at all. Imports come first in a
file, which is what makes the pre-pass safe — it stops at the first line that is not blank,
a comment, or a use, so the word appearing later inside a string is never mistaken for one.
One defect, and it is a good illustration of why the acceptance list had seven items rather
than two. Appending a newline after every file — including the last — moved the
end-of-file position one byte past the program, and expected ';', found the end of the file
started reporting a line the programmer had not written. The separator exists so one file’s
last token cannot run into the next file’s first; after the last file there is nothing to run
into. It goes between files now.
Still to come in this phase: use in stage-1’s driver, and then the acceptance test that
matters — splitting the compiler itself into lexer.bx, parser.bx, check.bx,
emit.bx and main.bx, with the fixpoint still holding. If the compiler cannot be split,
modules are not done.
v0.0.82: the compiler splits into five files, and still compiles itself
The acceptance test for modules, and the reason M6 was written the way it was: if the compiler cannot be split, modules are not done. It can.
src/burxt-compiler/main.bx 105 lines — the program, and five `use` lines
src/burxt-compiler/ast.bx 504 — the shapes, and the lexer
src/burxt-compiler/parser.bx 1,573 — tokens in, arena AST out
src/burxt-compiler/check.bx 1,583 — the rules
src/burxt-compiler/modules.bx 181 — `use "path";`, resolved before lexing
src/burxt-compiler/emit.bx 2,263 — textual LLVM IR, and the runtime with it
And the fixpoint holds: the split compiler emits 1,502,909 bytes of IR for its own five files, the compiler built from that emits the same bytes, and every test still passes. A compiler that can compile its own modular self is the only proof of a module system worth having.
Stage-1’s loader is a method on a Loader struct rather than a free function with mutable
parameters, because Burxt has none — a pass that accumulates must be a method on the thing it
accumulates into. That is the same constraint that forced allocates onto methods in
v0.0.47, showing up again in a different corner.
One defect, and it is the third appearance of the same shape. Splitting the file made
blank_imports run over six thousand lines, and it was building its answer line by line —
six thousand copies of a growing String, which exhausted the region on the first attempt. Only
the HEADER needs rewriting, so the body is appended in one piece now.
That is three times this project has paid for a = a + b in a loop: the emitter’s module
buffer (v0.0.68), its per-function body (v0.0.77), and now the module loader. The pattern is
clear enough to state as a rule: in Burxt today, building a large String means chunks or a
single pass, never an append per line. The real fix is a byte buffer that grows in place,
and it has now earned its trigger three times over.
v0.0.83: a standard library, and two rules it uncovered
lib/str.bx, lib/fs.bx, lib/os.bx — written in Burxt, from the same builtins any program
has. Nothing in them is privileged, and that is the point: every function could have been
written by the program that uses it, and the value is that it was written once, carefully,
instead of forty times.
The convenience is the smaller half. The real reason is the boundary: Burxt refuses a C return
whose ownership it cannot describe, so opendir, getenv and fopen are out of reach
directly, and the way through is the shell or an Int standing in for a pointer. Every
program that writes that itself is making a promise the compiler cannot check. fs_list(path)
is safe because fs.bx did the unsafe part once, in the open, with the reasoning next to it.
Writing it found two real language defects, which is exactly what the plan said a standard library would do.
An allocates function could not return a growable array. fn str_split(...) -> [String]
allocates was refused: “its storage lives in a region and would not outlive it” — a rule
written before allocates existed and never taught about it. But allocates says the storage
is the CALLER’s region, which is precisely why returning a built String has been legal since
v0.0.38, and a growable array is the same argument. Fixed in stage-0; stage-1 turned out to
have had it right all along, which is a divergence the differential test never caught because
no test program had asked.
Two modules could not both declare the same extern fn. lib/fs.bx and lib/os.bx both
need system, and neither can know the other did. An extern declares an external fact,
not a definition, so declaring it twice identically is harmless — while a MISMATCH is not,
because then the program holds two beliefs about one symbol and calls whichever the linker
picked. Both compilers now allow the first and refuse the second by name, and both emitters
declare the symbol once: LLVM had been renaming the second to system.1, and the linker asked
for a symbol nobody had.
What the library deliberately does not hide, because saying so is better than pretending:
failure has no type yet, so fs_read of a missing file answers "" and str_to_int("abc")
answers 0 — the honest fix is Option<T>, which is the next phase. fs_append reads the whole
file and writes it back. fs_quote refuses a path containing a single quote rather than
mishandling it. And every function is prefixed with its module, because there is no pub and
no namespacing yet, so a short name would collide with a program’s own.
v0.0.84: a tarball someone can use, and the next two milestones specified
Phase 6 of the plan, and the two specs that make what remains legible.
Distribution. sh scripts/release.sh produces a 14 MB tarball holding one binary, the
standard library and a README; sh scripts/install.sh puts them where use can find them.
The binary statically links LLVM, so whoever installs it needs no Rust, no cargo and no
LLVM — which the test proves by unpacking the tarball elsewhere and compiling a program
with LLVM_SYS_181_PREFIX removed from the environment.
The one thing a 40 MB binary cannot carry is a C compiler: burxt build hands its object
file to the system linker. The installer says so before installing rather than letting the
first build fail, and burxt check needs nothing at all.
M7 — Generics, specified. Monomorphisation rather than erasure, because Burxt’s promises
are all about what a value is: a Decimal<2> is a scaled i64, a struct has no hidden
header, dyn costs nothing unless written. Erasure would put a pointer where the value was
and quietly undo that, and dyn Trait already exists for the cases that want one
implementation over many types. Bounds are traits and they are required, because a
generic function whose constraints are whatever its body happens to do has a signature that
lies — the same argument allocates and rounding contracts already make.
M8 — Absence and failure, specified, and deliberately ordered after generics. Without
them Option would be OptionInt, OptionString, OptionDecimal — code that generics would
immediately delete. With them it is four lines:
enum Option<T> { None, Some(T) }
enum Result<T, E> { Ok(T), Err(E) }
That is the acceptance test for M7: if Option<T> needs compiler support, the generics are
not finished. Burxt already has payload-carrying enums and exhaustive match with no
wildcard, so a Result you did not handle is a match missing an arm — a compile error
already. No ?, no exceptions, no unwrap-that-panics: hidden control flow is a question this
language has already answered everywhere else.
v0.0.85: coming from classes
Andre asked what the difference is between a region and a class, since a class has constructors, initialization, reuse and dependency injection — and then asked the better question: why is there no example of any of that?
There wasn’t. The examples covered traits, enums, regions, contracts and money, and the answer to “how do I build an application with services and injected dependencies” existed only in a conversation. That is the first question anyone arriving from Java, C#, PHP or Python asks, and a language that cannot answer it in a file they can run has not answered it.
examples/services.bx is a small application: an interface, two implementations, a
constructor whose precondition is in its signature, and a service holding its dependency as a
dyn Rates field — injected by whoever builds it, swapped for a stub with no change to the
service. docs/guide/03-types.md gains a Coming from classes section with the mapping,
so it lives in the documentation rather than in a chat log.
The mapping itself, for the record: a constructor is a function returning the struct;
validation is requires on that function, checked on every call with no build mode that
removes it and no factory that bypasses it; initialization is a struct literal that must set
every field; an interface is a trait; polymorphism is impl and dyn; dependency
injection is a dyn Trait field; the destructor is the region; a static method is a free
function; private fields are deferred with pub; and inheritance is deliberately absent.
Writing it found a real wrinkle worth knowing before meeting it cold: the injected dependency
must be bound to a variable first. Checkout { rates: FlatRates { ... } } is refused,
because a trait object borrows the storage of the value it refers to and a temporary has none.
The error says exactly that, and now so does the example.
Also recorded, since the question keeps coming: a region is not a class and they are not
alternatives. A class says what a value is; a region says when built memory goes away — a
question a class has no answer to at all, and one that Java hands to a collector and C++ to a
destructor. In Burxt the type side is struct plus methods plus traits, and the memory side
is region. Both, for different jobs.
v0.0.86: the rule that was too strict
Andre asked why the language felt harder than others. The answer split three ways — about 70% missing convenience, 25% strictness he chose deliberately, and 5% one rule that was simply wrong. This is the 5%, and he agreed to change it.
Before: a rounding contract could only be declared where money entered the program.
let total: Decimal<2, RoundHalfEven> = price * qty; was refused when price was a plain
Decimal<2>, and Decimal<2> + Decimal<2, RoundHalfEven> was refused too — so the fix for
an error was never where the error was. You had to walk back to the binding of price and
change that instead. It cost three attempts on a seven-line invoice, which is a fair test of
a rule.
Now, two narrower rules:
- A contract may be ADDED where a value has none. The two types hold the same integer; a contract does not reinterpret a value, it constrains what future operations may do to it. That is strictly more information, not different information.
+and-need matching SCALES, not matching contracts, because they never round. One contract and no contract leaves exactly one answer, and the result carries it.
What did not change, because it is the part that protects money: scales must still match;
two different contracts are still refused; dropping a contract is still refused; and *
with mixed scales and / still always demand one.
examples/invoice.bx lost three annotations it only ever carried to satisfy the old rule, and
the contract now sits on the tax calculation — the line where rounding actually happens —
instead of on the struct field three functions upstream. Same numbers, and the program now
reads the way the reasoning goes.
tests/fail/mixed_rounding.bx used to say “not even with none”, which was precisely the
overreach; it now pins the surviving rule, and tests/pass/contract_widening.bx pins the
other side. A relaxation without both is a relaxation that keeps sliding.
And a fourth appearance of the string-building cost. The compiler grew past 46,000 tokens and exhausted its region again. The chunk threshold was 4,096 bytes, and the arithmetic is worth writing down: with A appends flushed at T bytes the copying is about A × T/2, while joining the N/T chunks pairwise costs N per round for log2(N/T) rounds — so at A ≈ 300,000 and N ≈ 1.5 MB, T = 4096 spends 600 MB and T = 512 spends 95 MB. Retuned, and the arithmetic is in the comment so the next person does not have to rediscover it.
That is four times now. The real fix — a byte buffer that grows in place, which needs one builtin to write one — has earned its trigger four times over and is the next thing to build.
v0.0.87: write_bytes, element assignment, and a wall worth naming
Two capabilities, and one measurement that matters more than either.
write_bytes(path, buffer) — the way past quadratic string building. a = a + b copies
the whole left side, so building a megabyte an append at a time copies gigabytes; this project
paid for that four times (v0.0.68, v0.0.77, v0.0.82, v0.0.86), each time with a cleverer
chunking scheme and a hand-tuned constant. The answer was never a better String: a growable
array already grows in amortised O(1), so what was missing was a way to write a buffer of
bytes. push fills it, this empties it — 880 KB in 0.2 seconds, byte by byte.
Element assignment on a growable array — xs[i] = v, and self.table[i] = v through a
field. Stage-1 had allowed this since it had arrays at all; stage-0 refused it, a divergence
found by needing it. Bounds-checked against the header’s length at run time, and a negative
literal index is refused at compile time whatever the length turns out to be.
And then the measurement. Adding sixty lines to the compiler tipped its self-compile into exhausting its own 1 GB region, so I measured the phases:
lex 0.05s
parse 0.05s
check 73s
emit 33s ...and ~1 GB of region traffic
Three minutes, and v0.0.86 was already 2m57 — so nothing regressed; the compiler has always been this slow and nobody had put a number on it. The language cannot grow further until this changes, because a program that exhausts its region stops rather than slows.
spec/1.0/M9-PERFORMANCE.md records it as a milestone with an acceptance number: a self-compile
under 20 seconds, region use under 200 MB, fixpoint intact.
The honest part of the story is what the fix attempt taught. The obvious quadratic was the type cache — a backwards linear scan over a list that grew with every typed expression, noted in v0.0.61 as “O(n²) but fine at bootstrap scale”. It is now indexed by node, O(1) per lookup, and the total time moved by three seconds out of a hundred and ninety. The obvious quadratic was not the expensive one, and the remaining cost has to be found by measuring rather than by reading. The candidates are written down in priority order, and the spec says plainly: every change here needs a before-and-after number, because guessing bought 1.5%.
A second lesson, recorded because it will recur: “fine for now” needs a number attached. The v0.0.61 note was honest and still useless — nobody notices when now ends. A limit with a figure beside it (“fine below 10,000 nodes”) would have failed loudly at the right moment.
The stage-1 side of write_bytes is deliberately unfinished: its checker knows the builtin,
its backend does not emit it yet, and rewiring the emitter to a byte buffer is held until the
performance work lands — because it was that rewiring that ran the region out, and a fix
layered on an unmeasured bottleneck is another guess.
v0.0.88: two shorthands, and 239 statements shorter
Andre asked about shorthands, and pointed at JavaScript’s arrow functions. The answer split in two: arrows are not shorthand at all — they are closures, and captured state needs an owner, which in a language with regions and no collector is the hardest question on the list. But a handful of real shorthands were free, and two of them landed here.
x += e, x -= e, x *= e — expanded by the parser into x = x + e, so nothing after
it knows they exist: no statement kind, no typecheck rule, no lowering. That is what makes
them safe rather than merely short. price += rate on mismatched scales gets exactly the
refusal price = price + rate gets, because it is that by the time anyone checks it, and
a fixture pins that.
They work on every target the long form does: a name, a field (a.total += $19.99), and an
element (a.hits[0] += 5). Stage-1 gets it almost free, because it parses the target as an
expression first and can share that node between the place written and the value read —
sharing that is only safe because Burxt has no expression with a side effect.
P { x, y } — a field taking its value from a variable of the same name. Order {
subtotal: subtotal, country: country } says nothing twice, and this is the one place a value
can be filled in without inferring a type.
And there is deliberately no x++. An expression with a side effect is the class of thing
this language already refuses — it is why if (x = 5) is unwriteable — so the refusal is a
fixture too, with the message naming the four operators that are allowed.
The sugar earns its keep immediately: 239 statements in the compiler’s own source got
shorter, mostly i = i + 1 becoming i += 1, and the fixpoint still holds byte for byte
with the compiler compiling its own newly-sugared self. A shorthand that shortens the compiler
is a shorthand that helps the performance wall rather than feeding it.
The reference grew a Shorthands section listing the whole set — six entries — with what
each desugars to, and what is absent and why: x++, a ? b : c (if as an expression would
make it redundant), arrow functions, and ?. Six is a small list on purpose. Every one of
them is sugar over something the language already means, never a second way to mean it.
v0.0.89: the compiler counts its own work
M9’s first step, and deliberately not a fix: a measurement that eliminates the leading hypothesis.
The compiler now reports what its checker did:
parsed 178 items and statements into 28,135 nodes
work: type_of called 18,369 times for 28,135 nodes
type_of is linear. I was confident the 73-second check phase came from retyping
subtrees — an argument against its parameter, a multiplication’s operands, a print’s target,
each re-walking what it had already walked, giving work exponential in expression depth. The
number says no: 18,369 calls for 28,135 nodes, which is fewer calls than nodes.
So the cost is ≈4 ms inside a single type_of call, not in how often it is made. That is
a different bug and a much narrower hunt, and finding it out cost one counter and one run
rather than an afternoon of rewriting.
Two things worth recording beyond the number.
The usual bisection trick does not work in this language. To find a hot path you normally
put an early return at the top of a suspect function and re-time it. Burxt refuses
unreachable code, so the probe does not build — the language’s strictness biting its own
author. Elimination here means deleting a body or threading a flag, and knowing that in
advance is worth an entry.
Half the counters could not be written at all. find_sym, find_fun and type_at take
self: Unit immutably, so they cannot increment anything, and making them mut would cascade
through every caller. A compiler that wants to measure itself needs somewhere to put a number
that is not part of the value being measured — which is an argument for interior mutability,
or for a profiler, and today Burxt has neither.
M9’s acceptance list gained a rule at position zero: a number for every attempt. v0.0.87 fixed the obvious quadratic and bought 1.5%. This version changed no behaviour and ruled out the wrong answer. Only the second kind of progress is trustworthy.
v0.0.90: 190 seconds to 1.2, and the guess that had to be given up first
M9 done. The compiler compiles itself in 1.17 seconds instead of 190, and peaks at 196 MB instead of very nearly the 1 GB region limit. The fixpoint still holds byte for byte, and the whole suite now runs in 20 seconds.
The cause was one sentence long, and not in the checker where two versions of hunting had been spent:
byte_at(s, i) bounds-checks against the string’s length, and a Burxt String is
NUL-terminated, so the length is a strlen. One per byte read is O(n) per byte, and a
compiler reads bytes all day.
Two things hid it, both in stage-0, both a single place in codegen.rs.
Stage-0 defined its own burxt.strlen — a hand-written byte-at-a-time scan loop, written
because it was easy and left because it worked. LLVM cannot prove such a loop terminates, so
the call is never willreturn, so LICM refuses to lift it out of any loop. Under its real
name, strlen is a library function LLVM knows: reads only its argument, always returns,
hoisted without being asked, and vectorised into the bargain. Stage-1’s emitter had called
libc’s strlen from the day it was written — so the compiler Burxt wrote was right about this
and the compiler that wrote it was wrong, which is the best argument for two implementations
I have had yet.
Stage-0 ran no IR optimisation at all. OptimizationLevel::Default was set on the
TargetMachine, which looked like the answer for eleven versions and governs only instruction
selection and scheduling. write_to_file shipped whatever codegen.rs built, unsimplified.
There was no LICM to hoist with. run_passes("default<O2>") is the missing line.
Together: a nine-line Burxt program that reads 133 KB one byte at a time went from 4.93 seconds to 0.001.
The check did not go away, and that is the point. Every program still refuses to read a byte it does not own. The length is computed once per loop instead of once per byte. This was never a trade of correctness for speed, and if it had been I would have taken the slow one.
Three more of the same shape, in Burxt, found by the same measurement: at_byte measured
len(src) on every probe; modules.bx did it per byte of every header line; and
blank_imports rebuilt the header a line at a time, which is one copy of a growing String per
line — and since a comment counts as header and every file here opens with a banner, a file of
nothing but comments spent 24 of its 29 seconds there.
How it was found, which matters more than what it was
Three wrong guesses, and each one cheap because it produced a number.
v0.0.87 fixed the obvious quadratic and bought 3 seconds out of 190. v0.0.89 counted the checker’s calls and killed the leading hypothesis without changing a line. Then v0.0.90 stopped reading code and ran a controlled experiment:
take a program and pad it with comments. Same tokens, same nodes, four times the bytes. 5.5 seconds became 38.2. One measurement, and it said the cost was driven by the size of the file rather than by anything in the program — which no amount of staring at the checker would ever have said, because the checker was innocent.
Everything after that was bisection. A file of only comments — one token — took 36 seconds,
so it was not the checker. Moving the code to the top so the module loader stopped at line 1
took it to 5, which separated modules.bx from the lexer. And then a nine-line Burxt program
that reads bytes in a loop was also quadratic, which moved the hunt out of the compiler
entirely and into the code stage-0 emits. Neither perf nor valgrind was installed; neither
was needed.
I also gave up a fourth guess in public, in the same version: reading bytes directly instead
of through at_byte, where the bound is already known, was going to remove the last
strlen. It was worth 5%, and the reasoning behind it was wrong — the remaining strlen
comes from the inner byte loops, not from at_byte. The 5% is kept and the comment now says
what was measured rather than what was hoped.
What is left, and the honest name for it
The lexer is still quadratic, with a constant roughly 350× smaller: 0.011 / 0.038 / 0.150 /
0.650 seconds for 189 KB / 378 KB / 756 KB / 1.5 MB of comments. Still 4× per doubling. The
arithmetic names it exactly — one strlen per line now, because byte_at’s check sits behind
a short-circuit and a conditional strlen on a pointer LLVM cannot prove valid is not
speculatable, so LICM lifts it out of the inner loop and no further.
Hoisting cannot fix that shape; it can only change how often the scan happens. A String needs
an O(1) length — carried as pointer-plus-length, still NUL-terminated so C keeps working. That
removes the class rather than the instances, and it touches both compilers, every extern fn,
and read_file/substring/concat. So it is a milestone, it is written down with the number
that says when to care (over a megabyte, which the compiler’s own 200 KB is not), and it is not
this version.
A test keeps what was won: the_compiler_compiles_itself_without_going_quadratic. It was
checked the only way a ratchet is worth anything — by putting the bug back and watching it fail
(15.2 seconds against an 8-second budget), then taking it out again.
v0.0.91: let x = 0;, and the rounding rule it exposed
M10’s first slice, and the beginning of the half of this language that had never been built: the strict half was finished, the pleasant half was not.
let count = 0; // Int
let price = $19.99; // Decimal<2>
let here = Point { x: 3, y: 4 }; // Point
let greeting = "hello, " + name; // String, built in the region
The boundary is the decision, not the feature. A statement may leave its type out; a
signature may not. Parameters, returns, struct fields, allocates and every contract stay
written down, because a signature is what everyone who calls a function reads, and one you have
to compute is not one you can read. Inference that stops at one line also means a reader never
looks further than the line in front of them.
spec/README.md flagged this at v0.0.18 as deserving its own decision rather than being
smuggled in with $19.99. Twelve versions of ceremony later, this is that decision.
Arrays are the exception, and not for the reason you would guess
error: an array literal does not say whether the array is fixed or growable, so an array
binding names its type: `let xs: [Int; 3] = [1, 2, 3];` for a fixed one, or
`let mut xs: [Int] = [];` for one that grows.
[1, 2, 3] obviously holds Ints. What it does not say is fixed or growable — two types
with different storage, different rules and different costs. Guessing means either picking the
cheap one and making push fail three lines later, or picking the flexible one and quietly
putting every array in a region.
I found this the awkward way. Stage-0 inferred [Int; 3] happily, and then stage-1’s backend
refused to index the result — because stage-1’s Ty names an array’s element type by the node
of a type annotation, so with no annotation there is nothing to point at. My first instinct was
to work around the representation. The better answer was that the representation was right and
the rule was wrong, and the two implementations disagreeing is exactly what the second one is
for.
The rule that got more correct on the way
Decimal<2> * Decimal<4> has an exact product with six decimal places. Until this version
Burxt demanded a rounding contract for that multiplication always — including when the target
was Decimal<6>, where nothing rounds. The old message even said so out loud, and nobody
noticed: “its exact product has 6 decimal places and must be rounded to reach 6.”
It had to behave that way, because a bare a * b had nowhere for a contract to live and the
rule could not tell “exact” from “narrowed”. Inference gave every expression somewhere to be,
and the flaw fell out immediately:
let exact = price * rate; // Decimal<6> — 1.649175, no contract
let tax: Decimal<2, RoundHalfEven> = price * rate; // narrowing, so the contract is required
let wrong: Decimal<2> = price * rate; // error, and now for the true reason
This makes the thesis sharper, not looser. A contract now appears in a program exactly where a value narrows, so its presence is information: somebody made a decision here. Demanded everywhere, it was ceremony — and ceremony is what readers learn to skip.
Two fail fixtures became pass fixtures, which is the honest record of a rule that was wrong:
dec_times_dec and mixed_scale_no_binding asserted that exact products were refused. They are
now dec_times_dec_is_exact and mixed_scale_is_exact, with the answers spelled out —
2.2000 and 1.649175. A third, money_no_inference, existed only to assert this version
could not happen, and is gone.
One convenience survived the rewrite because the pass suite defended it. print(a * b) where
both are Decimal<2, RoundHalfEven> must answer 0.11, not 0.1050: when the operands share
a type that already carries a contract, that contract lands the product at their own scale
(v0.0.86). My first version returned the exact product regardless and broke four fixtures,
including one that overflowed — 40000000.00 * 40000000.00 fits an i64 at scale 2 and does not
at scale 4. The precedence is now written down in both compilers: a contract in the expectation,
then the product’s own width, then the operands’ shared contract, then exact.
Where the type went
An inferred binding has no annotation, so hover is where its type lives. There is a test for
it now, and it is the one I would show a sceptic: a program in which the word Decimal never
appears, and hovering tax reports Decimal<6>. That is the honest trade — the type did not
disappear, it moved out of the source and into the tooling. Which is a reason to annotate when
the reader might be looking at a web page.
The other cost, stated rather than discovered later: recover_from bound a failed statement’s
name anyway, using the type the author declared, so one bad initializer did not cascade into a
page of “unknown name”. An inferred binding whose initializer fails has nothing to recover with.
Annotated bindings keep the better behaviour, which is a real argument for annotating inside a
long function.
v0.0.92: for x in xs, and a desugar that could not work
M10’s second slice. The loop everyone writes, without the index:
for line in lines {
print(line.render());
}
The element is a copy, immutable, scoped to the body — value semantics, so nothing can be
written back into the array through it. break and continue behave. Both compilers, and the
compiler’s own source uses it in two places, which is the acceptance test that matters: an
ergonomics feature its own author does not write has not landed.
Andre restated the goal while this was being built, and it belongs at the top of this entry because it is the standard the whole milestone answers to: as easy as Python but typed, as friendly as PHP but never compromised. If a construct is harder to write than its Python equivalent and the extra ceremony buys no correctness, the ceremony is the bug — and friendliness may never cost exactness.
The desugar that could not work, and why that was the interesting part
+= and the field shorthand are parser desugars, so for was written as one: a hidden
let mut for$i = 0; plus the while loop it means, with $ chosen because no identifier may
contain that byte. It worked in stage-0 in about forty lines.
It is impossible in stage-1, and the reason is a real fact about that compiler rather than
an inconvenience: stage-1 names every binding by its span in the source. A Sym is a start
and a length. A synthesized index has no span, because there is no byte sequence to point at.
I tried three ways around it — reuse the for keyword’s own token (nested loops collide,
because both spell for), a zero-length span (all of them compare equal), a counter (nothing
to count in a span) — before accepting that the representation was right and the design was
wrong.
A construct the two compilers implement two different ways is a construct they can disagree
about. So for became a statement: parsed as a node in both, checked in both, and lowered to
the same loop in each back end. Stage-1 gained node kind 72; stage-0 gained StmtKind::For and
lowers it in codegen, where an index that no program can name is exactly as easy to make as it
was in the parser.
It is better for a second reason I did not anticipate. A desugar can only produce errors about what it desugared to:
for x in n { } // where n is an Int
error: len(...) needs an array or a string, but this has type Int // the desugar
error: `for` iterates an array, and this is an Int // the statement
The author never wrote len. Four refusals now talk about for: a non-array, a String (with a
pointer to byte_at, which says BYTE so the byte-versus-character question cannot hide), a
call as the iterable, and a name already in scope.
The bug that hung a test
The index advances before the body, not after. continue jumps to the loop condition, so an
increment at the bottom is skipped and the loop never ends — which is what a two-minute timeout
on the first test run looked like. Both lowerings carry a comment saying so, because the next
person to write one will reach for the obvious order too. A lowering has to be read against
every control-flow statement the language has, not just against the happy path.
One refusal worth defending
error: `for` iterates a named array, and this is a call: its result would be recomputed
on every pass. Bind it first — `let items = ...;` — and iterate that.
The loop reads the iterable once per element, for its length and for the element. A call there is a hidden multiplication. M9 spent four versions finding exactly that shape in this compiler’s own source, so refusing it with the fix in the message is cheaper than the alternative, and the alternative is a program that is quietly quadratic.
for i in 0..n is deferred with a trigger, not forgotten: a range is a second construct with
its own type questions, and while i < n says it today.
v0.0.93: generics, and inference that fits on a page
M7’s first slice, in stage-0: generic functions, monomorphised.
fn identity<T>(x: T) -> T { return x; }
fn first<T>(xs: [T]) -> T { return xs[0]; }
print(identity(3)); // T = Int, inferred
print(identity("text")); // a second function in the object file
One definition, one copy per type that uses it. No boxing, no vtable, no runtime type
information — a Decimal<2> inside a generic is still a scaled i64. That is the whole reason
not to erase: every other promise this language makes is about what a value is, and erasure
would put a pointer where the value was.
Four things came out of building it that the spec did not predict.
Inference is one function, one direction, no backtracking. unify(declared, actual) walks
the two types together and binds a parameter to whatever stands opposite it. That is all of it
— no unification variables, no constraint set, no occurs check — and it is why identity(3)
needs no turbofish and why the rule fits on a page. The cases it cannot do are refused by name:
a parameter that appears only in the return type has nothing to infer from, and two arguments
declared T that disagree get told which two types they would have to be.
A generic calling a generic works by binding a parameter to a parameter. Checking echo’s
body, identity(x) binds identity’s T to echo’s T — still abstract — and records no
instantiation. The copy appears when echo$Int is specialised and its body finally names a
concrete type. My first version refused this with “the argument’s type is itself generic”,
which was me protecting an invariant that did not need protecting.
Instantiation is a work list drained to a fixpoint. Checking one instantiation can discover another, so a single pass is wrong. It also has to be bounded: a generic that reaches itself at a new type on every round would never converge, so after 64 rounds it is refused with the reason rather than compiled until the machine gives up.
An instantiation is substituted in the AST, not threaded through the checker. specialise
clones the declaration, replaces every parameter in the signature and in every let annotation
in the body, names it identity$Int, and hands it to exactly the code that checks every other
function. I started down the other road — a substitution map consulted wherever a declared type
enters the checker — and stopped when I counted the call sites. One checking path means no
second path that can disagree with the first, which is the same reason the two compilers exist.
What an unbounded parameter may do, and the hole I nearly shipped
T with no bound can be stored, copied, passed and returned. Nothing else, and that is
deliberate: a generic whose constraints are whatever its body happens to do is a generic whose
signature is a lie, because adding a > inside it would silently narrow every caller. Same
argument allocates and rounding contracts already make.
Most of that fell out for free — comparing two Ts, adding to a T, reading a field of a T,
len(T), to_string(T) were all already refused by rules that predate generics. print(x)
was not. It typechecked and then reached codegen, where a type parameter has no
representation, and produced “codegen bug: type parameter T survived”. A soundness hole found
by trying the obvious thing rather than by reasoning, which is the argument for writing the
negative tests before believing the feature.
One surprise, and it was the language being right
fn first<T>(xs: [T]) does not accept a [Int; 3]. [T] is a growable array and [Int; 3] is
a fixed one — different types, different storage, exactly as for x in xs says. My test fixture
was wrong, not the rule. A generic over a fixed array’s length is a different feature (N would
have to be a value parameter) and it is deferred with a trigger.
Where this stops, and why the tests live where they do
Remaining in M7: bounds (<T: Ordered>), generic structs and enums, and stage-1. The tests for
this slice are in tests/runner.rs rather than tests/pass/, because every pass fixture is
also swept by the two tests that assert stage-1 accepts it — and stage-1 does not read
generics yet. Putting them there would have asserted something untrue about the other compiler.
The same staging M5 used for the backend: stage-0 first, second implementation behind a ratchet,
and the tests saying honestly which one is under test.
The six refusals are tests/fail/ fixtures, because the stage-1 rejection count is a floor
rather than an equality.
v0.0.94: no null — Option<T> and Result<T, E>, in four lines of Burxt each
Generic enums, and with them the promise this language has made since its first design note: there is no null. Absence is a type, the compiler makes you handle it, and no dereference can fail because nothing is ever implicitly absent.
enum Option<T> { None, Some(T) }
enum Result<T, E> { Ok(T), Err(E) }
fn position_of(xs: [Int], want: Int) -> Option<Int> { ... }
match position_of(xs, 9) {
None => { print("not there"); }
Some(at) => { print(at); }
}
Both live in lib/. lib/option.bx and lib/result.bx are ordinary Burxt with no
compiler support beyond generics — which is exactly the test M7’s acceptance list set for
whether the generics were real: if Option had needed a keyword, they were not. It did not,
and there is a test that says so rather than a claim.
The mechanism, and why it was cheap
The same idea slice 1 used for functions, applied to types: an application is rewritten into
the nominal type of its instantiation. Option<Int> becomes Option$Int — a perfectly
ordinary enum with its own layout — in a pre-pass over the AST, and again after each function
instantiation, because substituting T := Int can turn Option<T> into Option<Int>.
After that rewrite no rule in the checker knows generics exist. match, exhaustiveness,
payload binding, layout, codegen: all of it sees what it has always seen. That is the whole
reason this landed in one version instead of five.
Three things it needed that functions did not.
Inference has a second source. Option.Some(3) gets T from the payload. Option.None
carries nothing, so it can only come from the context — which means an instantiation has to
remember what it was made from, so Option$Int reads back as (Option, [Int]) when a
declared type says what a variant does not. When neither says, it is refused with the place to
write it:
error: `Option.None` does not say what `T` is, and nothing here does. Write the type
where the value lands — `let x: Option<...> = Option.None;` — or pass it
somewhere that names it.
A generic’s match is checked generically. Inside fn or_else<T>(o: Option<T>, ...) the
scrutinee is still Option<T>: its variants are known even though T is not, so arms,
exhaustiveness and bindings are all checked once, at the declaration. I factored the 94 lines
of arm checking into one method so both paths share it — an instantiation must not be checked
differently from the declaration that produced it.
The mangled name must never be shown. My first pass leaked it — “this match on
Option$Int does not handle None” — and a reader did not write Option$Int and should not
have to learn that it exists. Every message now pretty-prints an instantiation back to
Option<Int>. It is twenty lines and it is not optional: the alternative is a language whose
errors talk about its own implementation.
What is deliberately not there
No unwrap. “Give me the value and abort if there is none” is null with extra steps, and
the crash it causes is precisely the one this type exists to prevent. option_or covers the
case where a default is right; match covers the case where it is not.
No ? yet. It is the right sugar — let n = divide(a, b)?; meaning “or return the error”
— and it needs one decision this language has not made: how the callee’s error type becomes
the caller’s. Written down as its own question rather than guessed at.
No map. It needs a function as a value, and a closure needs an owner for its captured
state. That is the region model’s question, and it is deferred with a trigger.
A payload is still a scalar in this cut, so Option<Point> is refused — and the message says
so in terms of the type argument that caused it, not in terms of the enum’s declaration:
“Option<Point> cannot be made: Option.Some would carry a Point.”
v0.0.95: the grammar swept against the bar
Andre clarified the standard: as easy as Python but typed, as friendly as PHP but never compromised is about the grammar and Burxt overall, not just the ergonomics milestone. So it is not a filter on new features — it is a lens to re-read the whole surface with, and this version is the first deliberate sweep.
The method is one question, asked of every construct: what does the Python or PHP equivalent cost to write? If Burxt costs more and the extra buys no correctness, the extra is the bug.
Three things failed it.
A trailing comma was refused in most lists. It was allowed in struct and enum
declarations and refused in parameter lists, argument lists, array literals, variant
payloads, match bindings and type-argument lists — an inconsistency nobody designed, just an
idiom copied to some sites and not others. Refusing it makes adding an item a two-line diff and
buys precisely nothing. Now one more_in_list(closer) helper in each compiler, and the answer
is the same everywhere.
impl Trait for Type made every method repeat the type.
impl Priced for Book {
fn (self) price() -> Decimal<2> { return self.cost; }
fn (self) label() -> String allocates { return "book: " + self.title; }
}
The header said Book. On a five-method trait the type was written six times. The guide even
had a note defending it — “in the impl it is written in full, because that is where the type
is known” — which reads backwards once you notice the header is right there. Outside an
impl the annotation stays required, because there nothing else says it, and the refusal names
both ways to fix it.
Stage-1 taught me something on the way. Its checker found self by counting two tokens back
from the receiver’s TYPE token, which is exact for (self: T) and (mut self: T) — and
wrong for (self), where the type token is the impl header’s and lives far away. It accepted
the program and then segfaulted in the emitter, which was doing the same arithmetic. Both now
scan back from the method’s name for the word self, which is shorter and right for all
three shapes. A representation that encodes “the type is always two tokens after self” was
encoding a coincidence.
Block comments reported a stray token. /* ... */ gave “expected statement, found /”
from two tokens after the mistake. It now says what is true and why:
error: Burxt has line comments only: `// like this`. There is no `/* ... */`, so there
is no nesting rule to get wrong — comment out a block by putting `//` on each
line, which every editor will do for you.
That is the difference between a language that lacks a feature and one that seems broken.
What the sweep checked and left alone
Five things I expected to find friction in and did not: a call kept for its effect
(push(xs, 1); needs no binding), negative literals, the field shorthand, +=, and len
over both strings and arrays. My own test code had been writing let a = push(...) and
0 - 1 out of habit, not necessity — worth recording, because “the author works around a
limit that is not there” is its own kind of documentation bug.
And five that stay missing with the reason written down rather than as omissions: % for
modulo (the symbol is the percent literal, and rem/div_floor/div_trunc also name which
convention is meant, which one operator cannot); multi-line string literals (a literal
spanning lines makes its own indentation part of the data); block comments; default parameter
values and named arguments (real friction, real feature, simply not built — not refused on
principle); and to_string of a struct (needs a display trait with a blessed name, which is a
decision rather than a shorthand).
The sweep is now a section in spec/1.0/M10-ERGONOMICS.md, including the “checked and already
fine” list, so the next one does not re-tread it.
v0.0.96: bounds, and a promise the compiler could finally keep
Every refusal on an unbounded type parameter has ended, since v0.0.93, with “say so in the
signature with a bound on T.” For three versions that was a promise the compiler could not
keep — which is the worst kind of error message, because it teaches the reader to distrust the
rest of them. It keeps it now.
fn largest<T: Ordered>(a: T, b: T) -> T { if a > b { return a; } return b; }
fn same<T: Equatable>(a: T, b: T) -> Bool { return a == b; }
fn describe<T: Priced>(item: T) -> String allocates { return item.label(); }
A bound cannot promise more than the language delivers. Ordered is Int and
Decimal<S> — the types < actually works on. It is not String, because Strings have no
ordering in Burxt yet, and a bound that claimed them would be a lie told in a signature.
Equatable adds Bool and String, because those are the types == works on. When Strings
gain an order they gain Ordered in one place and nowhere else.
I checked that rather than assuming it: five one-line programs, one per operator and type, to
find out what the language allows before writing down what a bound means. true < false is
refused (“Bools have no order”), "a" < "b" is refused (“byte ordering arrives with
collections”). Both facts are now encoded once.
A trait bound gives static dispatch. describe<Book> and describe<Meal> are two
functions in the object file; there is no vtable and no runtime type information. dyn Priced
remains for the opposite case — one implementation serving many types at run time. Generics
are for when many implementations should serve one shape, and having both means neither has to
pretend to be the other.
Two checks, in two places, and that is the whole design. The BODY is checked against the
bound, once, at the declaration — so adding a > inside a generic is a compile error until the
signature says so, rather than a silent narrowing of every caller. That is the same argument
allocates and rounding contracts make: the signature is the contract, and a contract you have
to compute is not one you can read. The ARGUMENT is checked where the type is chosen:
error: `describe` needs `T: Priced`, and `Book` does not implement it. Write
`impl Priced for Book { ... }` — conformance is declared, never inferred from
having the right method names.
Five refusals, each naming the parameter, the bound and the fix: a type that does not implement
the trait, Ordered asked of a Bool, > under an Equatable bound, a bound that is not a
trait at all, and a method the bound does not declare. One of them replaced an existing fixture
whose expected text was the old generic “cannot compare T and T” — a message getting better
is a fixture that has to be updated, and that is the right direction for that work to flow.
One thing the example taught me while being written. item.price() * $2.00 inside a function
returning Decimal<4> is refused, because the literal adopts the expected scale and becomes a
Decimal<4>, making the exact product six places wide. The fix is * 2 — money times a count
is exact — and it is a better example anyway, because it shows that a bound changes what is
allowed and never what anything means.
v0.0.97: ?, and the question it had been waiting on
I named ? as blocked three versions in a row, always for the same reason and never with the
reason answered. That is a bad habit — a blocker you cite without resolving is a decision you
are avoiding. So this version starts by answering it.
fn average(a: Int, b: Int, n: Int) -> Result<Int, String> {
let mean = divide(a + b, n)?; // the value, or return the Err from here
return Result.Ok(mean);
}
Decision A — no conversion. The error types must match.
If the callee fails with a String and you fail with something else, write the match. No
From-like trait, no implicit widening into a common error, no Box<dyn Error>. Every one of
those is a mechanism that decides on your behalf which information about a failure survives,
and every one is a place where a cause quietly becomes “something went wrong”.
The cost, stated plainly: a function calling two libraries with different error types writes two
matches. That is more typing, and it is also the honest amount of thinking — somebody has to
decide what the caller’s failure means, and that decision does not belong to an operator.
Deferred with a trigger, not refused forever: when a real program has two error enums it
genuinely needs to bridge, the bridge is a trait with one method, declared per pair.
Decision B — ? is spelled by VARIANT name, not by type name.
This is the one I am pleased with. ? works on any enum with exactly two variants, one of
them named Err or None. The other variant carries the value; its name does not matter, and
neither does the enum’s.
The alternative — teaching the compiler the names Result and Option — would have quietly
undone what v0.0.94 was for. Those types live in lib/option.bx and lib/result.bx as ordinary
Burxt precisely to prove the generics are real. A compiler that knows them by type name has
hardcoded one library, and a second library with the same shape becomes a second-class citizen.
Blessing two variant names is a much smaller commitment: it says “an enum whose failure case
is called Err behaves like a failure”, which is a convention a reader already holds. Write
enum Fetched<T> { Err(String), Got(T) }
and ? works on it, with nothing in the compiler knowing Fetched exists.
The lowering
? is a real expression, not a statement form, so it works anywhere an expression does —
f(x)? + 1 reads as “the value, or return the failure” and then plus one. Codegen reads the
tag, branches, and on the failure path rebuilds that failure as the enclosing function’s
return value and leaves immediately. The checker has already proved the two failures carry the
same payload types, so the rebuild is a copy and never a conversion — which is Decision A
turning into two build_stores instead of a rule someone has to remember.
Four refusals, each naming the fix: ? at top level (nothing to return from), an enclosing
function that cannot carry a failure, two failures with different payloads, and an enum whose
variants are called something else entirely.
M8 is done. Option<T>, Result<T, E> and ? — and the only thing the compiler knows about
any of them is that a variant called Err or None means it failed.
v0.0.98: every keyword is the word it means
Andre asked why a function is fn and a structure is struct, coming from PHP where both are
spelled out. I did not have a good answer, because there is not one. fn, mut, impl, dyn,
extern and struct were inherited from Rust — Rust is where the memory model and the type
discipline came from, and the keywords came along with them. That is a habit, not a decision.
And it sat badly beside everything else. Twenty-five keywords spelled out in full — let,
return, while, region, allocates, requires, decreases — against six clipped. The rule
had already been settled once in the other direction: RoundHalfEven, not HalfEven, because
the self-explanatory spelling wins. The clippings were the ones out of step, not the full words.
| Old | New | Why that word |
|---|---|---|
fn |
function |
a function is a function |
mut |
mutable |
a binding that can change is mutable |
impl |
implement |
implement Priced for Book reads as the sentence it is |
dyn |
dynamic |
the decision it names is made dynamically, at run time |
extern |
external |
the function it names is external to this program |
struct |
record |
named fields, copied by value, no inheritance, no hidden header |
enum stays. It is short for enumeration, but every language spells it enum and
enumeration reads worse rather than clearer.
record, and the thing I got wrong on the way
I first argued that struct should stay because it is a whole word. It is not — it is a
clipping of structure, which puts it in exactly the category being fixed. Andre asked whether
there was a better name and offered blueprint and capsule; ruling those out is what found
the right one.
blueprintdescribes a class: a plan you manufacture instances from. A Burxt record has no constructor and no factory — you write the fields down and that is the value. The word would promise machinery that is not there.capsuleimplies encapsulation, and a record has none: every field is public, there is noprivate, and the layout is exactly the fields with no hidden header. It would name the opposite of the guarantee.recordis what the thing is. In C#, Java, F# and Pascal it means precisely named typed fields, value semantics, no inheritance. And a PHP reader has nostructin their vocabulary while knowing exactly what a record is.
A clean break with a signpost
The old spellings stay reserved, and their only job is to name the new one:
error: Burxt spells this `record`, not `struct`: named fields, copied by value, with no
inheritance and no hidden header — which is what a record is, and what a class is
not. Every keyword in this language is the word it means — which is why
`allocates` and `decreases` are not `alloc` and `dec`.
Not two accepted spellings — “one obvious way to write each construct” is the standing rule, and
fn or function is precisely the kitchen sink. A rename whose reason a reader cannot see is
a rename they will resent, so every message carries its reason.
What it took, and the three things that bit
1,598 replacements across 237 .bx files, both lexers, the compiler’s own 6,000 lines of
Burxt, 28 inline programs inside tests/runner.rs, 62 diagnostic strings in typeck.rs, the
editor grammar, and every doc and spec. docs/log/ was deliberately left alone: it is
append-only, and its earlier entries record what the language was called at the time.
Two identifiers collided with the new keywords, and had to move first: stage-1’s Sym.mutable
field became writable (which check_body already used for the same idea), and three fixtures
had a function called record. A keyword rename is also a namespace grab, which is not obvious
until it breaks.
\nfn is not a word boundary. My first sweep of the inline programs in tests/runner.rs
missed five, because in the Rust source text \n is two characters, so \nfn reads as the
word nfn and \bfn\b does not match. Escape-aware boundaries found them.
'"' is not a string. Sweeping diag.rs and json.rs corrupted them, because my scanner
paired quotes without understanding char literals, and a '"' threw the whole pairing off. I
reverted both — and then found that neither file contains any Burxt syntax at all, so the right
answer was to leave them alone from the start. A sweep is only as good as its idea of where the
data ends.
I also excluded src/rust-compiler/lexer.rs from the message sweep on purpose: it is the one file that must
hold both spellings, since the old ones live there for the sole purpose of pointing at the
new ones. Its describe() arms still needed changing by hand, which one fixture caught by
reporting “expected a parameter name, found mut” about a program that said mutable.
Two agents ran the independent halves — the documentation sweep and the editor grammar — while I
did the coupled core, which is the only part where the lexer and 237 source files have to change
together or nothing compiles. The docs agent came back with nine ambiguous cases it refused to
guess on, and it was right to: one was spec/1.0/N1-BOUNDARY-EXACTNESS.md, whose example C function
is literally named record, and another was a set of bare-noun uses where a strict swap turned
“a trait implementation” into “a trait implement”. Both fixed by hand.
All 29 invariants green, and the self-hosted compiler still compiles its own renamed source to a byte-identical fixpoint in 1.1 seconds.
v0.0.99: the tooling is part of the language
Andre opened the repository in an editor after the rename and found three things: squiggles on
files that compile, squiggles on files that are not negatives, and function losing its colour.
All three were real. None was a compiler bug. And his diagnosis was the right one — “we really
need to update styling, linting and lsp everytime we do change.”
A change to the language is not finished until the grammar, the language server and the packaged extension have changed with it. A reader’s first contact with Burxt is an editor, not
cargo test. A language that compiles correctly while looking broken is broken.
1. The editor was colouring yesterday’s language
burxt-0.1.3.vsix had been built before the rename, so the installed grammar knew fn and had
never heard of function. The repository was correct and the thing on Andre’s screen was not.
Nothing noticed, because nothing was looking.
Repackaged as 0.1.4 — and the version bump matters, because an installed extension does not
upgrade to the same version number. the_packaged_extension_matches_the_grammar_in_the_repository
now reads the grammar back out of the .vsix and refuses a stale one.
2. A keyword list is not a grammar
function (self) price() — the receiver shorthand from v0.0.95 — had never highlighted. The
method pattern still demanded self: Type, so six declarations across the examples were plain
text for four versions.
editor_grammar_knows_every_keyword_the_compiler_does passed the entire time, and it was right
to: it checks that the grammar contains the language’s words. It cannot check that the grammar
can read the language. So there is a second test now —
editor_grammar_highlights_every_declaration_the_examples_write — which takes every declaration
line out of the real examples and requires some pattern to match it from column zero. I verified
it the only way a regression test is worth anything: removed the new pattern and watched it name
all six lines, then put it back.
Writing it needed a small regex subset implemented by hand (about eighty lines: \b, \s*,
\s+, group alternation, character classes with */+ and greedy backoff). That is more than I
wanted, and less than a dependency — this repository still has no npm and no regex crate, and a
test that reads the grammar next to the language it checks should be readable itself.
3. A file is not always a program
The one I would not have found without being told. src/burxt-compiler/check.bx is one of five
modules src/burxt-compiler/main.bx assembles, and the language server checked the file, so every
type declared in a sibling read as unknown: five files of squiggles that were not mistakes.
And underneath it, worse: main.bx itself reported a parse error on its own use lines.
The language server had never resolved imports — not since modules shipped in v0.0.81, eighteen
versions ago. No test could see it, because burxt check resolves them properly and only the
editor did not. The one path nobody exercised was the one every reader uses first.
The fix is the design M6 already chose, one layer up: check the program, keep the diagnostics
that landed in this file. If the open file has use lines it is a root, and is loaded as one.
If it has none, the nearby directories are searched for a program whose use closure reaches it.
Either way the concatenated buffer and its source map do the rest, and the editor’s unsaved text
is spliced over that file’s own span — so what the user is looking at is what gets checked, not
what is on disk.
the_language_server_checks_the_program_a_file_belongs_to drives the real server over stdio for
every example, the standard library, tests/runner.bx and all five compiler modules, and requires
"diagnostics":[] from each. examples/negative/ is excluded deliberately: those programs are
meant to be wrong, and a squiggle there is the whole point of the directory.
The cost, stated: checking a module now loads its whole program on every keystroke. For this compiler that is 200 KB — front end only, a few milliseconds, and it is what M9 bought. If it ever bites, the answer is to cache the program per root and re-check incrementally, not to go back to checking files.
One process note on myself
I reached for git stash mid-verification to test whether the new LSP test failed without the
fix, and the pop conflicted on Cargo.lock because a build had run in between. Recovered
intact — but the evidence I wanted already existed: I had run the diagnostics before the fix and
recorded the five failing files. Re-breaking working state to prove something I had already
measured was the wrong instinct, and it is worth writing down next to the entry where it happened.
32 invariants now, up from 29 — and the three new ones are all of the form “the thing a reader sees must match the thing the compiler does.”
v0.0.100: generic records, and a bug that hid behind Display
Andre asked whether I was confident enough to take all three remaining tasks, what I needed from him, and whether I could prove myself. The honest answers were: high confidence on generic records, medium on stage-1, and no on the O(1) String length — not because I cannot build it but because its trigger is measured and has not fired, and building it now would be exactly the scope creep the specs exist to prevent. This entry is the first half of the proof.
record Stack<T> { items: [T] } with methods, which is what generics were for. The record half
needed nothing new: a concrete application is rewritten into the nominal type of its
instantiation, exactly as generic enums do, and after that no rule in the checker knows generics
exist.
Methods were the new part. A method on a generic record is held back at registration — its
receiver has no layout until a use names the arguments — and one copy is made per instantiation,
so Stack<Int> and Stack<String> get their own push_one. That instantiation has to run
twice: once before any body is checked, because a body may call such a method, and again in
the drain loop, because a body can be what discovers a new instantiation. It is idempotent, so
the second call costs nothing and missing the first costs Stack$Int has no method named
push_one — which is exactly what it cost me before I noticed the ordering.
The bug that hid behind Display
item: T refused to substitute. I instrumented the specialisation and the debug line said
local={"T": Int} params=["T"] — the map was right and the parameter was still T. Then after
substitution: params=["T"]. Substitution doing nothing, with a correct map.
The parameter was Named("T"), not Param("T"). Both print as T. My own print statement
could not tell me the one thing I needed to know, and I stared at “correct map, wrong result” for
three rounds because of it. Instrumentation has to name the variant, not render the value.
The cause: parse_fn_signature replaced the type parameters in scope instead of extending
them. A method’s receiver has already put the record’s parameters in scope via self: Stack<T>,
and replacing them made T an ordinary named type in the parameter list. One word — extend
instead of = — and a lesson about Display that is worth more than the fix.
I also fixed the wrong site first: three functions assign self.type_params, and my
search-and-replace hit parse_record’s rather than parse_fn_signature’s. Checking which
function a line lives in before editing it is cheaper than the round trip.
One thing that was better than I assumed
Inferring a record’s arguments from its field values works: Holder { one: 1 } needs no
annotation. But probing every field is wrong — Stack { items: [] } cannot type [] alone,
and asking it to try surfaced an error about array literals in the middle of an unrelated rule.
The probe now stops once every parameter is bound, and a field that cannot type itself is skipped
rather than propagated. My fail fixture for “nothing says what T is” had to be rewritten, because
my first version of it wasn’t actually ambiguous.
The half I have not proved
I set the bar myself: the compiler uses a generic on itself, and the generics tests move from
tests/runner.rs into tests/pass/ where both compilers are held to them. Neither is done, and
they are the same blocker — stage-1 does not read generics, and stage-1 is the compiler
written in Burxt.
I looked for a natural generic in the compiler’s own source and did not find one; Loader and
Outcome are concrete and forcing a parameter into either would be contrived. So that proof
waits on stage-1 rather than being faked.
What I did instead of estimating: designed it. spec/1.0/M7-GENERICS.md §5 now records why
stage-1 cannot transcribe stage-0’s approach — it names every type by its SPAN in the source, and
Option$Int has no span, which is the same wall for x in xs hit — and the shape that fits:
Ty gains args_start/args_count into a new type_args side array, the way kids and subs
already work; comparison becomes structural; and mangling moves to the emitter, which builds
strings freely and is where symbol names belong anyway. Two different implementations of one
language, which is what the differential test exists to police.
32 invariants green, fixpoint intact. M7 is done in stage-0 and honestly labelled everywhere it is not.
v0.0.101: stage-1 reads generics, and says what it cannot do
Andre confirmed the editor: “I can say linting is perfect.” That closed v0.0.99’s loop — the one check I could not run myself — so this version starts the piece I said would prove the rest.
The design got simpler on contact, and I had it wrong on paper. §5 said stage-1’s Ty would
need two new fields and a type_args side array. It needs neither. For a named type (kind: 46)
scale and contract are unused, and a slice already stores its element type as a node
index. So Option<Int> is a named type whose scale is the start of its argument nodes in
subs and whose contract is how many — two dead fields reused, no widening of a record the
checker copies at every comparison. A type parameter is kind: 50 carrying the span of its own
name, and T is written down in the source, so that span is real. The wall I hit with
for x in xs — stage-1 names everything by its span, and a synthesized name has none — turns out
never to apply here, because a parameter’s name is not synthesized.
The parser is done. Type parameters on functions, records and enums; bounds; generic
receivers (self: Stack<T>); applications in any type position. Zero parse errors on all of it.
Parameters go in scope as token indices, compared by bytes like every other name in stage-1, and
parse_item truncates the list at each declaration boundary rather than at each parser exit —
the same argument commit(base) makes about child lists: dozens of exits are dozens of chances
to forget.
The checker refuses, and that is the version’s real content. It does not monomorphise yet. Before I added the guard, stage-1 parsed a generic, walked a type-parameter node, looked its name up as a record, got -1, and indexed an array with it — exit 70. Parsing more than you can check is worse than parsing less: a crash instead of an answer, and, if the crash had happened to be a silent success instead, a compiler quietly disagreeing with the other one about what a program means. So generics now get one clear refusal naming the milestone and the spec section.
There is a test for both halves, and it carries an instruction to whoever removes it: the parser
must stay complete, the checker must keep saying so, and when it stops needing to, move the
generics tests out of tests/runner.rs into tests/pass/ — where both compilers are held to
them. That is the bar I set for this task and it is not met yet. The ratchet stays honest rather
than widened.
One thing worth noting about the tooling rule. The moment I added generic declarations to
examples/generics.bx, editor_grammar_highlights_every_declaration_the_examples_write failed
and named all three lines it could not colour — function (mutable self: Stack<T>) push_one(...)
and friends. Written one version ago, and it caught the very next language change without anyone
having to remember. That is what the rule from v0.0.99 was for, and it is the first evidence it
works.
33 invariants green, fixpoint intact, self-compile 1.8 s.
v0.0.102: [Int] and [String] were the same type, and nothing noticed
I set out to write generic unification for stage-1 and got two lines in before stopping. Unifying
[T] against [Int] means comparing element types — and when I looked at how stage-1 compares
them, it doesn’t.
ty_same is a free function, and a slice or array holds its element type as a node index
into the arena. Comparing those two integers would compare positions, not types, so the two
kinds fall through to return true. Which means, for eleven versions:
function takes_ints(xs: [Int]) -> Int { return len(xs); }
region r {
let mutable words: [String] = [];
let a = push(words, "one");
print(takes_ints(words)); // stage-0: refused. stage-1: fine.
}
Nothing caught it, and the reason is worth more than the fix. The differential test’s rejection count is a ratchet — a floor. Stage-1 rejecting 154 of 190 kept it green, and a regression that leaves you above the line is invisible to it. A ratchet measures progress and is structurally incapable of noticing a retreat that stays above its threshold. Every ratchet in this repository has that shape, and now one of them has a paired equality test beside it.
The fix is a method rather than a free function — types_same recurses through ty_of into the
element, and compares a fixed array’s length as part of its type — and stage-1’s rejection count
went from 154 to 192 of 225 in one change. That jump is the size of the hole.
And the first message it produced was useless: “argument 1 should be a growable array, but
it is a growable array.” ty_show rendered every slice as the words “a growable array”, which
nobody had noticed because no message had ever needed to distinguish two of them. So show_ty
joined it — a method for the same reason, since reading an element out of the arena needs the
arena — and now both compilers say the same thing:
stage-0: in the call to `takes_ints`, argument 1 must be [Int], but it has type [String]
stage-1: argument 1 should be [Int], but it is [String]
A refusal a reader cannot act on is barely better than no refusal, and a rule that starts working is a rule whose messages get read for the first time.
show_ty also renders a generic application — Option<Int> rather than the name alone — which
the next slice needs and which cost nothing to add while the function was open.
Direction 4 in the agreement test pins the whole thing, with the reason written next to it. And the ratchet’s comment now says what the instrument can and cannot see, so the next person reading a green ratchet knows what it is not telling them.
33 invariants green, fixpoint intact, self-compile 1.1 s. The generic unification I sat down to write is still not written — but it would have been built on a type equality that did not work.
v0.0.103: Ty becomes Type, and Andre’s reason is the better one
I gave two reasons Ty was short: it was inherited from rustc along with the design, and
type_of was already taken by the expression typer so the annotation reader got the leftover
name. Both true, neither a defence.
Andre’s reason is better: ty already reads as “thank you” or “typo”. That generalises past
this one name, and it is the same test that chose record over capsule two versions ago —
a name that reads as the wrong thing is worse than a name that is merely short.
An abbreviation colliding with a common meaning does not save characters. It spends a beat of confusion at every occurrence, and there were 445 of them.
v0.0.98 fixed the language’s keywords. This is the same rule reaching the compiler’s own identifiers, which is fair game: its source is the reference Burxt program and the most-read Burxt in existence.
| Old | New |
|---|---|
Ty |
Type |
ty_of |
declared_type |
ty_unknown ty_simple ty_decimal |
unknown_type simple_type decimal_type |
ty_same + types_same |
same_type |
ty_show + show_ty |
show_type |
The collision was the real cause, and naming it properly dissolved it. type_of reads an
expression; the other one reads what the author wrote down. declared_type versus type_of
says that; ty_of versus type_of never did. The short name was not a choice about brevity, it
was a choice not to think about the pair.
It also swept up a smell I made myself. In v0.0.102 I needed arena access and added a method
beside each free function — types_same/ty_same, show_ty/ty_show — near-homophones
distinguished by nothing meaningful. I noticed at the time and shipped them anyway. Checking now,
the only caller of each free version was the method delegating to it, so both merged away
entirely. One concept, one function, and two fewer names to tell apart.
Fixpoint intact, 33 invariants green, self-compile 1.3 s.
v0.0.105: the standard library, the guide, and this log were never in git
I tried to git mv lib/str.bx lib/string.bx and git said “not under version control.”
.gitignore opens with /* and then re-admits directories one by one — a whitelist, chosen
in v0.0.31 to keep stray extensionless binaries out of the repository root, and the right shape
for that job. It re-admits assets, editors, examples, spec, src, tests. It has never
re-admitted lib, docs or scripts.
So for seventy-four versions:
| Directory | Files | What it holds |
|---|---|---|
docs/ |
18 | the entire guide, and every entry of this log |
lib/ |
6 | the whole standard library |
scripts/ |
2 | the release and install scripts |
git status was clean the whole time. That is the shape of the failure: a whitelist is safer
than a blacklist against stray artifacts and strictly more dangerous for new directories, because
forgetting one is silent and looks exactly like success.
Three tests in the suite READ those directories — the_standard_library_compiles_and_works,
the_guide_and_examples_are_linked_and_compile, and every_log_file_is_linked_from_its_index.
They pass here and would have failed on a fresh clone: green locally, broken for everyone else,
which is the worst shape a test failure can take. Nobody had cloned it, so nobody knew.
every_source_and_document_is_in_version_control now walks the tree and asserts that every
.bx, .md, .rs, .toml, .json, .sh and .py file is in git ls-files, skipping only
target/, dist/ and node_modules/ — outputs, which are reproducible by definition. It would
have caught this on the day lib/ was created.
Also removed tests/pass/emitted.ll, a leaked test artifact that *.ll had been hiding.
And I made the same mistake twice. To check the new test actually fails without the fix I
reached for git stash — the exact move I wrote up two versions ago as the wrong instinct. It
half-completed again. Nothing was lost, and the right way to verify was available and cheap: the
26 files were sitting in git status as additions, and git ls-files lib/ printing nothing was
already the proof. Writing a lesson down is not the same as having learned it.
34 invariants now.
v0.0.106: the compiler’s own names, and three ways to break a rename
The clip audit’s recommendations, carried out. Roughly 900 replacements across the compiler’s five
Burxt files, plus the standard library’s prefixes and Err → Error.
enum stays, and the lexer now says why rather than leaving it as an apparent oversight. The
full word is longer and wrong: a Burxt enum is a sum type whose variants carry values, not an
enumeration of integers. choice would be accurate, and the comment names it as the honest
alternative — because this is a weaker defence than record had over struct, where a better
word existed, and the next person should weigh it rather than assume it was considered.
Err → Error, in Burxt only. In Rust Err is std::Result’s own variant, so the sweep had
to stop at the language boundary. The compiler blesses the failing variant by name for ?, so
that string moved with it.
str_ → string_, fs_ → file_, file names included. os_ stays — an initialism read as
itself, which Python, Go and Node all establish. That distinction is the audit’s real product:
a clipping of one word is bad; an initialism read as itself is not.
The compiler’s names. Tok→Token, Kw→Keyword, Sym→Binding, Fun→DeclaredFunction,
Impl→Implementation; toks→tokens, kids→children, subs→nested_children,
tok→token, ret→return_type; no_struct→no_record_literal and parse_struct→parse_record,
because “struct” is not a word this language has; spans_eq→spans_equal; w→write_body; and
136 copies of let e: Int = self.complain(...) → let complained. One dead record, Typed,
deleted — it had exactly one occurrence, its own definition.
DeclaredFunction rather than Function, deliberately: a record differing from the function
keyword only in case is a trap, not a name.
Three self-inflicted bugs, and the rule they share
\barg\bmatched Rust’sCommand::arg(...)— 129 sites, 54 build errors, in v0.0.104.Result.Errmatched inside the already-renamedResult.Error, givingResult.Erroror.\bret\bmatched the LLVMretinstruction inside emitted-IR string literals. The compiler dutifully emittedreturn_type i64 0and the fixpoint broke.
I wrote the lesson down twice before and it was too narrow both times. Here it is generally:
A word-boundary rename is safe only when the word cannot occur in any OTHER language the file contains. These files hold three at once — Burxt, Rust, and LLVM IR carried as string data — and a replacement can also collide with its own output.
The third one is the interesting member of the set: the pattern was correct Burxt, the target was correct Burxt, and the damage was to a string literal that happens to be a different language. No amount of care about Burxt identifiers would have caught it. What caught it was running the tests.
All three were found by the suite within a minute of being introduced. Six batches, six full runs, every failure localised to the batch that caused it — which is the argument for testing between batches rather than sweeping once and hoping.
34 invariants green, fixpoint intact, self-compile 1.2 s.
v0.0.107: stage-1 checks generic functions, and substitution turned out to be unnecessary
The blocker I named two versions ago — substituting into [T] needs a type node, and a Type’s
name is a span with no token to build one from — dissolved. Nothing needs to be substituted.
A binding table maps a parameter’s span to a type, and resolve_shallow answers “what does
this parameter stand for, one level deep.” Every recursive walk over types calls it as it
descends, so same_type([T], [Int]) resolves the element exactly where the comparison reaches
it. A [T] is never rewritten into a [Int], so there is never a node to invent. No new arena
entries, no synthesized identifiers, and the wall was avoidable rather than merely climbable.
That is twice now that this representation looked like an obstacle and turned out to be a constraint worth respecting. It names everything by a span in the source, and both times the answer was to stop trying to manufacture names and let the spans do the work.
Stage-1 now binds a generic function’s type parameters at the call site by structural
unification, checks the arguments against the resolved types, resolves the return type, and
enforces Ordered / Equatable / declared-trait bounds. It agrees with stage-0 on generic
functions. What it still cannot do is give Option<Int> its own layout — genuinely one copy per
argument list, and the one thing that cannot be lazy — so the guard now refuses exactly
applications of generic records and enums, and nothing more.
Four bugs, and what each was really about
A crash on self: Stack<T>. self_token scanned back a fixed six tokens to find the word
self. That covers (self), (self: T) and (mut self: T) — and a generic receiver is
seven, so it returned -1 and the caller indexed the token array with it. Now it scans back to
the clause’s opening ( instead. A bound that depends on the shape of what it is scanning is a
bound that breaks when the shape grows — and this is the second time that function has been
wrong, having already been rewritten in v0.0.99 for the same reason.
Three Ts, one bound. bound_of looked a parameter’s bound up by name across every generic
in the program, so with identity<T>, largest<T: Ordered> and first<T> all present it found
identity’s absent bound for all three. A parameter’s bound has to be read from its own
declaration, which needed the checker to know which generic it is inside.
declared_type did not know parameters. It handled kinds 40–49 and returned unknown for
50, so every unification compared against nothing. One missing arm, and the symptom was
“argument 1 should be ?, but it is Int”.
The print branch reads a cache nobody filled. Trying to refuse print(x) where x: T, I
made the branch call type_of — and nine programs stage-0 accepts were suddenly rejected,
because the rules below it have been switching on unknown and were effectively dead. Making
them live revealed they were written against an assumption the file no longer holds. I found the
parameter case a different way, and left the larger gap recorded rather than fixed blind:
that branch needs its own investigation, not a drive-by.
The ratchet went down, on purpose, for the first time
192 → 189. Before this version stage-1 refused every program containing a generic with one blanket message, so three fail fixtures counted as rejections for the wrong reason. Two are now honestly pending on records and enums; the third likewise. Three fixtures traded from accidentally-right to knowingly-pending.
That is written into the ratchet’s own comment, with the fixtures named, because a floor that moves down silently is worse than no floor. It is also the second time in three versions that this instrument has needed a note about what it cannot see.
34 invariants green, fixpoint intact, self-compile 1.4 s.