Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Self-hosting: the spike, and the decision it was for

bootstrap.md named the reason the v0 compiler is written in Rust (“Cranelift is a Rust library”) and, in the same breath, named the cheap experiment that would tell this project whether that reason is load-bearing forever: “port the lex-ast/lex-vcs canonical forms and check byte-identical OpId/SigId/StageId over the existing op-log corpus.” ROADMAP.md’s “Beyond” section repeats the same line. This document runs that experiment, on the corpus this sandbox actually has rather than the one the original note assumed, and answers the question bootstrap.md deferred: is self-hosting worth planning next?

No — not yet, and not because anything found here is a hard blocker. Every concrete thing checked came back feasible. What is missing is an asker (AGENTS.md §7): no cancho program needs to compute a lex-lang SigId, and no consumer is waiting on a cancho-language compiler. This is the same discipline docs/vcs.md §8 already applied to whole-function merge and typed issues — “has no asker in this repository yet… not started” — applied here to a much larger piece of unrequested work.


1. Two different questions this note does not conflate

“Self-host the toolchain” and “make cancho-vcs’s OpId match lex-vcs’s” sound like the same idea and are not.

docs/vcs.md §5 already answered the second one, on contact, while building cancho-vcs: OpId is BLAKE3, not SHA-256, deliberately — “matching lex-vcs’s algorithm would have meant a second hash dependency for no reason but appearance.” cancho-vcs hashes cancho programs; a cancho program and a Lex program are never the same bytes, so there was never a reason for their content hashes to agree, and none was sought.

What bootstrap.md actually asks is the first, larger question: could cancho itself — the language — eventually be the implementation language of its own compiler (today’s cancho-syntax/cancho-ir/ cancho-codegen*, ~29,400 lines of Rust, counted below), the way a self-hosted compiler is normally understood. That is what the rest of this document measures.


2. The corpus this sandbox actually has

ROADMAP.md’s line says “the existing ~136k-op corpus.” No such corpus is checked into any of the four repositories this session can reach — lex-lang, cancho, lex-os, lex-gpu — confirmed by searching each for an op-log store, not assumed from the absence of a memory of one. Whatever corpus that line originally meant lives outside this sandbox (a production op-log from real use, most likely), and this spike does not have access to it.

What does exist, real and checked into lex-lang itself: 31 real .lex files (examples/, plus the fuzz seed corpus under fuzz/corpus/), two of which are deliberately malformed fuzz seeds that do not parse (expected type expression, got Some(Fn) — a fuzzer seed testing the parser’s own error path, not a program). The other 29 parse into 168 real Stages (fn/type declarations) through the real, unmodified lex-syntax/lex-ast pipeline.

This changes what is actually testable. Diffing a cancho hash against a lex-lang hash for the same corpus was never going to say anything — different languages, different ASTs, no reason to expect agreement. What is testable, and what the “byte-identical” phrasing in bootstrap.md is actually insurance against, is narrower and more useful: is the canonicalization algorithm — RFC-8785-flavored JSON, SHA-256, the specific SigId/StageId composition — specified clearly enough, in lex-ast’s own source and doc comments, that an independent implementation reproduces it exactly? A self-hosted port would be exactly such an independent implementation, in a different language; if the algorithm cannot be reproduced byte-for-byte from its own documented spec in the same language, porting it to a different one has no chance.


3. The experiment

A from-scratch Rust reimplementation of canon_json’s writer (UTF-8, no whitespace, object keys sorted byte-wise, serde_json’s default number rendering, JSON string escaping including \u00XX for control bytes) and of sig_id/stage_id’s composition (read from lex-ast/src/lib.rs’s own doc comments: “SigId: SHA-256 over canonical_json({name, input_types, output_type, effects})”, “StageId = SHA-256(structural_sig_hash || implementation_hash)”) — written without calling lex_ast::canon_json or lex_ast::sig_id itself, only depending on the real lex_ast::Stage type so both sides walk the identical parsed data. Run over every real Stage the 29 parseable .lex files produce, diffed against the real crate’s own sig_id(stage) / stage_id(stage).

corpus: 31 real .lex files
parse failure: fuzz/corpus/parser/seed_02.lex (deliberately malformed)
parse failure: fuzz/corpus/type_checker/seed_02.lex (deliberately malformed)
parse failures (excluded): 2
stages examined: 168
SigId byte-identical: 168/168
StageId byte-identical: 168/168
RESULT: every SigId and StageId reproduced byte-for-byte.

168 for 168. The canonicalization algorithm is specified precisely enough, in the source it already has, to reproduce without the original code — the property a port needs and the property bootstrap.md’s “byte-identical” phrasing was actually checking for. This is the one part of the spike with a clean, positive, falsifiable result.


4. What else a self-hosted toolchain would need, checked rather than assumed

Three more questions, each answered against this repository’s own real code rather than guessed:

Does cancho support what a hand-written compiler needs structurally? Recursion — fn fib(n: int) -> [] int { if n < 2 { return n; } return fib(n - 1) + fib(n - 2); }, built and run in this sandbox, returns 55 for fib(10). Heap-allocated recursive data (an AST node needs a Boxed child) — already proven, not hypothetical: examples/tree.cho is a binary search tree, exactly this shape, already in the corpus docs/README.md calls “why a language needs a heap at all.” Neither is a new risk.

What is the actual size of what would move? The Rust crates that are the compiler today, real line counts:

CrateLines
cancho-syntax5,318
cancho-ir12,282
cancho-codegen3,397
cancho-codegen-llvm4,615
cancho-types704
cancho-id1,909
cancho (CLI)1,143
Total29,368

That is the scale of a full port, not a slice — an order of magnitude past anything this repository has ported in one piece before (§2 of docs/vcs.md measured lex-vcs’s diff-facing code at 1,477 lines as the largest prior comparison point).

Does bootstrap.md’s own Cranelift blocker still hold? Yes, and it is not a soft one: Cranelift and LLVM are libraries with Rust (and C++) APIs — builder patterns, trait objects, complex owned types crossing by value — none of which a foreign call can cross today (docs/reach.md §3: “a foreign result is int, bool or ()”). Reaching either from .cho directly is not a missing convenience, it is outside what extern fn can express at all. But docs/reach.md §3.4’s own note that fork is already reachable through Ffi("libc") points at the actual escape hatch a self-hosted backend would take: emit textual assembly (a [byte] slice, no different from any other output this language already writes) and shell out to the system as/ld the way an early self-hosting C compiler did, rather than reimplement a code generator. Untried here — a real slice of work in its own right — but not blocked by anything this language currently lacks.


5. The decision

Nothing this spike checked is a wall. The hashing algorithm ports byte-for-byte from its own spec (§3). Recursion and heap-allocated trees, the structural minimum, already work (§4). The Cranelift/LLVM dependency that keeps v0 in Rust has a named way around it — shelling out to as/ld — that does not require reimplementing a code generator (§4). What is missing is scale (29,368 real lines, §4) and, more to the point, an asker: nothing in this repository today needs a cancho compiler written in cancho, and AGENTS.md §7’s rule against building what nothing asks for applies at this size exactly as it does at docs/vcs.md’s smaller ones.

Decision (as first written): stay on Rust for the compiler. Revisit this document, rather than re-running the spike, the day a concrete asker exists. Nothing in this document’s findings would need to change before that day; only the “no asker yet” line would.

Update: there is an asker. The project’s owner asks for a cancho compiler written in cancho, for the reason that a language that can build itself is the strongest proof of the language (2026-10-07). That is the “concrete asker” this section waited for, and AGENTS.md §7’s rule against building what nothing asks for no longer applies to it. What stands: the Rust compiler is the oracle at every stage (the staged port of section 6 compares against it, and a stage is not done until it agrees), and nothing is switched over until the last stage, a fixed point where the compiler written in cancho compiles itself to the same output. The aim is the whole bootstrap: the checker complete, IR generation, a textual backend (LLVM IR or assembly, with the system tools shelled out to as section 4 describes), and the fixed point.


