W# 0.1.1

Architecture

Five crates, one pipeline, and the decisions that shaped them.

source ──► wsharp-syntax ──► wsharp-sema ──► wsharp-codegen ──► native code
           lex, parse         infer, mono      Cranelift: JIT
                                               or object file
                            wsharp-runtime
                    heap, collector, header, builtins

The compiler is Rust, edition 2024, pinned to rustc 1.95. The backend is Cranelift, pinned hard so the workspace builds offline.

The crates

CrateJob
wsharp-syntaxLexer, recursive-descent parser with Pratt-style precedence, spans, diagnostic rendering
wsharp-semaName resolution, Hindley-Milner inference, the subtype lattice, overload selection, typed HIR, monomorphisation, value layout
wsharp-codegenHIR to Cranelift IR, the dispatcher, the write barrier, stack-map harvesting, and both backends over one lowering
wsharp-runtimeObject header, block and line heap, reference counting, the mark trace and its thread, evacuation, stack walker, type registry, builtins. A leaf crate with no dependencies at all
wsharp-cliThe wsharp binary, the module loader, the linker driver, and the end-to-end test suites
wsharp-startThe main a compiled program starts in, and the archive it links against

wsharp-runtime also holds the standard library and ingot, as .ws files compiled with your program rather than as Rust.

Two backends over one lowering

wsharp-codegen emits Cranelift IR once and hands it either to the JIT, for wsharp run, or to the object writer, for wsharp build. The lowering is the same code, so a program cannot behave one way when run and another when built. The end-to-end suite checks exactly that: every case is also built to a native executable and held to the same expected output.

Decisions worth knowing about

Inference runs per binding group. Top-level functions are grouped into strongly connected components of the call graph, inferred with their types held monomorphic, and generalised only once the whole group is done. That is what makes fib calling itself, and mutually recursive functions, check. An overload set lands in one group, so its members generalise together.

Subtyping is not in unification. Making unify directional would mean threading a polarity through every recursive call, including function types, where parameters are contravariant. Instead coerce consults the lattice after unification fails, which works because unification binds whichever side is still a variable, so anything reaching the subtype check is already concrete.

Levels, not environment scans. Type variables carry Rémy levels, so generalisation is a walk over one type rather than a scan of the environment.

Monomorphisation, because values are unboxed. i64, f64 and bool live in registers, so fn(T) T cannot be compiled once. Inference records the type arguments at each call site and a worklist pass emits one copy per instantiation. Unreachable functions fall out as dead code.

A value is a list of machine values, not one. Scalars and pointers take one slot; ?T and !T are a tag followed by the payload. No boxing, and no allocation for an optional.

A subtype’s fields are its supertype’s, first. That is what lets a field read compiled against a supertype run unchanged on any subtype, with no adjustment and no vtable.

Every function takes an environment pointer. Top-level functions ignore it and are called with null. That uniformity lets a plain fn be passed as a value without generating a wrapper.

A standard library entry is one table row, or one line of W#. builtins.rs holds (module, name, parameters, return type, function pointer); inference reads it to seed each module’s environment and code generation reads the same table to register JIT symbols. The HTTP status lattice is a second such table, and a program pays only for the statuses it names. Anything that moves a reference between objects is a .ws file instead, compiled with your program so that the collector’s barriers apply to it.

A module is a prefix on a name. Names are stored qualified in one flat table, and an unqualified lookup tries the current module and then the prelude. What a module cannot see is what it has no key for. Re-export falls out of that shape: pub const T = other.T; is one more key in the same table holding the same type, so nothing below the type checker knows it happened. Files are laid end to end in one offset space, so a span stays two u32s with no file in it and the renderer works out which file a span fell in.

Frame pointers are not optional

.cargo/config.toml sets -Cforce-frame-pointers=yes for the whole workspace. The collector walks the frame-pointer chain out of the runtime to find its roots, and the chain has to be unbroken through the Rust frames as well as the generated ones. Removing it produces a build that compiles and then collects the wrong objects.

Testing

212 end-to-end cases, around 40 of them negative cases checking that a bad program is rejected with the right message. The whole suite runs a second time under a collector that collects at every allocation and validates every root, and traces start on the same allocation schedule in both runs so the concurrent paths are covered both ways.

Last changed 8 September 2026. Improve this page

On this page