Devlog · July 2026
From spec to running VM: the initial build
NL started life fully specified: nlvm-specs defines the grammar, the semantics, 49 compile-time checks, the bytecode format and a milestone roadmap — before this implementation existed. This post retraces how the toolchain was built against it.
Two decisions before any code
Rust, as a multi-crate Cargo workspace. And a vertical-slice strategy: rather than finishing the lexer, then the parser, then the type checker, the first milestone was to get one minimal program through the entire pipeline — lexer → parser → bytecode → VM → exit code — with the YAML test runner in place from day one. Everything after that widens the slice.
The workspace layout has survived unchanged:
crates/
├── nl-syntax/ # lexer + parser + AST
├── nl-sema/ # name resolution, typing, the 49 checks
├── nl-bytecode/ # .nlm format: encoding AND decoding, shared
├── nl-codegen/ # validated AST → bytecode
├── nl-vm/ # interpreter, object model, exceptions, natives
├── nlc/ nlvm/ # the two CLI binaries
└── nl-test-runner/ # `nltest`, runs the YAML suites
The load-bearing choice is nl-bytecode: one crate owns the module format, the opcodes and the type descriptors, and both the compiler and the VM depend on it. There is no way for the two halves to disagree about what the bytecode means.
Tests as the steering wheel
The specification ships its own conformance tests — YAML files with NL sources, an expected stdout and an expected exit code. The test runner was built in phase 1, before most of the language existed, so from the first week every feature landed the same way: pick the spec tests it should unlock, write local tests for the corners, make them green, move on. The local suite now counts 232 files, one per feature.
Phase by phase
- Minimal slice — a
mainreturning an exit code, through a real.nlmmodule (magic number, constant pool, SHA-256) and a real frame-based interpreter. - Expressions & control flow — int/float arithmetic, comparisons, strings,
if/while/for, recursion, static calls. - Core semantics — union types (
T|null), flow-sensitive definite assignment, null-safety checks,auto, operator compatibility. The compiler started rejecting programs for good reasons. - Objects — classes, fields, constructors (
construct), instance dispatch, interfaces,instanceof, arrays with bounds checks. - Inheritance, exceptions, match —
extends/super, try/catch/finally with checked exceptions and unreachable-catch detection, exhaustivematch. - Closures & stdlib — function types, closures, and a native
system.*library: IO, regex, processes, threads. - Ergonomics — named and optional parameters,
refparameters, multi-dimensional arrays, enums (plain and value-backed),??and?:.
Decisions worth recording
Memory management is reference counting. The Arc refcount is the GC. Assumed limitation: object cycles are never collected, so their destructors don't run — a cycle collector is a possible later addition. Writing the limitation down mattered more than the choice itself.
Runtime safety is part of the spec. Some conformance tests check behavior like use-after-close on files — the specs demand runtime guarantees, not just compile-time ones, and the VM is built accordingly.
The toolchain releases itself. Since 0.3.0, tagging a release is done by tools/Release.nl — an NL script using the stdlib's file, regex and process APIs. Dogfooding found real bugs, which is the point.
The full phase-by-phase working notes (in French) are in journal/journal_01_initial_build.md.