fastC
2026-02-12 · 10 min read · language-design, compiler, history

Building the core: stages 0.1 through 1.1

How fastC's rust harness, safety core, type system, traits, generics, and stdlib MVP got built — six months of foundational work.

The first post laid out the thesis. This one walks through the six months of foundational work that took fastC from “compiler skeleton” to “language that can build real programs.” The structural-safety claims do not show up yet — those land in stage 1.4 and stage 1.5, which the next post covers. This is the pre-wedge work. It is unglamorous and load-bearing.

The sequencing matters. We could not ship capabilities without a type system that could carry them. We could not ship contracts without a diagnostic system that could explain why they failed. We could not ship a stdlib without generics. Every stage below is gated on the ones above it.

Stage 0.1 — the rust harness and minimal front-end

The first compile happened against a stub. A logos lexer, a recursive-descent parser, an AST with maybe twenty node kinds, a minimal type checker, and a C11 emitter. The whole thing was maybe four thousand lines of Rust. It compiled fn main() -> i32 { return 0; } and not much else.

The design choice that mattered at this stage was emitting readable C11 from the start. Not minified, not single-letter-named, not preprocessor-heavy. The output had to be auditable, because we knew we would be debugging compiler bugs by reading the generated C for the next two years. That choice paid for itself by stage 0.4.

The other early choice was the AST shape. We picked an arena-allocated AST with explicit NodeId indices rather than Box<Node> everywhere. The Salsa incremental layer that landed much later (stage A5, in the v1.0.x sprint) depends on the AST being content-addressable. We did not know we needed that yet; the shape kept the option open.

Stage 0.2 — the safety core

The second stage was the C-class safety baseline. unsafe blocks (smaller in surface than Rust’s because the type system around them is smaller). The pointer hierarchy: ref(T) for borrowed reads, mref(T) for borrowed mutation, raw(T) for raw pointers in unsafe, rawm(T) for mutable raw pointers. Signed-overflow traps via __builtin_add_overflow and its siblings. Bounds checks on array indexing. Null checks on opt(T) unwrapping.

The runtime cost of the overflow checks was the part we argued about most. The fib(40) benchmark runs ~26% slower than C with -O2 because of the checks. We considered making them opt-in. We did not, because the whole thesis is “what an agent generates should not silently corrupt.” A wrong number that looks plausible is worse than a trap. The benchmark numbers in the README are honest about the cost.

Stage 0.3 — data types

opt(T) (the Option monad), res(T, E) (the Result monad), enums with payloads, switch exhaustiveness checking, and @repr(C) for FFI struct layout. This stage is where fastC stopped looking like C with seatbelts and started looking like a language with a real type system.

The exhaustiveness check is the part that pays off in the agent loop. An LLM writing a switch over an enum with seven variants will miss one. The compiler catches it. The diagnostic includes the missing variant by name. The agent’s next iteration fixes it. We measured the iteration count drop on T3 (the enum-heavy benchmark) from a mean of 2.4 compile rounds to 1.1.

@repr(C) is the FFI escape hatch. fastC structs are not @repr(C) by default — the compiler is allowed to reorder fields. When you need to hand a struct to a C library, you mark it @repr(C) and the layout is fixed. This is the same pattern Rust uses; we kept the spelling familiar.

Stage 0.4 — structured diagnostics

The miette integration. Every diagnostic has a unique error code (E0042), a span (file, line, column, length), a machine-readable category, a fix-it hint where applicable, and a JSON serialization via --output-format=json. The fix-it hints are not aspirational — they include the literal replacement text, and fastc fix (which ships in stage 1.3) applies them in bulk.

Deterministic emit landed here too. Same source, same compiler, same .c output — byte-identical, across machines, across runs. The hash table iteration in the lowering pass was the bug we chased for two weeks. We replaced every HashMap in the lower pass with a BTreeMap keyed by source span. The determinism guarantee is what fastc fmt --check and the reproducible-build pipeline (post 4) are built on.

Stage 0.5 — the developer toolchain

fastc fmt (canonical formatter with no configuration; one style), fastc check (typecheck without emit), the LSP server (fastc-lsp bound to the VSCode and Helix extensions), and the build integrations for Make, CMake, and Meson. The build integrations are thin shims — fastC emits C11, so a Makefile that knows how to compile C knows how to ship fastC binaries once it knows the fastc compile step.