6. The staged port (epic #295)

§5 decided to stay on Rust and nothing below reopens it. What §3 and §4 could not give was a measured size, so the question “how big is a compiler in this language” has been run one stage at a time, the Rust compiler the oracle at every stage and any stage free to stop the effort by failing. This section records what the stages found, in place, the way this document corrects its own claims.

StageWritten in canchoOracleResult
1. Lexerexamples/selfhost/lexcore.cho (a module) and lexer.choexamples/dump_tokens.rs in cancho-syntaxSame token stream and the same refusals on every program in the repository (614 files)
2. Parserexamples/selfhost/parser.choexamples/dump_ast.rs in cancho-syntaxSame syntax tree, node for node and span for span, or the same refusal (rule and span), on the same 614 files, including its own source, and on a fuzz corpus (below) of 52,272 cases, 51,911 of them comparable and all identical, 29,097 of those refusals
3a. The treeexamples/selfhost/ast.cho (a module, the parser) and parser.cho (a walk that prints the tree)the same dump_ast.rsThe parser builds the tree in flat tables and the listing is produced by walking it; the same 621 programs and 52,314 fuzz cases (6 seeds), all comparable ones identical, 29,055 of them refusals
3b. Declarations (the first half of the checker, complete)examples/selfhost/pass1.cho, foreign.cho and checker.cho, over a generated tables.cho; programs of several filescheck_declarations (cancho-ir), the first half of the Rust checkerSame answer, OK or the first refusal’s rule and span, on every program of the repository alone (633) and with the whole standard library parsed with them (633, 464 of them OK), on 169 targeted cases, and on 62,892 fuzz cases (51,534 alone, 11,358 with the library): 62,434 identical, 37,967 of them refusals, none different; 458 are not UTF-8; nothing is skipped
3c. Bodies, function by function (scalar functions so far)examples/selfhost/body.cho (and bodies.cho)check_bodies (cancho-ir), the Rust checker’s body check, answered per functionFirst slice. The port answers OK, a refusal, or SKIP for each function; every function it answers is the Rust answer: 644 repository programs alone (278 OK bodies and 11 refusals among those it answers; 898 function answers skipped), the library’s 789 functions with one program (271 OK, 518 skipped), 304 targeted cases, and 57,579 fuzz cases (51,588 alone, 5,991 with the library): 57,166 identical, none different; 413 are not UTF-8
3d. References and slices of scalarsexamples/selfhost/types.cho and body.chocheck_bodiesSecond slice. Every function the port answers is the Rust answer: 644 repository programs, the library’s 789 functions with one program (437 verified OK, 55%, from 34%), 408 targeted cases (104 for references, regions and slices), and 39,826 fuzz cases (35,352 alone, 4,474 with the library): 39,547 identical, none different; 279 are not UTF-8
3e-1. Structs of scalarsbody.cho (types in types.cho)check_bodiesThird slice. Every function the port answers is the Rust answer: 81 targeted cases for struct literals, field reads and writes (539 in all), 8,000 fuzz cases alone and 4,200 with the library, 1,165 library cases: none different
3e-2. Enums of scalars and matchbody.chocheck_bodiesFourth slice. Every function the port answers is the Rust answer: 85 targeted cases for enum values, match and its errors (624 in all, 5 cross-module in the several-files test), ~11,000 fuzz cases and 1,269 library cases: none different; the library’s verified bodies are 461 of 814 (57%)
3e-3. Generic functionsbody.cho (type variables and parameters in types.cho)check_bodiesFifth slice. Every function the port answers is the Rust answer: 88 targeted cases for type parameters, inference at a call and ambiguous-type (712 in all, 4 cross-module in the several-files test), 15,774 fuzz cases (11,220 alone, 4,554 with the library) and 1,359 library cases: none different; the library’s verified bodies stay at 461 of 814
3e-4. borrow and region blocksbody.cho (block regions in types.cho)check_bodiesSixth slice. Every function the port answers is the Rust answer: 1,271 targeted body cases (456 of them for borrows, regions and their variants), 2 cross-module in the several-files test, 16,000 fuzz cases (11,463 alone, 4,726 with the library) and 1,475 library cases: none different; the library’s verified bodies stay at 461 of 814
3e-5. Builtins that perform nothing, and prelude typesprims.cho, over a generated builtins.chocheck_bodiesSeventh slice. Every function the port answers is the Rust answer: 1,376 targeted body cases (105 new, among them the ones that must be answered and not skipped), ~19,000 fuzz cases and 2,033 library cases: none different; the library’s verified bodies go from 461 to 590 of 814 (72%)
3e-6. Effect rowseffects.chocheck_bodiesEighth slice. Every function the port answers is the Rust answer: 1,443 targeted body cases (67 new, with the strict ones that must be answered), ~19,500 fuzz cases (13,434 alone, 6,044 with the library) and 2,156 library cases: none different; the library’s verified bodies are 625 of 855 (73%)
3e-7. The rest of the bodies: arena allocation (alloc, alloc_slice), the heap builtins (box_slice, contents), generic types, tuples, threads, then linearity, effects with arguments, regionsnot startedcheck_bodies
4a. Writing LLVM IR for functions of int and bool: literals, the arithmetic and bitwise operators with their traps, comparisons, calls, let, assignment, returnexamples/selfhost/emit.cho, written by the checker as it walks (compile.cho)the Rust compiler, by what the built programs doFirst slice of the backend. 74 programs built both ways and run: the same exit status, or a trap in both (18 trap); none different
4b. Control flow: if with else, while, && and `` (the right side only when the left has not decided it)emit.cho and body.cho
4c. World, release and a real main: main(world: World) calls the program, a World passed between functions, release(world)emit.cho, body.cho, types.cho, driver.chothe Rust compiler, by what the built programs doThird slice of the backend. 254 builds run and compared: every one of the 123 programs through the old run convention and through a real main, and 8 more with a main of their own (a World passed on, release used as a value): the same exit status or a trap in both (55 trap); none different
4d. Output: split(world) and its parts, a borrow of the Io, putchar, references to whole numbers and booleans, and what an owned capability dischargesemit.cho, body.cho, exprs.cho, effects.chothe Rust compiler, by what the built programs do and writeFourth slice of the backend. 268 builds run and compared, 14 of them print (characters, decimal numbers by recursion, loops, if chains, nested helpers, sequential borrows, a trap after output): the same exit status or trap and the same bytes on the standard output (56 trap); none different
4e. Strings and slices: a literal as a slice of bytes, len, s[i] with its bound, write_bytes, int_of/byte_of, a slice passed, kept and reassigned, a store through *r and s[i]emit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs do and writeFifth slice of the backend. 292 builds run and compared, 24 of them new, 60 trap; none different
4f. Structs of whole numbers, bytes and booleans: a literal, a field read, a copy, passed and returned, a field written through a unique reference, *r of oneemit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doSixth slice of the backend. 312 builds run and compared, 20 of them new, 63 trap; none different
4g. Enums and match on them: a variant with payloads, match by value and behind a reference (payloads are references, written through), _ arms, nestedemit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doSeventh slice of the backend. 332 builds run and compared, 20 of them new, 64 trap; none different
4h. Arenas: region and alloc_slice[a](n, fill) of whole numbers, bytes and booleans, in the checker (the arena in scope, the count, the fill a value that copies, the slice cannot leave) and in the codeemit.cho, body.cho, exprs.cho, scope.cho, types.cho, driver.chothe Rust compiler: the checker by answers (1,945 cases), the code by what the built programs doEighth slice of the backend, and a checker rule. 352 builds run and compared, 20 of them new, 71 trap; none different. The checker’s 1,945 cases are identical, 27 of them new
4i. The heap: box_slice, contents, unbox_slice of whole numbers, bytes and booleans, with a Heap borrowed and passed (compile mode only: the checker still answers SKIP for a function that has one)emit.cho, exprs.cho, types.cho, scope.cho, effects.chothe Rust compiler, by what the built programs doNinth slice of the backend. 367 builds run and compared, 15 of them new, 76 trap; none different. The checker’s 1,945 cases are unchanged
4j. static tables: static name: [T] { .. alloc_slice[static](n, fill) .. return t; } of whole numbers, bytes and booleans, and the name used as a shared reference to the slice, in the checker (20 new cases) and in the codeemit.cho, body.cho, exprs.cho, scope.cho, driver.chothe Rust compiler: the checker by answers (1,965 cases), the code by what the built programs doTenth slice of the backend, and a checker rule. 382 builds run and compared, 15 of them new, 78 trap; none different. The library’s skipped functions go from 641 to 612
4k. Functions that return a slice, and sub-slices s[a..b] of whole numbers, bytes and booleansemit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doEleventh slice of the backend. 404 builds run and compared, 22 of them new, 84 trap; none different. The census: 529 of the compiler’s own 548 functions get code (488 before)
4l. Structs and enums whose fields and payloads are structs, enums or a Box[[T]] (and res ones, std.buffer’s Buffer), destructuring a struct of the file, a field of a res type read through a reference as a borrow, byte parameters and results, and the builtins wrapping_*, copy_into, index_of_byte, getchar, write_erremit.cho, body.cho, exprs.cho, scope.cho, types.chothe Rust compiler, by what the built programs doTwelfth slice of the backend. 424 builds run and compared, 20 of them new, 85 trap; none different. The census: 558 of the compiler’s own 558 functions get code, and all 39 of std.bytes/buffer/io
4m. Several files, and the bootstrap: a function is named by its node (@lexs_<n>), so two modules may have a function of one name; what the code is not written for is left out and a comment says how many; compiler.cho is the compiler for a program of several filesdriver.cho, emit.cho, body.cho, compiler.cho, bootstrap.rsthe compiler against itself: built by Rust, it compiles its own source to IR, clang builds that, and the result compiles the same source to the same IRThe fixed point. 100,409 lines of IR, 637 functions, byte for byte the same from the compiler Rust built and from the one it built; 55 functions left out, all of std.math, which nothing calls. The checker built by the second compiler agrees with the Rust oracle on 1,980 of 1,980 cases
4n. Capabilities and handles in the code: a target (TARGET darwin/linux in front of the input), runtime.ll (paths checked against the prefix a capability was narrowed to, open_*, file_write/file_read/file_close, exec_spawn, child_wait, arg_count/arg), prelude enums and handles as values, narrow; and builder.cho, the compiler that runs clang itself through its Execruntime.ll, runtime.cho, emit.cho, exprs.cho, types.cho, scope.cho, driver.cho, builder.chothe Rust compiler: the programs by what they do (433 builds, 89 trap), the builder by building itselfThe compiler builds a program with no help. The builder built by Rust builds itself, the result builds itself again, and the three write the same 3.2 MB of IR (equal sha256). The census: 614 of 614 functions (the compiler, its runtime and the builder) and 39 of 39 in std.bytes/buffer/io
4o. static tables computed while the program is compiled: consteval.cho, an interpreter for the bodies that build a table (loops, conditionals, the operators, table reads and writes, string literals, len/int_of/byte_of, earlier statics), whose answer is data of the module; a body it cannot run (a call to a function of the program, a trap, too much room) still runs when the program startsconsteval.cho, body.cho, ast.cho, driver.chothe Rust compiler, by what the built programs doA difference closed. 442 builds run and compared (9 new), 89 trap; none different. The census: 644 of 644
4p. One value on the heap and in an arena: Box[T] of any type the code holds, box, unbox, contents of a box, alloc[a](v), and so a type that holds itself through a box (enum List { Empty, Cons(int, Box[List]) })types.cho, emit.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doThirteenth slice of the backend. 454 builds run and compared (12 new), 89 trap; none different. Of the 115 programs of tests/accept, 55 build and give what their headers say (49 before)
4q. Tuples of two or three parts as values ((int, bool), (Box[int], int), a tuple in a struct or a payload), .0 and let (a, b) = e;; narrow(fs, "a", "b") as a tuple of capabilities; the one-shot fs_read and fs_writetypes.cho, emit.cho, exprs.cho, body.cho, scope.cho, runtime.llthe Rust compiler, by what the built programs doFourteenth slice of the backend. 467 builds run and compared (13 new), 90 trap; none different. Of the 115 programs of tests/accept, 66 build and give what their headers say (55 before)
4r. Generics, by monomorphization: a call to a generic function asks (instances.cho) for the function at the types its arguments solved, each type travelling as a code from one function’s table to another’s; the driver compiles the instances after the other functions, each with its parameters standing for those types, and a call from one can ask for more; literals of generic structs and enums (Pair { .. }, Option::Some(5), Option::None learning its type from a let annotation, a return or a call’s other arguments)instances.cho, types.cho, scope.cho, exprs.cho, body.cho, driver.cho, emit.chothe Rust compiler, by what the built programs doA difference closed; up to two type parameters and four fields or payloads, none that is a reference; a var x = Option::None that a later statement types has no code yet
4s. float and f32: literals read to the last bit with whole numbers only (floats.cho: the digits as a big number, a power of five and a shift, one rounding, ties to even, as f64::from_str and f32::from_str do), + - * / and the ordered comparisons, negation, the thirteen builtins (float_of, truncate with its three traps, bits_of, is_nan, sqrt, the f32 conversions and bit casts), floats in locals, parameters, results, structs, slices, arenas and static tables (run when the program starts)floats.cho, floatcode.cho, exprs.cho, emit.cho, types.cho, body.cho, scope.chothe Rust compiler, by what the built programs doA difference closed. 577 builds run and compared (135 new: 23 programs, 32 float and 17 f32 literals, and the negative of each float one), 94 trap; none different. tests/accept: 76 of 115
4t. Threads: spawn(payload, f) and join(t) on real OS threads (pthread_create with the function as the start routine and the payload as its one pointer; pthread_join and the cell the result comes back in), a function used as a value (let w = worker;) and called through the name (w(5)), payloads and results that are a whole number, a byte, a boolean, a handle, a reference or a capabilitythreadcode.cho, exprs.cho, types.cho, scope.cho, emit.cho, fold.cho (split out of exprs.cho)the Rust compiler, by what the built programs doA difference closed. 9 thread programs added to the compile differential, none different. tests/accept: 82 of 115 (the spawn programs that stop do so at the clock, the network, usleep and pthread_self, not at threads)
4u. Foreign functions: a call of an extern fn crosses as the Rust backend sends it (a c_ptr as a pointer, an int as 64 bits, a byte or bool as 8, a float as a double, a borrowed [byte] as its pointer and length, a borrowed capability as nothing) and comes back as a pointer, 64 bits, a c_int made 64 by sign, or a boolean; c_ptr values, null_ptr(), == and != on them; the call spells its own function type, so a program’s extern fn write or memchr need not agree with the runtime’s declarationexterncode.cho, codes.cho (the builtin numbers, out of exprs.cho), exprs.cho, driver.chothe Rust compiler, by what the built programs doA difference closed. 4 foreign programs added to the compile differential, none different. tests/accept: 90 of 115
4v. Sockets and the clock: bind, connect, listen, accept (edition 2), tcp_listen, tcp_connect, tcp_accept, conn_read, conn_write, conn_nonblocking, listener_nonblocking, conn_nodelay, conn_close, listener_close (edition 5) with their answers (Listening, Dialed, Accepted, Received, Sent), and clock_ms, clock_unix_ms; the host and the port checked against the bound a Net was narrowed to, trapping when they differ. The support is C (runtime/net.c) turned into LLVM IR for each target by runtime/make.sh and printed only into a program that uses itnetcode.cho, runtime/net.c, runtime/net_linux.ll, runtime/net_darwin.ll, runtime.cho, exprs.cho, codes.cho, driver.chothe Rust compiler, by what the built programs doA difference closed, on both systems. 4 programs added (the edition 2 integers, a connection made, accepted, written and read inside the process, one refused, the clocks), none different. tests/accept: 94 of 115 on macOS arm64 and on Linux x86-64 (gram), the same 94
4w. Pipes: pipe_open (a socket pair, close-on-exec, SO_NOSIGPIPE on Darwin) with its four-leaf answer Piped, pipe_read, pipe_write, pipe_close, child_end_close, pipe_nonblocking, which are the connection verbs on the same support; a child’s streams as Stdio::Pipenetcode.cho, runtime/net.c, exprs.cho, codes.chothe Rust compiler, by what the built programs doA difference closed. 2 programs added (a child’s output read, and a child written to and read from: cat), none different. tests/accept: 95 of 115
4x. Directory handles and listings: open_dir, dir_enter, dir_open_read/new/append, dir_rename, dir_rename_new, dir_remove, dir_sync, dir_close, dir_list, dir_next, dir_list_close, dir_stat, dir_mode, dir_own_mode, each name one path component checked first; the support is C (runtime/dirs.c) whose only per-target text is the array of constants the compiler writes (platform.cho: open flags, AT_FDCWD, dirent and stat offsets, errno values) for macOS, Linux x86-64 and Linux aarch64 (TARGET linux aarch64)platform.cho, runtime/dirs.c, netcode.cho, driver.chothe Rust compiler, by what the built programs doA difference closed. A program that makes, measures, renames, removes through a handle compared both ways; the two accept programs pass. tests/accept: 97 of 115
4y. defer: the expressions wait on a stack and are written out where their block ends and at every return (after its value is read), innermost and last-written first, as the Rust lowering expands thembody.chothe Rust compiler, by what the built programs doA difference closed. 4 programs in the compile differential (the accept program, a loop, two frames, a res closed on both paths). tests/accept: 98 of 115
5a. The builtins on words: load_le64, byte_mask64, trailing_zeros, leading_zeros, popcount, mul_wide, add_carry, sub_borrow, value_barrier, copy_within, trap, with the bounds checks that trapwordcode.cho, codes.cho, exprs.chothe Rust compiler, by what the built programs doA difference closed. 11 programs added, among them the traps; none different. tests/accept: 104 of 115
5b. Capabilities held in structs (a place in the aggregate, {}, and no value), fork_heap and fork_clock (a new capability, nothing in the code)emit.cho, scope.cho, exprs.chothe Rust compiler, by what the built programs doA difference closed. The four accept programs that hold a heap or capabilities in a struct, and the two that fork, are compared both ways. tests/accept: 109 of 115
5c. References held in structs, enums and tuples: a reference to a value is a pointer, a run of bytes a pointer and a length ({ ptr, i64 }), put in and taken out like any field; the region such a field has is any_region, which outlives and is outlived by alltypes.cho, emit.cho, exprs.cho, body.cho, scope.chothe Rust compiler, by what the built programs doA difference closed. 5 programs added (a struct, an enum with a match, a tuple, through a borrow, destructured). tests/accept: 110 of 115
5e. The hardware AES and GHASH builtins (hw_aes_gcm, aes_encrypt_block, aes_ctr32, ghash_update, ghash_powers, gcm_tag, gcm_tag_diff) and the SHA-256 ones (hw_sha256, sha256_blocks, sha256_block_words): the functions are the text cancho-codegen-llvm writes for each target (runtime/crypto_*.ll, dumped and checked by a test of that crate), carried by a module that calls them; the call sites check lengths and trap as the Rust backend does (cryptocode.cho)cryptocode.cho, runtime/crypto_*.ll, ex.cho (split out of exprs.cho)the Rust compiler, by what the built programs doA difference closed on macOS. 4 programs added; of the 105 programs of examples/ and tests/programs/ the Rust compiler builds, 69 now build and give the same output (52 before). Not run on Linux yet
5f. The poller (epoll, kqueue: poller_new, poller_add_*, poller_modify, poller_remove, poller_wait, poller_close), the hand-over of a connection (conn_detach, conn_attach, with the table of epochs), conn_peer, conn_connect_status, tcp_connect_start, flush_out, read_bytes; runs of structs, enums and tuples (the offset is the size of the element), a reference returned, generic literals of up to 12 fields, a variant given as an argument typed by what the callee wants or by the other arguments; exec_spawn_in, a child’s pidfd on Linux, and the signal set and closefrom a spawn hasruntime/net.c, runtime/os_*.ll, netcode.cho, emit.cho, exprs.cho, lookup.chothe Rust compiler, by what the built programs doA difference closed. A program with a poller, a hand-over, a peer and a connect status, and one with flush_out and read_bytes, compared both ways; runs of structs. Of the 106 programs of examples/ and tests/programs/ that can be compared, 86 build and give the same output (69 before)
5g. A reached function the code is not written for is a refusal (SKIP, at its span); cancho build --compiler <exe>; functions held by their type (f: fn(int) -> [] int, up to three parameters) in a parameter or a name, called through the pointer; atoms.cho split from exprs.chodriver.cho, types.cho, scope.cho, exprs.cho, atoms.cho, emit.cho, crates/cancho/src/selfhost_build.rsthe Rust compiler, by what the built programs doA difference closed. tests/accept 111 of 115 build and agree (110 before); the programs of examples/ and tests/programs/ that can be compared stay at 86 of 106
5h. Operations on an open file (file_pwrite, file_pread, file_sync, file_truncate, file_size, file_lock) and on a path (fs_remove, fs_rename); the signal claim (signals_watch, signals_pending, signals_close, with the thread count spawn/join keep); tuples of up to eight parts; a reference or a capability a function lends and gives back; cancho build --compiler in a project; filecode.choruntime.ll, runtime/signals.c, filecode.cho, netcode.cho, types.cho, exprs.cho, crates/cancho/src/project.rsthe Rust compiler, by what the built programs do, and the signals conformance with the compiler in cancho as a third backendA difference closed. tests/accept 112 of 115 build and agree (111 before); 20 of 20 signal tests pass with it as a backend; cancho-cache, cancho-oci (golden digests), cancho-robot, cancho-gpu and cancho-hooks build with it
5i. A variable whose type is what later statements make it (var x = list.List::Empty;): a function with one is gone over twice, once to learn such types (written down after the function in the instance table as rows with a negative declaration) and once for the codeinstances.cho, driver.cho, body.cho, scope.cho, exprs.chothe Rust compiler, by what the built programs doA difference closed. tests/accept 113 of 115 build and agree (112 before); of the 106 comparable programs of examples/ and tests/programs/, 89 give the same output (86 before)
5j. The serial-port verbs (tty_open, tty_configure, tty_read, tty_write, tty_flush_input, tty_close), from runtime/tty.c with the layout of struct termios, the open flags, the Linux rates and the macOS IOSSIOSPEED step of cancho_ir::ttyruntime/tty.c, netcode.cho, codes.cho, driver.chothe Rust compiler, by what the built programs do; a program on a pseudo-terminal opened, configured, written to and read agrees with the Rust build on what it does and on what fails (macOS)A difference closed. tests/accept 114 of 115 build and agree (113 before); of the 106 comparable programs of examples/ and tests/programs/, 91 give the same output (89 before), 1 is refused (process_capture)
5k. child_kill (the signal by its bit, to the child whose word is in the slot; cancho_native_signal per system); a literal of a declaration with parameters assigned to a variable (x = Option::None;) is the type the variable hasruntime.ll, runtime/os_*.ll, netcode.cho, body.chothe Rust compiler, by what the built programs doA difference closed. Of the 106 comparable programs of examples/ and tests/programs/, process_capture builds and agrees; tests/accept stays at 115 of 116 (aliasing_in_a_slice_of_references, a slice whose elements are slices, is the one refused)
5l. The conformance suite run through the compiler in cancho: arenas of one 64 KiB chunk that trap when a request goes past it and are given back at a return; the datagram sockets (udp_connect, udp_bind, udp_recv, udp_recv_from, udp_send_to, udp_peer, udp_local_port, with the ring of senders and its tickets); tcp_listen_on (the address of its literal); the signal a child was killed by as its bit; udp_attach answering UdpOpened; --compiler auto and CANCHO_COMPILER; a refusal explained by the Rust front end’s sentencedriver.cho, emit.cho, body.cho, netcode.cho, runtime/net.c, runtime.ll, crates/cancho/src/selfhost_build.rsthe Rust compiler, by the whole conformance suite with CANCHO_COMPILER set, so that every plain cancho build of a test goes through the compiler in canchoA difference closed. 695 of 702 tests pass that way (667 of 703 before this stage); the 7 left are differences, not gaps: three cranelift-only tests, the wasm target, the object bytes of a tuple against a struct, the stride of a struct of bytes (3 bytes packed here, 24 there, so an arena of them holds more before it traps), and the one fixture whose slice has slices for elements
5m. The corpus of refused programs, measured against the Rust front end (tests/reject, 333): the compiler in cancho refused 180 with the Rust rule and built 127 that the language refuses (27 linear values not consumed, 27 effects not declared, 18 capabilities not narrowable, 14 uses after a move, 9 linear values taken apart, and a handful of others; #512). The effect row is now checked while code is written, in the direction that cannot be wrong (the body performs what the row does not declare; the other direction waits until every builtin records its effect: 24 valid programs were refused by it), and a builtin records the labels it performs whichever way its code is written (the sockets, the clock, the directories, the files had a path of their own that did not), which moves 23 programs to the right rule: 203 of 333 agree. refusals_agree_with_the_rust_front_end holds those 203 to the Rust rule and the 130 in reject_gaps.txt to still differing, so the list only shrinks. Also: the first builds with --compiler auto no longer collide on the compiler’s cache (eight started together failed 5 to 7 times in 8)body.cho, effects.cho, selfhost_build.rscancho check --output jsonA difference measured, and the first part closed. tests/accept: 115 of 116 build as before (the one SKIP is as it was)
5n. Linearity while code is written (docs/linearity-and-effects.md §4, linear.rs): each binding is 0, holds a res value, or has had it consumed (a fifth int in the log of bindings); a read of a res name moves it, a block that closes or a return with a value still held is linear-value-unconsumed, a second read or a borrow of a consumed one is linear-use-after-move, an assignment over a held one, a let that shadows a held one, an expression statement and a _ (arm or payload) that drop one are linear-value-unconsumed; branches save the states and the arms that go on must agree, a loop must leave them as it found them. A res value is a capability, a handle, a box, a thread, a prelude enum, a tuple or a struct or enum with such a member. Also linear-value-taken-apart for a field or component read out of a res value, * of a reference to one, and a pattern on a handle or a box. Compile mode only: the Rust oracle of the checker alone does not run the passscope.cho, body.cho, exprs.cho, rules.chocancho check --output jsonA difference closed, in part. tests/reject: 256 of 333 get the Rust rule (203 before); the accept corpus is unchanged and the whole suite through the compiler is 697 passed with the 7 known differences. Left in this family: the generic ones that need the mode bounds of a type parameter (val_generic_*, mode-bound-violated)
5g. What is not done: UDP, ttys, signals, function values as parameters, a struct as a thread payload in the code (5 of the 115 programs of tests/accept still stop: a function-typed parameter, a slice of references, a generic literal typed only by a later statement, tty_open, signals_watch); a static that calls a function of the program is still run at start; a compiler that checks as fully as the Rust one (about a fifth of the functions the fuzz cases and the library hold are still SKIP in the checker)not started
5p. region-chunk-exceeded (#514, docs/region-limit.md §2): a constant or folded count of alloc_slice in a region whose bytes do not fit the 65,536-byte chunk is refused while code is written, with the stride the Rust lowering uses (byte 1, f32 4, other scalars 8, a tuple of scalars 8 each; any other type is left to the trap at run time). Found by selfhost::parity going red on main when the rule landed in Rust (#518) without the compiler in cancho knowing itexprs.cho, rules.chocancho check --output jsonA difference closed. The three tests/reject fixtures get the Rust rule and leave reject_gaps.txt; tests/accept/region_slice_at_chunk.cho (the exact limit) builds and runs as the Rust build does
5q. narrow (lower/mod.rs, lower/narrow.rs, docs/narrowing-into-several.md): a literal narrowing a path capability (Fs, Exec, Tty) must be inside what it holds, at a separator, and not equal; Net by plain prefix; a set of libraries (Ffi) must be among those held and not the same set; several literals only for Fs and Exec, unrelated paths, none equal or nested; a borrowed capability, a non-literal, a capability with nothing to narrow are refused (narrowing.cho, from the literal each capability type already carries). A literal with an escape is left alone. Also: every trap is the one instruction the Rust backend writes (ud2, udf #0xc11f), once in the module as @cancho_trap, so a program stops with SIGILL whichever compiler built it (llvm.trap is SIGTRAP on arm64); the arena’s trap says why on standard error, as the Rust one (#514); an explicit --backend cranelift is not sent to CANCHO_COMPILER; the arena keeps how much is used beside its chunk and not in it, so all 65,536 bytes are the region’s (an allocation of exactly the chunk wrote 16 bytes past it and made glibc abort in the next malloc on Linux, found by the selfhost CI job)narrowing.cho, exprs.cho, driver.cho, emit.cho, runtime.ll, runtime/net.ccancho check --output jsonA difference closed. tests/reject: 278 of 336 get the Rust rule (259 before); every capability-not-narrowable program of the corpus; three of the known differences close (identity, crypto_builtins, sha256_builtins build with Cranelift natively); tests/accept unchanged
5r. The rest of the refusals that had a rule apiece (#512): program-shape (a main that does not take the one World and return int, or no main, said after every function is checked); capability-misused (a struct literal or a pattern of a capability or a handle, release of what is not one, a path or program reached through a capability that is not the borrowed Fs or Exec, a heap operation or fork_heap without the uniquely borrowed Heap); listen-host (the address of tcp_listen_on a literal dotted quad that the Net bound covers, tcp_listen and udp_bind only under a bare port); signal-not-claimable, foreign-scope, unsized-type (unbox of a boxed slice), thread-payload-type, no-function-values, not-a-tuple, not-a-place, pattern-shape, tuple and pattern arities; mode-bound-violated for a res value in an arena, a boxed slice or a T: val argument; type-mismatch for unbox of a reference to a box; an argument label (a path, a program, a library) performed by a function whose row is empty and which owns no capability is effect-not-declared, and a row that says more than the body does is refused again, with conc, heap and clock recorded and a function that performs what the masks do not keep (a label with an argument, a function value, a foreign call) left alone. New modules allocs.cho (the ends of alloc_slice, box, unbox, unbox_slice, box_slice) and, from narrowing.cho, the checks for listeners; exprs.cho is kept under the 2,000-line budget by moving the part of each that follows the evaluation of its operandsbody.cho, exprs.cho, atoms.cho, allocs.cho, narrowing.cho, effects.cho, driver.cho, rules.chocancho check --output json, and the rule each fixture declaresA difference closed, almost. tests/reject: 322 of 336 get the rule (278 before; the three fixtures that are a property of a target are not compared). The 14 left need a port of the thread leases of linear.rs, the check of a generic definition with its parameters unknown, a row in the type of a function value, and what an owned capability discharges by path
5s. The leases of linear.rs (docs/aliasing.md §6.1, docs/threads.md §3): a &! given to a thread is lent to it until the handle is joined; a spawn of a reference that is lent already, a read of a copy of a lent reference, branches that disagree about whether a handle was joined, and a loop that leaves a lease behind are borrow-conflict. A reference is a copy of the one it was bound from (a sixth int in the log of bindings); five leases are kept in the header after the log, and branches and loops save and compare them as they do the states; only the two shapes linear.rs follows end a lease (a handle named by a binding, a spawn joined where it is made). Thread[T, R] is a type that can be written in a signature, carries its payload so that a handle cannot outlive the borrow block the payload points into (reference-escapes-region), and unifies by its result; join no longer records conc (only spawn does). A static the interpreter is sure traps (an index out of range, a division by zero) is constant-trapsscope.cho, types.cho, exprs.cho, body.cho, consteval.cho, tables.cho (the Thread index)the rule each fixture declaresA difference closed. tests/reject: 329 of 336 get the rule (322 before). Left, each a feature of its own: the check of a generic definition with its parameters unknown (arithmetic_on_a_type_parameter, generic_body_checked_when_unused, generic_array_over_a_resource), a row in the type of a function value (function_value_row_undeclared), what an owned capability discharges by path (fs_owned_prefix_covers_sibling, exec_owned_prefix_covers_sibling), and the region of a variable settled by inference (reference_escapes_via_inference)
5t. Refusals as sentences (#479): the compiler writes the sentence of the rule it refuses by (Rule::explanation, generated into tables.cho) on a line of its own after ERR <rule> <from> <to>, and cancho build renders where (file:line:column), why, the rule, the line and what of it, without asking the Rust front end; the CLI no longer calls the Rust front end to explain or to say whether the two agree. The sentence is the rule’s, the same for every program it refuses; the names and types of the Rust compiler’s messages (125 shapes) are not portedtables.cho (generated), rules.cho, driver.cho, selfhost_build.rsthe sentence of Rule::explanationA stage. What docs/agent-errors.md calls the shape in JSON waits for check in cancho (#485)
5u. The default (#478): the compiler in cancho writes the code of build, run and test on macOS and Linux unless the Rust compiler is asked for (CANCHO_COMPILER=rust), the build is Cranelift or WebAssembly, or the target is not a host it writes code for. Its sources are compiled into the cancho binary (build.rs lists each file) and built once into the cache, so nothing has to be next to the binary; CANCHO_SELFHOST_SOURCES builds from a directory. While reject_gaps.txt lists a program, the Rust front end checks first (RUST_CHECKS_FIRST, and a test that says it goes when the list is empty); a build the compiler has no code for, or that breaks, is done by the Rust compiler (an arena of aggregates with a byte or f32 leaf is such a build: the two compilers do not lay them out alike), and named alone (--compiler, CANCHO_COMPILER=auto) the compiler in cancho says what it cannot do. write_bytes is one fwrite, as the Rust backend’s, so a write that fails is reported by flush_out as it is there. A bug found on the way: the binding log grew to six ints and the header of the linearity state fell, in a program of 330,000 tokens, into the module textselfhost_build.rs, main.rs, project.rs, build.rs, driver.cho, allocs.cho, ast.chothe conformance suite under the default, and with CANCHO_COMPILER=auto in CIThe first version. 710 of 710 conformance tests pass under the default on macOS
5v. The process builtins of edition 9 (docs/processes.md §4.11, #524/#525): exec_spawn_detached is exec_spawn whose child leads a session of its own (a 19th argument of cancho_spawn, and POSIX_SPAWN_SETSID in the flags cancho_spawn_setup sets: 0x80 on Linux, 0x400 on Darwin) and performs exec_detached as well as exec(p); child_pid(&child) reads the pid out of the word; child_release(child) closes the pidfd and starts a detached thread of 64 KiB of stack whose one call is waitpid on that pid (runtime/process.ll, included only by a program that releases a child). All three are edition 9, as in Rust. A row that carries an argument label is no longer a reason not to compare the plain labels: Label::covers never lets a label with an argument cover a plain one, so a plain label performed and not written in the row is effect-not-declared whatever else the row hasfilecode.cho, exprs.cho, codes.cho, effects.cho, driver.cho, runtime.ll, runtime/os_linux.ll, runtime/os_darwin.ll, runtime/process.llthe rule each fixture declares; the seven process_release conformance tests and tests/accept/process_release.cho through CANCHO_COMPILER=autoA difference closed. tests/reject: 330 of 337 compared programs get the rule (327 before): child_release_undeclared, child_released_then_used and exec_spawn_detached_undeclared leave reject_gaps.txt; the three builtins leave unported_builtins.txt and six process_release:: tests leave .github/selfhost-known-differences.txt. Measured on Linux x86-64 only; not run on macOS or Linux aarch64 before CI
5w. A generic function is checked once with its type parameters unknown (rigid), as the Rust front end does (#512): the body runs through the checker with code writing off (body.cho check_function), so a name that only a type parameter can’t support (arithmetic_on_a_type_parameter), a body that is wrong whether or not anything calls it (generic_body_checked_when_unused) and an array of a resource type (generic_array_over_a_resource) are refused with the Rust rule. Linearity is tracked only when code is written, so a generic function with an unbounded parameter that passes this check is SKIP, left to its instancestypes.cho, scope.cho, body.cho, exprs.cho, allocs.chothe rule each fixture declares; the_body_checker_in_cancho_agrees_with_the_rust_one_per_functionA difference closed. tests/reject: 333 of 337 compared programs get the rule (330 before); three fixtures leave reject_gaps.txt, four remain. Measured on Linux x86-64 only (conformance selfhost filter: checker, body checker and refusals tests); not run on macOS or Linux aarch64 before CI
5x. child_kill_group (docs/processes.md §4.12, #533): child_kill’s signals to the process group the child leads. Builtin 138 in codes.cho (spelled exactly, so child_kill is untouched), arity 2 and cancho_child_kill_group in netcode.cho, and no support flag in exprs.cho since the function is in the runtime every module has; runtime.ll has it beside cancho_child_kill, the pid negated and a pid of 1 or less EINVAL with no call. Edition 9, row child_signal, in the regenerated tablescodes.cho, netcode.cho, exprs.cho, runtime.ll, runtime.cho, tables.cho, builtins.cho, signatures.chothe five process_group conformance tests, which build the driver with the compiler in cancho on the llvm backend and with the Rust one on Cranelift, and tests/accept/process_group_kill.cho through CANCHO_COMPILER=autoA port with no gap. Nothing is added to unported_builtins.txt, reject_gaps.txt or selfhost-known-differences.txt; the two fixtures that reach the Rust front end’s rules (child_kill_group_is_edition_nine, child_kill_group_undeclared) get the same rule from both. Mutating runtime.ll (the pid not negated) fails three of the five tests. CANCHO_COMPILER=auto cargo test -p cancho --test conformance: 724 passed, 7 failed, all seven already in selfhost-known-differences.txt. Measured on Linux x86-64 only

The method. A port that builds no tree has nothing to compare, and one that does needs a printer, which is another port. So stage 2’s parser wrote the tree the Rust parser would have built as a postfix listing, one node per line, children before their parent, at the moment the Rust parser pushes the node into its arena, and says how many children it takes. dump_ast.rs writes the same listing from the Rust AST. Each node carries its span, so the comparison covers every position the parser records; every refusal is one line, ERR rule start end. examples/selfhost/diff.sh compares the two over files, fuzz.py over the files plus hostile edge cases (the limits of int and of float and f32, escapes, the order of module, import and items) and mutants (a token dropped, duplicated, replaced or swapped, the file cut short, a byte range deleted), and tests/conformance/selfhost.rs runs both ports over the repository’s programs on every CI run.

Stage 3a: the tree. Stage 2 printed as it parsed; the checker needs something to walk, so ast.cho is the same parser building the Rust AST’s own design: flat tables and indices, not owned children. A node is a record of 16 integers in one table (kind, span, the next node of its list, and what its kind keeps), a reference to a node is its index, a list is a chain (the parent holds the first child and the count). The whole front end, state, tokens and nodes, is one allocation whose size follows from the length of the text (every node and token takes a byte, so tokens bound nodes), 160 bytes of state per byte of source. parser.cho is now only the walk that prints the tree in the listing’s order, so a match with the oracle is a statement about the tree: the walk reads nothing the tree does not hold, except where the parser had already checked the tokens and the node keeps only where they start (an effect row, a declaration’s [T, &r where ...], a destructuring pattern, a match pattern). Everything stage 2 was checked with passes unchanged, and the translation compiled and matched the Rust parser on all 621 programs the first time. The cost of the walk: ast.cho (1,921 lines, 1,678 not comment or blank) and parser.cho (847, 761) are 2,768 lines against stage 2’s single 1,970; parsing and walking its own 80 KB takes 0.24 s (stage 2 took 6 ms for its own 59 KB), with a 13 MB table to fill first; where the time goes was not measured.

Stage 3b: the declarations. The Rust checker is one long function, and its first half, collect_declarations, reads no body: imports, type declarations, foreign declarations, statics and function signatures, in that order, stopping at the first refusal. That half is now a function of its own (cancho_ir::check_declarations, a pure extraction; lower calls it), and it is the oracle: the port’s first refusal must be the Rust checker’s, rule and span, which makes the order of the checks part of what is compared. pass1.cho ports, in that order: imports (check_imports), type names (built-in and duplicate), members (duplicate fields and variants, enum with no variants), the finite-size check (reaches, with Box the finite way back), statics, and signatures (built-ins, duplicates, generic and region names, where clauses, duplicate parameters, pub signatures naming private types), and under all of it resolve_type: modules, qualifiers, arity, type parameters, scalars, regions, [T]’s size, tuples, pub. A type is resolved in place: each TName node of the tree is annotated with what it names, which is what the tree is for.

Two things make a partial port honest. SKIP at the point of divergence. While the port was partial, a construct whose checks were not ported (an extern fn, a val declaration, a val-bounded parameter at an argument that was not a plain scalar, Ffi with a library) ended the check with the answer SKIP where the Rust checker would have reached it, so a refusal found before it still counted and anything after it did not, and a skipped file was counted, by what the oracle said, not hidden. Each slice removed some; the last removed the answer, and the harnesses now fail on anything but the oracle’s. Generated tables. The prelude’s 49 types (46 before udp.md added Udp, UdpOpened and Datagram) (name, arity, edition, whether another module may name it, which parameters are val-bounded) and the 137 builtins’ names (118 when this was written) and editions are data the port cannot read from Rust, so tables.cho is generated, and a cancho-ir test fails when it is not what prelude_types, mode_of and Builtin::ALL say (UPDATE_SELFHOST_TABLES=1 cargo test -p cancho-ir selfhost_tables rewrites it).

What it found: the oracle caught an off-by-one in the import span in the first run, and nothing else differed in 51,150 cases; the same two keywords as before (module, region) cost a compile cycle each as the names of locals; and cancho fmt refuses import m.ast as ast, an alias equal to the last segment, which the file then spells without the as. pass1.cho is 920 lines (788 not comment or blank); the Rust it ports is spread over defs.rs, function.rs and lib.rs, 942 non-comment lines in the ranges that hold it, about a quarter of which (foreign declarations, modes) is not ported, so the ratio is nearer 1.2 than 0.8. That split is an estimate, not a count.

Several files, and modes. The compiler parses a program as a set of files, the user’s and then std’s, into one tree (parse_into, each file at its own base offset), so the port now does: driver.cho reads a stream of files on standard input (a line FILE <length> and that many bytes, each), lays them end to end with one byte between them as the Rust SourceMap does, and ast.cho tokenizes and parses them one after another into the same tables, with a module table (a path names one module however many files declare it) and an edition per file. Parsing std and a program together, 485 KB, gives the Rust parser’s listing byte for byte, in 0.27 s. Checking with the library then exposed what the first slice had skipped: std names types with a val bound (Option[T], Map[V: val]) at arguments that are not plain scalars, so modes are ported too. A type’s mode is written as a function of its parameters, a bit for “res whatever they are” and a bit for each parameter that makes it so, so nothing is substituted; the prelude’s bits are generated from mode_of itself (probing each parameter with a res type, which is exact because a mode is a disjunction over members), and a type the file declares has the bits of the members resolved so far, as the Rust checker, which fills members in as it goes, sees them. Ffi’s scope (parse_scope) is ported with it. Only an extern fn still ended a check in SKIP, which is the next paragraph.

Foreign declarations. The last part of the declarations, foreign.cho, ports the loop that handles every extern fn: the name is not a builtin’s nor a symbol another declaration binds, every parameter and the result resolve (c_ptr, and c_int for a result, are names only a foreign signature has), what crosses is what C can name (an int, a bool, an opaque pointer, a borrowed capability, a borrowed [byte]), an Ffi is narrowed to a library and not to nothing, exactly one Ffi is borrowed, and the row is the same set as what the borrowed capabilities discharge. What a capability discharges is data and is generated into tables.cho from discharged_by (each label plain, narrowed to the literal the type is written with, or to the empty string), the way the prelude’s modes are. The comparison is by equality of strings as written, with one subtlety it found a way to be wrong about and did not: the Rust checker canonicalises an Ffi’s library set (sorted, once each) in the type but compares the row’s ffi("...") as written, so ffi("libssl,libc") is not ffi("libc,libssl"), and the port reproduces that. The rules moved to rules.cho and the order of the checks to checker.cho, to keep ast.cho under the file budget.

What it does not show, and the next slice: the declarations half of the checker is complete; the bodies are not started, and they are most of it. The library is parsed in name order and not the compiler’s, which cannot change an answer about the program’s file because the library has no refusal of its own and the program’s items come first. A type that contains itself met first by a mode check makes the Rust checker recurse without end (the program is refused anyway, after the stack overflows); the port answers res past a depth of 64 instead, and no case reached it.

Stage 3c: bodies, one function at a time. The second half of the Rust checker reads function bodies, and a body is checked against other functions’ signatures and never their bodies, so each function is a case of its own. cancho_ir::check_bodies (the loop lower already ran, extracted as body_results) answers per function, and the port answers per function: OK, the first refusal of the body, or SKIP where the body uses something it does not check yet. That makes a partial port useful at once: it can be exactly right about the functions it handles while it does not handle the next. SKIP is raised where the Rust checker would have met the construct, so a refusal found earlier in the function still counts.

body.cho handles functions with no type or region parameters and only scalar parameters and result: let and var, assignment to a local, expression statements, if, while, return; integer, float, f32 (edition 6) and bool literals, locals, the unary and binary operators, and calls to such functions (arity, argument types, locals that are not functions, module qualifiers, visibility). What the Rust checker does for them is more than types: a literal operation that can only trap is refused at compile time (constant-traps) and folds through nested literals, so (1 + 2) * 9223372036854775807 is refused where the outer operation is met; unreachable-statement and missing-return are decided on the lowered statements; and an empty row is exact, so a declared label that nothing performs is refused. The order is the Rust checker’s: the body, then the row, then the return. Integer arithmetic in cancho traps on overflow, so every folded operation is decided before it is taken (the boundary cases for + - * are in fuzz.py).

Results: nearly all of what the port can say is checked against the Rust answer and none differs. How much it can say is the number to watch: with the library, 271 of its 789 functions (34%) are verified OK and the rest are skipped, because the port has no references, structs, match, generics or builtins yet. Every program’s main is skipped (a World is not a scalar), so no program is wholly verified yet.

How strong the comparison is was measured, not assumed: a mutation test changes one comparison or arithmetic operator at a time in body.cho (144 mutants) and asks whether the comparison with the Rust checker notices. The first run caught 82; the survivors were boundaries the edge cases never reached (an overflow decided at exactly int::MAX, a product at exactly -2^63), and adding them took it to 101; an operator-by-operand-type matrix (every operator on int, bool, float and byte) took it to 105 of 144. The 39 that survive were not each analysed. The ones looked at are of two kinds: equivalent (a loop over the node list that starts at the first item, b > 0 against b >= 0 where the zero case is decided earlier) and hidden by SKIP, which is a limit of the method: a mutant that makes the port skip a function it used to answer cannot be told from a function it does not handle yet. So the comparison cannot see a port that becomes more cautious, only one that becomes wrong. fuzz.py --bodies and bodies_diff.py are the harnesses, and selfhost.rs runs it in CI over the corpus, the edge cases, and a sample with the library.

Stage 3d: references and slices of scalars. types.cho is the type machinery a reference needs: a table of types in the state (a scalar is just a small integer, so it costs nothing), regions as integers (a parameter of the function, static, or a variable a call makes), Unifier::unify and unify_regions, outlives (reflexive and transitive over the declaration’s where clauses, with static outliving everything), and expect_type with its two coercions: a reference whose region outlives the expected one, and a unique reference where a shared one is wanted, never the reverse, the referent invariant. A call instantiates the callee’s region parameters as fresh variables that its arguments solve, then checks the callee’s where clauses against what they solved to (an unsolved variable outlives nothing but itself). Order matters and is the Rust one: a reference is unified by uniqueness first, then region, then referent, which decides whether a mistake is a type-mismatch or a region-mismatch.

On top of it body.cho gains *r, *r = v, s[i], s[i] = v, s[a..b], len(s), string literals (a shared slice of bytes in static), let with a reference type, and calls whose parameters and results are references. A reference is val whatever it points at, so nothing here is a resource and the linearity half of the checker cannot refuse any of it; that is what made this a slice the port could take without the borrow blocks, which are where borrow-conflict lives. What it still skips: references to anything but a scalar or a slice of one, borrow and region blocks, *s of a slice, and writing through a slice reference.

Result: with references the port verifies 437 of the library’s 789 function bodies (55%), up from 271 (34%), every one the Rust answer, and 375 OK bodies among the repository’s own programs, up from 278.

Stage 3e-1: structs of scalars. A struct the file declares, with no parameters, not res, and only scalar fields, is a type the table knows (NAMED, by the node of its declaration); it is val, so a value of it copies and linearity still has nothing to say. On top of it body.cho checks the struct literal (every field once, in declaration order, each of the declared type: unknown-name, duplicate-declaration, field-order, missing-field, not-a-struct, not-public in the order Rust meets them), p.x through a value or one reference, and p.x = v through a unique reference. Anything else that mentions a struct (a prelude type, an enum, a generic or res struct, a field that is not a scalar) is SKIP. The library gains little, since its structs are mostly generic or res (439 of 788 verified); the repository’s own programs gain the most.

The mutation test of body.cho kills 139 of 199 (105 of 144 before); the survivors in the new code are node numbers that are never 0 and the equal-index case a duplicate has already caught.

Stage 3e-2: enums of scalars and match. An enum the file declares, with no parameters, not res, and only scalar payloads is a NAMED type like a plain struct, and val. body.cho checks the variant expression (not-an-enum, unknown-name, not-public, arity-mismatch, each payload of its declared type) and match on a value or on a reference to one: the arms, in order, each a _ or a variant named once (match-arm-unreachable for a repeat, for an arm after _, and for a _ when every variant is already named; unknown-name, arity-mismatch, duplicate-declaration for a pattern; match-not-exhaustive at the end), with the bindings in a scope of their own. Through a reference each binding is a reference to the payload with the scrutinee’s uniqueness and region, so nothing moves. A pattern is not kept in the tree, so the checker reads the tokens after the arm’s first. . on an enum is match-on-a-non-enum (a read) or linear-value-taken-apart (a write), and a match terminates when every arm does. Anything else about an enum (a generic or res one, a payload that is not a scalar, a prelude type) is SKIP. The mutation test of body.cho kills 180 of 255; the survivors in the new code are node numbers that are never 0 and loop bounds the guards above them already settle.

Stage 3e-3: generic functions. The type table gains a rigid type parameter (Param, by its position) and a type variable (Var, with its solution in an array beside the table, like the region variables). A call to a generic function makes a fresh variable for each type parameter of the callee, lowers the callee’s declared types with the parameters as those variables, and unifies each argument with its parameter, binding variables as Unifier::unify does (an occurs check, and the result a type-mismatch, a region-mismatch or an infinite-type). Once the arguments have been unified every variable has to be solved: the first that is not is ambiguous-type at the call. The return type is then resolved all the way down, so no variable leaves the call, and nothing else in the checker needs to look through one. A function whose own type parameters are all val-bounded is checked with them as rigid types; one with an unbounded T is SKIP, because a value of T could be a resource and linearity is not checked yet. Generic structs and enums (a type applied to types) are the next slice. The library gains nothing (its generic functions mostly take a Heap or have an unbounded T), so the number of bodies verified stays at 461 of 814. The mutation test of body.cho kills 190 of 271, its survivors in the new code changing only whether a function is SKIP (which the comparison cannot see) or a loop bound the guards settle; that of types.cho kills 60 of 78 (40 of 54 before), the survivors in the new code being an occurs check no found type can reach, a variable compared with itself that a found type cannot be, and counters that stay distinct under the swap.

Stage 3e-4: borrow and region blocks. A block is a region of its own, numbered in the order it opens and remembering the block that was open around it; a reference into it is Ref with that region, and outlives answers as Region::Block does (a block outlives the ones it encloses, a region parameter outlives every block, nothing outlives static). borrow x as &r in {} looks x up (unknown-name), opens the block, freezes x (or locks it, for borrow mut), binds r to a reference of the block’s region, checks the body and thaws; region a {} is the same with nothing frozen. Whether a binding is borrowed is a flag in its record: owned, frozen by n shared borrows, or locked by a unique one. A name read while locked, an assignment while frozen or locked, a second unique borrow, and a unique borrow of a frozen name are borrow-conflict. Rust finds those in its linear pass, which runs after the body and only if the body had no error, so the port notes the first as it walks and reports it at the end, after the check that no binding holds a reference into a block it outlives (every binding is logged with the block open where it was declared) and before the row and the missing return. A let annotation that names a region inside a block is SKIP, because the name may be the block’s own. A borrow or region whose body returns terminates the block around it. The header of the state grows to 48 slots and a binding record to four. Of the first mutation run’s survivors, some showed that the cases used the first binding and the first block everywhere (so an offset swap changed nothing); the cases now come padded with bindings, with sibling blocks opened before, and without the final return that made the statement after a returning block unreachable-statement before a conflict could be reported. The mutation test of the new code in body.cho kills 33 of 42, the survivors being equivalent comparisons, a stored name nothing reads, and loops whose only effect is whether a function is SKIP.

Stage 3e-5: builtins and prelude types. What the checker needs to know of a builtin is data, so it is generated: builtins.cho, beside tables.cho, holds for each builtin its signature as a prefix-coded run of integers (a scalar, a reference with its region parameter, a slice, a prelude type, a tuple, a type parameter), its region parameters and its effect labels, and for each prelude struct its fields (the Split of each edition). prims.cho decodes a signature into the checker’s types, with each region parameter a fresh variable, and a call to a builtin goes the way a call to a function does: the arity (arity-mismatch), each argument unified with its parameter, the result. What it cannot hold (a tuple, a type parameter, a literal type, a prelude type that is a resource and not behind a reference) decodes to nothing and the call is SKIP; so is a builtin the lowering checks by hand (split, release, len, box), and a builtin that performs an effect, because rows are not checked yet and answering OK for a function whose row Rust would refuse would be wrong. The prelude types without arguments are now types of the checker too: Io, Heap, Args, File behind a reference (a borrowed capability is val), and Done, Read (not resources) by value; a field read or a match on one is SKIP. The case list now has a strictness: a skipped function cannot be seen by a comparison, so a change that only makes the port give up would pass; the cases written to be answered are marked, and one of them skipped is a failure. The mutation test of prims.cho kills 27 of 38 (9 of 38 before the cases were written for it), the survivors being branches no builtin the checker can call reaches. The state and body.cho were split for length: scope.cho (bindings, borrows, blocks and the lowering of written types), exprs.cho (expressions, folding, calls) and body.cho (statements and check_function).

Stage 3e-6: effect rows. A function’s row is exact (docs/linearity-and-effects.md section 7.3): what its body performs, which is what the builtins it calls perform and what the rows of the functions it calls say, must be the row, both ways. Rust finds the first label in the body that the row does not declare (effect-not-declared), and failing that the first in the row the body does not perform (effect-declared-not-performed); both are refused at the function. A set of labels is an integer here, bit i for the i-th label of the generated table (twenty, so far), a label a function’s row names that no builtin performs is a flag in a high bit, and so is one that carries an argument (fs_read("/tmp")). A call to a builtin adds its labels, a call to a function adds the labels of its row, in whatever branch or loop it is, and check compares the two sets once the body is clean and the borrow conflicts are reported, where Rust does, and before the missing return. What it leaves to SKIP: a callee whose row has an argument or an unknown label, and a body that performs anything in a function whose own row has an argument label, because a plain label and one with an argument are different labels and the port does not yet follow how an owned capability discharges them. A function that owns a capability by value is not checked either, so nothing is discharged here. The cases include the row in either order, with a trailing comma, with a duplicate, with a name nothing performs, rows that propagate through two and three callees and through recursion, and an effect in a dead branch, a short-circuit and an argument. The mutation test of effects.cho kills 24 of 31, the survivors being loop bounds that read one label past the table and a sentinel, and one case it found missing (a callee whose row is only an argument label), which is now one. With the pure builtins this takes the library from 590 of 814 to 625 of 855 verified, and the checker can now answer for a function that prints.

Stage 4a: the first code. The backend is written the way the Rust one is: the module is LLVM IR as text, and clang turns it into the executable (cancho-codegen-llvm does the same, so nothing here needs a library cancho cannot reach). The port writes it while the checker walks, because the checker already visits every expression in the order it runs and knows its type: the state gains three buffers (the finished output, the function being written, and its allocas, which belong in the entry block however late a let comes), and emit.cho the operations that write to them, an operand being a constant or a temporary %t<n>. Each function is define i64 @lexs_name(...) with a stack slot for each binding, a load for a name, llvm.sadd.with.overflow and its siblings and a branch to a trap for +, - and *, the explicit checks sdiv and srem need for a zero divisor and int::MIN by -1, the range check on a shift, comparisons as icmp, a call for a call and a ret for a return. The checker does what it did and writes only when compile mode asks; anything it cannot write yet (floats, bytes, references, control flow, a generic function) is a SKIP, which in this mode is a refusal with the function named. The IR is not compared with the Rust backend’s, which is another text; the programs are: each of 74 is built by both compilers and run, and must end the same way, the same exit status or a trap. A program the Rust build needs a main(world) for: the cancho compiler calls a function named run from a C main for now, until World and release are checked.

Stage 4b: control flow. An if is a branch on the condition to a then block, an else block (empty when there is none) and an end block; a while is a head block where the condition is computed, a branch to the body or the end, and a jump back. Each construct takes one number from the same counter the trap blocks use, so no two share a label. a && b and a || b keep their result in a stack slot of their own: after a the slot holds it and b is computed only if a did not decide the answer. The condition now hands its value back so the branch can name it. After a return the next block is a dead one, so every br that ends an arm has a block to end, and the end of the function is an unreachable the checker has shown nothing reaches. Programs that matter here: a short-circuit whose right side would trap if it ran (it must not), loops that trap on the iteration that overflows, an early return from inside a loop, and the recursive ones.

Stage 4c: World and main. World is the first prelude type with a type of its own in the table (by its index in tables.cho), and only in compile mode, where it is a type a parameter may have and a function may pass on. Like the Rust backend the port writes no code for it: a function’s World parameter has no argument and no stack slot, and a call’s World argument is not passed. release(w) is a call that writes nothing and answers 0. The driver calls the program’s main from the C one when it has it, and a function called run when it has not, as before. The checker’s answers do not change: outside compile mode a World still makes a function SKIP, because checking main means checking that its World is consumed exactly once, which is linearity and not written; in compile mode the program is taken to be well formed, which is the Rust compiler’s to say. The 123 programs are built through a real main as well as through run.

Stage 4d: output. split(world) answers a prelude struct (which one depends on the edition) and the destructuring let Split { io, ffi, fs, heap, args } = split(world); binds each part with its own type, taken from the generated fields; none of them has a code, as in the Rust backend. A reference is a ptr: borrow mut io as &!r in { .. } stores in the reference’s slot a pointer to a byte of its own (a Io has no representation to point at), and a borrow of a whole number or a boolean stores the address of its stack slot, so a function can be given &!i Io and a call passes the pointer. putchar is a trunc to i32, a call to the C function and a sext back, as the Rust backend writes it. What it made visible was the row of main: a function that owns a capability needs no label for what the capability allows (section 8.2 of docs/linearity-and-effects.md), so the effect check subtracts what the parameters discharge, taken from the same generated table that discharged_by fills, before comparing. The test now compares what a program writes as well as how it ends, and the first thing a cancho-written compiler printed was Hi.

Stage 4e: strings and slices. A reference to a slice is two values, a pointer and a length: a slot for each (%s7_0, %s7_1), two parameters in the header, two arguments in a call, and len is a load of the second. A string literal is a private constant @str.N written to a fourth buffer (the six escapes the lexer knows are decoded as the constant is written) and the driver prints the constants before the functions. s[i] is a comparison of the index against the length, unsigned so a negative index fails the same test, a trap, then a getelementptr; a store through it or through *r is the same address and a store. write_bytes is a loop over putchar in a helper the driver writes once, so the two stay in order in the C library’s buffer. int_of is a zext; byte_of traps outside 0 to 255, which the first run showed: the first version truncated, and the Rust build trapped. Reassigning a slice variable (s = "yyyy") stored one pointer into a slot that does not exist, which clang found.

Stage 4f: structs. A plain struct is an LLVM aggregate of its fields, { i64, i1 }, which is one value: the slot, the parameter, the argument and the return are each a single operand, as they are for a whole number, and the checker’s existing walk needed only the pieces that look inside. A literal is a chain of insertvalue from undef, in declaration order (the order Rust already enforces); a field read of a value is an extractvalue; behind a reference it is a getelementptr and a load, and r.x = v is the same address and a store. A reference to a struct binding is the address of its slot, as for a whole number. Only structs of whole numbers, bytes and booleans have code; a struct with a float or another struct in it is still skipped, so the answer is SKIP and not wrong code.

Stage 4g: enums and match. An enum is an aggregate of its tag and, one after the other, the payloads of every variant ({ i64, i64, i1 } for A(int) and B(bool)); only the tag and the payloads of the variant it names mean anything, and the tag is the variant’s position. A value is built with insertvalue as a struct is; a match extracts the tag once (an extractvalue, or an address and a load behind a reference) and is a flat chain of tests: arm n is ma<n> when the tag is its variant and mb<n>, the next test, when it is not; the last arm has no test, since the arms cover everything (Rust has already said so), and each ends in a branch to the end of the match. A payload is bound by extractvalue, or, behind a reference, as the address of its element, so *w = .. writes the enum in place. Enums with a float or a non-scalar payload are still skipped.

Stage 4h: arenas. alloc_slice was not in the checker at all (it answered SKIP), so this stage is a rule first: the arena named must be open here, and an alloc_slice[static] in a function is an escape (only a static item may add to the static region); then the count, an int; then the fill, which must copy (a res one is mode-bound-violated); the result is a unique reference to a slice in the arena’s block. A return of anything pointing into a block is now an escape checked before the types are compared, as Rust does it: a function returning s from its arena as an int said type-mismatch, and Rust says reference-escapes-region; the new cases found that. In the code, a region is a cell holding the head of a list of its allocations, each a malloc with a 16-byte header linking to the one before, and the end of the block frees them all; both are two helper functions the driver writes (@cancho_arena_alloc, @cancho_arena_free). A negative count traps, and so does a size that overflows, as in the Rust backend, and the fill is a loop. What differs from Rust is the allocator (it bumps within a 64 KiB chunk) and one thing that is a gap: a return from inside a region leaves without freeing, so the memory lasts until the program ends.

Stage 4i: the heap. Box[[T]] is a pointer and a length, which in the code is the aggregate { ptr, i64 }: one value, like a struct, so it is stored, moved and passed as one operand, and a reference to a box is the address of its slot, so borrow mut b as &!q in { contents(q) .. } is the borrow of a whole number again. box_slice is the arena’s size and fill with a malloc in place of the arena (a null answer traps, as in the Rust backend); contents(q) loads the pointer and the length back into a slice in the block of the borrow; unbox_slice is a free and the length. They are written for compile mode only: the checker has no Box type and no ownership of one, so in the other modes a function that holds a box is still SKIP, as it was, and the 1,945 cases are unchanged. That is the honest limit of this stage: the code for the heap is compared against Rust by what programs do, and the rules for it (unbox_slice consumes, contents on an owned box) are Rust’s alone until the checker learns to follow ownership.

Stage 4j: static tables. The compiler’s own generated tables are static items, so the compiler could not compile itself without them. In the checker the name of a static of this module declared [T] for a scalar T is a shared reference to a slice in the static region, as docs/compile-time-data.md says; the oracle checks only functions, so what had to agree was the uses (20 new cases, none different, and 29 more of the library’s functions are verified because they read a table). In the code a static is a global pair of a pointer and a length, @sd<n>, and a function @sinit<n> that runs its body and stores the pair there; alloc_slice[static], legal only inside such a body, is the heap’s malloc and fill; and the C main the driver writes calls every @sinit before main, in the order the items come. Rust evaluates a static while compiling and puts it in read-only data; this builds it when the program starts, so a static that reads another is not supported, and the table is writable memory. Neither is visible to a program, but the first is a difference: a static whose body traps is a compile error in Rust and a trap at startup here.

The census (mode 4, census.cho). To measure how far the compiler in cancho is from compiling a program, the bodies mode runs with the code on and says, for each function, OK (the checker accepts it and code is written) or SKIP <from> <to> with the first construct it has no code for. Fed the compiler’s own source and the std modules it imports (bytes, buffer, io, math; 644 functions), on 2026-10-12: 488 of the compiler’s own 544 functions (90%) get code, 20 of the 39 in std.bytes/buffer/io, and 6 of the 61 in std.math, which is floating point and which the compiler does not use. OK here means code is written, not that it has been run: the programs run so far are the 382 of the differential test. What the other 56 of the compiler’s functions stop at: a function that returns a slice or calls one (tables.*, builtins.effect_name, rules.rule_tag: about 20), a sub-slice s[a..b] (about 12), a struct with a field that is not a whole number, byte or boolean (block’s result, Buffer: about 14), a few builtins (wrapping_mul) and floats (1). Not measured: calls across modules, which the test programs do not make, and a binary that has been built from all of it.

Stage 4k: slices that come back. A function that returns a reference to a slice answers the pair, the LLVM aggregate { ptr, i64 }, and a call to one takes the pair apart again into a pointer and a length; return s; builds it. s[a..b] is Rust’s two tests, as in the backend: an end past the length traps, then a start past the end, both unsigned so that a negative number is a large one; then the address of the first element and the difference. The census, rerun after it: 529 of the compiler’s 548 functions (97%) and 24 of the 39 in std.bytes/buffer/io. What is left of the compiler’s own: block and lc.step, which return structs with a field that is a list or a boolean pair (8), wrapping_mul (2), Buffer and what returns it (std.buffer, 19 of its own): the struct that owns a Box.

Stage 4l: structs that hold structs. The census said what was left, and it was a few things. A field or a payload now has a type that is a scalar, a struct or an enum of the file, or a Box[[T]] of scalars (types.node_type), and the LLVM aggregate nests: { { i64, i1 }, i64 }. A struct declared res is one too, since what ends it is Rust’s to say; let S { a, b } = value; takes one apart. Reading a field through a reference follows docs/reading-references.md §2.0: a val one is copied out, a res one (the Box of Buffer) comes back as a reference to it, which is what contents(b.held) needs. Two smaller things were in the way of std.buffer: a byte could not be a parameter or a result, and five builtins had no code (wrapping_add/sub/mul are the plain instructions, copy_into a bounds test and memmove, index_of_byte a memchr, getchar the C function sign-extended, write_err a write to descriptor 2). The census, rerun: 558 of 558 of the compiler’s functions and 39 of 39 in std.bytes/buffer/io. That is the end of what the census can see and the start of what it cannot: a function that calls another module’s, two modules that each have a function of one name, and a whole program built into a binary.

Stage 4m: several files, and the fixed point. A function was named in the code by its identifier, which two modules may share (ast.get and lexcore.get would both be @lexs_get); it is now named by the number of its node in the tree, @lexs_<n>, which nothing else shares, and a call names the node it resolved to. A function the code is not written for is no longer a refusal of the program: it is left out, with its body rolled back from the output and a comment at the end of the module (; left out: 55) saying how many, and a call to it, or the program’s main, being left out, is a refusal (SKIP, at the construct the compiler stopped at); a function nothing names is left out in silence. A static that is left out gets a stub. compiler.cho is the compiler for a program of several files (drive(world, 3, true)), and the first try was to give it its own source and the std modules it imports, in the stream driver.cho reads:

  • the compiler built by Rust (the first) wrote 100,409 lines of IR, 637 functions, for it;
  • clang -O1 built that without a word, 343 KB: the second, which no Rust compiler took part in;
  • the second, given the same stream, wrote the same IR, byte for byte (shasum equal); clang builds that too, and the third writes it again. bootstrap.rs is that test (5.5 seconds);
  • the checker (bodies.cho) built by the second compiler agrees with the Rust oracle on 1,980 of 1,980 cases, as the one Rust built does.

What this is and is not. It is a compiler, written in the language, that is a fixed point of itself on its own source. It is not yet the end of epic #295: clang is still run by hand (or by the test), the first compiler is still built by Rust, the 55 functions left out are std.math’s (floating point), a program that needs generics, tuples, floats, sockets or threads in the code is not one this compiler can build, a static is evaluated when the program starts and not while compiling, and the checker part is a port of what the Rust checker does for about four functions in five (the rest is SKIP, which in compile mode means the program is trusted to have been checked). The compiler trusts a program that Rust has checked; it does not yet replace Rust as the thing that says a program is wrong.

Stage 4n: the compiler runs clang. The compiler wrote a module and a person ran clang on it. The builder (builder.cho, edition 7) does both: it reads a program on standard input, compiles it, writes the module to <output>.ll through an Fs, and starts clang on it through an Exec, waits for it, and answers its status. It is made of what the language has for the purpose, and the stage is mostly the front end that makes that possible in the code:

  • a prelude type that is a handle (File, Child…) is one value of the code, a descriptor or a pid; a prelude enum (Opened, Read, Spawned, Exited, Stdio) is the same aggregate as an enum of the file, built and matched alike; a capability narrowed to a literal (Fs("/tmp"), narrow(exec, "/usr/bin")) is its type with the token of the literal kept, which the code gives the runtime to check a path with. The generated tables gained the enums’ arms and how many leaves a handle is; the rows of functions are Rust’s to check in this mode;
  • runtime.ll is the support the code calls, written by hand and printed by the compiler into every module (runtime.cho is its text, checked against the file by a test): a path copied and checked (no .., the prefix, and /tmpevil is not under /tmp), fopen and dup for a file (the close-on-exec descriptor the Rust backend makes), read and write, posix_spawn with the three streams, waitpid. An error is its negative errno, and the code builds the enum from the number;
  • the module differs by target only in where errno lives (__error, __errno_location), so a line TARGET darwin or TARGET linux in front of the input says which. The Rust backend knows its triple; the compiler is told. The harness gives every program the line of the machine it runs on.

What this does not copy from the Rust backend: posix_spawn is given the three streams and nothing else (no closefrom, no signal mask: the child inherits what the parent has open above descriptor 2 that is not close-on-exec, and its signal dispositions), a child is its pid and not a pid with a pidfd, and clang’s own output is discarded (Stdio has no way to inherit), so a build that fails says its status and nothing more.

Along the way: an emit buffer was never bounds-checked, and the runtime alone is bigger than the buffers of a program of a few tokens, which silently wrote the end of a function into the middle of the runtime; a buffer is now given a floor, and a module that outgrew its buffers is a refusal (emit.overflowed). The signature table moved to its own generated module (signatures.cho) because builtins.cho passed the repository’s 2,000-line budget.

The bootstrap, again, with no hand. bootstrap.rs has a second test: the builder built by Rust builds the builder’s own source (and runs clang itself) into the second builder; the second does it again into the third; the three write the same IR. The tests/accept fixtures are a scoreboard for what is left: 49 of 115 build with the compiler built by Rust and give the output and the exit status their headers say, none gives a different one, and the other 66 stop at a function the code is not written for: generics (Option[T]), tuples (narrow(fs, "a", "b"), (int, bool)), threads, floats, pipes and the one-shot fs_read/fs_write.

Stage 4o: a static is computed by the compiler. Stage 4j built each static when the program started, which a program could not tell from docs/compile-time-data.md’s data in the binary except by a trap, a write or the start-up cost. consteval.cho is a small interpreter: it runs a static’s body over the tree, with one int to an element of its store whatever the type, and the answer is written into the module as @sd<n>.data, a constant array, and @sd<n>, a constant pair of its address and its length: read-only, in the binary, and nothing runs at the start (the initialiser stays, but empty). It gives up, and the static is run at start as before, on anything it does not do exactly: a call to a function of the program (doubled in static_data.cho calls twice), a match, an overflow or a division by zero (the Rust compiler refuses these at compile time; here the program is trusted to have been checked and the trap is at start), an index outside a table, a byte outside 0 to 255, more store than the 1,048,576 elements it has or fuel than 20 million steps, or a table that would not fit what the module has left. A static that reads another reads the answer of the one before it, if that was computed. The tables of the compiler itself, 1,100 lines of t[i] = v; each, are computed this way, and the fixed point holds.

Stage 4p: one value on the heap. Box[T] for a T that is not a slice is a pointer in the code, whatever T is, so a type that holds itself through a box has the size of a tag and a pointer and the declaration that did not compile in docs/heap.md §4 does. box(heap, v) is a malloc of the size of T (the address of the second element of an array of them, a constant of the module) with the value stored, unbox loads it and frees the block, contents of a borrowed box loads the pointer from the slot the reference points at and is a reference to what it holds, with the mode and the region of the borrow; alloc[a](v) is the same store into the arena’s list. A field of a struct or a payload of an enum that is a box is read through a reference as a borrow, as the other res ones are. The corpus of the stage: a list built, walked by a reference and freed, boxes of structs written through a unique borrow, a box in a box, structs allocated in a loop in an arena, a res struct holding a Box[int]. The first run of the checker modes found that the new expression also ran there and wrote code into buffers that were not: it is code only, and SKIP elsewhere, as it was.

Stage 4q: tuples. A tuple of two or three parts is an anonymous struct, so it is an aggregate ({ i64, i1 }), built with insertvalue, taken apart with extractvalue, passed and returned as one operand; .0 through a reference copies a val part and borrows a res one, as a field does, and let (a, b) = e; binds each part. A tuple of nothing in the code (the capabilities narrow(fs, "/tmp", "/var") returns) is nothing in the code. Tuples of more than three parts have no code yet. Along with them, the two builtins that open a file, read or write it once and close it (fs_read and fs_write) are one more call of the runtime.

Stage 4r: generics. A function with type parameters is not compiled where it is declared: a call to it knows the types its parameters are (the checker solved them from the arguments), so the call asks for an instance and names it @lexs_<node>i<n>. The types cross from the caller’s table to the instance’s as codes (instances.cho: scalars, a declaration with its arguments, boxes, tuples, prelude types), and the driver, after the functions, compiles each instance with its T standing for the type asked; a call from an instance asks again, so the worklist grows until nothing new is asked. A literal of a generic declaration has variables for its parameters until the values it holds say them; Option::None learns its parameter from the type a let or a return expects, or from the other arguments of the call it is in (it is written last), and the code of a literal is written only when all of them are solved.

Stage 4s: floats. A float is an LLVM double and an f32 a float; a constant is written as the hexadecimal of the bits of the double (an f32 is widened, which is exact). The one hard part is the literal: the Rust compiler reads it with from_str, correctly rounded, so the compiler in cancho has to as well, and has no floats to do it with. floats.cho keeps the digits as a big number in 30-bit limbs, multiplies by 5^e or shifts left and divides by 5^e (in chunks of 5^13, remembering whether anything was left over), takes the top 53 (24) bits, or fewer in the subnormals, and rounds to nearest even by the next bit and the rest. Arithmetic never traps; == is oeq and != is une, so NaN is unequal to itself; truncate and int_of_f32 trap on NaN and at 2^63 either way, as the Rust backend’s three checks do. The scalar types a slice, a field or a payload may hold are now int, byte, float, f32 and bool (types.is_plain).

Stage 4t: threads. spawn(payload, f) runs f on a new thread: f is pthread_create’s start routine as it is, so nothing is built for it, and the payload is the pointer it receives (a whole number, byte or boolean widened and made a pointer, a reference as it is, null for a capability, which is nothing in the code). join(t) waits and reads what it returned out of the cell pthread_join writes. A thread is a pointer in the code and a value that must be joined; a function used as a value is the address of its code, and a call through the name that holds it is a call to that function. A payload or a result of more than one leaf (a struct) has no code yet.

Stage 4u: foreign functions. A call of an extern fn is a call that spells its own function type (call i64 (i64, ptr, i64, i64) @write(...)), so the symbol is declared once whatever a program or the runtime says of the same C function, and a declare is written only where the runtime does not already have one. A bool is widened to the byte C is given, a [byte] is its pointer and then its length (so write(fd, buf, n) passes the length where C reads the count, as the Rust backend does), a c_int result is 32 bits and is sign-extended. A c_ptr is a pointer the code holds, compares with null_ptr() and gives back to C, and nothing else.

Stage 4v: sockets and the clock. The Rust backend writes each socket builtin out as a hundred lines of LLVM in the body of the function that calls it. Here they are C (runtime/net.c), compiled once for each system with -DDARWIN=0 and -DDARWIN=1 into IR that needs no header and no target (runtime/make.sh; every constant that differs, SOL_SOCKET, SO_NOSIGPIPE, EAGAIN, the offsets in addrinfo, is a macro, the same values SocketOs has), and the module of a program that uses a socket or the clock carries the IR of its own target. A call pushes its arguments and the bound the Net was narrowed to (pointer and length, as a path prefix is), and a function that answers a tag, a handle and a reason writes the three whole numbers to a cell, which become the leaves of the prelude enum. The Rust compiler’s behaviour is kept to the detail: an empty buffer to conn_read never reaches the kernel, accept clears O_NONBLOCK on Darwin, a host that is not the bound’s traps. The 115 programs of tests/accept were also built on Linux x86-64 (ssh gram, clang 21, LINUX_OUT in the scoreboard script) and the same 94 pass there.

Stage 4w: pipes. A pipe is a socket pair, so its verbs are the connection’s: pipe_read is conn_read and pipe_write is conn_write, and pipe_open answers Piped, whose four leaves are the tag, the parent’s end, the child’s end and the reason.

Stage 4x: directory handles. runtime/dirs.c is the same text for every architecture: what differs, the open flags (O_DIRECTORY and O_NOFOLLOW are not the same on Linux x86-64 and aarch64), AT_FDCWD, the offsets in struct dirent and struct stat, are an array of 21 whole numbers (cancho_plat) that the compiler writes into the module for its target, so the input line says TARGET linux aarch64 where Linux has two. A name is one component, checked before any call, as in the Rust backend: not empty, not longer than NAME_MAX, not . or .., no / or NUL.

Stage 4y: defer. defer E; is sugar, and stays sugar here: the statement puts the expression on a stack, nothing is written, and the block that holds it writes the expressions out in reverse where it ends (unless its last statement returns), and a return writes out all of them, after the value it returns has been read. Only in compile mode, like the other things the checker’s own modes do not handle yet.

Stages 5a to 5c. The builtins on words are plain LLVM written the way the Rust backend writes it (wordcode.cho). A capability held by a struct has no value, so its place in the aggregate is {} and nothing is put in or taken out. A reference held by a declaration is typed with a region that matches any: the code is written for programs the Rust compiler has accepted, so the regions a declaration names are not followed, which loses the escape check of a reference taken out of a struct and nothing else. A run of bytes is held as { ptr, i64 }.

The mutation test of types.cho (54 operator swaps) killed 40 on the first corpus of 458 targeted body cases (after region-variable, where-closure and coercion cases were added; 33 before); the 14 that survive are guards the other conditions already make redundant (&& on kinds that are checked again below, the bounds of a table index, the fuel of a closure that cannot cycle).

Size. parser.cho is 1,970 lines (1,760 that are not comment or blank) for the Rust parser’s 1,507 (1,225): 1.3 to 1.4 times. lexcore.cho is 963 lines (897) for the Rust lexer’s 633, with its generated tables. Counted with grep -v '^\s*//' and without blank lines; both ports are written one statement to a line, as cancho fmt leaves them. The port parses its own 59 KB in 6 ms.

What it found.

  • The lexer had drifted, silently. Between the spike and this stage the Rust lexer gained the f32 suffix on float literals (docs/f32.md), and the port, which had no test, would have refused every such literal. It has one now (selfhost.rs), which is the first thing the oracle approach is for: a port that is not run against its oracle in CI is a port of whatever the oracle was.
  • No integer constants. A token has to be stored as an integer, so the lexer grew a generated code function (one arm per token kind) and the parser compares with look(st, 0, lc.Tok::Comma), which runs that match on every test. A table of named integers is the one thing the port wanted from the language here.
  • No error propagation, so a refusal is sticky. The first refusal is recorded in the state and every later call does nothing; kind answers end of file once the parser has failed, so every list loop ends. That works, and it has one failure mode ? does not: every function has to be total on the state a refusal leaves behind. The fuzzer found the one place it was not (edition ): the number was parsed after the refusal, as if it were a number, and the arithmetic trapped). Fixed, with the fuzz seeds that found it kept.
  • Checked arithmetic shaped one function. An integer literal is read as a magnitude that must fit in 64 signed bits; summing it positively overflows at exactly the boundary the check is for, so it is accumulated as a negative number (which holds one more value) and each step is checked before it is taken. The boundary cases (9223372036854775807, ...808, -...808, the hexadecimal ones) are in the corpus.
  • Row exactness was a help. declaration_params first declared io_write without writing anything, and the checker refused it: the parse is pure and the printing is a separate walk over its tokens, which is the structure the listing needed anyway (the [...] of a declaration interleaves two lists the Rust AST keeps apart).
  • Smaller: region is a keyword, so no local can carry the name most parsers give it; the state is one slice of integers because that was the cautious choice, and a &!p reference to a struct with field assignment does work (checked while writing this), which would read better for the scalar slots.

What it does not show.

  • The tree is flat tables, not recursive Box values, because that is the design of the Rust AST being ported and what an arena-sized front end wants. Whether recursive boxed trees at this size are comfortable is still unmeasured (§4 showed they work, at a small one).
  • Float values are not computed: the listing has a float literal’s span and whether it is an f32, not its bits. Whether a literal rounds to infinity is checked, exactly, by comparing its digits with those of 2^1024 − 2^970 (2^128 − 2^103 for f32); the conversion itself is std.json’s to_float, which is there to be reused.
  • One file at a time. The Rust parser’s parse_into (many files, a base offset on every span) is not ported.
  • Inputs that are not UTF-8. The oracle takes a &str, so it decodes lossily and reports spans in the decoded text; the port reads bytes. The two cannot be compared and the harnesses skip those inputs (361 of the 52,272 fuzz cases).
  • Names are printed from their spans, not interned, so symbol ids are not compared.

Where it stands. Stages 1, 2, 3a and 3b are done as far as stated and nothing in them blocks the rest of stage 3, the checker (cancho-ir, 12 thousand lines), which is the real test: it is the first stage with enough shape (resolution, linearity, regions, effect rows) to tell whether the language is comfortable writing its own compiler. §5’s decision does not change.

Building with it from the command line

cancho build --compiler <exe> <file.cho>... [--std] builds the program with the compiler in cancho: <exe> is examples/selfhost/compiler.cho and its modules built once (cancho build ... --std). The command reads the files (and the standard-library modules they import), sends the target and the files to it, takes the LLVM IR, and has clang and the usual link finish the build. A refusal is exit status 1 with the Rust front end’s sentence for the same files and the rule the compiler in cancho refused it by (the compiler in cancho answers a rule and a place; if the Rust front end accepts what it refused, the message says the two disagree), and a program the compiler has no code for yet says so, at the construct. --compiler auto (or CANCHO_COMPILER=auto, or CANCHO_COMPILER=<exe>) has the compiler built from this repository’s examples/selfhost by the Rust compiler the first time, and kept in $CANCHO_CACHE or ~/.cache/cancho under a hash of its sources (a binary installed without the repository next to it says so). Targets: aarch64 macOS, x86-64 and aarch64 Linux. It is a way to try the compiler on real programs; cancho build without the flag is unchanged.