The kit underneath
knotkit
knotkit is a Rust library for building compilers. Its goal is to make a new language cheap enough to build that, for a problem with clean algebraic structure, writing a DSL costs less than working around an existing language's abstractions. Viv is the language it was built for; three other languages use it too.
The kit is 2,242 lines of Rust. It is small: a library you call rather than a framework you build inside, and each piece can be used on its own.
What a language gets from it
A C backend. The C99 backend emits readable C with no dependencies, so
clang -O3 applies the LLVM optimizer without the kit containing any LLVM code, and
the output can call any C library. A direct LLVM backend would be far more code to maintain for
most of the same benefit.
A compiler that runs in a browser. The compiler does all file and output access through an interface the host supplies, so it compiles to wasm and runs in a browser unchanged. The compiler on this website is the same Rust crate.
The architectural commitments
The kit's design rests on a written list of commitments, recorded because they are expensive to change later. The main ones:
- The IR is the source of truth
- Source text is what people edit, the AST exists only inside the parser, and the IR is what every later phase works on. No code after lowering sees the AST.
- Every node carries identity, location and lineage
- A NodeId (its structural hash), a Span (where it came from), and a lowering chain (what transformed it). All three are mandatory, because retrofitting any of them would mean touching every IR-aware component.
- NodeId is a structural hash
- BLAKE3 over the variant tag, the literal payload, and the children’s hashes. References between nodes are by hash, not by pointer or index — so structurally identical nodes have one identity, and changing a child changes the parent’s address. The IR is immutable; transformations produce new IR.
- The IR is stratified
- When a construct is lowered, both the construct and what it becomes stay in the IR, linked by the lowering chain. sum(xs) and the accumulator loop it becomes are both kept, so a tool can show or debug either one.
- Lowering rules are stored as data
- A lowering rule is a pattern, a replacement and some metadata, stored in a table and content-addressed like the IR, instead of a hardcoded compiler pass. A better lowering for an operator product can then be shared as data rather than as a compiler patch.
- Compiled programs contain no kit code
- No kit code appears at runtime in a compiled program. No kit allocator, no GC, no imposed calling convention. Racket programs carry Racket’s runtime; programs compiled with the kit carry nothing from it.
- Errors and warnings are structured values
- Errors and warnings are values with severity, spans and suggestions. Renderers consume them: ANSI for a terminal, JSON for an editor, HTML for a page, plain text for tests. Adding a renderer touches nothing that produces diagnostics.
- The host supplies all I/O
- The compiler core never reads a file or touches the network directly; everything goes through an environment the host passes in. The native CLI supplies one, the wasm build supplies one that bridges to JS, tests supply an in-memory one, so the same compiler runs in a browser.
There are structural commitments too: a language is described to the kit rather than written as a subclass of it, ownership is an opt-in the kit supports with a borrow checker at the IR level, and backends stay faithful to IR semantics so debug information survives code generation. There is also a list of things the kit deliberately does not do. It is not general compiler infrastructure: it assumes structured control flow and statically determinable types, so heavily dynamic languages, first-class continuations and reflection are out of scope. It is not LLVM and not MLIR. And it provides no standard library, because a standard library would add a runtime to every compiled program.
One rule keeps the kit small: a feature is added only when a language built on it cannot proceed without it.
Parts of the kit
| Piece | File | Lines |
|---|---|---|
| content-addressed IR store | crates/knotkit/src/ir.rs | 184 |
| C backend + C AST + DebugMap | crates/knotkit/src/backend.rs | 1,494 |
| diagnostics as data | crates/knotkit/src/diag.rs | 161 |
| spans + source map | crates/knotkit/src/span.rs | 313 |
| injected I/O | crates/knotkit/src/io.rs | 90 |
The C backend is the biggest piece and the most reused: it is generic over the source IR, so any language that implements the kit's code-generation interface gets a C99 backend without writing one. Viv and skein both use it unmodified. See it working on a nine-line program in the internals walkthrough.
Languages built on the kit
Building a first language shows what the kit has to support. Building a second one shows which parts work the same way for any language and which were specific to the first.
viv
the language the kit was built for
A general-purpose numerical language with floats, arrays, records and complex numbers, functions that cross-check several methods against each other, and three backends.
in useweft
compiles to Viv
A language for the weak form of a PDE, in the style of FEniCS's UFL. Write the variational form, such as bilinear: dot(grad(u), grad(v)), and its compiler generates the finite-element solver. For nonlinear problems it differentiates the residual symbolically to build the Newton Jacobian.
v1 + nonlinearskein
a second language, written to test the kit
A teaching Scheme that compiles to C through the kit: tagged values, lambda-lifted closures with capture, and proper tail calls through a trampoline, so (sum-to 1000000 0) runs in constant stack space.
v1, GC pendingoa
an experiment
Operator algebra for many-body problems. An early skeleton, written to find out what the kit needs from a language whose values are operators rather than numbers.
skeletal weft compiles to Viv, not to C. A weak form compiles to a Viv program, which
compiles to C, which compiles to native code or wasm. The element kernel it generates is
bit-identical to the hand-written one in fem_poisson.viv.
What is not done
The commitments document separates what is built from what the design
only allows for. The stratified IR and lineage tracking exist, but
zoom-level abstraction — rendering one program at several levels at once and
cycling through them interactively — is not built. Content addressing is in from day one, but
the content-addressed store (definitions kept by hash, names as a mutable
index) is at v0: viv hash and viv diff work, the store and
snapshot/log do not. The borrow checker is a
supported opt-in in the design and no language opts in yet. And
cross-language interop at the IR level — compiling two kit languages into one
program — is possible in the design but not built.