Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

interpretervm
Entry pointskorrin::run, run_filekorrin::run_vm, run_file_vm
CLIkorrin runkorrin run --vm
Localsnames in a chain of Environment scopesslots in a frame
Closurescapture the whole enclosing scopecapture named variables in cells (0023)
Korrin callsnest Rust calls (hence stacker)push a frame; the loop keeps going
finallyrun on the way out of each signalinlined 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

ModuleResponsibilityKey ADRs
spanSpan (byte offsets) and Spanned<T>.0008
diagnosticsDiagnostic, ErrorCode, SourceMap, rendering.0008
lexerBytes → tokens; indentation state machine; bracket-depth tracking for newline suppression.0001, 0003, 0006, 0007
astNode types for expressions and statements. Spans on every node.—
parserRecursive-descent statements + Pratt-parsed expressions; error recovery.0003, 0006
resolverStatic checks only (return/break/super placement, duplicate params, self-inheritance). Validates; annotates nothing.0005, 0011
interpreterTree 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
compilerTree → bytecode. chunk (instructions, constants, spans), scope (the lexical binding scan), emit (the walk), disassemble.0022, 0023, 0025
vmRuns bytecode: operand stack, local slots, frames, captures, handlers, imports.0022, 0023, 0025
builtinsThe global built-in functions (print, range, str, …). Operations on values are interpreter::methods.0016, 0021
stdlibBuilt-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.

ModuleResponsibilityKey ADRs
serverThe initialize/initialized handshake and the main dispatch loop: one match over lsp_server::Message, no async runtime.0026
documentThe open-document store (Documents), keyed by URI; each Document owns a LineIndex built from its own text.—
positionByte 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.—
diagnosticsRuns korrin::lexer → parser → resolver, converts whatever Vec<Diagnostic> comes back. First-failing-stage only, matching the CLI.0027
hoverBuiltin/method text, parsed once from an include_str!'d docs/spec/08-builtins.md.0028
goto_definitionA 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.

ModuleResponsibilityKey ADRs
lib.rsCli/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
replThe interactive REPL loop.—
pkgThe 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.

ModuleResponsibilityKey ADRs
manifestkorrin.toml: parse, validate (exactly-one-of tag/branch/rev, identifier-shaped names), in-place add/remove via toml_edit.0031
lockfilekorrin.lock: parse/write the resolved commit for every dependency.0031
gitEvery git operation as a git subprocess — no linked git library.0033
resolvediscover, install, add, remove, update, packages_for_run — the leaf-only and built-in-collision checks live here, not in manifest.0031, 0032
native_runnerGenerates .korrin/native-runner/Cargo.toml and src/main.rs from the current lockfile's native dependencies.0037
buildkorrin pkg build's actual work: calls native_runner, runs cargo build --release, writes the project-root wrapper script.0037, 0038
errorPkgError, 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). No panic! on user input, ever — panics are for internal invariant violations only. Both engines follow this: a panic in vm means the compiler emitted something impossible, not that a program was wrong.
  • Control flow is a value too. return / break / continue are an interpreter::Signal enum threaded through Result, not Rust panic/catch and not exceptions. ADR 0004 predicted this would be the shape a VM wanted, and it was: the VM raises and catches the same Signal. (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, no std::process::exit. That is korrin-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.kor prints 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 follow editors/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 over lsp_server::Connection::memory() instead of a real subprocess.