Implementation architecture
This is the map for someone working on the Rust code. For why the pieces are shaped this way, follow the ADR links. For what the language does, see the spec.
The workspace: four crates
crates/
korrin/ the language: lexer, parser, resolver, both execution engines,
the standard library, the embedding API. No CLI or LSP
dependency of any kind. This is what gets published.
korrin-cli/ the `korrin` binary *and* a library: parse args, read a
file, call korrin, print results and diagnostics. The
library half exists so a project with a native-module
dependency can build its own custom `korrin` binary by
calling it, rather than re-implementing a second CLI
(Milestone 6, ADR 0037). Depends on korrin and korrin-pkg.
korrin-lsp/ the language server: diagnostics, hover, go-to-definition,
over stdio. Depends on korrin. Depends on nothing korrin-cli
depends on, and vice versa — they are siblings, not layered.
korrin-pkg/ manifest/lockfile parsing and git-backed package fetching.
A library, not a binary — korrin-cli depends on it for its
`korrin pkg ...` subcommands. Depends on korrin only for
`lexer::is_identifier`, to validate a dependency's local
import name the same way the parser validates one derived
from a file path.
ADR 0012 drew the first boundary
(korrin versus korrin-cli) and named the reasoning that a third crate
would follow it without restructuring: keep the published library's
dependency tree lean (ADR 0013), and let anything that is a tool built on
Korrin — a CLI, a language server, someday perhaps a formatter — depend on
it freely without that tool's own dependencies (clap, lsp-server, …)
ever becoming korrin's own. korrin-lsp followed exactly that pattern when
it was added in Milestone 4 (ADR 0026),
and korrin-pkg followed it again in Milestone 5
(ADR 0033): each is a new
crates/<name>/, added to [workspace] members, its own
[lints]\nworkspace = true to opt into the shared lint table, a korrin
path dependency, nothing more. No existing crate needed to change either
time. korrin-pkg's own dependencies (toml_edit; git access is by
shelling out to the installed git binary, not a linked library) stay
entirely out of korrin-cli's Cargo.toml, the same way korrin-lsp's
(lsp-server, lsp-types) never touch korrin-cli's either — each tool
crate carries only what its own concern needs.
Adding a fifth tool crate means repeating that shape: a new directory
under crates/, added to the workspace's members list, [lints] workspace = true in its own Cargo.toml (lints are workspace-defined but
each crate opts in individually — skipping this stanza silently means none
of unsafe_code = "forbid", missing_docs, or clippy's pedantic set apply
to it), and a dependency-policy note in
dependencies.md placing it in a tier (published-library
strictness, or the freer tool-crate latitude korrin-cli, korrin-lsp, and
korrin-pkg already share). Every Rust CI job (test, lint, msrv,
docs) uses --workspace and names no crate explicitly, so a
correctly-added crate is covered automatically.
One crate, korrin-lsp, is not Rust-only: its editor-facing half,
editors/vscode/, is a VS Code extension written in TypeScript, because the
vscode extension API has no other surface (ADR 0030).
It has its own package.json, its own npm-based build, and its own CI job
(vscode-extension) — self-contained and unrelated to the Rust jobs, the
same way korrin-web/'s mdbook build is self-contained and unrelated to
both.
The pipeline
Korrin has one front end and two execution engines. Everything up to a resolved tree is shared; after that a program is either walked or compiled and run.
source: &str
│
▼
┌──────────┐ Vec<Token> ┌──────────┐ ast::Module ┌──────────┐
│ lexer │ ──────────────────▶ │ parser │ ──────────────▶ │ resolver │
└──────────┘ + INDENT/DEDENT └──────────┘ (tree) └──────────┘
│
the same tree, checked │
┌───────────────────────────────┴────┐
▼ ▼
┌─────────────┐ ┌──────────┐
│ interpreter │ ──▶ Output │ compiler │
└─────────────┘ └──────────┘
walks the tree │
CompiledFunction
(chunks)
│
▼
┌────────┐
│ vm │ ──▶ Output
└────────┘
The resolver validates and returns nothing but Result<(), Vec<Diagnostic>>; it
does not annotate the tree. Slot resolution lives in the compiler, which is
the one pass that needs it
(ADR 0022).
Every stage takes the previous stage's output and either produces the next
artifact or a Vec<Diagnostic>. Nothing prints; the caller (korrin::run,
korrin::run_vm, or the CLI) decides what to do with output and errors.
Two engines, on purpose
The tree-walking interpreter was Milestone 1's whole implementation (ADR 0004). Milestone 3 added the bytecode compiler and VM beside it, and kept the interpreter as the oracle the VM is tested against (ADR 0024). It is not deprecated.
| interpreter | vm | |
|---|---|---|
| Entry points | korrin::run, run_file | korrin::run_vm, run_file_vm |
| CLI | korrin run | korrin run --vm |
| Locals | names in a chain of Environment scopes | slots in a frame |
| Closures | capture the whole enclosing scope | capture named variables in cells (0023) |
| Korrin calls | nest Rust calls (hence stacker) | push a frame; the loop keeps going |
finally | run on the way out of each signal | inlined at static exits (0025) |
What they share is interpreter::ops: arithmetic, comparison, indexing,
attribute access, and iteration. Those are questions about values, not about
execution, and two answers would only invite drift. Scoping, control flow,
calls, and closures are deliberately not shared, because that is where two
independent implementations are worth having.
On the benchmarks in crates/korrin/benches/engines.rs, the VM is about 1.7x
faster on call-heavy code and 1.5x on local-variable-heavy loops. It is about
15% slower on a tight loop over module globals: both engines do the same
hashed lookup there, so slots buy nothing, and the VM still pays to push and pop
each operand where the interpreter returns values directly. Inline caching for
globals would close that, and is not something Milestone 3 attempted.
Modules in crates/korrin/src
| Module | Responsibility | Key ADRs |
|---|---|---|
span | Span (byte offsets) and Spanned<T>. | 0008 |
diagnostics | Diagnostic, ErrorCode, SourceMap, rendering. | 0008 |
lexer | Bytes → tokens; indentation state machine; bracket-depth tracking for newline suppression. | 0001, 0003, 0006, 0007 |
ast | Node types for expressions and statements. Spans on every node. | — |
parser | Recursive-descent statements + Pratt-parsed expressions; error recovery. | 0003, 0006 |
resolver | Static checks only (return/break/super placement, duplicate params, self-inheritance). Validates; annotates nothing. | 0005, 0011 |
interpreter | Tree walk. Value, Environment, the Signal control-flow enum, the Host seam, and ops (shared value operations). Submodule modules is its import loader (four-way dispatch since Milestone 6: file, built-in, native module, package); native_module defines the NativeModule trait a Rust crate implements. | 0004, 0009, 0010, 0011, 0017, 0018, 0021, 0035, 0036 |
compiler | Tree → bytecode. chunk (instructions, constants, spans), scope (the lexical binding scan), emit (the walk), disassemble. | 0022, 0023, 0025 |
vm | Runs bytecode: operand stack, local slots, frames, captures, handlers, imports. | 0022, 0023, 0025 |
builtins | The global built-in functions (print, range, str, …). Operations on values are interpreter::methods. | 0016, 0021 |
stdlib | Built-in modules reached with a bare import (math, io). | 0019, 0020 |
Modules in crates/korrin-lsp/src
The server never talks to the filesystem for source; every request answers
against whatever document.rs currently holds for that URI, kept in sync by
the editor's own didOpen/didChange/didClose notifications. Diagnostics,
hover, and go-to-definition are otherwise independent of one another — each
re-derives what it needs (a token stream, a parsed tree) from the document's
current text rather than sharing cached intermediate state, since Korrin
files are small enough that this costs nothing worth optimizing away yet.
| Module | Responsibility | Key ADRs |
|---|---|---|
server | The initialize/initialized handshake and the main dispatch loop: one match over lsp_server::Message, no async runtime. | 0026 |
document | The open-document store (Documents), keyed by URI; each Document owns a LineIndex built from its own text. | — |
position | Byte offset ↔ lsp_types::Position (0-based line, UTF-16 code-unit column) — deliberately not korrin::diagnostics::SourceMap, which is 1-based and counts Unicode scalar values for its own, different purpose. | — |
diagnostics | Runs korrin::lexer → parser → resolver, converts whatever Vec<Diagnostic> comes back. First-failing-stage only, matching the CLI. | 0027 |
hover | Builtin/method text, parsed once from an include_str!'d docs/spec/08-builtins.md. | 0028 |
goto_definition | A standalone lexical scope walker (mirrors compiler::scope's traversal, records spans instead of slots), single-file. | 0029 |
Modules in crates/korrin-cli/src
main.rs is a one-line wrapper (main_with_native_modules(Vec::new()));
everything else lives in the library half.
| Module | Responsibility | Key ADRs |
|---|---|---|
lib.rs | Cli/Command, argument parsing, run_file/dump_*. Exposes main_with_native_modules, GIT_URL, GIT_REV — what a generated .korrin/native-runner/ project links against and pins to. | 0021, 0035, 0037 |
repl | The interactive REPL loop. | — |
pkg | The korrin pkg subcommands (add/remove/install/update/list/build); the rebuild-trigger check that decides whether a resolve operation needs korrin pkg build. | 0031, 0037 |
Modules in crates/korrin-pkg/src
No manifest or lockfile format, and no git access, is known to crates/korrin
itself — everything here produces an already-resolved name -> entry file
map that korrin-cli hands to Interpreter::set_packages/Vm::set_packages.
| Module | Responsibility | Key ADRs |
|---|---|---|
manifest | korrin.toml: parse, validate (exactly-one-of tag/branch/rev, identifier-shaped names), in-place add/remove via toml_edit. | 0031 |
lockfile | korrin.lock: parse/write the resolved commit for every dependency. | 0031 |
git | Every git operation as a git subprocess — no linked git library. | 0033 |
resolve | discover, install, add, remove, update, packages_for_run — the leaf-only and built-in-collision checks live here, not in manifest. | 0031, 0032 |
native_runner | Generates .korrin/native-runner/Cargo.toml and src/main.rs from the current lockfile's native dependencies. | 0037 |
build | korrin pkg build's actual work: calls native_runner, runs cargo build --release, writes the project-root wrapper script. | 0037, 0038 |
error | PkgError, printed as korrin pkg: ..., never a Korrin diagnostic. | — |
Cross-cutting conventions
- Errors are values. A stage returns
Result<T, Vec<Diagnostic>>(or accumulates into a sink). Nopanic!on user input, ever — panics are for internal invariant violations only. Both engines follow this: apanicinvmmeans the compiler emitted something impossible, not that a program was wrong. - Control flow is a value too.
return/break/continueare aninterpreter::Signalenum threaded throughResult, not Rustpanic/catchand not exceptions. ADR 0004 predicted this would be the shape a VM wanted, and it was: the VM raises and catches the sameSignal. (ADR 0004) - Native code names no engine. Builtins, methods, and the standard library
take
&mut dyn Host, which is four capabilities and nothing else. One implementation of each serves both engines. (ADR 0021) - Spans everywhere. If you add an AST node, it carries a
Span. If you raise a diagnostic, it has a primary label. Bytecode carries one span per instruction for the same reason. - The library never touches the terminal. No
println!, no colour detection, nostd::process::exit. That iskorrin-cli's job. (ADR 0012)
How to…
- add a builtin: see
adding-a-builtin.md. - add a keyword: see
adding-a-keyword.md. - see what the compiler produced:
korrin bytecode file.korprints each chunk, outermost first, with constants spelled out beside the instructions that use them. - check the engines agree:
cargo test -p korrin --test differential. - try the language server against a real editor:
cargo install --path crates/korrin-lsp, then followeditors/vscode/README.md. To exercise it without VS Code at all, spawn the built binary yourself and speak LSP over its stdio (Content-Length-framed JSON-RPC) — useful for confirming the shipped binary works, not just the in-process test suite, which always drives it overlsp_server::Connection::memory()instead of a real subprocess.