The CMake integration is the one we recommend for most projects. The shipped template:

$ fastc init --build=cmake my-project
$ cd my-project && cmake -B build && cmake --build build

The Meson and Make integrations exist for teams that already standardized on them.

Stage 0.6 — examples and scaffolding

Ten tutorial examples (hello, add, fizzbuzz, read_lines, parse_int, vec_sum, hashmap_count, enum_match, option_chain, result_chain) and ten advanced examples (http_get, sqlite_open, cli_parse, json_decode, toml_read, regex_match, crypto_hash, base64_roundtrip, uuid_v4, time_iso). Plus fastc new and fastc init for scaffolding new projects.

The examples are not toys. Each one is shipped in the test suite and runs in CI. When the docs say “here is how to read a config file in fastC,” the code in the docs is exactly the code that compiles in the example tree. The agent’s training data ends up downstream of these examples (when training data is built from public repos), so they are also the corpus that shapes how models learn fastC idiom.

Stages 0.7 and 0.8 — foundation completion

These two stages were the unglamorous work of compile-time discipline: tightening up which annotations applied at which AST level, fixing inconsistencies in the type checker’s handling of opt(T) versus res(T, E), making error spans accurate inside macro-expanded module declarations, and getting the test suite to 200+ passing tests on every supported platform.

Nothing user-visible landed in these stages. They are the reason stages 0.9 through 1.1 could land cleanly.

Stage 0.9 — generics via monomorphization

Generics in fastC are monomorphized at compile time. A function fn id<T>(x: T) -> T produces a specialized C function for every concrete T it is called with. The mangling scheme is module__name__T1__T2__..., which makes the C output readable when something goes wrong.

We considered a typeclass-style approach where generic functions compile to one C function with a vtable. We rejected it because the C output stops being auditable. With monomorphization, you can fastc compile foo.fc -o /tmp/foo.c and read exactly which specializations the compiler generated. The binary-size cost is real (a heavily-generic program can be 2-3x the size of its non-generic equivalent) but the readability of the C output is what we picked.

Stage 1.0 — traits and method syntax

Traits arrived with a fixed set of standard traits: Eq, Ord, Copy, Drop, Hash, Clone. Dispatch is monomorphization-time: a call to x.hash() where x: T: Hash becomes a direct call to the specialized hash_for_T at the mono pass, then the C compiler inlines it. There is no dynamic dispatch in fastC — no dyn Trait, no vtable. The cost is no runtime polymorphism. The payoff is the C output stays inlineable.

Method syntax x.method() desugars to method(x) in the lowering pass. The reason x.method() exists at all is readability for chained operations. vec.iter().map(...).collect() reads better than collect(map(iter(vec), ...)). The desugar is mechanical; the readable C output still contains the function calls in the expected order.

Stage 1.1 — the stdlib MVP

The stage that made fastC usable for real programs. vec (growable arrays), hashmap (open addressing, robin-hood probing), math (the obvious set: abs, min, max, pow, sqrt), mem (allocator interface, the default system allocator, arena_alloc for scoped allocation), io (the byte-stream layer that fs::read and net::connect later built on), log (structured leveled logging with kv_int/kv_str for typed fields), and str (UTF-8 string primitives).

Closures landed here too, in their final shape: capture-free. A fastC closure is sugar for a struct-plus-function-pointer pair, but the struct is empty — closures cannot capture variables from their enclosing scope. The reason is a deliberate trade: capturing closures bring lifetime questions we did not want to answer without a borrow checker. The cost is verbose code (you pass captured state explicitly through arguments). The payoff is the closures land without lifetime analysis. We will revisit this in v2.x.

What this gives you

After stage 1.1, fastC is a language that can compile non-trivial programs. The eleven curated packages in fastc-core (post 6) are written in fastC and exercise everything stage 1.1 ships. The agent can generate fastC code that uses generics, traits, the stdlib, and the diagnostic system, and the first-compile success rates on the README’s T1 benchmark hit production-quality numbers from this point forward.

The structural-safety claims still are not in. That is the next post. Stage 1.4 brings the cap-types and the fabrication check; stage 1.5 brings contracts; stage 2.1 brings the SMT discharger. That is where the wedge actually lands.


Comments? Issues? Disagreements? Open an issue at github.com/Skelf-Research/fastc/issues.

← all posts