Korrin documentation
Four kinds of documentation, each with a distinct job:
| Directory | Question it answers | Audience |
|---|---|---|
spec/ | What does Korrin do, exactly? | Language users; anyone implementing tooling. Normative. |
guide/ | How do I learn Korrin? | Newcomers. Prose, examples, informal. |
decisions/ | Why is Korrin like this? | Contributors; future maintainers. |
internals/ | How is the implementation built? | Contributors to the Rust code. |
API documentation for the korrin crate lives in the source as rustdoc; build it
with cargo doc --open.
Which should I read?
- Using the language:
guide/, thenspec/as a reference. - Hacking on the interpreter:
internals/architecture.md, then the rustdoc, withdecisions/open for the "why". - Proposing a change: the contribution guide in the repository, and ADR 0000 for how decisions are recorded.
The rule that ties them together
Code and docs are kept in agreement by CI, not by good intentions — see ADR 0002. Runnable spec examples are executed on every build; missing API docs fail the build; every ADR is checked for structure and linkage.
Roadmap
Where Korrin has been and where it's going. This page tracks milestones at a summary level — for what changed in detail, see the changelog; for why a given decision was made, see design notes.
Korrin is pre-1.0. Milestone order and scope can still change.
Done
Milestone 1 — the language. A tree-walking interpreter: closures, classes with single inheritance, the full value set, a REPL and a file runner.
Milestone 2 — writing real programs. String interpolation, methods on
str/list/map, exceptions (try/except/finally/raise), a module
system (import), and the first standard library (math, io).
Milestone 3 — a second execution engine. A bytecode compiler and virtual machine beside the interpreter, not replacing it — the interpreter stays on as a permanent differential-testing oracle every program in the test suite is checked against. Roughly 1.7x faster on call-heavy code, 1.5x on local-variable-heavy loops.
Milestone 4 — editor tooling. A real Language Server Protocol
implementation (korrin-lsp) and a VS Code extension: live diagnostics,
hover, go-to-definition, and running a file from the editor.
Milestone 5 — packaging and a community index. A package is a git
repository — korrin pkg add <git-url> --tag v1.0.0 fetches and pins one in
a new korrin.lock, and a bare import "name" reaches it exactly the way it
already reaches a built-in module, no new syntax. No central registry or
hosting of any kind. korrin-packages/
is the community index — a second site, listing packages by pull request,
not a submission form.
Milestone 6 — a native-module system. A Korrin module can now be written
as a Rust crate and linked into the korrin binary at compile time — no
dynamic loading, no WASM sandbox. korrin pkg add <git-url> --native marks a
dependency as one; korrin pkg build regenerates and compiles a
project-specific korrin binary with it linked in, and writes a wrapper
script forwarding to the result. math and io no longer need to be wired
into the loader by hand for a third party to do the same thing they do.
Next
Milestone 7 — GameKorr. A Pygame-style library: windowing, drawing, input, a game loop, built as an ordinary native module on M6. Design work on the same order as a language feature, not a thin wrapper — what a sprite is, how input/timing/collision work, all need real answers.
After that
Milestone 8 — concurrency. The biggest and least-scoped item here on purpose: no decision yet on async vs. threads vs. something else, and none before M5-M7 land. This one touches both execution engines directly, closer in size to Milestone 3 than to a library.
Not planned
Anything not listed above isn't ruled out, just not scoped. If you're building something on Korrin and want to talk about where it's headed, see the repository — issues and discussion are welcome.
Changelog
All notable changes to Korrin are recorded here. The format follows
Keep a Changelog. Korrin is pre-1.0 and
uses 0.MINOR.PATCH; while the major version is 0, any release may contain
breaking changes, which are called out under Changed / Removed.
Unreleased
Milestone 6 is complete: a native-module system, letting a third party write
a Korrin module in Rust. Milestone 5 is complete: packaging (git-based
dependencies, no central registry) and a community package index. Milestone 4
is complete: a real language server for VS Code. Milestone 3 is complete: a
bytecode compiler and virtual machine, alongside the tree-walking interpreter
rather than replacing it. Milestone 2 is complete: string interpolation,
methods on the built-in types, exceptions, modules, and the first
standard-library modules (math, io). Milestone 1's entries are further
below.
Added — Milestone 6
-
Native modules. A Korrin module can now be implemented as a Rust crate and linked into the
korrinbinary at compile time — no dynamic loading, no WASM sandbox, matching this workspace's ownunsafe_code = "forbid"stance (ADR 0035). A native module implements one trait,NativeModule, and can build stateful objects with their own methods via a new, twelfth kind of value,Value::NativeObject— dispatched through the exact same method-lookup pathstr/list/mapmethods already use (ADR 0036). See specification §13. -
native = trueon a[dependencies]entry inkorrin.tomlmarks it as a native module rather than.korsource:[dependencies] gamekorr = { git = "https://github.com/korrin-lang/gamekorr", tag = "v0.1.0", native = true }import "gamekorr"resolves in exactly the same merged namespace a plain package does; the only difference is what's on the other end. -
korrin pkg build, a new subcommand that regenerates and compiles a project's.korrin/native-runner/— a small, generated Cargo project depending onkorrin-cli(pinned to the exact commit and git remote the runningkorrinbinary was itself built from) plus every native dependency — and writes a project-root wrapper script (./korrin/./korrin.cmd) forwarding to the result. Runs automatically afteradd/install/remove/updatewhenever the set of native dependencies changes; a project with no native dependencies is entirely unaffected and pays no new cost (ADR 0037). Which binary actually runs a program is never auto-detected — the generickorrinand a project's generated wrapper are two separate binaries, by design (ADR 0038). -
korrin-cligains a library half (main_with_native_modules) alongside its binary, so a generated project's custom binary supports every subcommand the generic one does, identically, rather than needing a second hand-written CLI.
Added — Milestone 5
-
Packages. A bare
import "name"now also checks installed dependencies, alongside the built-in module table it already checked (ADR 0032). A package is a git repository, pinned by tag, branch, or commit in a newkorrin.tomlmanifest, resolved into a newkorrin.lock, and checked out locally under.korrin/packages/— no central registry or hosting of any kind (ADR 0031).# korrin.toml [package] name = "app" version = "0.1.0" [dependencies] greet = { git = "https://github.com/alice/korrin-greet", tag = "v1.0.0" }A package's own dependencies must be empty — packages are leaf-only in this milestone, so there is no dependency-graph solver and no risk of a name collision beyond what TOML already forbids. A dependency name that collides with a built-in module is refused at
add/installtime, not silently shadowed. See specification §12. -
korrin pkg, new subcommands on the existingkorrinbinary:add <git-url> [--tag|--branch|--rev] [--name],remove <name>,install,update [name],list. Implemented by the newkorrin-pkglibrary crate — manifest/lockfile parsing (toml_edit) and git access by shelling out to the installedgitbinary, not a linked git library (ADR 0033). -
A community package index,
korrin-packages/: a second static site alongside this one, listing packages from one JSON file per submission underdata/packages/. Submitting a package is a pull request, not a form — there is no submission backend (ADR 0034).
Added — Milestone 4
-
korrin-lsp, a Language Server Protocol implementation for Korrin: live diagnostics, hover, and go-to-definition, over stdio, synchronous with no async runtime. (ADR 0026, 0027, 0028, 0029.)Diagnostics are the same lex/parse/resolve checks
korrin runperforms, shown live as you type instead of after the fact. Hover, over a builtin function or astr/list/mapmethod, is pulled directly fromdocs/spec/08-builtins.md, embedded into the binary at compile time. Go to definition resolves a local variable, parameter, function, class, or import alias to where it was bound, within the current file (reaching into an imported file is not yet supported).Install with
cargo install --path crates/korrin-lsp. -
A real VS Code extension, in
editors/vscode/: the existing syntax-highlighting grammar now startskorrin-lspand shows what it sends, plus Korrin: Run File / Korrin: Run File (bytecode VM) commands that run the active file in an integrated terminal. (ADR 0030 — this is also where Node and TypeScript enter the repository's tooling, scoped to this one directory.)
Added — Milestone 3
-
A bytecode compiler and virtual machine. Korrin now has a second execution engine. A program is compiled once into flat instruction sequences, one per function, and run by a VM that finds local variables by slot instead of by name. It is roughly 1.7x faster on call-heavy code and 1.5x on local-variable-heavy loops, and about 15% slower on a tight loop over module globals, where slots buy nothing and its operand stack still costs something. (ADR 0022, 0023, 0025.)
korrin run --vm file.korruns a program through it;korrin::run_vmandrun_file_vmare the embedding equivalents. The interpreter remains whatkorrin runandkorrin::runuse. -
korrin bytecode file.korprints the compiled instructions, one chunk per function, alongside the existingkorrin tokensandkorrin ast. -
The tree-walking interpreter is now a permanent differential-testing oracle. Every golden program, every runnable specification example, and every module fixture runs under both engines on each build, and their output must match byte for byte. (ADR 0024.)
-
New
E04xxdiagnostics for the bytecode's operand-width limits (E0401throughE0404): more than 65 535 locals or constants, or more than 255 arguments. Reaching one takes a generated program.
Changed — Milestone 3
-
Name resolution is lexical. The nearest-binding rule is unchanged — assigning to a name an outer scope already binds still updates that outer binding, with no keyword — but "does an outer binding exist?" is now answered from the enclosing program text rather than from the live scope chain. This is what makes local variables compilable to slots. The two engines differ only for a program that reads an outer name after a call that would have created it and before the text that does. (ADR 0022.)
-
Native functions no longer name an execution engine. Builtins, built-in methods, and standard-library functions receive
&mut dyn Hostinstead of&mut Interpreter, whereHostis the four capabilities any engine offers them: output, command-line arguments, a line of input, and a call back into Korrin. One implementation of each now serves both the tree-walking interpreter and the bytecode VM. (ADR 0021.)For embedders this changes the public
NativeFnPtrandNativeMethodFntypes, and movesInterpreter::call_valueonto theHosttrait. No Korrin program behaves differently. -
The
inputbuiltin reads throughHost::read_linerather than reaching forstd::io::stdin()directly, which also makes it testable for the first time.
Added — Milestone 2
-
String interpolation. Every string literal is a template:
"hi {name}"splices thestr()value of each{ expr }hole, evaluated left to right.{{/}}are literal braces. Holes are full expressions and need no escaping for nested strings ("{m["k"]}"). (ADR 0015, specification §1.7 and §4.2.) -
Methods on
str,list, andmap. The.operator now works on the built-in types. New vocabulary:str:len,upper,lower,trim,split,replace,find,contains,starts_with,ends_with,slice,repeat.list:len,push,pop,insert,remove_at,index_of,contains,slice,reverse,sort,join.map:len,keys,values,has,get,remove,pairs.
(ADR 0016, specification §8.2–8.4.)
-
Exceptions.
raise <value>unwinds until atry:/except [name]:/finally:catches it, or it reaches the top level asE0312. Any value may be raised;Error(message?)is the built-in exception type, with.messageand.code. Built-in runtime faults (E0301–E0313) are now catchable — anexceptbinds them as anErrorcarrying the faulting code.finallyruns on every exit path, includingreturn/break/continue. (ADR 0017, specification §5.10–5.11 and §9.6.) -
Keywords
try,except,finally,raise(19 → 23). Parser errorE0107for atrywith no clause, or a danglingexcept/finally. -
Modules.
import "./util"runs a Korrin file (resolved relative to the importing file,.korimplied) and binds a module object —type()is"module", members aremod.name, top-level_names are private.import "./x" as yrenames. Modules load once and are cached;importing the same module twice yields the same object. A circular import isE0315; a module that will not resolve, read, or compile isE0314.import "math"(no./) loads a built-in module. Newkorrin::run_fileAPI;korrin runresolves relative imports against the file's directory. (ADR 0018, specification §11.) -
mathstandard-library module.import "math":sqrt,pow,floor,ceil,round,abs,min,max, and the constantspiande. (ADR 0019, specification §11.4.) -
iostandard-library module.import "io":read_file(path),write_file(path, text), andargs()(command-line arguments). Failures raise a catchableE0316.korrin run script.kor a b cpassesa b ctoio.args(). NewInterpreter::set_args. (ADR 0020, specification §11.5.) -
Keywords
import,as(23 → 25). Parser errorE0108for a malformedimportor a strayas.
Changed — Milestone 2
-
Breaking: a literal
{in a string must now be written{{. No existing Korrin code, spec example, or guide example was affected. -
Breaking: the collection built-in functions are removed in favour of methods (ADR 0016):
Removed Replacement len(x)x.len()append(xs, v)xs.push(v)pop(xs)xs.pop()keys(m)/values(m)m.keys()/m.values()has(m, k)m.has(k)get(m, k, default?)m.get(k, default?)remove(m, k)m.remove(k)print,type,str,int,float,bool,range, andinputremain functions.
Added — Milestone 1
-
Project scaffold: Cargo workspace (
korrinlibrary +korrin-clibinary), toolchain pin, CI, lint configuration. -
Documentation backbone: ADR process and the initial decision records (0000–0013), specification and guide skeletons, contributor docs.
-
spanmodule: byte-offsetSpanandSpanned<T>. -
diagnosticsmodule:Diagnostic,ErrorCode,Severity,SourceMap, andariadne-backed rendering. -
lexermodule: full tokenizer with the indentation state machine (INDENT/DEDENT/NEWLINE), bracket-aware and operator-aware newline suppression, all literal forms, and local error recovery. Specification section 01 (lexical structure) written to match. -
astmodule: expression and statement node types, all span-carrying, plus an S-expressiondumpfor debugging and tests. -
parsermodule: recursive-descent statements + Pratt-parsed expressions, non-associative comparisons, error recovery that reports multiple problems. the grammar (specification section 02) and sections 04, 05 written to match. -
resolvermodule: static validation pass (return/break/continue/superplacement, duplicate parameters, self-inheritance). -
interpretermodule: tree-walking evaluator — values, environments, the nearest-binding rule, closures, classes with single inheritance andsuper, checked integer arithmetic, on-demand stack growth with a recursion backstop. -
builtinsmodule:print,len,type,str,int,float,bool,range,input, and the collection functionsappend,pop,keys,values,has,get,remove. Specification section 08 written to match. -
korrin::runembedding API andInterpreterfor REPL use. -
korrinbinary:run,tokens, andastsubcommands, plus an interactive REPL with multi-line input and expression echo. -
Runnable example programs under
crates/korrin/examples/. -
CI-enforced documentation harnesses:
tests/spec.rsexecutes every runnable example in the specification and the guide,tests/builtins.rschecks builtin/spec agreement both ways,tests/adr.rschecks ADR structure, index completeness, and numbering. -
Golden end-to-end program corpus (
tests/programs/), property tests (tests/properties.rs: the front end never panics), and CLI black-box tests. -
The complete specification (sections 01–10) and the learn-by-example guide.
-
mdBook skeleton (
docs/book.toml,docs/SUMMARY.md) for the eventual site. -
VS Code syntax-highlighting extension (
editors/vscode/) and a workspace.vscode/settings.jsonthat falls back to Python highlighting for.kor. -
playground.kor— a runnable scratch file for experimenting. -
branding/— the Korrin mark, wordmark, favicon, and usage notes. -
korrin-web/— the website: a static landing page plus the full guide, specification, and design notes rendered fromdocs/by mdBook (themed to the Korrin identity).scripts/deploy-site.shbuilds and deploys it to Cloudflare Pages.
Changed — Milestone 1
selfis no longer a keyword; it is a conventional identifier (the name of a method's first parameter). The reserved-word count is now 19. See ADR 0006.- Scoping is function-level, not block-level: a name first assigned inside an
if/while/forbody stays visible afterwards. See ADR 0011. - The grammar moved from a standalone
korrin.ebnffile into specification §2 — one source, and it renders in the documentation site.
The Korrin guide
A hands-on introduction. Read it front to back once; keep the specification open afterwards as a reference.
Every code block below is run by CI, so what you see is what Korrin does.
- Getting started — install, run a file, use the REPL.
- Values — numbers, strings, booleans,
nil, lists, maps. - Control flow and functions —
if, loops,fn, closures. - Classes — objects, methods, inheritance,
super. - Errors —
try,except,finally,raise. - Modules —
import, module objects, the standard library. - A worked example — a small program, start to finish.
The whole language on one page
- Blocks are indentation; a newline ends a statement. No braces, no semicolons.
- 25 keywords:
and as break class continue elif else except false finally fn for if import in nil not or pass raise return super true try while. #starts a comment.- Assignment is bare:
x = 1. Nolet. - Every string interpolates:
"hi {name}";{{is a literal brace. - Operations on
str/list/mapare methods:xs.push(v),text.trim(),m.keys(),x.len().print,str,int,range, … stay functions. - Only
nilandfalseare falsy. - Errors are exceptions:
raise Error("..."),try:/except err:/finally:. Built-in faults are catchable and carry.code/.message. import "./file"for your own modules,import "math"for the standard library; members aremod.name,_nameis private./always gives a float; integer overflow is an error, not a wraparound.
1. Getting started
Building korrin
You need a recent stable Rust toolchain. From the repository root:
cargo build --release
The binary is target/release/korrin.
Running a file
Put this in hello.kor:
name = "world"
print("hello, {name}")
# => hello, world
Run it:
korrin run hello.kor
Korrin source files use the .kor extension. Anything after the file name is
passed to the program, readable with io.args() (see
Modules):
korrin run report.kor data.csv --verbose
The REPL
Run korrin with no arguments for an interactive prompt:
$ korrin
Korrin 0.0.0 — Ctrl-D to exit
korrin> 2 + 3
5
korrin> greeting = "hi"
korrin> greeting + " there"
hi there
A bare expression has its value echoed. An assignment does not print anything. To enter a block, the REPL keeps reading until you leave a blank line:
korrin> fn double(n):
... return n * 2
...
korrin> double(21)
42
Press Ctrl-D to leave.
Reading errors
When something is wrong, Korrin points at it:
$ korrin run broken.kor
[E0301] Error: cannot find `nam` in this scope
╭─[broken.kor:2:19]
│
2 │ print("hello, " + nam)
│ ─┬─
│ ╰── not defined here
───╯
Every error has a code like E0301. The error reference
lists them all.
Debugging aids
Three subcommands show how Korrin sees a program on its way to running:
korrin tokens hello.kor # the token stream
korrin ast hello.kor # the parsed syntax tree
korrin bytecode hello.kor # the compiled instructions
Two engines
Korrin has two ways of running a program, and they are meant to be
indistinguishable. korrin run uses a tree-walking interpreter, which walks
the syntax tree directly. korrin run --vm compiles the program to bytecode
first and runs that, which is faster for code that makes a lot of calls.
korrin run --vm hello.kor
You should never need to care which one you use. The interpreter is kept deliberately, as the reference the virtual machine is checked against: every program in Korrin's test suite runs both ways, and the two have to produce the same output. If you ever find a program where they disagree, that is a bug worth reporting.
2. Values
Korrin has eleven kinds of value. This chapter covers the everyday ones; functions get their own chapter, and classes, instances, and modules get theirs.
Numbers
int (whole numbers) and float (with a decimal point or exponent) are
separate types:
print(type(3))
print(type(3.0))
print(10 + 5)
print(10 / 4)
print(10 % 3)
# => int
# => float
# => 15
# => 2.5
# => 1
/ always gives a float, even for 10 / 2 (which is 5.0). If you want a
whole number, convert: int(10 / 4) is 2.
Integer overflow is an error, not a silent wraparound — a program that overflows
int is almost always buggy, so Korrin tells you.
Strings
Double quotes only. Join them with +, measure them with .len(), index them
by character:
first = "Ada"
last = "Lovelace"
full = first + " " + last
print(full)
print(full.len())
print(full[0])
# => Ada Lovelace
# => 12
# => A
The escapes are \n, \t, \r, \\, and \".
Strings are immutable, so their methods return a new string. They chain:
print(" Grace Hopper ".trim().upper())
print("a,b,c".split(","))
print("ha".repeat(3) + "!")
# => GRACE HOPPER
# => ["a", "b", "c"]
# => hahaha!
The full list — upper, lower, trim, split, replace, find,
contains, starts_with, ends_with, slice, repeat — is in
specification §8.
Interpolation
Any { } in a string is a hole: the expression inside is evaluated and its value
dropped in. Every string works this way — there is no special prefix.
name = "Ada"
score = 90
print("{name} scored {score}%")
print("next year: {score + 1}")
# => Ada scored 90%
# => next year: 91
A hole can hold any expression — a call, an index, another string:
row = {"user": "grace", "wins": 12}
print("{row["user"]} has {row["wins"]} win{"s"}")
# => grace has 12 wins
For a literal brace, double it: "{{" prints {. This replaces the old
"count: " + str(n) style — you almost never need + str(...) any more.
Booleans, nil, and truthiness
true, false, and nil (the "no value"). In a condition, only nil and
false are false — 0, "", and [] are all true:
if 0:
print("zero is truthy")
if "":
print("so is the empty string")
# => zero is truthy
# => so is the empty string
and and or return one of their operands, which makes or a neat way to
supply a default:
name = nil
print(name or "anonymous")
# => anonymous
Lists
Ordered, mutable, written with [ ]. Operations are methods: push, pop,
sort, reverse, insert, remove_at, index_of, contains, slice,
join, len.
xs = [3, 1, 2]
xs.push(4)
print(xs)
print(xs[0])
print(xs.len())
last = xs.pop()
print(last)
# => [3, 1, 2, 4]
# => 3
# => 4
# => 4
push, insert, remove_at, reverse, and sort change the list in place:
xs = [3, 1, 2]
xs.sort()
print(xs)
# => [1, 2, 3]
Lists are shared, not copied. If two names refer to the same list, a change through one is seen through the other:
a = [1, 2]
b = a
b.push(3)
print(a)
# => [1, 2, 3]
Maps
Key–value pairs, written with { }, keys kept in insertion order. Keys must be
nil, a bool, an int, or a str:
ages = {"ada": 36, "alan": 41}
ages["grace"] = 45
print(ages["ada"])
print(ages.has("alan"))
print(ages.get("linus", 0))
print(ages.keys())
# => 36
# => true
# => 0
# => ["ada", "alan", "grace"]
m[key] on a missing key is an error; m.get(key, default) is the safe
version. The other map methods are values, remove, pairs, and len.
3. Control flow and functions
Making decisions
fn sign(n):
if n > 0:
return "positive"
elif n < 0:
return "negative"
else:
return "zero"
print(sign(3))
print(sign(-8))
print(sign(0))
# => positive
# => negative
# => zero
The body of an if is not a separate scope — a variable you assign there is
still around afterwards:
if true:
message = "set inside the if"
print(message)
# => set inside the if
Repeating work
while repeats while a condition holds; for walks a list, string, map, or
range:
n = 5
factorial = 1
while n > 1:
factorial = factorial * n
n = n - 1
print(factorial)
# => 120
for word in ["one", "two", "three"]:
print(word)
# => one
# => two
# => three
range(n) gives [0, 1, ..., n-1]; range(a, b) and range(a, b, step) give
the rest of what you would expect:
total = 0
for i in range(1, 101):
total = total + i
print(total)
# => 5050
break leaves a loop; continue skips to its next turn.
Functions
fn defines one. Parameters are positional, and a call must pass exactly the
right number:
fn greet(greeting, who):
return greeting + ", " + who + "!"
print(greet("Hello", "world"))
# => Hello, world!
A function with no return (or a bare return) gives back nil.
Functions are values
You can pass a function to another function, return one, or store one in a list:
fn twice(f, x):
return f(f(x))
fn inc(n):
return n + 1
print(twice(inc, 10))
# => 12
Closures
A function defined inside another one remembers the variables around it — and can change them:
fn make_counter():
count = 0
fn next():
count = count + 1
return count
return next
tick = make_counter()
print(tick())
print(tick())
print(tick())
# => 1
# => 2
# => 3
4. Classes
A class bundles data (fields) with behaviour (methods).
Defining a class
class Dog:
fn init(self, name):
self.name = name
fn bark(self):
return self.name + " says woof"
d = Dog("Rex")
print(d.bark())
# => Rex says woof
Two things to notice:
- The first parameter of every method is
self— the instance the method was called on. It is a normal parameter name, not magic. initis the constructor. You call the class itself (Dog("Rex"), nonew) and Korrin runsinitfor you.
Fields
Fields come into being when you assign to self.something, usually in init.
Reading a field that was never set is an error:
class Box:
fn init(self, value):
self.value = value
b = Box(42)
print(b.value)
b.value = 99
print(b.value)
# => 42
# => 99
Inheritance
class Child(Parent): inherits Parent's methods. A method on the child with
the same name overrides the parent's:
class Animal:
fn init(self, name):
self.name = name
fn sound(self):
return "some noise"
fn describe(self):
return self.name + " makes " + self.sound()
class Cat(Animal):
fn sound(self):
return "a meow"
print(Cat("Whiskers").describe())
# => Whiskers makes a meow
Notice describe is defined only on Animal, but calling it on a Cat still
uses Cat's sound.
super
Inside a method, super.method(...) calls the parent's version — useful when you
want to extend behaviour rather than replace it:
class Vehicle:
fn init(self, wheels):
self.wheels = wheels
fn describe(self):
return "{self.wheels} wheels"
class Car(Vehicle):
fn init(self, brand):
super.init(4)
self.brand = brand
fn describe(self):
return "{self.brand}, {super.describe()}"
print(Car("Volvo").describe())
# => Volvo, 4 wheels
5. Errors
When something goes wrong at run time — an index past the end of a list, a
int("nope"), a key that isn't in a map — Korrin raises an exception. Left
alone, it stops the program and prints a diagnostic. You can catch it instead.
try / except
try:
n = int("not a number")
print(n)
except err:
print("could not parse: {err.message}")
# => could not parse: `int` cannot parse "not a number" as a number
The body runs; if it raises, the except block runs with the error bound to the
name you give (err here). A caught built-in fault is an Error with two
fields:
try:
print([1, 2, 3][10])
except e:
print(e.code)
print(e.message)
# => E0307
# => index 10 is out of range for a list of length 3
If you don't need the value, leave the name out:
try:
risky = 1 / 0
except:
print("handled")
# => handled
raise
Raise your own error with raise. Error(message) is the usual thing to raise,
but any value works:
fn withdraw(balance, amount):
if amount > balance:
raise Error("insufficient funds")
return balance - amount
try:
withdraw(100, 250)
except e:
print(e.message)
# => insufficient funds
There is one except per try — it catches everything. To handle only some
cases, check inside and re-raise the rest:
try:
try:
raise Error("timeout")
except e:
if e.message == "timeout":
print("retrying")
else:
raise e
except outer:
print("gave up: {outer.message}")
# => retrying
finally
A finally block runs no matter how the try exits — normal completion, a
caught error, an uncaught one passing through, even a return:
fn read_config():
try:
return "config contents"
finally:
print("closing the file")
print(read_config())
# => closing the file
# => config contents
Because finally always runs, a return (or raise, or break) inside it
wins over whatever the try was going to do — including swallowing an error.
Use it for cleanup, not for control flow.
Uncaught errors
An exception with no try around it ends the program:
$ korrin run bank.kor
[E0312] Error: uncaught error: insufficient funds
╭─[bank.kor:8:9]
│
8 │ raise Error("insufficient funds")
│ ─────────────┬────────────
│ ╰── raised here
───╯
Every built-in fault has a code (E0307, E0304, …); the
error reference lists them, and they are what err.code
gives you.
6. Modules
A program grows past one file by splitting into modules. Each .kor file is
a module; import runs another one and gives you an object to reach into.
Importing a file
Say geometry.kor sits next to your program:
pi = 3.14159
fn area(r):
return pi * r * r
Import it by relative path — ./ for "next to me", ../ to go up:
import "./geometry"
print(geometry.area(2))
import "./geometry" binds geometry (the file's name, no extension; the .kor
is implied). Reach members with ., like a class instance — this prints
12.56636.
To pick a different name — or avoid a clash — add as:
import "./geometry" as geo
print(geo.area(1))
(These blocks aren't executed by the CI check the way the rest of the guide is — they need real files on disk. The behaviour is covered by the interpreter's own module tests.)
What's public
Every top-level name in a module is visible through the module object, except
names starting with _:
fn shout(s):
return _emphasise(s.upper())
fn _emphasise(s):
return s + "!!!"
From another file, util.shout("go") returns "GO!!!", but reaching
util._emphasise is an error. Inside util.kor, _-names work normally — the
line is only drawn at the module boundary.
Loaded once
A module runs the first time it is imported. Import it again — from the same file
or a different one — and you get the same object back, already initialised, so
geometry == also_geometry is true.
Two modules that import each other are an error (E0315): Korrin will not
hand you a half-loaded module. If two modules need something in common, put it in
a third module they both import.
The standard library
A specifier with no ./ is a built-in module — part of Korrin, imported the
same way, and it works from any program:
import "math"
print(math.sqrt(144))
print(math.floor(3.7))
print(math.max(2, 9, 5))
# => 12.0
# => 3
# => 9
math has sqrt, pow, floor, ceil, round, abs, min, max, and the
constants pi and e.
io talks to the outside world — files and command-line arguments:
import "io"
io.write_file("note.txt", "hello from Korrin\n")
print(io.read_file("note.txt").trim())
print(io.args())
An io call that fails raises (E0316), so wrap it in try when the file
might not be there:
import "io"
try:
config = io.read_file("config.txt")
except e:
config = "(defaults)"
Full list of both modules in specification §11.
Because "math" always means the built-in, a file called math.kor next to your
program does not shadow it — import that one as "./math".
7. A worked example
A tiny to-do list, built up piece by piece. It uses classes, a list of instances, methods, a loop, and string building.
The task item
class Task:
fn init(self, title):
self.title = title
self.done = false
fn complete(self):
self.done = true
fn line(self):
if self.done:
return "[x] " + self.title
else:
return "[ ] " + self.title
The list
class TaskList:
fn init(self):
self.tasks = []
fn add(self, title):
self.tasks.push(Task(title))
fn complete(self, index):
self.tasks[index].complete()
fn remaining(self):
count = 0
for task in self.tasks:
if not task.done:
count = count + 1
return count
fn show(self):
for task in self.tasks:
print(task.line())
print("{self.remaining()} left")
Using it
class Task:
fn init(self, title):
self.title = title
self.done = false
fn complete(self):
self.done = true
fn line(self):
if self.done:
return "[x] " + self.title
else:
return "[ ] " + self.title
class TaskList:
fn init(self):
self.tasks = []
fn add(self, title):
self.tasks.push(Task(title))
fn complete(self, index):
self.tasks[index].complete()
fn remaining(self):
count = 0
for task in self.tasks:
if not task.done:
count = count + 1
return count
fn show(self):
for task in self.tasks:
print(task.line())
print("{self.remaining()} left")
todo = TaskList()
todo.add("write the guide")
todo.add("run the tests")
todo.add("ship it")
todo.complete(0)
todo.complete(1)
todo.show()
# => [x] write the guide
# => [x] run the tests
# => [ ] ship it
# => 1 left
Where to go next
- The specification for the exact rules.
- The decision records for why Korrin is shaped this way.
crates/korrin/examples/in the repository for more sample programs.
The Korrin language specification
This is the normative description of Korrin: if the implementation disagrees with a statement here, one of them is a bug. It describes the language as of Milestone 2.
Nothing here is speculative — features not yet implemented are not described. Every runnable example is executed by CI against the implementation.
| # | Section |
|---|---|
| 01 | Lexical structure |
| 02 | Grammar |
| 03 | Values and types |
| 04 | Expressions |
| 05 | Statements |
| 06 | Functions |
| 07 | Classes |
| 08 | Built-in functions and methods |
| 09 | Errors and diagnostics |
| 10 | Execution model |
| 11 | Modules |
| 12 | Packages |
| 13 | Native modules |
Conventions
-
Grammar fragments use the notation defined in section 02, which also gives the complete grammar.
-
A fenced code block tagged
korrinis an example. Blocks that also carry an expected-output comment are executed by CI (crates/korrin/tests/spec.rs) and must produce exactly that output. The convention:```korrin print(1 + 2) # => 3 ```A
# =>line states the next line of expected standard output. A# error: E0304line states that the program must fail with that code. -
"must", "must not", and "may" are used in the RFC 2119 sense.
1. Lexical structure
This section defines how Korrin source text is divided into tokens. It is
normative. The implementation is crates/korrin/src/lexer/.
Governing decisions: 0001, 0003, 0006, 0007.
1.1 Source encoding
A Korrin source unit is a sequence of Unicode scalar values, encoded as UTF-8. Byte offsets into this sequence are used to report positions.
The only significant line terminator is U+000A (line feed, \n). A U+000D
(carriage return, \r) is ignored wherever it appears, so \r\n is accepted as
a line ending.
1.2 Logical lines and layout tokens
The lexer produces four synthetic tokens that do not correspond to any run of source characters:
| Token | Meaning |
|---|---|
NEWLINE | The end of a logical line that contained at least one token. |
INDENT | The following logical line opens a more-indented block. |
DEDENT | The end of an indented block. One is emitted per level closed. |
EOF | End of input. Always the final token. |
A logical line is one or more physical lines that the lexer treats as a single statement-bearing unit. A physical line break is not a logical line break when:
- any bracket —
(,[, or{— opened earlier is still unclosed; or - the last token before the break was one after which the line cannot end: a
binary operator (
+ - * / % == != < <= > >=),=,,,., or a word operator (and,or,not).
There is no line-continuation character. A logical line may not be split except by the two rules above.
total = (1 +
2 +
3)
print(total)
# => 6
1.3 Blank lines and comments
A comment starts with # and runs to the end of the physical line. There are
no block comments.
A physical line that, after removing leading spaces, is empty or begins with #
is a blank line. Blank lines produce no tokens — not even NEWLINE — and
never affect indentation.
1.4 Indentation
Indentation is the run of space characters (U+0020) at the start of a physical line that begins a logical line. Its width is the count of those spaces.
- A tab (U+0009) anywhere in that leading run is an error
(
E0006). Korrin indents with spaces only. - Tabs elsewhere on a line — between tokens, inside a string — are not restricted (though between tokens a tab is simply whitespace).
The lexer maintains a stack of indentation widths, initially [0]. For each
logical line, let w be its indentation width and t the top of the stack:
w > t: pushw; emit oneINDENT.w < t: pop while the top is greater thanw, emitting oneDEDENTper pop. If the resulting top is not equal tow, that is an inconsistent dedent (E0007).w == t: emit nothing.
At EOF, a NEWLINE is emitted if the last logical line had tokens, then one
DEDENT for every stack entry above the base level 0.
The indentation width that opens a block is not fixed by the language — any consistent increase works — but the style guide requires 4 spaces.
if true:
a = 1
if true:
b = 2
c = 3
print(a + c)
# => 4
1.5 Identifiers
An identifier begins with _ or a character in Unicode XID_Start, and
continues with _ or characters in XID_Continue (Unicode UAX #31). Identifiers
are compared by exact scalar-value sequence; there is no case folding and no
normalization.
1.6 Keywords
The following 25 identifiers are reserved and may not be used as names (ADR 0006):
and as break class continue
elif else except false finally
fn for if import in
nil not or pass raise
return super true try while
self is not reserved — it is the conventional name of a method's first
parameter (ADR 0005) and is
otherwise an ordinary identifier.
1.7 Literals
Integer literals
One or more ASCII digits: 0, 7, 1000. The value must fit in a signed 64-bit
integer, otherwise E0005. There are no digit separators, sign
characters (a leading - is the unary operator), or radix prefixes.
Float literals
Digits, then either a fraction, an exponent, or both:
- fraction:
.followed by one or more digits —3.14,0.5; - exponent:
eorE, an optional+or-, then one or more digits —1e9,2.5e-3.
A . is only a decimal point when a digit follows it, so x.field and
3.name tokenize as an access, not a malformed number. A . or identifier
character glued to the end of a number (1.2.3, 10abc) is
E0004.
print(type(10))
print(type(10.0))
# => int
# => float
String literals
Text between double quotes: "hello". A string literal may not contain a raw
newline — an unclosed string at end of line is E0002.
Single quotes are not string delimiters.
The recognized escape sequences are exactly:
| Escape | Character |
|---|---|
\n | line feed |
\t | tab |
\r | carriage return |
\\ | backslash |
\" | double quote |
Any other \x is E0003. Raw strings and triple-quoted strings
do not exist.
Interpolation
Every string literal is a template (ADR 0015).
A { begins a hole; the text up to the matching } is a Korrin expression,
and its value is spliced into the string when the string is evaluated (§4.2).
{{and}}are literal{and}. A lone}is also literal.- A hole is tokenized as ordinary tokens, so a string inside a hole needs no
escaping:
"{m["k"]}". - An empty hole
"{}"isE0103. A{not closed before the end of the line isE0002. - A hole may not contain a newline (a string may not span lines).
A string with no holes is lexically a single string token. A string with at
least one hole is lexed as STRSTART, then a run of STRTEXT chunks and
{ expression } holes, then STREND — the hole tokens carrying their real
source positions.
1.8 Operators and punctuation
( ) [ ] { } , : .
+ - * / %
= == != < <= > >=
! is not a token on its own; it exists only as part of !=. A lone ! is
E0001, which suggests != or not.
2. Grammar
This section defines the notation used for grammar fragments throughout the specification, and then gives the complete grammar. Fragments quoted in other sections are taken from the complete grammar verbatim.
2.1 Notation
A variant of Extended Backus–Naur Form:
| Form | Meaning |
|---|---|
A = B ; | Rule: A is defined as B. |
"x" | A terminal: the literal source text x. |
UPPERCASE | A token produced by the lexer (§1), e.g. NAME, INT, NEWLINE. |
A , B | A followed by B. |
A | B | Either A or B. |
( … ) | Grouping. |
[ A ] | Zero or one A. |
{ A } | Zero or more A. |
2.2 What the grammar does and does not say
The grammar is context-free and written for readability. Two things are specified elsewhere:
- Operator precedence and associativity. The
expressionrule below is deliberately ambiguous (expression , binary_op , expression). The real precedence is the table in §4, implemented by a Pratt parser. Comparisons are non-associative —a < b < cis a syntax error. - Assignment targets. The grammar allows
assignable = NAME | attribute | index, but the parser actually parses a fullexpressionon the left of=and then checks that it is one of those three forms, so that a mistake likea + b = 1gets a precise "cannot assign to this expression" message (E0104) rather than a generic parse failure.
2.3 Layout tokens in the grammar
NEWLINE, INDENT, and DEDENT appear in the grammar as ordinary terminals.
They are produced by the lexer from indentation (§1.2, §1.4), so the grammar
itself never mentions whitespace. Every block is exactly:
block = ":" , NEWLINE , INDENT , statement , { statement } , DEDENT ;
2.4 The complete grammar
This is the whole of Korrin's syntax. It is kept in step with
crates/korrin/src/parser/ and its tests.
Tokens
Produced by the lexer (§1):
NEWLINE end of a logical line
INDENT start of a more-indented block
DEDENT end of an indented block
NAME identifier that is not a keyword
INT integer literal
FLOAT float literal
STRING string literal with no interpolation holes
EOF end of input
An interpolated string (§1.7) is not one token — the lexer emits
STRSTART ( STRTEXT | STREXPRSTART expression STREXPREND )* STREND.
plus the 25 keywords and the operator / punctuation tokens listed in §1.8.
Modules and blocks
module = { NEWLINE } , { statement } , EOF ;
block = ":" , NEWLINE , INDENT , statement , { statement } , DEDENT ;
Statements
statement =
simple_statement
| if_statement
| while_statement
| for_statement
| function_definition
| class_definition
| try_statement ;
simple_statement =
( assignment | return_statement | raise_statement | import_statement
| "break" | "continue" | "pass" | expression ) ,
NEWLINE ;
assignment = assignable , "=" , expression ;
assignable = NAME | attribute | index ;
return_statement = "return" , [ expression ] ;
raise_statement = "raise" , expression ;
import_statement = "import" , STRING , [ "as" , NAME ] ;
if_statement =
"if" , expression , block ,
{ "elif" , expression , block } ,
[ "else" , block ] ;
while_statement = "while" , expression , block ;
for_statement = "for" , NAME , "in" , expression , block ;
function_definition =
"fn" , NAME , "(" , [ parameter_list ] , ")" , block ;
parameter_list = NAME , { "," , NAME } , [ "," ] ;
class_definition =
"class" , NAME , [ "(" , NAME , ")" ] , ":" , NEWLINE ,
INDENT , class_member , { class_member } , DEDENT ;
class_member = function_definition | ( "pass" , NEWLINE ) ;
try_statement =
"try" , block ,
[ "except" , [ NAME ] , block ] ,
[ "finally" , block ] ;
A try_statement must have at least one of the except and finally clauses;
a bare try is E0107.
Expressions
Written ambiguously; see §4 for precedence.
expression =
literal
| interpolated_string
| NAME
| "super" , "." , NAME
| list_literal
| map_literal
| "(" , expression , ")"
| unary_op , expression
| expression , binary_op , expression
| call
| attribute
| index ;
call = expression , "(" , [ argument_list ] , ")" ;
argument_list = expression , { "," , expression } , [ "," ] ;
attribute = expression , "." , NAME ;
index = expression , "[" , expression , "]" ;
list_literal = "[" , [ expression , { "," , expression } , [ "," ] ] , "]" ;
map_literal = "{" , [ map_entry , { "," , map_entry } , [ "," ] ] , "}" ;
map_entry = expression , ":" , expression ;
interpolated_string =
STRSTART , { STRTEXT | ( STREXPRSTART , expression , STREXPREND ) } , STREND ;
unary_op = "-" | "not" ;
binary_op =
"+" | "-" | "*" | "/" | "%"
| "==" | "!=" | "<" | "<=" | ">" | ">="
| "and" | "or" ;
literal = INT | FLOAT | STRING | "true" | "false" | "nil" ;
3. Values and types
Korrin is dynamically typed: values carry their type, variables do not. There are twelve kinds of value.
3.1 The value kinds
| Type | Literal(s) | Notes |
|---|---|---|
nil | nil | The single "no value". A function with no return yields it. |
bool | true, false | |
int | 0, 42 | Signed 64-bit (ADR 0010). |
float | 3.14, 1e9 | IEEE-754 double. |
str | "hi" | Immutable, UTF-8, indexed by character (§1.7). |
list | [1, 2, 3] | Ordered, mutable, grows with list.push. |
map | {"k": 1} | Key→value, insertion-ordered, mutable. |
function | fn definitions; builtins | First-class; type() reports "function". |
class | class definitions | Callable — calling it constructs an instance. |
instance | ClassName(...) | type() reports the class name. |
module | import "..." | A loaded module (§11); members are m.name. type() reports "module". |
| (native object) | returned by a native module's own functions | Opaque state owned by a linked-in Rust crate, with its own methods (§13.3); type() reports the name the module chose. Only exists in a build with at least one native module linked in. |
3.2 Numbers
int and float are distinct types. A literal is an int unless it has a .
or an exponent. Arithmetic between an int and a float produces a float;
/ always produces a float. Integer overflow is a runtime error, not
wraparound. See ADR 0010 and §4.3.
print(type(5))
print(type(5 / 1))
# => int
# => float
3.3 Identity and equality
== compares by value for nil, bool, int, float, str, list, and
map (structurally and recursively for the last two). 1 == 1.0 is true.
For function, class, and instance, == is identity: two values are
equal only if they are the same object.
print([1, 2] == [1, 2])
print({"a": 1} == {"a": 1})
# => true
# => true
3.4 Mutability and sharing
str is immutable. list, map, and instance are mutable and shared by
reference: assigning one to a new variable, passing it to a function, or
storing it in a collection does not copy it.
a = [1, 2, 3]
b = a
b.push(4)
print(a)
# => [1, 2, 3, 4]
3.5 Truthiness
Only nil and false are falsy. Every other value — including 0, 0.0, "",
[], and {} — is truthy (ADR 0009).
if []:
print("an empty list is truthy")
# => an empty list is truthy
3.6 Display form
print and str() render a value as:
| Type | Form |
|---|---|
nil | nil |
bool | true / false |
int | decimal digits |
float | decimal, always with a fractional part (3.0, not 3); inf, -inf, nan |
str | the characters themselves (no quotes) |
list | [e1, e2, ...] with elements in repr form |
map | {k1: v1, ...} with keys and values in repr form |
function / class | <function name> / <class Name> |
instance | <Name instance> |
module | <module name> |
Repr form is the display form except that a str is shown quoted and
escaped ("a\nb"). Collections always show their elements in repr form, so
print(["a"]) writes ["a"] but print("a") writes a.
3.7 Hashable values
Only nil, bool, int, and str may be used as map keys. Using any other
type as a key is E0311. (float is excluded to avoid
nan-key hazards; mutable types are excluded because their identity can change.)
3.8 Map keys and ==
Because keys are compared with ==, 1 and 1.0 would be the same key — but
1.0 is a float and therefore not hashable, so in practice integer keys are
distinct and unambiguous.
4. Expressions
This section defines Korrin's expression forms and how they are grouped. Runtime behaviour of operators (arithmetic, comparison, truthiness) is in §3 and §10; this section is about syntax and structure.
4.1 Precedence and associativity
From loosest to tightest binding:
| Precedence | Operators | Form | Associativity |
|---|---|---|---|
| 1 | or | a or b | left |
| 2 | and | a and b | left |
| 3 | not | not a | prefix |
| 4 | == != < <= > >= | a == b | non-associative |
| 5 | + - | a + b | left |
| 6 | * / % | a * b | left |
| 7 | - | -a | prefix |
| 8 | f(...) a.b a[i] | postfix | left |
"Non-associative" means a comparison may not be chained: a < b < c is a syntax
error (E0101). Write (a < b) and (b < c).
print(2 + 3 * 4)
print(-(1 + 2) * 3)
# => 14
# => -9
There is no exponentiation operator; the full operator list is in §1.8.
4.2 Primary expressions
Literals
INT, FLOAT, STRING (§1.7), and the keyword literals true, false, nil.
String interpolation
A string literal with one or more { expression } holes (§1.7) is itself an
expression. Evaluating it evaluates each hole left to right, renders each
result with display semantics — the same form str() and print produce, so a
string is spliced as-is and not re-quoted — and concatenates the literal text
and the rendered holes into one str.
name = "Ada"
n = 3
print("{name} has {n} item{"s" }")
print("{n} + {n} = {n + n}")
# => Ada has 3 items
# => 3 + 3 = 6
A hole is a full expression, including calls, indexing, and further strings:
row = {"user": "grace", "score": 90}
print("{row["user"]}: {row["score"]}%")
# => grace: 90%
Names
A bare NAME refers to the nearest binding in scope
(ADR 0011). An unbound name is
E0301 when evaluated.
self
Inside a method, self is just the first parameter — an ordinary name
(ADR 0005). Outside a method it
is an unbound name like any other.
Parentheses
( expression ) groups without producing a distinct value or node; it only
overrides precedence.
List literals
[ e1, e2, ... ], with an optional trailing comma. [] is the empty list.
Elements are evaluated left to right.
Map literals
{ k1: v1, k2: v2, ... }, with an optional trailing comma. {} is the empty
map (there is no set type). Keys and values are arbitrary expressions, evaluated
in source order, key before value.
pair = {"x": 1 + 1, "y": [3, 4]}
print(pair["x"])
# => 2
4.3 Operator expressions
Unary
-a negates a number. not a produces true if a is falsy and false
otherwise (ADR 0009).
Binary arithmetic and comparison
+ - * / % and == != < <= > >=. Both operands are always evaluated. Types and
results are defined in §3; note / always yields a
float (ADR 0010).
and / or
These short-circuit and return one of their operands, not a coerced boolean:
a and bevaluatesa; ifais falsy it is the result, otherwisebis evaluated and is the result.a or bevaluatesa; ifais truthy it is the result, otherwiseb.
name = nil
print(name or "anonymous")
# => anonymous
4.4 Postfix expressions
Call — callee(args)
callee is evaluated, then each argument left to right, then the call happens.
callee must be a function, a builtin, or a class (§6,
§7); otherwise E0302. Argument count must
match the callable's arity (E0303). A trailing comma in the
argument list is allowed.
Attribute — object.name
Reads name from object:
The result of reading a method is a callable bound to object; it is almost
always called immediately (items.push(x)). On a module, object.name is a
public top-level member (§11.3). An absent attribute — or a
module member starting with _ — is E0309; reading a method
name without calling it is allowed and yields a function.
Index — object[index]
Reads from a list (integer index, E0307 if out of range),
a map (E0310 if the key is absent), or a string. Indexing
anything else is E0308.
super.method
Only valid inside a method whose class has a parent
(ADR 0005). super.method
resolves method starting from the parent class, bound to the current self.
It is essentially always immediately called: super.method(args).
5. Statements
A Korrin program is a sequence of statements, executed top to bottom. Each
statement occupies one logical line (§1.2), except the compound statements
(if, while, for, fn, class), whose header is one line and whose body is
an indented block.
5.1 Expression statements
Any expression on its own line is a statement; its value is discarded. The common case is a call:
print("hello")
# => hello
5.2 Assignment
assignment = assignable , "=" , expression ;
assignable = NAME | attribute | index ;
target = value evaluates value, then stores it:
name = value— binds per the nearest-binding rule (ADR 0011): ifnamealready exists in this or an enclosing scope, that binding is updated; otherwise a new binding is created in the current scope.object.field = value— sets a field on an instance.object[key] = value— sets a list element (index must be in range) or a map entry (creating it if absent).
There is no compound assignment (+= etc.) and no multiple assignment
(a, b = ...). Assigning to anything else — a literal, a call, an
operator expression — is E0104.
counter = 0
counter = counter + 1
print(counter)
# => 1
5.3 if / elif / else
if_statement =
"if" , expression , block ,
{ "elif" , expression , block } ,
[ "else" , block ] ;
Conditions are tested in order. The first truthy one's block runs; if none is
truthy and an else is present, its block runs. Truthiness:
only nil and false are falsy
(ADR 0009).
n = 2
if n == 1:
print("one")
elif n == 2:
print("two")
else:
print("many")
# => two
5.4 while
while_statement = "while" , expression , block ;
The condition is tested before each iteration; the block runs while it is truthy.
i = 0
while i < 3:
print(i)
i = i + 1
# => 0
# => 1
# => 2
5.5 for
for_statement = "for" , NAME , "in" , expression , block ;
NAME is bound to each element of the iterable in turn. The iterables are:
lists (elements), strings (characters as one-character strings), maps (keys), and
the ranges produced by the range builtin. A malformed header is
E0106.
total = 0
for x in [10, 20, 30]:
total = total + x
print(total)
# => 60
5.6 break and continue
break exits the innermost enclosing loop; continue skips to its next
iteration. Outside any loop they are E0202 / E0203,
reported by the resolver before the program runs.
5.7 pass
The do-nothing statement. Its only purpose is to be the body of a block that would otherwise be empty (ADR 0001):
fn todo():
pass
5.8 return
Valid only inside a function body (E0201 otherwise). return
with an expression yields that value; bare return yields nil. Reaching the
end of a function body without return also yields nil. See
§6.
5.9 fn and class
fn defines a function (§6) and class defines a class (§7). Both are
statements: they bind a name in the current scope when executed.
5.10 try / except / finally
try_statement =
"try" , block ,
[ "except" , [ NAME ] , block ] ,
[ "finally" , block ] ;
At least one of except and finally must be present; a bare try is
E0107. A except or finally with no preceding try is the
same error.
The try body runs. Then:
- If it raised (via
raise, §5.11, or a built-in fault) and there is anexcept, the handler runs.except NAME:binds the raised value toNAMEfor the handler body;except:catches without binding. There is exactly oneexceptpertry— it catches every exception. A caught built-in fault is bound as anErrorvalue with.messageand.code(§9). - If it raised and there is no
except, the exception keeps propagating afterfinallyruns. finally, if present, runs on every exit path: normal completion, a caught exception, an uncaught one passing through, and areturn/break/continueleaving thetry. If thefinallyblock itself raises or transfers control, that outcome replaces whatever was pending — so areturninsidefinallysuppresses a propagating exception. Usefinallyfor cleanup, not control flow.
fn classify(text):
try:
n = int(text)
return "number"
except:
return "not a number"
finally:
print("classified {text}")
print(classify("42"))
print(classify("x"))
# => classified 42
# => number
# => classified x
# => not a number
try:
total = 10 / 0
except err:
print("{err.code}: caught")
# => E0305: caught
5.11 raise
raise_statement = "raise" , expression ;
Evaluates the expression and raises it as an exception, unwinding until a try
with an except catches it (§5.10) or it reaches the top level, where it is
E0312. Any value may be raised; Error("message") is the
conventional choice. There is no bare raise — to re-raise in a handler, name
the bound value: raise err.
fn checked_sqrt(x):
if x < 0:
raise Error("cannot take the square root of {x}")
return x
try:
print(checked_sqrt(-4))
except e:
print(e.message)
# => cannot take the square root of -4
5.12 import
import_statement = "import" , STRING , [ "as" , NAME ] ;
Loads the module named by the string and binds it in the current scope. Without
as, the bound name is the specifier's final path component with any extension
removed; if that is not a valid identifier, as is required
(E0108). The string must be a plain literal — no interpolation
holes. Full semantics — path resolution, the module object, privacy, cycles — are
in §11.
import "math"
import "math" as m
print(m.floor(2.9))
# => 2
6. Functions
function_definition = "fn" , NAME , "(" , [ parameter_list ] , ")" , block ;
parameter_list = NAME , { "," , NAME } , [ "," ] ;
6.1 Definition and binding
fn name(params): defines a function and binds name in the current scope when
the definition statement executes. A function defined inside another function is
bound in that call's scope and is not visible outside it.
fn square(n):
return n * n
print(square(9))
# => 81
6.2 Parameters and arity
Parameters are positional. A call must pass exactly as many arguments as the
function has parameters, otherwise E0303. There are no default
values, variadic parameters, or keyword arguments. A trailing comma is allowed in
both the parameter list and the argument list.
6.3 Return value
return expr ends the call with expr's value. return with nothing, and
falling off the end of the body, both yield nil. return outside any function
is E0201, reported before the program runs.
fn first_even(xs):
for x in xs:
if x % 2 == 0:
return x
return nil
print(first_even([1, 3, 4, 7]))
print(first_even([1, 3, 5]))
# => 4
# => nil
6.4 Functions are values
A function is an ordinary value: it can be stored, passed, and returned.
type() reports "function" for user functions and builtins alike.
fn apply_twice(f, x):
return f(f(x))
fn inc(n):
return n + 1
print(apply_twice(inc, 10))
# => 12
6.5 Closures
A function captures the scope in which it was defined. It sees, and can modify, variables from enclosing functions by the nearest-binding rule (ADR 0011).
fn make_adder(n):
fn add(x):
return x + n
return add
add5 = make_adder(5)
print(add5(1))
print(add5(100))
# => 6
# => 105
The capture is by binding, not by value, so a closure that assigns to a captured variable changes it for every other closure over the same binding:
fn counter():
count = 0
fn tick():
count = count + 1
return count
return tick
t = counter()
print(t())
print(t())
# => 1
# => 2
6.6 Recursion
A function may call itself. Call nesting is bounded (see §10.5);
unbounded recursion is reported as E0313 rather than crashing.
7. Classes
class_definition =
"class" , NAME , [ "(" , NAME , ")" ] , ":" , NEWLINE ,
INDENT , class_member , { class_member } , DEDENT ;
class_member = function_definition | ( "pass" , NEWLINE ) ;
A class body contains only method definitions (and pass). It binds the class
name in the current scope when it executes.
7.1 Methods and self
A method is a fn in a class body. Its first parameter — conventionally named
self — receives the instance the method was called on
(ADR 0005). self is an
ordinary parameter, not a keyword.
class Point:
fn init(self, x, y):
self.x = x
self.y = y
fn manhattan(self):
return self.x + self.y
p = Point(3, 4)
print(p.manhattan())
# => 7
7.2 Construction
Calling a class constructs an instance: a new empty instance is created, then, if
the class has a method named init, it is called with the instance as self
and the remaining arguments. There is no new keyword.
If a class has no init, calling it with any argument is E0303.
7.3 Fields
Fields are created by assignment to self.name (usually in init). Reading a
field or method that does not exist is E0309. Fields shadow
methods of the same name.
class Box:
fn init(self, value):
self.value = value
b = Box(42)
print(b.value)
b.value = 99
print(b.value)
# => 42
# => 99
7.4 Inheritance
class Child(Parent): makes Parent the superclass. Method lookup checks the
class, then its parent, then the grandparent, and so on. Inheritance is single
only — a class has at most one parent. A class that names itself as parent is
E0208.
class Animal:
fn init(self, name):
self.name = name
fn noise(self):
return "..."
fn speak(self):
return self.name + " says " + self.noise()
class Dog(Animal):
fn noise(self):
return "woof"
print(Dog("Rex").speak())
# => Rex says woof
7.5 super
Inside a method of a class with a parent, super.method(args) calls the
parent's version of method, bound to the current self. super outside a
method is E0205; super in a class with no parent is
E0206. Both are reported before the program runs.
class Base:
fn init(self, x):
self.x = x
fn show(self):
return "x=" + str(self.x)
class Derived(Base):
fn init(self, x, y):
super.init(x)
self.y = y
fn show(self):
return super.show() + " y=" + str(self.y)
print(Derived(1, 2).show())
# => x=1 y=2
8. Built-in functions and methods
Korrin's core vocabulary needs no import: a small set of functions is
bound in every program's global scope, and the built-in types str, list, and
map carry methods reached with .. (Anything beyond the core — the
standard library — is imported; see §11.)
The division is deliberate (ADR 0016):
- A function constructs or converts a value, or does I/O —
print,str,range. It is not an operation on a particular value. - A method operates on its receiver —
xs.push(v),text.trim(),m.keys().
Argument count is checked before the function or method runs
(E0303); argument types are checked by each
(E0304 unless noted). For a method, the receiver is not
counted as an argument.
Every name below must match the interpreter exactly;
crates/korrin/tests/builtins.rs fails the build otherwise
(ADR 0002).
One further name, Error, is bound in the global scope: it is the built-in
exception type, documented with the error model in §9.6.
8.1 Functions
print(*values)
Writes each value's display form (see §3.6), separated
by single spaces, followed by a newline. print() writes just a newline.
print("x", "=", 3)
# => x = 3
input(prompt?)
Writes prompt (if given, with no trailing newline) to standard output, then
reads and returns one line from standard input as a str with its trailing
newline removed. Returns nil at end of input.
type(value)
The name of value's type as a str: "nil", "bool", "int", "float",
"str", "list", "map", "function", "class", "module", or — for an
instance — the class's name.
print(type(1))
print(type(1.0))
print(type([]))
# => int
# => float
# => list
str(value)
value's display form as a str (what print would write).
int(value)
Converts to int: an int unchanged; a bool to 0 or 1; a float
truncated toward zero (E0005 if outside the 64-bit range); a
str parsed as a decimal integer (surrounding whitespace ignored;
E0304 if it is not a valid integer). Other types are
E0304.
print(int("42") + 1)
print(int(3.9))
# => 43
# => 3
float(value)
Converts to float: a float unchanged; an int or bool widened; a str
parsed (E0304 on failure). Other types are
E0304.
bool(value)
value's truthiness as a bool — false only for nil and false
(ADR 0009).
range(stop) / range(start, stop) / range(start, stop, step)
Returns a list of ints from start (default 0) up to but not including
stop, in increments of step (default 1). step may be negative; a step
of 0 is E0304. All arguments must be ints.
print(range(4))
print(range(2, 5))
print(range(6, 0, -2))
# => [0, 1, 2, 3]
# => [2, 3, 4]
# => [6, 4, 2]
8.2 Methods on str
Strings are immutable, so every str method returns a new value; the
receiver is unchanged. Index arguments and results count characters, like
[] indexing (§4.4).
| Method | Result |
|---|---|
str.len() | number of characters, as an int |
str.upper() / str.lower() | case-folded copy |
str.trim() | copy with leading and trailing whitespace removed |
str.split(sep) | list of the pieces between each sep; sep must be a non-empty str |
str.replace(old, new) | copy with every old replaced by new; old must be non-empty |
str.find(sub) | character index of the first sub, or -1 |
str.contains(sub) | whether sub occurs, as a bool |
str.starts_with(prefix) / str.ends_with(suffix) | bool |
str.slice(start, end) | characters start up to (not including) end; bounds are clamped to 0..len |
str.repeat(n) | n copies joined; n must be ≥ 0 |
print(" Grace Hopper ".trim().upper())
print("a,b,c".split(","))
print("na".repeat(4) + " batman")
print("hello".slice(1, 4))
# => GRACE HOPPER
# => ["a", "b", "c"]
# => nananana batman
# => ell
8.3 Methods on list
Lists are mutable and shared. push, insert, remove_at, reverse, and
sort change the list in place; push, insert, reverse, and sort
return nil, while pop and remove_at return the element removed.
| Method | Result |
|---|---|
list.len() | number of elements, as an int |
list.push(value) | appends value; returns nil |
list.pop() | removes and returns the last element; E0307 if empty |
list.insert(index, value) | inserts before index (0..=len); E0307 otherwise |
list.remove_at(index) | removes and returns the element at index (0..len); E0307 otherwise |
list.index_of(value) | index of the first element equal to value, or -1 |
list.contains(value) | whether any element equals value, as a bool |
list.slice(start, end) | a new list of elements start up to end; bounds clamped to 0..len |
list.reverse() | reverses in place; returns nil |
list.sort() | sorts in place, ascending; returns nil. Every element must be a number, or every element a str (E0304 otherwise) |
list.join(sep) | a str of the elements separated by sep; every element must be a str |
xs = [3, 1, 2]
xs.push(0)
xs.sort()
print(xs)
print(["ready", "set", "go"].join(" "))
# => [0, 1, 2, 3]
# => ready set go
8.4 Methods on map
| Method | Result |
|---|---|
map.len() | number of entries, as an int |
map.keys() / map.values() | a list of the keys / values, in insertion order |
map.has(key) | whether key is present, as a bool |
map.get(key, default?) | the value for key, or default (or nil) if absent; never raises E0310 |
map.remove(key) | removes key, returning its value, or nil if absent |
map.pairs() | a list of [key, value] pairs, in insertion order |
scores = {"ada": 10, "alan": 8}
print(scores.keys())
print(scores.get("linus", 0))
scores.remove("ada")
print(scores.has("ada"))
# => ["ada", "alan"]
# => 0
# => false
9. Errors and diagnostics
9.1 The diagnostic model
Every problem Korrin reports is a diagnostic: a stable error code, a one-line message, one or more labelled spans of source, and optional notes and a help line. Diagnostics are produced by four stages — lexer, parser, resolver, interpreter — and rendered the same way regardless of origin (ADR 0008).
9.2 Stability of codes
Once a code is assigned a meaning it keeps it forever. Wording may improve; the
code–meaning mapping does not change. New codes are appended. The authoritative
list is the ErrorCode enum in crates/korrin/src/diagnostics.rs; this section
is its prose companion.
9.3 When does a program stop?
- Lexer and parser errors are collected — one mistake does not hide the rest — and reported together. The program does not run.
- Resolver errors are all reported in one pass. The program does not run.
- A runtime fault raises an exception (§9.6). If a
trycatches it (§5.10), the program continues; otherwise it stops at that point, and output written before then has already been produced.
9.4 Codes
Lexical — E00xx
| Code | Meaning |
|---|---|
E0001 | A character that cannot begin any token (includes a lone !). |
E0002 | A string literal with no closing " before the end of the line. |
E0003 | A \ escape that Korrin does not define. |
E0004 | A malformed number literal (1.2.3, 10abc). |
E0005 | A number literal outside the range of its type. |
E0006 | A tab character in a line's leading indentation. |
E0007 | A dedent to a column that matches no enclosing block. |
E0008 | An opening (, [, or { with no matching close. |
Syntax — E01xx
| Code | Meaning |
|---|---|
E0101 | A token where the grammar does not allow it (includes a chained comparison). |
E0102 | A required token (:, ), in, a name, end of line, …) was missing. |
E0103 | An expression was required and none was found. |
E0104 | The left side of = is not a name, attribute, or index. |
E0105 | (reserved) An indented block with no statements. |
E0106 | A for header not of the form for NAME in EXPR:. |
E0107 | A try with neither except nor finally, or a dangling except / finally. |
E0108 | An import with no path, a non-literal path, an unusable derived name, or a stray as. |
Static — E02xx
| Code | Meaning |
|---|---|
E0201 | return outside any function. |
E0202 | break outside any loop. |
E0203 | continue outside any loop. |
E0204 | (reserved — self is an ordinary name) |
E0205 | super outside any method. |
E0206 | super in a class with no parent. |
E0207 | Two parameters of one function share a name. |
E0208 | A class names itself as its own parent. |
Runtime — E03xx
| Code | Meaning |
|---|---|
E0301 | Read of a name with no binding in scope. |
E0302 | Call of a value that is not a function or class. |
E0303 | A call with the wrong number of arguments. |
E0304 | An operator, builtin, or method got a value of the wrong type. |
E0305 | Integer or float division/modulo by zero. |
E0306 | An integer operation overflowed the 64-bit range. |
E0307 | A list or string index outside its bounds (includes list.pop / list.remove_at / list.insert out of range). |
E0308 | Indexing a value that does not support it. |
E0309 | Access of an attribute a value does not have (an unknown instance field/method, or an unknown method on str / list / map). |
E0310 | A map[key] lookup for an absent key. |
E0311 | A value that is not hashable used as a map key. |
E0312 | An exception (raised value or built-in fault) reached the top level uncaught. |
E0313 | Function-call nesting exceeded the interpreter's recursion limit. |
E0314 | An import could not be resolved, read, or compiled (§11). |
E0315 | Modules import each other in a cycle (§11). |
E0316 | A filesystem or stream operation failed (io module — §11.5). |
Compilation — E04xx
Raised by the bytecode compiler, which runs only when a program is executed by the virtual machine. These are limits of the bytecode's operand widths rather than of the language: each is a ceiling on how much one function or one chunk can hold, and reaching any of them takes a machine-generated program.
| Code | Meaning |
|---|---|
E0401 | One function needs more than 65 535 local variables. |
E0402 | One chunk needs more than 65 535 constants. |
E0403 | One call passes, or one function declares, more than 255 arguments. |
E0404 | One function captures more than 65 535 variables from enclosing scopes. |
9.6 Exceptions
A runtime fault is an exception: it unwinds until a try
(§5.10) catches it, or it reaches the top level as
E0312.
Catching. except NAME: binds the exception to NAME. What NAME holds
depends on how the exception arose:
- A built-in fault (
E0301–E0313) is bound as anErrorvalue with.message(the diagnostic's text) and.code(its"E03xx"string). - A
raised value is bound as-is —raise 42binds42,raise "x"binds"x",raise Error("x")binds thatError.
The Error type. Error is a built-in name bound in every program.
Error(message?) constructs an instance with:
.message— the argument's display form, or"error"if none was given;.code—nil(a program-created error has no diagnostic code).
type(Error("x")) is "Error".
e = Error("out of range")
print(e.message)
print(e.code)
print(type(e))
# => out of range
# => nil
# => Error
Uncaught. An exception with no enclosing try prints an E0312 diagnostic
pointing at the raise (or the faulting expression) and the program exits
non-zero. An uncaught Error shows its .message; any other value shows as
uncaught: <repr>.
try:
value = {"a": 1}["b"]
except err:
print("{err.code}")
# => E0310
9.5 Examples
x = 1 + undefined_name
# error: E0301
fn f(a, a):
return a
# error: E0207
print(10 % 0)
# error: E0305
10. Execution model
How a Korrin program runs, once it has lexed, parsed, and resolved without error.
This section describes the language, not an implementation. Korrin has two engines — a tree-walking interpreter and a bytecode virtual machine — and everything here holds for both; every runnable example below is executed under each of them on every build (ADR 0024).
10.1 Program start
A program starts at one file — the entry module. Its top-level statements
execute in order, top to bottom, in that module's global scope. fn and
class statements bind their names when reached, so a function must be defined
above its first call at the top level (though mutual recursion between
functions works, because the calls happen later, at run time).
An import statement runs another module to completion (once) before the
importing statement continues; each module has its own global scope
(§11).
fn even(n):
if n == 0:
return true
return odd(n - 1)
fn odd(n):
if n == 0:
return false
return even(n - 1)
print(even(10))
# => true
10.2 Scopes and name resolution
There are exactly two kinds of scope: the global scope, and a function-call
scope (ADR 0011). if, while,
and for do not create scopes. Each call gets a fresh scope, and a function
can see the scope it was defined in, not the one it was called from — this is
what makes closures work.
A name refers to the nearest binding of that name: the current function
scope, then each enclosing function scope, then global. = updates the binding
it finds, or creates one in the current scope if there is none. That is why a
function can update a global without any keyword:
count = 0
fn bump():
count = count + 1
bump()
bump()
print(count)
# => 2
Which binding a name refers to is fixed by the program text. A scope binds
the names that appear in binding position anywhere in its own body: an
assignment target, a for variable, an except binding, a parameter, or the
name of a fn, class, or import ... as. Position within the body does not
matter, so a name assigned late in a function is that function's local from its
first line, and reading it before the assignment runs is
E0301:
fn f():
print(later) # E0301: `later` is f's, and has no value yet
later = 1
Names not bound anywhere nearer are global, and reading a global that nothing
has assigned yet is also E0301.
The tree-walking interpreter answers "is there a nearer binding?" while the program runs rather than from the text, which differs in one narrow case: a function that assigns a name the module also binds, called before the module's own assignment has run. See ADR 0022.
10.3 Evaluation order
- Binary operators evaluate the left operand, then the right, then combine.
- A call evaluates the callee, then each argument left to right, then calls.
and/orevaluate the left operand, and the right only if needed.- List and map literals evaluate their elements in source order (for a map, each key before its value).
10.4 Value semantics
Scalars (nil, bool, int, float) and str are effectively immutable —
operations produce new values. list, map, and instance are reference
types: the same object can be reached through several names, and a mutation
through one is visible through all (§3.4).
A for loop iterates over a snapshot of a list taken when the loop starts,
so appending to the list inside the loop does not extend the iteration.
xs = [1, 2, 3]
for x in xs:
xs.push(x)
print(xs.len())
# => 6
10.5 Runtime limits
Function-call nesting is bounded: a call depth beyond the limit (currently
10 000) is reported as E0313 rather than aborting the process,
which turns an infinite recursion into a diagnostic.
Programs compiled for the virtual machine also meet the limits of the bytecode
itself — how many locals one function may have, how many constants one chunk may
hold (E04xx). Reaching any of them takes a generated program;
they are limits of the instruction encoding, not of the language.
10.6 Termination
A program ends when its last top-level statement finishes, or at the point an exception reaches the top level uncaught (§9.6). There is no explicit exit function.
11. Modules
A Korrin program is one or more modules. Each .kor file is a module; the
import statement (§5.12) loads another module and binds it
as a value. This section is the normative description of loading, module objects,
and the standard library (ADR 0018,
ADR 0019).
11.1 Specifiers and resolution
import_statement = "import" , STRING , [ "as" , NAME ] ;
The string is the specifier. Its first characters decide how it resolves:
./…or../…— a file, resolved relative to the directory of the file containing theimport(not the process's working directory, not the entry file). If the specifier has no extension,.koris appended. A file that does not exist or cannot be read isE0314.- anything else — a built-in module (§11.4), looked up by exact name.
No filesystem is consulted, so
import "math"is always the standard library'smath. An unknown name isE0314.
A relative import requires a file on disk to resolve against. When a program is
run from memory rather than a file (an embedder's in-process call), a relative
import is E0314; built-in imports still work.
11.2 The bound name
import "spec" binds the specifier's final path component, with any extension
removed: import "./a/config" binds config, import "math" binds math.
import "spec" as name binds name instead. If there is no as and the derived
name is not a valid identifier (§1.5), the import is
E0108 and must use as.
The specifier must be a plain string literal; one with interpolation holes is
E0108.
11.3 Module objects
Evaluating an import runs the module once and produces a module object.
type() reports "module".
- Isolation. A module's file runs in its own scope — a fresh global with the
built-in functions and
Error, then the module's own top level. It does not see the importing program's names, and vice versa. - Caching. Loading is cached by canonical path (files) or name (built-ins).
Importing the same module in two places yields the same object;
==on module objects is identity. - Members.
module.namereadsnamefrom the module's top-level bindings. A name that is not bound isE0309. - Privacy. A top-level name beginning with
_is private: reading it through the module object isE0309, even though it is bound. Code within the module uses its own_-names normally.
A module object is not callable (E0302), not indexable
(E0308), and not iterable.
import "math"
print(type(math))
print(math.pi > 3)
# => module
# => true
11.4 The math module
import "math". Numeric helpers and two constants. math is float-flavoured:
sqrt and pow return float; the rounding functions return int.
| Member | Result |
|---|---|
math.sqrt(x) | the square root of x as a float; E0304 if x < 0 |
math.pow(base, exp) | base raised to exp, as a float |
math.floor(x) | the largest int not greater than x; E0304 if it does not fit int |
math.ceil(x) | the smallest int not less than x |
math.round(x) | x rounded to the nearest int, halves away from zero |
math.abs(x) | the absolute value of x, keeping its type (int or float) |
math.min(a, ...) | the smallest of one or more numbers, returned unchanged |
math.max(a, ...) | the largest of one or more numbers, returned unchanged |
math.pi | π, as a float |
math.e | e, as a float |
import "math"
print(math.sqrt(49))
print(math.floor(2.9))
print(math.abs(-7))
print(math.max(3, 8, 1))
# => 7.0
# => 2
# => 7
# => 8
11.5 The io module
import "io". Filesystem access and command-line arguments
(ADR 0020). Every operation that can fail
raises E0316, which try can catch. Paths are used as
given — a relative path is relative to the process's working directory, not the
script's location. There is no sandbox.
| Member | Result |
|---|---|
io.read_file(path) | the file's contents as a str; E0316 if it cannot be read or is not UTF-8 |
io.write_file(path, text) | writes text to path, creating or truncating it; returns nil; E0316 on failure |
io.args() | a list of the command-line arguments after the script name, as strs (empty when run from an embedder) |
import "io"
try:
text = io.read_file("does-not-exist")
except err:
print(err.code)
# => E0316
To read a line from standard input, use the input builtin.
11.6 Cycles
While a module is loading, its path is recorded as "in progress". An import
that resolves to a module already in progress is E0315 — Korrin
never exposes a partially-initialised module. Move the shared definitions into a
third module that both import.
11.7 Compilation errors in imported modules
A lexer, parser, or resolver error in an imported file is reported as a single
E0314 at the import site, carrying the imported module's own
error text. A runtime raise or fault from a module's top level propagates to
the importer as an ordinary exception, catchable with try around the import.
12. Packages
A package is a git repository containing Korrin source that another
program can import. This section is the normative description of the
manifest, the lockfile, how a bare import specifier resolves once packages
exist, and the korrin pkg command that manages them
(ADR 0031,
ADR 0032).
Packages, in the sense this section describes, are pure Korrin source. A dependency written in Rust and compiled in — a native module — is a related but distinct kind of dependency, covered separately in §13.
12.1 The manifest: korrin.toml
A directory with a korrin.toml and a src/lib.kor is a package.
[package]
name = "requests"
version = "0.3.0"
[dependencies]
json = { git = "https://github.com/alice/korrin-json", tag = "v1.2.0" }
[package].nameand[package].versionare the package's own declared identity. They play no role in how a dependent resolvesimport— see §12.3.[dependencies]is a table of local name → source. Each entry needs agitURL and exactly one oftag,branch, orrev. A package's own[dependencies]table must be empty — see §12.4.- The entry file is always
src/lib.kor, regardless of[package].name. - An entry may also set
native = true, marking it as a native module (§13) rather than.korsource. Everything in this section (name resolution, the lockfile, the collision rule) applies to a native dependency exactly as it does to a plain one; only what happens after fetching it differs.
12.2 The lockfile: korrin.lock
# @generated by `korrin pkg` — do not edit by hand.
version = 1
[[package]]
name = "json"
git = "https://github.com/alice/korrin-json"
rev = "a1b2c3d4e5f6..."
path = ".korrin/packages/json"
korrin.lock pins the exact commit resolved for every dependency, even one
specified in korrin.toml by tag or branch. korrin pkg install writes
it; korrin pkg update is the only command that moves an existing pin. An
application commits korrin.lock; a package intended to be depended on does
not.
Fetched packages are checked out locally at .korrin/packages/<name>/, next
to the korrin.toml that declares them.
12.3 Resolution: one merged namespace
import_statement = "import" , STRING , [ "as" , NAME ] ;
Extends §11.1's resolution
rule with a third outcome for a bare (non-./) specifier:
./…or../…— a file, as §11.1 describes. Unchanged.- anything else — checked against the built-in module table
(§11.4, §11.5)
first, then against the current package's installed dependencies. No new
syntax distinguishes the two —
import "math"andimport "requests"are written identically. A name cannot appear in both tables:korrin pkg add/installrefuses to add a dependency whose local name collides with a built-in module's name (ADR 0032). A name in neither table isE0314.
import "not_a_real_package"
# error: E0314
A package's own bare imports resolve against that package's installed
dependencies (from its own korrin.toml/korrin.lock), not the top-level
program's — but since packages are leaf-only (§12.4), a package's own
[dependencies] table is always empty, so in practice a package's bare
imports can only ever reach built-in modules. A package's relative
(./) imports resolve against its own directory, exactly like any other
file's.
12.4 Leaf-only packages
A package's own korrin.toml must declare an empty (or absent)
[dependencies] table. korrin pkg add/install rejects a package that
declares one:
korrin pkg: "json" (https://github.com/alice/korrin-json) declares
dependencies of its own — packages with dependencies are not supported
in Milestone 5
A package may depend on nothing. Depending on another package is not yet possible for a package itself, only for a top-level program.
12.5 The korrin pkg command
| Command | Effect |
|---|---|
korrin pkg add <git-url> [--tag T | --branch B | --rev R] [--name NAME] [--native] | Adds a dependency to korrin.toml and resolves it into korrin.lock. NAME defaults to the repository's basename. --native marks it a native module — see §13. |
korrin pkg remove <name> | Removes a dependency and re-resolves korrin.lock. |
korrin pkg install | Fetches everything korrin.toml declares that isn't already checked out at its pinned commit; writes or refreshes korrin.lock. |
korrin pkg update [name] | Re-resolves a tag- or branch-pinned dependency to its current tip commit. Updates every dependency if name is omitted. |
korrin pkg list | Prints each resolved dependency: name, git URL, pinned commit, local path. |
korrin pkg build | Regenerates and compiles this project's native-module runner — see §13.4. |
add/install/remove/update run korrin pkg build automatically
whenever they change the set of native dependencies; see
§13.4 for exactly when.
korrin pkg operations that fail (an unreachable git URL, a malformed
manifest, a rejected leaf-only or name-collision dependency) print a message
to standard error and exit non-zero. They are not Korrin diagnostics — there
is no source span to point at, since nothing runs a Korrin program during
korrin pkg add/install/update.
12.6 Errors
An installed-package import that cannot be resolved — no such name in the
lockfile, the checkout is missing on disk, or the package's src/lib.kor
fails to compile — is E0314, the same code a bad ./ or
built-in specifier already raises: all three are the same category of
problem ("this import could not be resolved, read, or compiled"), not
three different ones. A cycle that crosses from a program into a package and
back is E0315, exactly as for files (§11.6).
13. Native modules
A native module is a Korrin module implemented as a Rust crate and linked
into the korrin binary at compile time, rather than a src/lib.kor file
(ADR 0035). It is a distinct
kind of dependency from a package (§12) — same manifest
table, same git/tag/branch/rev fetching, but a different native = true flag, a different validity check, and a different consequence: using
one means the project's own korrin binary must be rebuilt from source with
that crate compiled in.
There is no dynamic loading and no sandboxed bytecode format (WASM) —
korrin's own workspace forbids unsafe code everywhere
(ADR 0035), and a native
module is expected to make real, direct calls into the operating system (open
a window, read a device) that a sandboxed guest could not.
13.1 Declaring a native dependency
[dependencies]
gamekorr = { git = "https://github.com/korrin-lang/gamekorr", tag = "v0.1.0", native = true }
Everything §12.1 says about a
[dependencies] entry — the local name is what you import it by,
independent of the upstream crate's own name; exactly one of tag/branch/
rev; a name collision with a built-in module is rejected — applies
unchanged. The only difference native = true makes is what a fetched
checkout is validated against: a Cargo.toml at its root (the one thing
checkable without invoking rustc) rather than a src/lib.kor entry file.
The leaf-only rule (§12.4) still
applies if the fetched crate happens to carry its own korrin.toml.
import "gamekorr" resolves exactly like a plain package's bare specifier —
same merged namespace, same E0314 if the name is absent —
except that what actually satisfies the import is a Rust Environment a
linked-in crate builds, not a compiled .kor file
(ADR 0036).
13.2 Writing one: the NativeModule trait
A native-module crate depends on korrin (via the same git-rev pin
described in §13.4) and implements one trait:
pub trait NativeModule {
fn build(&self) -> Environment;
}
build is called once per run, the first time a program imports the module —
the same "build once, cache forever" contract a built-in module like math
already has. It returns an Environment populated the same way math's and
io's own Rust source populates one: ordinary functions via Value::Native,
and — where a module needs its own stateful object type, like an open
window — values built with Value::native_object (§13.3).
The crate exposes exactly one well-known free function at its root, the
thing a generated project's main.rs calls:
pub fn native_module() -> Box<dyn korrin::NativeModule>
This mirrors the existing math::module() / io::module() convention a
built-in module already follows — Rust has no reflection to discover "what
to construct" any other way.
A native module's own Rust dependencies (a windowing crate, for example)
talk to the operating system directly. They never go through
Host — Host exists only to reach Korrin's own
engine state (printing, script arguments, calling back into a Korrin
function), which a native module needs only if it wants to invoke a Korrin
callback; nothing about opening a window or reading a file requires it.
13.3 The native-object value
A native function may return an opaque, stateful object with its own
methods — Korrin's twelfth value kind (§3.1).
From a Korrin program's side, it behaves exactly like a built-in str/
list/map value with methods: . on it dispatches to a method table, and
nothing about the call syntax distinguishes it from any other method call.
win = gamekorr.open(640, 480, "hello")
win.draw_rect(10, 10, 100, 100, 16763904)
type(win)
(Illustrative only — gamekorr is a hypothetical native module, not one
linked into the binary that runs this specification's examples, so this
block is checked for valid syntax but not executed.)
type() reports whatever name the module chose when it built the object —
gamekorr's own Window, not a generic "native" label — the same way
type() on an instance reports its class's name rather than a fixed
"instance" string. Two objects returned by two separate calls (two windows,
say) never compare equal to each other by value; a native object's == is
identity-only, the same stance already taken for function/class/
instance values (§3.3).
There is exactly one method table per native object type, fixed by the
module that built it — not a general, extensible, multi-type registry
(ADR 0036). This is enough for a
module like GameKorr's Window; a module needing more than one distinct
native object type builds each with its own method table, the same way it
would define more than one Korrin class.
13.4 korrin pkg build
Because there is no dynamic loading, using a native module means compiling a
project-specific korrin binary that has it linked in. korrin pkg build
does this:
- Regenerates
.korrin/native-runner/— a small, gitignored,@generated — do not edit by handCargo project (the same conventionkorrin.lockuses) that depends onkorrin-cliplus every native dependencykorrin.lockcurrently records. - Runs
cargo build --releaseinside it. - On success, writes a wrapper script at the project root —
./korrinon Unix,./korrin.cmdon Windows — forwarding to the binary that build produced.
korrin-cli's own dependency in the generated project is pinned to the
exact commit and git remote the currently-running korrin binary was
itself built from, embedded at its own compile time
(ADR 0037) — this is
specifically the checkout's own origin remote, not a fixed upstream URL,
so that a fork's korrin pkg build links against that fork's own code.
This runs automatically after add/install/remove/update, but
only when those commands actually change the set of native dependencies
recorded in korrin.lock — adding or updating a plain .kor package in a
project that also happens to have a native dependency does not trigger a
rebuild. Run korrin pkg build directly to retry after a compile failure,
or to force a rebuild after upgrading korrin itself (a stale pinned
korrin-cli revision is otherwise left alone, not auto-detected — the same
"explicit update, not automatic tracking" stance
§12.2 already takes for a
branch-pinned package going stale).
Which binary actually runs a program is never auto-detected. The
generic korrin on $PATH and a project's generated wrapper are two
separate binaries; running the wrong one for a project with native
dependencies fails with the ordinary E0314 an absent import always
produces, not a silent fallback or a crash
(ADR 0038).
13.5 Errors
korrin pkg operations on a native dependency can fail in ways a plain
package's cannot, printed the same non-diagnostic way §12.5 describes:
korrin pkg: "gamekorr" (https://github.com/korrin-lang/gamekorr) is marked
native but has no Cargo.toml
korrin pkg: could not run `cargo` — is a Rust toolchain installed and on
PATH? (...)
korrin pkg: building this project's native korrin binary failed:
<cargo's own compiler output, unmodified>
The last of these prints cargo's/rustc's own stderr verbatim — it is
never re-parsed or re-summarized, since that output belongs to the Rust
toolchain, not to Korrin.
No new Korrin diagnostic code exists for native modules.
import "gamekorr" through a binary that never linked it in fails exactly
like an unresolved package import already does — E0314,
the same category §12.6 already covers.
import "gamekorr"
# error: E0314
Architecture Decision Records
This directory records why Korrin is the way it is. Each file captures one decision — the context that forced it, the choice made, the consequences, and the alternatives rejected. The process itself is ADR 0000.
New ADRs use template.md. Every ADR must appear in the table
below; CI (crates/korrin/tests/adr.rs) enforces that, and that each ADR has the
required sections. README.md and template.md are exempt.
For what Korrin does (as opposed to why), see the specification.
Index
| # | Title | Status |
|---|---|---|
| 0000 | Architecture Decision Records: the process | Accepted |
| 0001 | Indentation defines blocks | Accepted |
| 0002 | Documentation is enforced by CI | Accepted |
| 0003 | Newlines terminate statements | Accepted |
| 0004 | A tree-walking interpreter first | Accepted |
| 0005 | Methods take an explicit self parameter | Accepted |
| 0006 | The Milestone 1 keyword set | Accepted |
| 0007 | INDENT / DEDENT tokenization, spaces only | Accepted |
| 0008 | Byte-offset spans, one diagnostic type, ariadne | Accepted |
| 0009 | Only nil and false are falsy | Accepted |
| 0010 | Numeric model: int/float split, / true division, checked overflow | Accepted |
| 0011 | Bare assignment, lexical block scope, nearest-binding rule | Accepted |
| 0012 | Cargo workspace: library plus thin CLI | Accepted |
| 0013 | Dependency policy | Accepted |
| 0014 | Built-in collection operations are free functions (M1) | Accepted — amended by 0016 |
| 0015 | String interpolation — every string, {expr} | Accepted |
| 0016 | Operations on built-in values are methods (amends 0014) | Accepted |
| 0017 | Exceptions for error handling (try / except / finally / raise) | Accepted |
| 0018 | The module system (import, module objects, _ privacy, cycles are errors) | Accepted |
| 0019 | Built-in modules and the first standard library (math) | Accepted |
| 0020 | The io module and standard-library principles | Accepted |
| 0021 | A Host trait decouples native functions from the execution engine | Accepted |
| 0022 | Names resolve lexically, not by runtime search (narrows 0011) | Accepted |
| 0023 | Closures capture variables, not scopes | Accepted |
| 0024 | The tree-walking interpreter is kept as a differential-testing oracle | Accepted |
| 0025 | finally is inlined at every exit the compiler can see | Accepted |
| 0026 | korrin-lsp's transport: lsp-server, no async runtime | Accepted |
| 0027 | Diagnostics: first-failing-stage only for v1 | Accepted |
| 0028 | Hover text is compiled from the specification, not read at runtime | Accepted |
| 0029 | Go-to-definition is a standalone scope walker, single-file for v1 | Accepted |
| 0030 | Node and TypeScript, scoped to the VS Code client only | Accepted |
| 0031 | Package distribution: git, no registry | Accepted |
| 0032 | Bare-name imports merge installed packages with built-ins | Accepted |
| 0033 | The korrin-pkg crate and its dependency tier | Accepted |
| 0034 | The community package index: a second static site | Accepted |
| 0035 | Native modules load by compile-time linking, not dynamic loading or WASM | Accepted |
| 0036 | Value::NativeObject: one opaque object type per module | Accepted |
| 0037 | The native-module rebuild mechanism | Accepted |
| 0038 | No auto-detect/re-exec for a project's native binary | Accepted |
Open questions
Decisions we know we need to make but have deliberately deferred. Each becomes a
Proposed ADR when work starts on it.
- Floor-division operator (
//). Only ifint(a / b)proves insufficient in practice. - Arbitrary-precision integers. Whether
intshould stop beingi64. - Multiple / typed
exceptclauses. Deferred; Korrin has no exception hierarchy to filter on yet (0017). - Per-file diagnostics for imported modules. Errors inside an imported
module currently surface at the
importsite as text; a caret into the imported file needs multi-file rendering (0018). Resolved: string interpolation (0015); methods on built-in types (0016, amending 0014); string indexing iss[i]for one character ands.slice(a, b)for a range; error handling is exceptions (0017); modules are path-resolved objects (0018) and the standard library rides the same mechanism (0019); packages are git repositories with no central registry, resolved through the same bare-importsyntax as built-ins (0031, 0032).
0. Architecture Decision Records: the process
- Status: Accepted
- Date: 2026-09-02
Context
Korrin's guiding constraint is that every meaningful design decision is written
down when it is made, not reconstructed later from code archaeology or commit
messages. "Why does / always return a float?" and "why spaces-only
indentation?" are questions that must have a durable, discoverable answer.
We need a format that is:
- lightweight enough that writing one is not a chore;
- versioned alongside the code it describes;
- append-only, so the history of thinking is preserved even when a decision is later reversed.
Architecture Decision Records (ADRs), as popularised by Michael Nygard, fit exactly.
Decision
Every meaningful decision about the Korrin language or its implementation gets an
ADR in docs/decisions/, named NNNN-kebab-case-title.md, using
template.md.
Rules:
- One decision per file. If a discussion produces two decisions, write two ADRs.
- Numbers are permanent and never reused.
0007is0007forever. - ADRs are immutable once Accepted, except to change their
Statusline or add a link to the ADR that supersedes them. To change a decision, write a new ADR whose "Context" explains what changed, and set the old one's status toSuperseded by NNNN. - Status is one of:
Proposed,Accepted,Superseded by NNNN,Deprecated. - The index lists every ADR. CI fails if a file in this
directory is missing from the index or is missing a required section
(
Status,Context,Decision,Consequences). - Code cites ADRs by path in a comment whenever it implements something an ADR explains, so a reader of the code can find the reasoning.
Consequences
- A pull request that changes behaviour without either matching an existing ADR or adding a new one will be flagged in review (see the PR template).
- The
docs/decisions/directory becomes the canonical history of the language's design intent. The specification (docs/spec/) says what Korrin does; the ADRs say why. - Some overhead per decision. Accepted as the cost of the project's core promise.
Alternatives considered
- Design notes in a wiki or issue tracker. Not versioned with the code, rots when the tool changes, invisible from a checkout.
- Rationale only in code comments. Good for local "why", but there is nowhere to record a decision that spans many files or one that is about something not in the code (a feature deliberately omitted).
N. Short title of the decision
- Status: Proposed | Accepted | Superseded by NNNN | Deprecated
- Date: YYYY-MM-DD
Context
What is the situation that forces a decision? What constraints, requirements, and forces are in play? Write this so that someone with no memory of the discussion can understand why a choice was necessary. State facts, not the conclusion.
Decision
The choice that was made, stated plainly and in the present tense ("Korrin indents with spaces only"). Include the specifics needed to implement it.
Consequences
What becomes easier, and what becomes harder or is given up. Include follow-on work this decision creates and any traps it sets for the future. Be honest about the downsides.
Alternatives considered
Each realistic option that was not chosen, with one or two sentences on why not. "We didn't think of anything else" is rarely true; if an option was obvious and rejected, it belongs here so nobody has to re-litigate it.
1. Indentation defines blocks
- Status: Accepted
- Date: 2026-09-02
Context
A block-structured language needs a way to mark where a block begins and ends.
The mainstream options are delimiter pairs ({ }, begin/end, do/end) or
significant indentation (Python, Haskell, F#, YAML).
Korrin's stated philosophy is minimal syntax and "one obvious way". Braces introduce a second, redundant signal: well-written code is already indented to show structure, and braces then have to agree with that indentation — when they disagree, the reader is misled and the compiler is not. Brace styles (same-line vs next-line, "cuddled" else) are also a perennial source of bikeshedding that a language can simply not have.
Decision
Indentation is the only thing that delimits a block. There are no { }, no
begin/end, no end keyword.
- A block is introduced by a header line ending in
:followed by a newline. - The block's body is the run of following lines indented further than the header.
- The body ends at the first line indented back to (or past, as a dedent) the header's level.
- An empty body is written with the
passkeyword on its own indented line.
The lexer turns this into explicit INDENT and DEDENT tokens; see
0007.
Consequences
- No brace-style debate, no missing-brace bugs, less visual noise.
- Copy-pasting code requires re-indenting — true of Python and broadly accepted.
- The lexer is more complex: it carries an indentation stack and emits synthetic tokens. This complexity is contained in one place (see 0007).
- Generated code and one-liners are not possible in the same way as a brace language. Korrin does not target code-generation-heavy use or shell one-liners, so this is acceptable.
- Tooling that manipulates Korrin source must be indentation-aware.
Alternatives considered
- Braces (
{ }). Familiar, editor-friendly, but redundant with indentation and stylistically contentious. Rejected as "bolted on because other languages have it". endkeyword (Ruby/Lua style). Still redundant with indentation, adds vertical noise, and creates its own "stack ofends" problem in deep code.- Offside rule with optional braces (Haskell, Scala 3). Two ways to do one thing — directly against the design goal.
2. Documentation is enforced by CI
- Status: Accepted
- Date: 2026-09-02
Context
Korrin's non-negotiable requirement is thorough documentation that never goes stale. Documentation that is merely encouraged rots: it drifts from the code, nobody trusts it, and eventually nobody reads or updates it. The only reliable way to keep docs honest is to make the build fail when they are wrong or missing.
There are three distinct things to keep honest:
- API documentation — every public item has a doc comment.
- The language specification — its described behaviour matches the implementation.
- Design rationale — every meaningful decision has an ADR.
Decision
CI enforces all three:
- API docs. Every crate sets
#![warn(missing_docs)]and CI builds withRUSTFLAGS=-Dwarningsand runscargo doc, so a missing doc comment or a broken intra-doc link fails the build. Module headers must explain purpose and rationale, not just restate the module name. - Specification. Every runnable code block in
docs/spec/is tagged and extracted by a test harness (crates/korrin/tests/spec.rs), executed, and its output compared against the block's declared expected output. A spec example that lies fails CI. A companion test asserts every registered builtin is documented indocs/spec/08-builtins.md. - ADRs. A test checks that every file in
docs/decisions/is linked from the index and contains the required sections.
Additionally: doc examples on public API are real cargo test doctests, a link
checker runs over docs/, and the PR template carries a docs checklist.
Consequences
- Changing behaviour requires updating the spec in the same change, or CI goes red. This is the point.
- The spec harness needs the interpreter to be embeddable and output-capturing
([
korrin::run]). That shaped the library API — a good outcome. - Slightly slower CI. Worth it.
- Writing a new public function means writing its doc comment before it merges. Treated as part of "done", like tests.
Alternatives considered
- Convention and code review only. This is the default that fails everywhere. Reviewers miss things; standards slip under deadline pressure.
- A separate docs site maintained by hand. Guaranteed to drift from a fast-moving implementation.
- Doc coverage as a non-blocking metric. A number nobody is forced to act on is a number that goes down.
3. Newlines terminate statements
- Status: Accepted
- Date: 2026-09-02
Context
A language needs to know where one statement ends and the next begins. The
options are an explicit terminator (;), an explicit separator only when
statements share a line, or newline-as-terminator with rules for continuation.
Semicolons at end of line are pure ceremony in a language that also uses indentation: the newline is already there and already meaningful.
Decision
A newline ends a statement. There is no statement terminator or separator character, and no way to put two statements on one line — that is the "one obvious way" choice; a line is a statement.
A statement may span multiple physical lines only when the break is unambiguous:
- inside unclosed
(,[, or{— the lexer suppressesNEWLINEwhile any bracket is open; - immediately after a binary operator or a comma (the line is syntactically incomplete).
There is no line-continuation character (\).
Consequences
- No
;, no "missing semicolon" errors, no ASI-style hazards from inserting one incorrectly. - Long expressions are wrapped by opening a bracket, which is a mild constraint and a common style anyway.
- The lexer must track bracket depth to decide whether a newline is significant. This lives next to the indentation logic (see 0007).
- No
a = 1; b = 2on one line. Deliberate.
Alternatives considered
- Optional trailing
;(JavaScript, Swift). Creates two ways to write every line and an ASI rule that has to be learned. ;as a separator for multiple statements per line (Python). Adds a feature whose main use is writing code that is harder to read.- Explicit
\continuation. More syntax for something brackets already handle.
4. A tree-walking interpreter first
- Status: Accepted
- Date: 2026-09-02
Context
Korrin needs an execution engine. The realistic options, in rough order of effort: a tree-walking interpreter (evaluate the AST directly), a bytecode compiler plus a stack VM, or lowering to an existing backend (LLVM, Cranelift, transpile to another language).
The language design is still moving. Semantics will change as the spec is written and as real programs are attempted. Whatever we build first will be rewritten in part.
Decision
Milestone 1 is a tree-walking interpreter that executes the resolved AST
directly. Values are reference-counted (Rc/RefCell); there is no bytecode and
no separate compile step beyond parsing and resolution.
A bytecode VM is explicitly anticipated as a later milestone. To keep that door open:
- name resolution and static validation live in a separate
resolverpass between parsing and execution — in Milestone 1 it validates (placement ofreturn/break/super, duplicate parameters, and so on); it is the natural place to add lexical slot/depth precomputation when a VM needs it, without disturbing the interpreter; - the interpreter is isolated behind the
korrin::run/InterpreterAPI, so a VM can replace it without touching callers; - control flow (
return,break,continue) is modelled as an explicit signal enum, not host-language exceptions or panics, which maps cleanly to a VM later.
Consequences
- Fastest path to a language that actually runs, which is what unblocks spec work, example programs, and dogfooding.
- Slower execution than a VM — acceptable for M1, whose goal is correctness and a complete feature set, not speed.
- Some work (the resolver's scope analysis, the signal enum) is done now partly to serve a future VM. Judged worthwhile because it also makes the interpreter clearer today.
- A performance-focused rewrite is on the roadmap and should surprise nobody.
Alternatives considered
- Bytecode VM from the start. Two to three times the initial work, spent optimising semantics that are still in flux.
- Native compilation (LLVM/Cranelift). Enormous scope; wrong shape for a dynamically typed scripting language at this stage.
- Transpile to another language. Ties Korrin's semantics and error reporting to a host that was not designed for it.
Amendment (2026-09-04): the VM has landed
Milestone 3 added the bytecode compiler and virtual machine this ADR deferred
(0022, 0023,
0025). Two of the three seams named above paid
off as intended: Signal was already the shape the VM wanted, and the
Interpreter API stayed stable while a second engine grew beside it. The third
did not: slot resolution went into the new compiler rather than into the
resolver pass, which remains validation-only.
The tree-walking interpreter is not superseded. It is now the oracle the VM is tested against (0024), which makes it a permanent part of the implementation rather than a stage that was passed.
5. Methods take an explicit self parameter
- Status: Accepted
- Date: 2026-09-02
Context
Korrin has classes with methods (decided with the project owner). A method needs
access to the instance it was called on. Languages either pass it implicitly and
expose it as a keyword (this in Java/C++/JavaScript, self in Ruby/Swift) or
pass it explicitly as the first parameter (Python, Rust).
Decision
A method is just a function defined in a class body whose first parameter is
self:
class Point:
fn init(self, x, y):
self.x = x
self.y = y
fn dist_sq(self):
return self.x * self.x + self.y * self.y
self is an ordinary parameter name, bound like any other. It is not a keyword
and is not implicitly in scope. Calling p.dist_sq() passes p as self.
The constructor is the method named init; instances are created by calling the
class (Point(1, 2)), which allocates the instance and runs init on it.
Consequences
- No special scoping rule for
self; the resolver treats it as a normal binding. The only method-specific checks are "self/superonly appear inside a method" and those are about lexical position, not about a magic variable. - The relationship between a function and a method is "a method has
selfas parameter one" — nothing more. One concept, not two. - Method definitions are two characters wider and the
self.prefix is required to touch fields. This explicitness is the point: field access is never ambiguous with local-variable access. - A function pulled out of a class body and a method are the same kind of value, which keeps the interpreter's callable handling uniform.
Alternatives considered
- Implicit
thiskeyword. Saves typing, but introduces an invisibly-injected binding and the "unqualified name: is it a field or a local?" ambiguity that causes real bugs. @fieldsigil for fields (Ruby, CoffeeScript). New punctuation for somethingself.already expresses.newkeyword for construction. An extra keyword; calling the class is sufficient and reads well.
6. The keyword set
- Status: Accepted — amended for Milestone 2 (see below)
- Date: 2026-09-02
Amendment (2026-09-03, Milestone 2): six keywords were added, taking the set from 19 to 25.
- Exceptions (ADR 0017):
try,except,finally,raise. Three distinct block roles that cannot share a word, plusraise, which is a statement (it unwinds — no return value, cannot be passed around).raise/exceptwere already listed as "deferred to a later milestone" below.- Modules (ADR 0018):
import,as.importis a statement (it runs a file and binds a name — not an expression);asis the rename clause, shared with no other construct.importwas likewise already listed as deferred.None of the six has an alias. The set is larger than the "small, deliberate" goal implied at 19, but each word buys a whole capability (structured error handling; multi-file programs) that the language could not express at all before, and the total is still well below Python's ~35.
Context
"A small, deliberate set of keywords — nothing bloated" is a core design goal. The keyword set needs to be fixed early because it affects the lexer, the grammar, and what identifiers user code may use.
Decision
Milestone 1 defines exactly these 19 reserved words:
| Group | Keywords |
|---|---|
| Functions | fn return |
| Conditionals | if elif else |
| Loops | while for in break continue |
| Boolean logic | and or not |
| Literals | true false nil |
| Classes | class super |
| Empty block | pass |
Rationale for the debatable members:
fnoverdef/func/function— shortest unambiguous choice.elifoverelse if— one token, no nesting ambiguity, matches the single-keyword-per-concept style.and/or/notas words, not&&/||/!— they read as English, they cannot be confused with future bitwise operators, and Korrin has no C heritage to honour.selfis not a keyword. It is a conventional identifier — the name of a method's first parameter (0005). Using it outside a method is an ordinary "cannot findself" error, which is clear enough; making it a keyword would buy a marginally better message at the cost of one more reserved word and a special case in the resolver.superis a keyword, becausesuper.methodis special syntax: there is no value to bind a plain identifier to.passexists because indentation-defined blocks cannot be empty (0001).
Deliberately not keywords in M1: self, import, try, raise, catch,
let, var, const, match, lambda, global, nonlocal, async, await,
yield, with, del. Each is either deferred to a later milestone with its own
ADR or ruled out by another decision (let/var by
0011; global/nonlocal likewise).
Consequences
- The lexer's keyword table is a fixed 19-entry match.
import/try/raiseland later and will each get an ADR when their design is settled; user code today may use those words as identifiers, and reserving them later is a documented breaking change. Accepted for a pre-1.0 language.- Booleans-as-words means
notparticipates in operator precedence as a unary operator, handled in the Pratt parser.
Alternatives considered
- Symbol operators for logic (
&&,||,!). No benefit here; costs readability and a lexer that has to distinguish&/&&. - Reserving the post-M1 words now. Would break fewer future programs, but reserves words for features whose design (and therefore whose exact keywords) is not yet decided. Prefer to reserve a word when its ADR lands.
deffor functions. Fine, butfnis shorter and equally clear.
7. INDENT / DEDENT tokenization, spaces only
- Status: Accepted
- Date: 2026-09-02
Context
0001 makes indentation the only block delimiter. The parser is a conventional recursive-descent parser and wants a flat token stream with explicit block boundaries — it should not be counting spaces. Something has to translate leading whitespace into structure.
The classic hazard is mixing tabs and spaces: two lines that look identically indented but are not, so the block structure the reader sees is not the one the compiler sees.
Decision
The lexer maintains an indentation stack and emits synthetic tokens:
- At the start of each logical line, measure the leading run of spaces.
- Greater than the top of the stack ⇒ push it, emit one
INDENT. - Less than the top ⇒ pop until the top matches, emitting one
DEDENTper pop. If no stack entry matches the new level exactly ⇒E0007inconsistent dedent. - Equal ⇒ emit nothing.
NEWLINEis emitted at the end of each non-blank logical line.- At end of file, emit a final
NEWLINEthen oneDEDENTper remaining level.
Rules:
- Indentation is spaces only. A tab in the leading whitespace of a line is
E0006. This removes the tab/space ambiguity entirely rather than trying to define an equivalence. - The indent width is not fixed. Any consistent increase opens a block; the matching decrease closes it. (The style guide recommends 4 spaces; the language does not require it.)
- Blank lines and comment-only lines carry no indentation and never produce
INDENT/DEDENT/NEWLINE. - While any bracket is open, indentation is ignored and no
NEWLINEis emitted (see 0003).
Consequences
- The parser sees
INDENT/DEDENTas ordinary tokens and its block rule isINDENT stmt+ DEDENT. No whitespace logic in the parser. - Tabs-in-indentation being a hard error will surprise users pasting from tab-configured editors. The error message says exactly this and how to fix it.
- Not fixing the indent width means a file could use 2 spaces at one level and 4 at the next. Legal but ugly; the formatter (post-M1) will normalise it.
- Tab inside a line (e.g. between tokens, in a string) is fine; only leading indentation is restricted.
Alternatives considered
- Tabs allowed, tab = 8 (or 4) columns. Every "tab width" choice is wrong for someone; the ambiguity is the problem, not the number.
- Tabs or spaces but not both in one file. Still two ways to indent; tooling and snippets from elsewhere still clash.
- Fixed 4-space indent, enforced. Tempting, but a language rule where a style rule suffices. Left to the formatter.
- Parser-driven layout (Haskell's
parse-error(t)rule). Powerful, but notoriously hard to specify and to give good errors for.
8. Byte-offset spans, one diagnostic type, ariadne for rendering
- Status: Accepted
- Date: 2026-09-02
Context
Good error messages are a feature, not a nicety, and they are hard to retrofit. Three sub-decisions, taken together:
- How is a source position represented on tokens and AST nodes?
- How are errors from different stages (lexer, parser, resolver, runtime) represented?
- How are they rendered to a terminal?
Decision
- Positions are byte-offset [
Span]s — a{ start: u32, end: u32 }pair of byte offsets into the source. Line and column are derived on demand from a [SourceMap], never stored on nodes.u32keeps aSpanat 8 bytes and caps source size at 4 GiB. - One [
Diagnostic] type for every stage. It carries a stable [ErrorCode] (E00xxlexer …E03xxruntime), a message, ordered labelled spans (first = primary), notes, and an optional help line. Stages differ only in which codes they raise. - Rendering uses the
ariadnecrate, configured for byte indexing. Korrin owns theDiagnostictype;ariadneis an implementation detail of therenderfunction and is not exposed in the public API.
Error codes are permanent: once E0203 means "continue outside loop", it always
does. Wording may change; meaning may not.
Consequences
- The CLI and the spec-test harness share one rendering path.
- Tests assert on
codeandspan, not on rendered text, so message wording can be improved without breaking tests. ariadnecounts in characters by default; Korrin passes byte offsets and setsIndexType::Byte, so the two never disagree even with non-ASCII source.- If
ariadneis ever swapped out, onlydiagnostics::renderchanges. - Every error site must produce a span. Synthesized nodes carry
Span::DUMMY; code that renders must not rely on those, and in practice a real span is always available at the point an error is raised.
Alternatives considered
(line, column)on every node. Redundant, and painful to keep correct across multi-line tokens and spans.codespan-reporting. Comparable toariadne;ariadne's default output is a little clearer and its byte-index mode fits our span representation.- Hand-rolled renderer. More control, but caret alignment, multi-line spans, and tab handling are genuinely fiddly and not where the project's effort should go in M1.
- A distinct error enum per stage. More "typed", but forces the CLI and harness to handle four shapes and makes cross-stage batching awkward.
9. Only nil and false are falsy
- Status: Accepted
- Date: 2026-09-02
Context
if, while, and, or, and not need a rule for which values count as true.
Two traditions:
- Minimal (Lua, Ruby-ish): only a dedicated "nothing" value and
falseare falsy; everything else, including0and"", is truthy. - Extended (Python, JavaScript, C):
0,0.0,"",[],{}, andnilare all falsy.
The extended rule is convenient (if items: to mean "non-empty") but it conflates
"absent" with "present but empty", which is a well-known source of bugs — a
function that returns a count of 0 and one that returns "no answer" become
indistinguishable in a condition.
Decision
Exactly two values are falsy: nil and false. Every other value — 0, 0.0,
"", [], {}, every instance — is truthy.
To test emptiness, write it: if items.len() > 0:.
and and or return one of their operands (not a coerced boolean), following
short-circuit evaluation. not returns a genuine true/false.
Consequences
- One sentence defines the whole rule. Easy to teach, nothing to look up.
if x:wherexmight be0does what a reader expects (0is a value).- Slightly more typing for the common "is this collection non-empty" check. Judged a good trade: the explicit form is also clearer.
and/orreturning operands enablesname or "default", which is idiomatic and does not misfire on0/""the way the Python rule can.
Alternatives considered
- Python-style extended truthiness. Convenient but semantically muddy;
0/""/empty-collection falsiness causes real defects. - Strict: condition must be a
bool, everything else is a type error. Safest, but verbose in a dynamically typed scripting language and against the language's lightweight feel. May be reconsidered if a type checker is ever added.
10. Numeric model: separate int and float, / is true division, checked overflow
- Status: Accepted
- Date: 2026-09-02
Context
Number handling is one of the highest-leverage decisions in a dynamic language.
Choices: one number type (float64, like JavaScript/Lua 5.1) or two (int + float);
what / does; and what happens on integer overflow.
Decision
- Two numeric types.
intis a signed 64-bit integer.floatis an IEEE-754 double. A literal with no.and no exponent is anint; otherwise afloat. - Arithmetic promotes.
int op intstaysint(except/); if either operand isfloat, the other is converted tofloatand the result isfloat. /always produces afloat.7 / 2is3.5.10 / 2is5.0. There is one division operator and it means mathematical division. Floor division is not in Milestone 1; if it proves necessary it gets its own operator and ADR.- Integer overflow is a runtime error (
E0306), not silent two's-complement wraparound. Allintarithmetic uses checked operations internally. %is the remainder with the sign of the dividend;x % 0isE0305.- Equality across types:
1 == 1.0istrue(compared by mathematical value). Ordering likewise.
Consequences
intandfloatare visibly different (type(3)vstype(3.0),str(3)="3"vsstr(3.0)="3.0"), which matches user expectation for "is this a whole number"./never surprises a beginner with1/2 == 0. The cost is thata / bon two ints you wanted floored needsint(a / b)(or a future//).- Overflow-as-error trades raw speed and C-like wraparound for predictability. A
program that overflows
i64is almost always buggy; failing loudly is right for a scripting language. Bignum integers are a possible future ADR. - The interpreter's arithmetic path has a 2×2 type match plus checked ops — a little verbose, fully contained in one module.
Alternatives considered
- Single float64 number type. Simplest implementation, but
type()can't distinguish whole numbers, large integers lose precision silently past 2^53, and array indices/loop counters get awkward. /= floor when both ints (Python 2, C). The1/2 == 0footgun; Python itself abandoned it.- Wrapping overflow. Fast and predictable if you know it happens; a silent correctness trap if you don't. Wrong default for scripting.
- Arbitrary-precision
intby default. No overflow at all, but a performance and implementation cost that M1 does not need; leavesi64semantics to reintroduce later. Deferred, not rejected.
11. Bare assignment, lexical block scope, nearest-binding rule
- Status: Accepted
- Date: 2026-09-02
Context
How are variables introduced and where do they live? Options for introduction: a
declaration keyword (let/var/const), or bare assignment (x = 1 both
creates and updates). Options for scope: function scope with hoisting (old
JavaScript), block scope, or dynamic scope.
The project owner chose bare x = 5 — no let/var. That settles introduction
and forces a decision about what a bare assignment means when an outer scope
already has that name.
Decision
x = valueis the only binding form. No declaration keyword.- Scope is lexical, and only functions introduce a scope. The global level is
one scope; each function body (with its parameters) is a scope.
if/elif/else/while/forblocks do not — a name first assigned inside one of them stays visible after it, in the enclosing function or global scope. - Nearest-binding rule: evaluating
x = valuefirst looks for an existing binding ofx, searching the current function scope then each enclosing function scope out to the global scope.- Found ⇒ that binding is updated, wherever it lives.
- Not found ⇒ a new binding is created in the current scope.
- Reading
xuses the same nearest-binding search; an unresolved read isE0301. - A
forloop variable is an ordinary binding in the enclosing scope; after the loop it holds the last value iterated. - Closures capture by reference to the binding, so a closure can mutate a variable from an enclosing function.
There are no global / nonlocal keywords — the nearest-binding rule makes
"assign to the outer x" the default when an outer x exists.
Consequences
- No keyword, no "declared but unused" ceremony, closures-that-mutate just work.
- Function-only scoping matches Python and most scripting languages, so it holds
no surprises:
if cond:\n result = ...followed byprint(result)works. - You cannot shadow an outer variable by assigning the same name in an inner block — the inner assignment updates the outer one. To get a fresh variable, use a fresh name. This is the deliberate "one obvious way" tradeoff and the opposite of Python's local-by-default rule.
- A typo in an assignment target that happens to match an outer name silently mutates that name. The resolver can warn on suspicious cases later; not in M1.
- Closures over a
forvariable all see its final value (the well-known Python behaviour), because the variable is one binding, not one per iteration. If per-iteration capture is wanted, wrap the body in an immediately-called function. This may get a dedicated fix later. - A future bytecode VM will want per-use slot/depth resolution; the
resolverpass is where that goes when the time comes (ADR 0004).
Amendment (2026-09-04): resolution is lexical
Milestone 3 found that the nearest-binding rule as implemented here decides
at run time whether an assignment targets a local or an enclosing binding, so
the same line in the same function can mean different things on different calls.
That cannot be compiled to a slot. ADR 0022
narrows this ADR: the rule itself is unchanged, but "is there an enclosing
binding of this name?" is now answered from the enclosing program text rather
than from the live scope chain. It also lands the slot resolution this ADR
anticipated in the compiler rather than in the resolver pass, which stays a
validation-only pass.
Alternatives considered
- Python's rule (assignment always creates a local unless
global/nonlocal). Enables shadowing but needs two extra keywords to write to an outer scope, and theUnboundLocalErrorsurprise is a classic beginner trap. letfor new bindings,=for update (Swift-ish). Clear, but the owner explicitly wants no declaration keyword, and it's two forms for one idea.- Block scope for
if/while/forbodies. Cleaner in theory (a loop temp does not outlive the loop), but it makes the extremely common "compute a value in a branch, use it afterwards" pattern fail, which is a worse surprise than the one it prevents. Rejected. - Function scope with hoisting. Rejected outright — a known mistake.
12. Cargo workspace: library plus thin CLI
- Status: Accepted
- Date: 2026-09-02
Context
The implementation could be a single binary crate, a single library crate with a
[[bin]], or a workspace with separate library and binary crates.
Two forces: the language should be embeddable (the spec-test harness in 0002 is itself an embedder, and a public release benefits from being usable as a library), and the CLI concerns (argument parsing, REPL line editing, terminal colours) should not leak into the language core.
Decision
A Cargo workspace with two members:
crates/korrin— the entire language: lexer, parser, resolver, interpreter, and therun/Interpreterembedding API. No dependency on any CLI or terminal crate. This is what gets published to crates.io.crates/korrin-cli— thekorrinbinary. Depends onkorrinplusclapandrustyline. Contains only: parse args, read source, call the library, print results and diagnostics.
Shared dependency versions, lints, and package metadata live in the workspace
Cargo.toml ([workspace.dependencies], [workspace.lints],
[workspace.package]).
Consequences
- The library is testable and reusable without pulling in
clap/rustyline. - The line between "language" and "tool" is enforced by the crate boundary, not by discipline.
- Two
Cargo.tomls and a workspace root to maintain — minor. - Future crates (
korrin-fmt, a fuzz target, a language server) drop in as new workspace members without restructuring. Cargo.lockis committed, since the workspace produces a shipped binary.
Alternatives considered
- Single crate with
src/lib.rs+src/main.rs. Simplest, but the binary's dependencies become the library's dependencies for anyone who depends on it, and there is no hard wall against CLI code creeping into language modules. - Split the library further now (separate
korrin-lexer,korrin-parser, …). Premature. The stages share types freely and are changing together; internal modules give the same organisation without publishing five crates that must be versioned in lockstep. Revisit if a stage needs independent release.
13. Dependency policy
- Status: Accepted
- Date: 2026-09-02
Context
A language implementation that may be released publicly and depended upon should be deliberate about its dependency tree: every crate is attack surface, a compile-time cost, and a maintenance obligation. At the same time, reimplementing solved problems (terminal diagnostics, line editing, arg parsing) wastes effort and tends to produce worse results.
Decision
Dependencies are allowed, but each one is justified in
docs/internals/dependencies.md with: what it does, why not the standard
library, and what replacing it would cost. The bar:
crates/korrin(the published library) stays lean. A new runtime dependency needs a clear, load-bearing reason. As of M1:ariadne(diagnostic rendering),thiserror(error boilerplate),unicode-ident(identifier classification per UAX #31).crates/korrin-climay depend more freely on mature, widely-used crates for genuinely CLI concerns:clap,rustyline.- Dev-dependencies (
insta,proptest,assert_cmd,predicates) are unrestricted — they do not ship. - Prefer crates that are widely used, actively maintained, and
#![forbid(unsafe)]or close to it. Avoid crates that pull large transitive trees. - No dependency is added in the same change as the feature that needs it without a line in review calling it out.
unsafe_code is forbid across the workspace; if that ever needs to change it
requires its own ADR.
Consequences
- Slightly more friction to add a dependency; a short written justification each time.
- The published library's tree stays auditable.
- Some wheels may get partially reinvented if a dependency is judged too heavy. Accepted case by case.
Alternatives considered
- No third-party runtime dependencies at all. Purity that would cost a lot of
effort on diagnostic rendering for a worse result than
ariadne. - No policy, add what's convenient. How dependency trees become unauditable and how supply-chain incidents propagate.
14. Built-in collection operations are free functions (Milestone 1)
- Status: Accepted — amended by ADR 0016
- Date: 2026-09-02
Amendment (2026-09-03, Milestone 2): the collection operations are now methods (
xs.push(v),m.keys()), and the free functions listed below were removed.len(x)is nowx.len().type/str/int/float/bool/range/inputremain free functions. See ADR 0016. The Context and Alternatives below still explain why the free-function form was right for Milestone 1.
Context
Korrin has user-defined classes with methods, so x.foo() syntax exists. The
question is whether the built-in types — list, map, str — also carry
methods (items.append(x), text.upper()), or whether operations on them are
plain functions (append(items, x)).
Full method support for built-in types means the interpreter's attribute-access path has to handle every built-in type, each with its own method table, bound- method values for primitives, and decisions about which methods mutate. That is a meaningful amount of surface area for Milestone 1, whose goal is a complete but small language.
Decision
For Milestone 1, operations on built-in types are free functions in the
global scope. Attribute access (.name) is defined only for class instances.
The collection functions are:
| Function | Effect |
|---|---|
append(list, value) | appends value to list; returns nil |
pop(list) | removes and returns the last element; error if empty |
keys(map) / values(map) | a list of the map's keys / values, in insertion order |
has(map, key) | whether key is present |
get(map, key, default?) | the value for key, or default (or nil) if absent |
remove(map, key) | removes key, returning its value or nil |
len(x) already covers length for all three types.
This is explicitly a Milestone 1 decision. Adding methods to built-in types later is a compatible change (it adds syntax that currently errors); if it happens, these free functions may be kept as aliases or deprecated, decided in a follow-up ADR.
Consequences
- The interpreter's
.handling stays tiny: instances only. - Everything is a function call, which is arguably the most "one obvious way"
answer for M1 — there is no "is it
len(x)orx.len()?" question. - No method chaining on collections (
sort(filter(xs)), notxs.filter(...).sort()). Accepted for M1. appendmutating its first argument and returningnilis slightly unusual; the alternative (returning the list) invitesxs = append(xs, y)which would wrongly suggest lists are immutable. The spec is explicit about the mutation.
Alternatives considered
- Methods on built-in types now. The "right" long-term design, but a lot of
interpreter surface for M1 and it forces early decisions (which methods, which
mutate, how
str's immutability interacts) better made with real usage. - A hybrid — methods on
list/map, functions elsewhere. Inconsistent, and still needs the primitive-method machinery.
15. String interpolation
- Status: Accepted
- Date: 2026-09-03
Context
Building a string from parts in Milestone 1 means `"total: " + str(count) + " of "
- str(total)
. This is the single most common piece of ceremony in real Korrin code — every message, path, and label pays it. The+ str(x) +pattern is noisy, easy to get wrong (forget astr`, get a type error), and reads nothing like the sentence it produces.
The options for fixing it:
- A prefix form — only
f"total: {count}"interpolates; plain"..."never does (Python, C#, JS template literals with a different delimiter). - Every string interpolates —
{ }is always a hole,{{is a literal brace (Rust'sformat!, .NET's olderString.Format, many template languages). - A dedicated function —
format("total: {}", count)(Rustformat!, Python.format). Positional, no new syntax, but the arguments are separated from where they appear.
Decision
Every string literal interpolates. "{ expr }" splices the str() value of
expr into the string. {{ and }} are literal { and }. There is no prefix
— a Korrin string is a template, always.
- Any expression may appear in a hole, not just a name:
"{a + b}","{items[0].name}","{f(x)}". A hole is lexed as ordinary tokens, so a nested string needs no escaping:"{m["key"]}"is fine. - Holes are evaluated left to right, at the point the string expression is evaluated.
- A hole value is rendered with display semantics (as
str()/printwould show it): a string is spliced as-is, not re-quoted; a list shows as[1, 2, 3]. - An empty hole
"{}"is a syntax error. A{with no closing}before the end of the line is a lexical error (E0002). - To get a literal
{, write{{. A lone}is already literal;}}also works, for symmetry. - A string still may not span lines, so neither may a hole.
This also settles a deferred open question: string slicing is
text.slice(start, end) (a method, arriving with the M2 method work), and
text[i] stays as single-character access.
Consequences
- The
+ str(x) +pattern all but disappears."Hello, {name}!"reads like the output. - Breaking: any M1 string containing a literal
{now needs{{. A sweep of the codebase, spec, guide, and examples found none, so the practical cost was zero — but it is a real rule change and is noted in the changelog. - The lexer gains real complexity: a
"..."with a hole is emitted as a token run —StrStart, alternatingStrTextandStrExprStart … StrExprEndbracketed hole tokens, thenStrEnd— rather than oneStrtoken. The hole tokens are ordinary tokens with real source spans, so the parser reusesparse_expressionunchanged and a diagnostic inside a hole points at the right byte in the file. Plain strings (no hole) still emit oneStrtoken on the fast path. - The AST gains
ExprKind::Interpolate(Vec<InterpPart>); the interpreter concatenates the parts. - No new keywords.
Alternatives considered
f"..."prefix. Explicit — you can tell at a glance whether a string interpolates — but it adds a sigil, a second kind of string, and a thing to forget. Against "one obvious way": there would be two ways to write a plain string and the wrong one fails silently (f"{x}"typo'd as"{x}").format(template, args...). No new syntax, but it separates each value from where it lands in the text, which is exactly the readability problem interpolation solves. Still worth having later as a function for the build-a-format-string-at-runtime case.${ }or\( )delimiters.{ }is the lightest and matches the most prior art (format!, shell-ish, most template engines).${}earns nothing.
16. Operations on built-in values are methods (amends 0014)
- Status: Accepted
- Date: 2026-09-03
Context
ADR 0014 made every operation on
a str / list / map a free function — append(xs, v), keys(m),
len(s) — and said so explicitly for Milestone 1, whose goal was a complete
but small language. It named the follow-up: "Adding methods to built-in types
later is a compatible change… decided in a follow-up ADR." This is that ADR.
The free-function design has three costs that show up as soon as real code is written:
- No chaining.
" a, b ".trim().split(",")has to be writtensplit(trim(" a, b "), ",")— inside-out, and the reader parses it backwards. - A crowded global scope.
append,pop,keys,values,has,get,removeare all top-level names a program cannot use for its own bindings, and none of them says which type it is for without reading the docs. - It fights the language it is part of. Korrin already has
instance.method()for user classes. Havinglistoperations beappend(xs, v)while a user's own collection type usesxs.add(v)is an inconsistency with no upside.
Milestone 2 also adds a real string and collection vocabulary (upper, split,
sort, join, slice, …). Introducing a dozen more free functions would make
all three costs worse.
Decision
Operations on str, list, and map are methods, reached with . like any
instance method. The . operator resolves on these three built-in types as well
as on class instances.
- The M1 collection free functions are removed, not kept as aliases:
append→list.push,pop→list.pop,keys/values/has/get/remove→ the same-namedmapmethods.len(x)→x.len(). There is one way, per the project's core goal; an alias would mean two. print,type,str,int,float,bool,range,inputstay free functions. They construct a value, convert between types, or do I/O — they are not operations on a receiver.type(x)andstr(x)in particular must work uniformly on every value, includingnilandint, which carry no methods.- A method read (
xs.push) evaluates to a callable bound to its receiver, the same shape as a bound instance method. It is normally called immediately. - Mutating
listmethods (push,insert,remove_at,reverse,sort) change the receiver in place and returnnil;popandremove_atreturn the element removed. This matches how a mutable, sharedlistalready behaves and avoids thexs = xs.push(v)misreading 0014 warned about. - Implementation: a
NativeMethodtable per type incrates/korrin/src/interpreter/methods.rs, aValue::BoundNativefor the bound-but-not-yet-called value, and one new arm each in the interpreter's attribute-read and call paths. No lexer, parser, resolver, or grammar change — the syntax is the existingattributeandcallproductions. - The method set for M2 is specified in §8.2–8.4.
crates/korrin/tests/builtins.rsnow checks method coverage against the spec the same way it checks functions: a method missing from §8, or documented but not implemented, fails the build.
Consequences
- Code reads left to right:
text.trim().lower().split(" "). - The global scope drops seven names; the remaining eight builtins are all constructors or I/O, which is a describable rule rather than a list.
- Breaking, on top of removing the free functions: this is the second M2 change (after interpolation) that invalidates M1 code. Every doc example, spec block, golden program, and the guide were swept; the CHANGELOG lists the rename table.
- The interpreter's
.path is no longer instance-only, which is the surface 0014 was deferring. It stays small: three static tables, a linear name lookup, no per-typeValuemachinery beyondBoundNative. lenis nowx.len()everywhere, including inside interpolation ("{items.len()} left"). Slightly more to type thanlen(items); consistent with every other operation, andlenwas the only builtin that read as an operation on its argument.- Future built-in types (a
set, abytes) get methods by adding a table, with no new global names.
Alternatives considered
- Keep 0014 as-is. Rejected: the costs above are real and compound as the standard vocabulary grows. 0014 itself scoped the free-function choice to M1.
- Methods, but keep the free functions as aliases. Two ways to do the same thing, forever — the exact thing Korrin's design goal rules out. A short deprecation window was considered unnecessary for a pre-1.0 language with no external users yet.
- Keep
len(x)as a free function, make everything else a method. Tempting —lenreads well and Python does this. Rejected for consistency:lenis an operation on its argument (unliketypeorstr), so it belongs with the other operations. One rule ("operations are methods, constructors are functions") beats one rule plus an exception. - A hybrid — methods on
list/map, functions forstr.strgains the most from chaining (.trim().lower()); excluding it makes no sense.
17. Exceptions for error handling
- Status: Accepted
- Date: 2026-09-03
Context
Through Milestone 1 every runtime fault was terminal: an out-of-range index, a
type error, a int() that could not parse — each printed a diagnostic and the
program stopped. There was no try, no raise, and no way for a program to
respond to a failure it could reasonably expect (a missing map key it wants to
default, a parse it wants to retry, a file that might not exist — the last one
blocked by the absence of file I/O, which this milestone also adds).
A language that cannot recover from failure cannot be used to write a tool that keeps running. The question is which recovery model:
- Exceptions —
raisea value,try/exceptcatches it, unwinding the stack in between. Familiar (Python, Ruby, JS), and it maps directly onto the interpreter's existingSignalenum, which already unwinds the Rust stack forreturn/break/continue. - Result values — fallible operations return an
ok/errunion the caller must inspect (Rust, Go-ish). No stack unwinding, errors are values on the normal path. But it needs either a?-style operator or pervasive manual propagation, and it forces every existing built-in operation to change its return type. - Error callbacks / handlers — a registered handler function. Clumsy for the common "try this, fall back to that" shape.
Korrin already has dynamic typing, nil, and stack-unwinding control flow.
Exceptions fit that grain; a Result type does not without more type machinery
than the language has.
Decision
Korrin gets exceptions, with four new keywords (try, except, finally,
raise — keyword count 19 → 23, see the amendment to
ADR 0006).
raise
raise <expression> evaluates the expression and unwinds with that value. Any
value may be raised — raise Error("bad port"), raise "boom", raise 404.
There is no "throwable" type to conform to. Bare raise (re-raise) is not
provided; re-raise is raise err from inside a handler that bound err.
try / except / finally
try:
<body>
except [name]: # optional
<handler>
finally: # optional
<cleanup>
- The body runs. If it raises and there is an
except, the handler runs withnamebound to the raised value (except:with no name catches without binding). There is exactly oneexceptpertry— it is a catch-all, not a type filter. To handle only some errors, inspect the value in the handler andraiseit again otherwise. finallyruns on every exit path: normal completion, a caught exception, an uncaught exception passing through, and areturn/break/continueleaving thetry. Iffinallyitself raises or transfers control, that outcome replaces whatever was pending.- At least one of
except/finallymust be present; a baretry:isE0107. A danglingexcept:/finally:with notryis the same error.
Built-in faults are catchable
The Milestone 1 runtime faults (E0301–E0313) are no longer special: a try
catches them exactly as it catches a raise. When caught, the fault is bound as
an Error instance with .message (the diagnostic text) and .code (the
"E03xx" string).
The Error type
Error is a built-in class, bound in the global scope. Error(message?)
constructs an instance with .message (the argument, or "error") and .code
(nil for program-created errors). It is the conventional thing to raise, and
the thing a caught built-in fault is presented as, so a handler can treat both
uniformly:
try:
...
except err:
log("{err.code}: {err.message}")
Uncaught
An exception that reaches the top level with no try around it prints a
diagnostic and exits non-zero, like a Milestone 1 fault did. An uncaught Error
shows its .message; any other uncaught value shows as uncaught: <repr>. The
caret points at the raise. The code is E0312.
Implementation
Signal gains a Raise { value, span } variant beside the existing
Error(Diagnostic). Both are "exceptions"; try catches either, return /
break / continue pass through it (but still trigger finally). No fault
site in the interpreter had to change — Signal::Error is caught at the try
boundary and wrapped into an Error instance there, lazily.
Consequences
- Programs can recover. File I/O (added in the same milestone) and every
conversion (
int("x")) become usable without a pre-check for every case. - The
Signalenum and every exhaustive match on it grew one arm — the compiler found them all. finally's "runs on every path, its own control flow wins" rule is powerful and slightly dangerous:finally: return xsilently swallows a pending exception. This matches Python; it is documented in spec §5.10 with a warning.- One
exceptpertry, no type filtering, keeps the feature small. The cost is that selective handling is a manualif err.code == "..."/raise err. For a language this size that is an acceptable trade; a typedexcept Foo:form can be added later without breaking anything. Erroris a real class value but with a native constructor (it sets.code, which a plain Korrininitcould not distinguish from a user field). This is the first built-in class; the mechanism (instantiatespecial-cases it by identity) is contained and does not generalise yet.- Four keywords is a large single addition to a set that prides itself on being small. Each is load-bearing and none has an alias — see the ADR 0006 amendment.
Alternatives considered
- A
Result/ok–errtype. The other mainstream model. Rejected: it needs a propagation operator or verbose manual threading, changes the return type of every fallible built-in, and sits awkwardly in a language with no algebraic types or pattern matching. Exceptions reuse machinery Korrin already has. - Multiple typed
exceptclauses (except KeyError:/except:). More familiar to Python users and more precise. Deferred: Korrin has no exception hierarchy to filter on yet, and adding clauses later is backward compatible. - Bare
raisefor re-raise. Convenient in Python. Left out because it needs the interpreter to track "the currently-handled exception" as hidden state;raise errnaming the bound value is explicit and needs nothing. throwinstead ofraise.raisepairs withexceptin the Python tradition Korrin's block syntax already borrows from;throw/catchis the C++/JS pairing. Either works;raise/exceptwas chosen for consistency with the rest of the surface.- No
finally, justexcept.finallyis the part that is genuinely hard to get right by hand (cleanup on every path, includingreturn). Keeping it is worth the extra keyword.
Amendment (2026-09-04): a second implementation
The semantics above are unchanged. The bytecode VM implements them a different
way, inlining finally at each statically visible exit and keeping a runtime
handler stack only for exceptions arriving from elsewhere
(ADR 0025). Both engines are held to the
behaviour described here by the differential suite
(ADR 0024).
18. The module system
- Status: Accepted
- Date: 2026-09-03
Context
Every Korrin program so far has been a single file. Real programs are not: they
split into pieces that import each other, and they reach for a standard
library. Milestone 2 adds both. This ADR covers the system — how a module is
named, found, loaded, and used; ADR 0019 covers the
built-in modules that ride on it.
The decisions in play:
- What is a module? A file? A namespace object? A set of names spliced into the importer?
- How is one named — by path, by a registered name, by package?
- What does
importbind — the module, or names out of it? - Isolation — does an imported module see the importer's globals?
- Cycles — allowed (with partially-initialised modules, like Python), or an error?
The user chose: path + module object. import "./utils" binds utils;
members are reached as utils.foo(). import "./a/config" as cfg handles a name
collision. Top-level names are public; _name is private by convention. A
circular import is an error. A bare import "math" (no ./) is a built-in
module.
Decision
Syntax
import "<specifier>" # binds the specifier's final component
import "<specifier>" as <name> # binds <name>
import is a statement and may appear anywhere a statement may (it binds its
alias under the normal scoping rules — at the top level that means a global).
The specifier is a plain string — no interpolation holes. When there is no
as, the alias is the specifier's last path component with any extension
removed ("./a/config" → config, "math" → math); if that is not a valid
identifier, as is required (E0108).
Resolution
- A specifier starting with
./or../is a file, resolved relative to the directory of the file doing the importing (not the process's working directory, and not the entry file)..koris appended if the specifier has no extension. The path is canonicalised; a file that cannot be found or read isE0314. - Any other specifier is a built-in module (ADR 0019).
An unknown one is
E0314.
Relative imports need a file on disk to resolve against, so they only work when
the program was started from a file (korrin run file.kor, or korrin::run_file
for an embedder). In-memory execution (korrin::run) has no directory: a
relative import from it is E0314, while built-in
imports still work.
The module object
import evaluates the module's file (or builds the built-in) once and binds
a module object — type() reports "module". Loading is cached by
canonical path (or built-in name), so importing the same module from two places
yields the same object (== is identity).
A module's file runs in its own scope: a fresh global with only the builtins
and Error, then the module's top level. It does not see the importing
program's names, and the importer does not see the module's names except through
the object.
module.foo reads foo from the module's top-level bindings.
- A name that is not bound is
E0309("modulexhas no memberfoo"). - A name starting with
_is private: reading it through the module isE0309, even though it exists. Code inside the module uses its_-names normally; privacy is only enforced at the boundary.
There is no way to enumerate, re-export, or splice a module's names. import
binds exactly one object.
Cycles
While a module is being loaded its path is marked "in progress". An import
that resolves to a module already in progress is
E0315 — Korrin does not expose half-initialised
modules. Break the cycle by moving the shared thing into a third module both
import.
Implementation
Value::Module(Rc<ModuleObject>)whereModuleObjectholds the name, origin (for diagnostics), and the module's scopeEnvironment.Interpreterholds aModuleRegistry— a directory stack (for./resolution), a cache, and an in-progress set.korrin::run_fileseeds the stack with the entry file's directory; each nested file import pushes its own.- Compile errors inside an imported module surface as a single
E0314at theimportsite, carrying the module's own error text. Per-file diagnostic rendering (a caret into the imported file) is a known gap, tracked for a later milestone.
Consequences
- A Korrin codebase can span files, which is the point.
- Module objects (rather than name-splicing) keep every name's origin visible at
the use site:
geometry.area(r)says whereareacame from. Nofrom x import *ambiguity, ever. - The "fresh globals per module" rule means a module cannot accidentally depend
on a name the importer happened to define. It also means every module re-pays
for its own builtin bindings (cheap) and that two modules' top-level
_helpersnever collide. - Cycles being an error is stricter than Python. It rules out a real (if awkward) pattern, in exchange for never handing code a module that isn't finished. Consistent with Korrin's "fail loud" stance.
- Imported-module diagnostics are worse than single-file ones until multi-file rendering lands. Acceptable for M2; noted.
importanywhere (not just the top level) is permissive. It costs nothing and a top-level-only rule would be one more thing to explain.
Alternatives considered
- Name-splicing (
from "./x" import foo). Familiar, but it detaches a name from its source and invites collisions andimport *. The module object gives the same access with the origin always visible. - Cycles allowed, with partial modules. Python's model. Powerful for a few patterns, a source of confusing bugs for many more (a name is sometimes there, sometimes not, depending on load order). Rejected for predictability.
- A package manifest / registry. Way beyond M2. Path-based resolution needs
no configuration and is enough to build with; a package story can come later
without changing
import's surface. - Resolving
./against the process CWD (like a shell). Surprising — moving where you runkorrinfrom would change which files a program sees. Relative to the importing file is what every modern language does. exportkeyword / explicit public list. More ceremony. The_prefix convention (already Korrin's style for "internal") extended to a hard boundary rule covers it with no new syntax.
19. Built-in modules and the first standard library
- Status: Accepted
- Date: 2026-09-03
Context
ADR 0018 built the module system and split specifiers
two ways: "./x" is a file, anything else is a "built-in module". This ADR
decides what a built-in module is, how import tells the two apart, and what
the first one (math) contains.
Constraints:
- Korrin's builtins (
print,range, …) are a deliberately tiny set of constructors and I/O (ADR 0016). Numeric helpers likesqrtdo not belong there — they are a library, and a program that never does maths should not carry them. - The module system already gives us namespacing (
math.sqrt) and lazy loading (a module builds on firstimport). A standard library should use it, not a second mechanism.
Decision
"name" vs "./name"
The specifier's first characters decide, with no filesystem probe:
- starts with
./or../→ a file (ADR 0018); - anything else → a built-in module, looked up by exact name in a fixed table.
So import "math" is always the standard library's math, even if a
math.kor sits next to the importing file. To import that file, write
import "./math". This keeps the two namespaces from ever shadowing each other,
in either direction, and makes an import line's meaning obvious without knowing
what files exist.
What a built-in module is
A built-in module is a scope populated in Rust and handed to the loader, which
wraps it in the same Value::Module a file import produces and caches it under
builtin:<name>. From Korrin it is indistinguishable from a file
module: type() is "module", members are module.x, _-names are private,
loading happens once.
Built-in modules live in crates/korrin/src/stdlib/, one file each, registered
in stdlib::load. crates/korrin/tests/builtins.rs checks each module's
exported names against its specification section, both ways.
math (Milestone 2)
math is float-flavoured: operations that are naturally real-valued return
float; the rounding operations return int.
| Name | Result |
|---|---|
math.sqrt(x) | √x as a float; x must be ≥ 0 (E0304) |
math.pow(base, exp) | base ** exp as a float |
math.floor(x) / math.ceil(x) / math.round(x) | nearest int in that direction (round is half-away-from-zero); E0304 if it will not fit int |
math.abs(x) | absolute value, keeping x's type (int → int, float → float) |
math.min(a, …) / math.max(a, …) | the smallest / largest argument (one or more numbers), returned unchanged |
math.pi / math.e | the constants, as float |
io (file reading and writing, CLI arguments) is delivered the same way —
see ADR 0020, which also writes down the standard-library
principles this ADR started.
Consequences
- The global scope stays tiny; numeric code opts in with one
import "math". - A standard library is "just modules" — no new runtime concept, and a
third-party could ship a module that is imported identically (
import "./their/lib"). import "math"meaning the stdlib unconditionally is a small foot-gun for someone who wants to shadow it with their ownmath.kor— but that is rare, and"./math"is right there. The predictability is worth it.- Native modules cannot (yet) be written in Korrin —
mathis Rust. A future "stdlib written in Korrin, bundled as source" is possible on this same mechanism and is not foreclosed. - Rounding functions returning
int(notfloat) is a small inconsistency withsqrt/pow, chosen becauseint(math.floor(x))everywhere would be noise and a floored value is conceptually a whole number.
Alternatives considered
- A filesystem probe — try
./math.kor, fall back to built-in. Rejected: animportline's meaning would depend on which files exist, and adding a file could silently change behaviour. - A
std:or@prefix for built-ins (import "std:math"). Explicit, but ugly for the common case and a new bit of syntax. "No./means built-in" is a rule you learn once. - Putting
sqrtetc. in the global builtins. Against ADR 0016's "builtins construct or convert, they are not a library". Every program would carry them. - A bigger first cut (
json,time,random, …). Deferred.mathandioare what "write a real tool" needs first; the rest can land per-module with no design cost now that the mechanism exists.
20. The io module and standard-library principles
- Status: Accepted
- Date: 2026-09-03
Context
ADR 0019 built the built-in-module mechanism and
shipped math. It named io — file reading and writing, command-line
arguments — as the next module, needed before "write a real tool in Korrin" is
true. This ADR settles io's surface and the questions a filesystem module
raises, and writes down the standard-library principles that were implicit in
0019.
Questions specific to io:
- How does a failing operation report? Return
nil/ a sentinel, return aResult-like value, or raise? - What are paths relative to? The process's working directory, or the script
that called
io? - Is there a sandbox? Can
iotouch any file, or only some? - A file-handle object (
io.open(path)→ something with.read()/.close()), or whole-file helpers?
Decision
io's surface (Milestone 2)
| Member | Behaviour |
|---|---|
io.read_file(path) | the file's contents as a str |
io.write_file(path, text) | write text, creating or truncating; returns nil |
io.args() | the command-line arguments after the script name, a list of str |
There is no io.read_line(): the input builtin
already reads a line from standard input (its optional prompt is the only extra),
and a second way to do the same thing is exactly what Korrin avoids.
Failures raise
Every io operation that can fail raises E0316 on
failure — a catchable exception (ADR 0017), not a nil
return or an error value. This matches how the rest of Korrin fails (a bad index,
a divide by zero): loudly, at the point of failure, with a message. A program
that wants to handle a missing file wraps the call in try.
Paths are OS-relative
io passes paths to the operating system unchanged. A relative path is relative
to the process's working directory — the same as open() in Python, fs in
Node, File::open in Rust. This is deliberately different from import, whose
"./x" is relative to the importing file (ADR 0018):
import wires a program together at load time and must not depend on where it is
run from; io is a runtime data operation and should behave like every other
language's file I/O.
No sandbox
io can read and write anything the user running korrin can. A Korrin script
is trusted code, like a shell script or a Python script. A capability/permission
system (an allow-list of paths, a --deny-io flag) is a plausible future
addition and is not foreclosed, but it is not Milestone 2.
Whole-file helpers, not handles
read_file / write_file read or write the whole thing. There is no file-handle
object, no streaming, no seek. Most scripts want the whole file; streaming can
be added later (io.open, or an iterator of lines) without changing these.
Standard-library principles (making 0019 explicit)
- The stdlib is modules. Reached with a bare
import "name", indistinguishable from a file module once loaded (ADR 0019). - Native for now. Each module is Rust, in
crates/korrin/src/stdlib/<name>.rs, registered instdlib::load. A stdlib-in-Korrin is possible on this mechanism later. - Small, and grows per module. A new module needs a file, a spec section in §11, a guide mention, and a coverage test — no language change. So modules can be added one at a time as a real need appears, not bundled speculatively.
- Each module has a coherent flavour.
mathis float-oriented numeric helpers;iois "talk to the outside world". A module is not a junk drawer. - Failures raise, conversions are explicit, no hidden state. The same rules as the core language.
Consequences
- A Korrin program can now read its input, write its output, and see its arguments — the last missing piece for command-line tools.
iofailing by raising means every file operation needs atryif the program wants to recover — more verbose than checking a return value, but consistent and impossible to forget silently.- OS-relative paths can surprise someone who expects
io.read_file("data.txt")to find a file next to the script. It is the universal convention, and it is documented; a script that wants script-relative behaviour can build the path from a known location. - No sandbox means
korrin run untrusted.korcan do anything. This is stated plainly; it is the same trust model as every scripting language and the CLI docs will say so.
Alternatives considered
- Return
nilon failure.text = io.read_file(path)thenif text == nil. Easy to forget the check, and it conflates "file was empty" edge cases. Raising is Korrin's model. - A
Result/ok–errreturn. Korrin has no such type and ADR 0017 chose exceptions over that model language-wide. - Script-relative paths. Consistent with
import, but against every other language'sopen(), and it would needioto know which file called it. Rejected —importandioresolve paths for different reasons. - A sandbox by default. Safer for running untrusted scripts, but Korrin is not positioned as a sandbox runtime, and it would block the common case (a tool that legitimately writes files) behind configuration. Left for later.
io.open(path)returning a handle. More capable (streaming, partial reads). More surface, and a.close()to forget. Whole-file helpers cover the common case; handles can come when a real need does.
21. A Host trait decouples native functions from the execution engine
- Status: Accepted
- Date: 2026-09-04
Context
Milestone 3 adds a second execution engine: a bytecode compiler and virtual machine (ADR 0004 always planned for one). The tree-walking interpreter is not being replaced. It stays permanently, as the oracle a differential test suite compares the VM against, which means Korrin will have two engines running the same programs indefinitely.
Everything written in Rust rather than Korrin sits between them. The builtins
(crates/korrin/src/builtins.rs), the methods on str / list / map
(crates/korrin/src/interpreter/methods.rs), and the standard library
(crates/korrin/src/stdlib/) were all written against one concrete type:
pub type NativeFnPtr = fn(&mut Interpreter, &[Value], Span) -> Exec<Value>;
Left alone, that signature forces one of two bad outcomes: a second copy of
every builtin written against &mut Vm, or a VM that constructs an
Interpreter it does not otherwise need just to satisfy a type. The first
guarantees the two copies drift, which is precisely the drift the differential
suite exists to detect, so the suite would be testing our copy-paste discipline
instead of the VM. The second is a lie in the type system.
What the natives actually need from an engine turns out to be very little. An
audit of all 8 builtins, all 30 built-in methods, and all 11 standard-library
functions found exactly three uses: print and input write output, io.args()
reads the command-line arguments, and input reads a line of stdin (which it
did by calling std::io::stdin() directly, bypassing the interpreter
altogether). Every other native touches nothing but its own arguments. A fourth
capability, calling back into Korrin, is used by nothing today but is the whole
point of a higher-order native such as a sort that takes a comparison
function.
Decision
Native functions receive &mut dyn Host, never a concrete engine.
pub trait Host {
fn emit_output(&mut self, text: &str);
fn script_args(&self) -> &[String];
fn read_line(&mut self) -> Option<String>;
fn call_value(&mut self, callee: Value, args: Vec<Value>, span: Span) -> Exec<Value>;
}
NativeFnPtrbecomesfn(&mut dyn Host, &[Value], Span) -> Exec<Value>andNativeMethodFnbecomesfn(&mut dyn Host, &Value, &[Value], Span) -> Exec<Value>.- The trait lives in
crates/korrin/src/interpreter/host.rsand is implemented by every engine.Interpreterimplements it; the VM will. - These four methods are the complete list of what native code can reach outside its own arguments. Adding a fifth is a decision, not a detail: it widens what every engine must provide and what a builtin is able to do.
- The
inputbuiltin now reads throughHost::read_linerather than touchingstd::io::stdin()itself, so the last piece of native code that reached past its engine no longer does. - Errors need nothing from the host. A native builds them from free functions
(
runtime_error,Signal::error), and the only variant it originates isSignal::Error.
Consequences
- One implementation of every builtin, method, and standard-library function
serves both engines. When the VM lands,
mathandioneed no VM-specific work at all. crates/korrin/src/builtins.rsandcrates/korrin/src/stdlib/no longer name any engine, and no longer import one. They should not know which engines exist, and now they cannot.- The trait is the audit surface. "What can a builtin do?" is answered by reading four method signatures, which is also where a sandbox would attach if Korrin ever grows one.
- Method resolution moved:
emit_output,script_argsandcall_valueare no longer inherent methods onInterpreter, so a caller needsHostin scope. Inside the crate that is a one-line import. It does slightly widen the public API, since these are now reachable through a public trait rather than beingpub(crate). call_valueis on the trait despite having no caller today. This is deliberate: adding it later would break every implementation of the trait, and the first higher-order native (list.sort(by:)is the obvious candidate) will need it. The cost of carrying it unused is one method body per engine.- Native calls now go through a vtable rather than a direct call. This is one indirect jump at the boundary of a function that was about to do real work, and it is not on the hot path of either engine's inner loop.
Interpreterkeeps its own private machinery (call_function,instantiate,import_module, the module registry) off the trait. Nothing native ever needed it, and a VM's equivalents will not have the same shapes.
Alternatives considered
- An
enum Engine<'a> { Tree(&'a mut Interpreter), Vm(&'a mut Vm) }passed to natives. Avoids the vtable, but makesbuiltins.rsandstdlib/name and import every engine that exists, and every added engine edits the enum and every match on it. The dependency points the wrong way: the generic code would depend on the specific. - Generic natives,
fn<H: Host>(&mut H, ...). Monomorphised and so marginally faster, but a generic function has no single function-pointer type, and the builtin tables (BUILTINS,STR_METHODS, and friends) are arrays of function pointers. It would mean boxing closures or a registry per engine, for a saving that does not exist at this granularity. - Duplicating the natives per engine. Rejected outright. Two
implementations of
str.splitthat must agree forever is exactly the failure the differential test suite is built to catch, and it would be catching a problem we chose to create. - Passing the capabilities as separate arguments (an output sink, an args slice, a callback). The signature grows with every capability, every native pays for what it does not use, and there is no longer one name for "what an engine offers".
22. Names resolve lexically, not by runtime search (narrows 0011)
- Status: Accepted
- Date: 2026-09-04
Context
ADR 0011 gave Korrin bare assignment and the
nearest-binding rule: x = value looks for an existing binding of x in the
current function scope, then each enclosing scope, out to global. Found means
update it; not found means create x in the current scope. Reads use the same
search. This is what removes the need for global / nonlocal keywords, and it
is deliberate.
The tree-walking interpreter implements that search at run time, over a chain of
Rc<RefCell<HashMap<String, Value>>> scopes (Environment::assign_existing).
A bytecode VM wants the opposite: names resolved once, at compile time, to
integer slots, so the inner loop indexes an array instead of hashing a string.
Milestone 3 has to reconcile the two.
Reading ADR 0011 is not enough to know what needs reconciling, so the current
interpreter was measured directly. Four facts, each from a program run against
korrin run:
-
Assigning to a name that exists in an outer scope updates that outer binding. A function containing
count = count + 1, withcounta global, increments the global. This is the rule working as designed and it is load-bearing. -
An assignment in a branch that never executes creates nothing. With a global
g, a function whose onlyg = 999sits underif false:reads the global. -
Assignments to names that exist nowhere else stay local and do not leak to the global scope when the call returns.
-
The same assignment in the same function can mean different things on different calls. Given
fn f(): x = 1 print(x) f() # no global `x` yet: creates a local, global untouched x = 5 f() # a global `x` exists now: writes it, so `x` becomes 1the first call creates a local and the second writes the global.
Fact 4 is the problem. Under ADR 0011 as implemented, whether an assignment targets a local or an enclosing binding is not a property of the program text at all. It is a property of the state of the scope chain at the moment the assignment runs, and it can differ between two calls of the same function. No compiler can assign that name a slot, because there is no answer to give.
Nor is this a corner case that could be quarantined and accepted as a difference
between the two engines: fact 1 is the same mechanism, and it is the documented,
intended behaviour that makes global unnecessary.
Decision
Name resolution becomes lexical. Which binding a name refers to is fixed by
the program text, at compile time, and is the same on every execution of that
line. For a name n appearing in function f, in order:
- If
nis a parameter off, it isf's local. - Otherwise, if
nis lexically bound in an enclosing function (see below), it is that function's binding, reached as an upvalue. - Otherwise, if
nis lexically bound at module top level, or is a name the prelude installs (a builtin, orError), it is a module global. - Otherwise, if
nis assigned anywhere inf's body, it isf's local. - Otherwise it is a module global, and reading it before anything binds it is
E0301, as today.
"Lexically bound in a scope" means the name appears anywhere in that scope's own
body, in any position that introduces a binding: an assignment target, a for
loop variable, an except binding, a parameter, or the name of a fn, class,
or import ... as. Position within the body does not matter, only membership.
The nearest-binding rule survives unchanged: an assignment still targets the nearest binding of that name, and still creates one locally when there is no other. What changes is that "is there another one?" is answered by looking at the enclosing text rather than at the live scope chain.
Two supporting consequences of slotting locals:
- A local slot begins each call uninitialized, which is distinct from
nil. Reading a local before its first assignment in that call isE0301, matching the interpreter's "not found in this call yet". - Globals stay a name-keyed table, not slots. Module globals are created in execution order, the REPL adds to them between chunks, and modules each have their own; a hash lookup is the right shape for that and is what every comparable language does.
The tree-walking interpreter is not changed to match. It keeps its runtime search, and the differential test suite carries one documented exception for the programs where the two disagree (below). Rewriting the interpreter's scoping would compromise the very thing it is being kept for: being a simple, independently-written implementation that was not derived from the VM.
Consequences
-
Locals become array slots, which is the single change that makes a bytecode VM worth building. Reads and writes of a local are an index, not a string hash and a chain walk.
-
Facts 1, 2 and 3 above are all preserved exactly. Assigning to a global from inside a function still writes the global, with no keyword, which was ADR 0011's point.
-
The narrow divergence. The engines disagree only when a function assigns a name that is also bound at module top level (or in an enclosing function), and the function runs before that outer binding has been created, and the program observes the outer name in between. Under the interpreter the early call creates a throwaway local; under the VM it creates the outer binding. The smallest program that can tell:
fn f(): x = 1 f() print(x) # interpreter: E0301. VM: 1 x = 5Note that fact 4's program above, despite being built to expose the order-dependence, prints the same under both rules, because nothing observes
xduring the window. Reaching the difference takes a program that reads an outer name after a call that would have created it and before the text that does. -
This is pinned as a golden test rather than left to be re-discovered: a future differential-fuzz failure of this shape must be recognised in seconds, not investigated as a new bug.
-
A consequence Korrin already had, now made lexically visible: assigning to a builtin's name inside a function clobbers the builtin for the whole program (
str = "hi"in any function leavesstr(42)failing withE0302). The rule above does not introduce this, but it does make it something a compiler can see, and so something a future lint could warn about. -
The specification's execution-model section needs rewriting. Its claim that name resolution "searches the current scope, then each enclosing scope" describes the mechanism rather than the language, and is now only true of one engine.
-
Compilation gains a pre-pass per function body to collect binding sites before emitting code, since rule 4 needs the whole body before the first statement can be compiled.
Alternatives considered
- Static locals, Python's rule: a name assigned anywhere in
fis a local off, full stop. This is what the Milestone 3 plan originally proposed, on the assumption that the interpreter's dynamic behaviour was a narrow corner. Fact 1 shows it is not: this would silently break every function that updates a global, and recovering that would require theglobal/nonlocalkeywords ADR 0011 explicitly refused. It is a different language, not a different implementation of this one. - Implement the runtime search faithfully in the VM. Correct by construction and preserves fact 4, but then no name in a function body can be slotted, because any of them might resolve outward on any given call. The VM would still beat the tree-walker on dispatch and call overhead, and would lose most of the reason to exist.
- A hybrid: slot only names that provably cannot be outer bindings, keep the
dynamic path for the rest. Sound, and it preserves fact 4 exactly. Rejected
because the set of "possible globals" is every top-level binding in the module,
so adding an unrelated global named
iwould silently deoptimise every function in the file that usesias a loop variable. Performance that depends on invisible name collisions is worse than a rule people can hold in their heads. - Changing the interpreter to match the VM. Would remove the divergence entirely, and was rejected because the interpreter's value as an oracle comes from being an independent implementation. Two engines that share a scoping implementation cannot catch each other's scoping bugs.
23. Closures capture variables, not scopes
- Status: Accepted
- Date: 2026-09-04
Context
The tree-walking interpreter gives a closure a reference to the whole
Environment it was defined in. That is one pointer, it is trivially correct,
and it makes the closure keep alive every binding of every enclosing scope for
as long as it lives, reachable only by walking a chain of hash maps at each use.
The bytecode VM cannot work that way, because the point of compiling is that a local variable is an index into a frame rather than a name in a map. But a frame is gone once its call returns, and a closure can outlive the call that made it:
fn counter():
n = 0
fn tick():
n = n + 1
return n
return tick
tick is still using n after counter has returned. Something has to move
n somewhere that survives, without breaking the case where counter is still
running and both it and tick must see the same n.
Decision
Closures capture individual variables, in shared cells, following the design in Crafting Interpreters' bytecode VM.
- A closure is a compiled function plus a list of cells, one per variable it actually mentions from an enclosing function. It does not capture anything it does not use.
- A cell is
Open(slot)orClosed(value). While the owning frame is alive the cell isOpenand reads and writes go straight to that frame's live local slot, so the closure and the frame see each other's changes. When the frame goes away the value is copied into the cell, which becomesClosed. - One cell per slot. Two closures capturing the same variable get the same
cell, so a mutation through one is visible through the other. This is what
makes a
get/setpair over one variable work. - The compiler records, for each capture, whether it comes from the enclosing frame's locals or from the enclosing closure's captures. A closure three levels deep reaches a variable at the top by each level forwarding it outwards.
- Cells close whenever a frame goes away, not only when it returns. An
exception unwinding past a frame discards it just as surely as a
returndoes, and a cell leftOpenafter that would point at a slot the VM has since reused.
The interpreter is not changed. It keeps capturing environments, which is part of why it is worth keeping as an oracle (ADR 0024): two genuinely different closure implementations that agree are evidence, where two copies of one implementation would not be.
Consequences
- A closure keeps alive exactly the variables it uses. The interpreter keeps alive every binding in scope, so the VM is also the better-behaved engine for a long-lived callback.
- Reading a captured variable is a pointer hop, not a chain of map lookups.
- Loop variables behave as they always have: Korrin's
forvariable is one binding reused each iteration, not one per iteration (ADR 0011), so closures made in a loop all see its final value. Under the VM this is not a special case, it falls out of there being one slot. It is pinned by a test either way, because it is the kind of thing an "improvement" could silently change. - The "close on every frame exit" rule is the part most likely to be got wrong,
since the returning path is the one you naturally write first and the
unwinding path is the one you only notice when a closure that escaped a
raisestarts reading someone else's data. There is a differential test for exactly that shape. Valuenow has two closure representations:Functionfor the interpreter andClosurefor the VM. Both report"function"totype(), and no program can hold both, since only one engine runs it.- Cells are
Rc<RefCell<..>>, so a closure that captures a variable holding the closure itself is a reference cycle and will not be freed. Korrin has no garbage collector, and this is the same leak the interpreter already has with a self-referential environment.
Alternatives considered
- Capture the environment, as the interpreter does. Correct, and it would make the two engines agree by construction, but then locals cannot be slots and the VM is a tree-walker with extra steps.
- Copy captured values into the closure at creation time. Simple, no cells, no closing. It also silently breaks every closure that mutates what it captured, which ADR 0011 explicitly promises works.
- Put every captured variable in a heap cell from the start, skipping the open/closed distinction. Simpler, and it costs an allocation per captured variable per call even when nothing escapes, which is the common case.
- Close cells only on
return, treating unwinding as rare enough to ignore. Rejected: "rare" and "impossible" are different, and the failure is silent data corruption rather than a crash.
24. The tree-walking interpreter is kept as a differential-testing oracle
- Status: Accepted
- Date: 2026-09-04
Context
Milestone 3 gives Korrin a second way to run a program. The obvious thing to do with the first one is delete it: it is slower, it is now redundant, and two implementations of one language is twice the surface to maintain.
The problem is that the new engine is much harder to be sure about. The interpreter's correctness argument is "it does what the tree says". The VM's is "a compiler turned the tree into instructions that are equivalent, and a machine with three stacks runs them in the right order, keeping the right things alive". The failure modes are different in kind: a mis-emitted jump, a stack left one value deep, a capture closed a moment too late. None of those look like a crash. They look like a program quietly printing the wrong number.
Meanwhile there is an oracle sitting right there. The interpreter already runs every program the test suite has, it was written months before the VM and without reference to it, and its answers have been trusted through two milestones.
Decision
The tree-walking interpreter stays, permanently, as the oracle the VM is tested against. It is not deprecated, not feature-frozen relative to the language, and not an implementation detail scheduled for removal.
Both engines are reachable: korrin::run / run_file use the interpreter,
korrin::run_vm / run_file_vm use the VM.
The agreement contract
Given the same program, the engines must agree that:
- Both succeeded, or both failed. Succeeding under one engine and failing under the other is a bug even when the failure looks reasonable.
- On success,
stdoutmatches byte for byte. - On failure, the primary diagnostic's
ErrorCodematches.
Point 3 deliberately stops at the code. Messages and spans are not compared: the
VM knows the exact instruction that faulted and often points at a better place
than the interpreter's whole-expression span, and holding it to the worse
position would make the oracle a ratchet against improvement. This mirrors what
tests/programs.rs already does with its .err fixtures.
The one accepted disagreement
ADR 0022 narrows Korrin's scoping rule from a runtime search to a lexical one, which leaves exactly one program shape where the engines differ. It is pinned by a test that asserts the difference is still exactly that shape, so a future fuzz failure of that kind is recognised in seconds rather than investigated.
Where sharing is allowed
The engines share the value-level operations: arithmetic, comparison, indexing,
attribute lookup, iteration (interpreter::ops). They do not share
scoping, control flow, calls, or closures.
The line is drawn at "does an independent implementation of this tell us
anything". Two implementations of 1 + 1 do not; two implementations of "which
binding does this name refer to" very much do, and that is where the two engines
were always going to differ.
Consequences
- Every existing golden program and every runnable specification example becomes a VM test for free, which is a far broader corpus than anything written specifically for the VM.
- Bugs surface as a disagreement with a known-good answer instead of as a mismatch with what someone thought the answer should be. Four real bugs in the VM were found this way on the first run of the suite.
- Two engines are two things to update when the language grows. Every future feature has to be implemented twice or, where it is a value operation, once in the shared module. This is the price, and it is paid deliberately.
- The interpreter is also the fallback if a VM bug is found in the field, and the reference when the specification and an implementation disagree.
- The oracle only checks what it is run on. It is not a proof, and the proptest-style fuzzing it enables is only as good as the programs generated.
Alternatives considered
- Delete the interpreter once the VM passes the existing tests. The existing tests were written to exercise the language, not a compiler, and passing them says little about jump patching or stack discipline. It also throws away the only independent implementation at exactly the moment one becomes valuable.
- Keep it but freeze it. A frozen oracle drifts out of the language and stops being able to run new programs, which is precisely when the check is most needed.
- Compare spans and messages too. Would catch more, and would fail every time the VM produced a better diagnostic, training everyone to paper over differences rather than look at them.
- Trust a large test suite instead. A test suite encodes what someone thought to check. An oracle answers questions nobody thought to ask, which is the entire point.
25. finally is inlined at every exit the compiler can see
- Status: Accepted
- Date: 2026-09-04
Context
ADR 0017 settled what try / except / finally mean, and the tree-walking
interpreter implements those meanings directly: it runs the body, catches what
comes back, and runs the finally on the way out whatever happened. Its exact
contract, which the VM has to reproduce:
finallyruns on every way out: falling off the end,return,break,continue, a caught exception, an uncaught one.- If the
finallyitself exits some other way, that wins, replacing whatever the body or handler was doing, including a pendingreturnand an exception still propagating.
Reproducing that in bytecode is the hardest part of the milestone, because a
return in the middle of a try is no longer just an instruction. It has to run
an arbitrary block first, and if that block returns, the original return must
never happen.
The classic answer is a subroutine: compile finally once, jump into it, and
have it jump back to whoever called it. The JVM did this with jsr/ret and
spent years on the verification problems it caused, because "jump back to
wherever you came from" is exactly the construct that makes control flow
unanalysable. Modern compilers stopped doing it.
Decision
Split by whether the compiler can see the exit.
Statically visible exits get the finally inlined. Falling off the end of
the body, falling off the end of a handler, and any return / break /
continue written lexically inside them are all points the compiler is standing
on when it emits them. Each gets its own copy of the compiled finally, placed
immediately before the real instruction.
This needs no runtime bookkeeping at all, and it makes "the finally wins" fall
out for free rather than being a rule to enforce: if the inlined copy contains a
return, that return simply executes, and the exit it interrupted is never
reached. There is no pending-action register to consult and get wrong.
Exceptions arriving from elsewhere get a runtime handler. An exception
raised in a nested call, or by a faulting operation, cannot be routed at compile
time. Op::PushHandler records where the except and finally bodies are; the
unwinder walks the handler stack, discards the frames the exception escaped,
restores the operand stack, and jumps to the except body, or to the finally
with the exception held in a single pending register that Op::EndFinally
resumes.
Two details that are easy to get wrong and are therefore written down:
- Entering an
exceptbody replaces the handler with one covering just that body, so an exception raised inside the handler still runs thefinallyon its way out but is not caught by the sameexcepta second time. When thetryhas nofinallythis replacement has nothing to do; it exists only so the compiler's matchingPopHandlerhas something to pop, and the unwinder treats it as a pass-through. - While a
finallyis being inlined into an exit, its owntryis off the compiler's stack. Areturnwritten inside afinallyis leaving the enclosingtrystatements, but it is not leaving that one again, and compiling it as though it were inlines the block into itself without end.
Consequences
- The runtime carries one handler stack and one
pendingslot. Everything else is ordinary jumps, which the disassembler shows and a reader can follow. - A
finallybody is compiled once per static exit. Atrywith a body that falls through, areturn, and abreakgets three copies. Bodies are small and exits are few, so this is bytecode size traded for having no runtime mechanism, and it is the trade CPython andjavacalso make. - Nested
trycomposes without extra machinery:returnwalks outward through every enclosingtryin the function, emitting each one'sfinallyin turn, innermost first. The handler stack does the same for exceptions. - A
finallythat swallows an exception is silent, exactly as ADR 0017 says it should be. The mechanism does not make that easier to notice. - The order in which a
finallyand the exit it interrupted appear in the bytecode is the reverse of how they read in source. The disassembler is the tool for that, and it exists partly for this reason.
Alternatives considered
- A subroutine: compile the
finallyonce, jump in, return to the caller. Smallest bytecode. It is alsojsr/ret, whose problems are well documented and were serious enough that the JVM removed it. - One
pendingregister holding the interrupted action, for every exit. This was the original plan for this milestone. It movesreturn,break, andcontinueinto runtime state thatEndFinallymust dispatch on, which means four ways for the resume logic to be subtly wrong instead of zero. Inlining eliminates three of the four cases outright. - Run
finallyfrom a RustDropguard. Ties Korrin's control flow to the host's unwinding, which is precisely the coupling ADR 0004 avoided by makingSignala value in the first place.
26. korrin-lsp's transport: lsp-server, no async runtime
- Status: Accepted
- Date: 2026-09-04
Context
Milestone 4 adds a language server so an editor can show live diagnostics,
hover text, and go-to-definition for .kor files. Every real LSP server needs
a transport layer: something that speaks JSON-RPC over stdio, handles the
initialize handshake, and dispatches incoming requests and notifications to
handler code.
Rust has two well-known choices. tower-lsp wraps the protocol in tower's
service abstraction and dispatches through an async fn trait
(impl LanguageServer for Backend), which requires an async runtime — in
practice tokio — to drive it. lsp-server (maintained by the rust-analyzer
project, and what rust-analyzer itself runs on) is synchronous: a
crossbeam-channel-backed connection that hands you lsp_server::Message
values to match on and reply to in a plain loop, no runtime required.
Nothing in three completed milestones — a tree-walking interpreter, a bytecode compiler and VM, a CLI with a line-editing REPL — has ever needed an async runtime. The question is whether a language server changes that.
It doesn't, for a concrete reason: korrin-lsp serves one file at a time, and
recompiling that file on every keystroke is not a latency problem.
korrin::lexer::tokenize's own doc comment (crates/korrin/src/lexer/mod.rs:54)
already states it is "cheap enough to redo on every keystroke for typical file
sizes." The full tokenize -> parse -> resolve chain on a .kor file runs
in microseconds to low milliseconds. There is no request that benefits from
being handled concurrently with another, because there is only ever one
editor, one open document being actively typed into, and no I/O slow enough to
want to await around.
Decision
korrin-lsp uses lsp-server + lsp-types, synchronously, with no async
runtime.
lsp-serversupplies the JSON-RPC connection (Connection::stdiofor the real server,Connection::memoryfor tests — see the verification section of the Milestone 4 plan) and theMessage/Request/Notification/Responsetypes the dispatch loop matches on.lsp-typessupplies the protocol's own vocabulary as serde types (Position,Range,Diagnostic,Hover,Location, the capability structs) — this crate is useful with either transport and would be needed regardless of the choice above.- The dispatch loop in
crates/korrin-lsp/src/server.rsis a plainfor msg in &connection.receiverloop with amatchon method name. More boilerplate thantower-lsp's trait dispatch, but bounded, one-time, and contained to one file.
Consequences
- No
tokio(or any async runtime) enters the dependency tree. The workspace stays true to ADR 0013's "avoid crates that pull large transitive trees" — confirmed in practice:lsp-server+lsp-typespulled 11 packages total (crossbeam-channel,crossbeam-utils,bitflags,fluent-uri,itoa,serde,serde_derive,serde_json,serde_repr, plus the two crates themselves), none of them an executor. - This is also the decision that fills a real gap in ADR 0013. That ADR's
dependency bar names exactly two tiers by name —
crates/korrin(published, stays lean) andcrates/korrin-cli(freer, for CLI concerns) — and says nothing about a third crate, because none existed when it was written. ADRs are immutable once accepted (docs/decisions/0000-adr-process.md), so 0013 is not edited. This ADR placeskorrin-lspexplicitly in the looser,korrin-cli-like tier: not published, a tool crate, free to depend on mature crates for its genuine concern (speaking a wire protocol), the same latitude 0013 already grantskorrin-cliforclapandrustyline.docs/internals/dependencies.mdgets a new## crates/korrin-lspsection in that same style. - The dispatch loop is hand-written rather than generated by a trait, which means adding a new LSP method later (semantic tokens, code actions) means writing its match arm by hand rather than implementing one more trait method. A deliberate, small, recurring cost in exchange for the dependency savings above.
- If a future feature genuinely needs concurrency (for example, indexing a whole workspace of files in the background while still answering requests on the one currently open), that is the point to revisit this decision, not before.
Alternatives considered
tower-lsp+tokio. The more common choice in the Rust LSP ecosystem and more ergonomic to write against. Rejected for pulling in an async runtime this project has never needed, for a workload with no actual concurrency requirement — exactly the "large transitive tree" ADR 0013 asks to be avoided without a load-bearing reason, and none exists here.- Hand-rolled JSON-RPC over stdio, no framework at all. Removes even
lsp-server's dependency, butlsp-serveris small, maintained by the team that runs the most demanding LSP server in the Rust ecosystem on it daily, and correctly handles the handshake/shutdown edge cases (partial reads,Content-Lengthframing, cancellation) that are easy to get subtly wrong by hand. Not worth reimplementing for the dependency count it would save.
27. Diagnostics: first-failing-stage only for v1
- Status: Accepted
- Date: 2026-09-04
Context
korrin-lsp publishes diagnostics by lexing, parsing, and resolving a
document's text, exactly the chain the CLI's own front end runs
(crates/korrin-cli/src/main.rs's compile). korrin::lexer::tokenize and
korrin::parser::parse both discard their output entirely on any error — a
lexer error means no token stream is returned at all, not even a partial one,
and a parse error means no tree, by design (their own doc comments say so).
Each stage's failure short-circuits the ones after it: today, a file with a
syntax error shows only that syntax error, never a resolver-level problem
(return outside a function, super with no parent) that might sit further
down in the same file.
That was an acceptable, unremarked-upon property of a batch tool (korrin run, which stops at the first problem anyway) and becomes a visible product
decision the moment the same chain feeds live, on-every-keystroke diagnostics
in an editor: a user fixing one syntax error only then discovers a resolver
error on the next keystroke, one at a time, rather than seeing everything
wrong with the file at once.
The alternative is real: change lexer::tokenize and parser::parse to
return best-effort partial results even after recording an error, so later
stages can still run over whatever they managed to produce. The parser
already recovers from a syntax error internally (its synchronize()) — the
scaffolding partially exists. But exposing that as a new return shape is a
change to korrin's public front-end contract, not an editor feature: every
caller of these two functions needs auditing, including the differential
testing harness that holds the tree-walker and the VM to identical behavior
(ADR 0024) and the spec-example runner that executes every runnable block in
the documentation (ADR 0002). Neither of those exists to validate a
"diagnostics quality" feature; changing what they depend on for one is a
disproportionate risk this milestone doesn't need to take.
Decision
v1 ships with diagnostics only from the first stage that fails, matching
korrin run's behavior exactly. korrin-lsp's diagnostics::check calls
korrin::lexer::tokenize, then korrin::parser::parse, then
korrin::resolver::resolve, propagating whichever stage's Vec<Diagnostic>
comes back first and never reaching the later stages if an earlier one
failed.
The user-visible cost: a file with one syntax error surfaces only that error until it's fixed, then whatever the resolver finds appears on the next diagnostics pass — which happens on every keystroke, so in practice this resolves itself within one edit cycle rather than staying hidden. This is recorded here as a conscious, accepted v1 limitation, not discovered later as an unexplained gap.
Consequences
- No changes to
korrin's public front-end API.lexer::tokenizeandparser::parsekeep their documented all-or-nothing contract, and every existing caller (the CLI, the VM's own compiler front end, the differential and spec-example test harnesses) is unaffected. korrin-lsp's own diagnostics pipeline is three lines matching a pattern that already exists and is already trusted (crates/korrin-cli/src/main.rs'scompile), rather than a new code path exercising an unproven partial-result contract for the first time.- A file with several unrelated problems is fixed one visible layer at a time rather than all at once. Worth revisiting once the LSP has enough other features that this becomes the sharpest remaining edge — likely after hover and go-to-definition, not before.
Alternatives considered
- Return best-effort partial results from
lexer/parseron error. The real long-term fix, and plausible future work, but a front-end redesign belongs to its own milestone with its own ADR, tested against the existing differential and spec-example harnesses on its own terms — not folded silently into "add a language server." - Re-run each stage independently, ignoring earlier failures. For
example, always run the resolver against whatever partial tree the parser
produced internally before its own error return, even though
parsedoesn't expose that tree. Rejected: this would meankorrin-lspreaching into parser internals that aren't public API, which is a worse coupling than either of the two options above.
28. Hover text is compiled from the specification, not read at runtime
- Status: Accepted
- Date: 2026-09-04
Context
textDocument/hover needs somewhere to pull explanatory text from for a
builtin function (print) or a built-in method (str.trim). Korrin has one:
docs/spec/08-builtins.md. Its free functions (§8.1) are each a
### \name(sig)`` heading followed by prose and an example; its methods
(§8.2-8.4) are markdown tables, one row per method, no heading at all — two
different shapes in one file, both needing a parser.
The question is where that file is read from when korrin-lsp actually runs.
The binary's install location has no fixed relationship to a checkout of the
Korrin repository: it might be on $PATH after cargo install, sitting
wherever a VS Code extension chose to bundle it, or run straight out of
target/debug/ during development. A runtime path read (docs/spec/ relative
to something) means solving "relative to what" for every one of those cases,
and shipping the markdown file as a separate asset alongside the binary.
Decision
hover.rs embeds docs/spec/08-builtins.md at compile time with
include_str!, and parses it once, lazily, into two lookup tables — one for
free functions, one for methods (a method name maps to every type that has
one, since Korrin has no static types to disambiguate by receiver: hovering
.len() shows str, list, and map together rather than guessing).
No new dependency for the parsing itself: the heading and table-row structure
is mechanical enough (### \...`for a heading, `` |...` `` for a table
data row) that splitting on those markers is simpler than reaching for a
markdown-parsing crate for two shapes this specific.
Every documented name must be a real one, checked automatically:
korrin-lsp's own test suite compares the parsed function and method names
against korrin::builtins::names() and korrin::interpreter::methods::names()
— the same interpreter functions crates/korrin/tests/builtins.rs already
uses to hold the spec accountable to the code. This is a second copy of that
check because korrin-lsp now depends on the doc's structure being
accurate, not just its prose, and a spec/code drift that the existing test
would catch should be caught here too, before it silently produces wrong or
missing hover text.
Consequences
korrin-lspis fully self-contained: copy the binary anywhere and hover works, with no path discovery, no bundled sidecar file, no "cannot find docs/spec" failure mode to handle.- The spec file's prose is now also product copy shown directly in an editor, not only documentation read on the website. It was already held to a high bar by ADR 0002; this raises the cost of a sloppy edit slightly further, which is the right direction.
- Changing
docs/spec/08-builtins.md's structure (adding a new subsection shape, say) means updatinghover.rs's parser in the same change, or hover text silently goes missing for whatever no longer parses. The anti-drift test catches exactly this — a name in the interpreter with no matching parsed entry fails the build — but only for missing/extra names, not for a correctly-named entry whose body parsed wrong. korrin-lspmust be rebuilt for hover text to reflect a spec edit; a doc change alone (with no crate rebuild) has no effect on a running server, since the file is compiled in rather than watched.
Alternatives considered
- Read
docs/spec/08-builtins.mdat runtime, from a path discovered relative to the binary or a workspace root. Solves nothing thatinclude_str!doesn't already solve for free, and adds real path-discovery logic (search upward for adocs/directory? require an environment variable? fail gracefully?) purely to reproduce compile-time embedding by hand. - Duplicate the hover text as Rust doc comments or a separate data file maintained independently of the spec. Two sources of truth for the same information, which is exactly the kind of drift ADR 0002's documentation discipline exists to prevent elsewhere in this project.
- A real markdown-parsing dependency, for correctness beyond the two mechanical shapes this file actually uses. Not justified yet — revisit if the spec's structure grows more varied than headings and tables.
29. Go-to-definition is a standalone scope walker, single-file for v1
- Status: Accepted
- Date: 2026-09-05
Context
textDocument/definition needs to answer "which binding does the name under
the cursor refer to, and where in the source is that binding?" Milestone 3's
bytecode compiler already answers a version of this question for every name in
a program — crates/korrin/src/compiler/scope.rs's bound_names and
crates/korrin/src/compiler/emit.rs's Compiler::resolve /
resolve_upvalue / enclosing_local — using exactly the lexical rule
ADR 0022 defines: a name is a local of the innermost enclosing function
unless it is already bound in an enclosing function or at module level, in
which case it refers outward to that binding instead.
That machinery cannot be called from korrin-lsp directly. It is pub(super)
or private, internal to compiler, and — more fundamentally — it answers the
wrong kind of question: it maps a name to a bytecode slot (u16), and
bound_names returns a bare BTreeSet<String> with no position information
at all. Reusing it would mean threading source spans through code whose entire
job is to forget them in favor of a slot number, which is not a small change
bolted onto existing code — it is rewriting the compiler's resolution pass for
a second, unrelated consumer.
Decision
A new, independent pass, crates/korrin-lsp/src/goto_definition.rs,
implements the same lexical rule from scratch, recording name -> Span
instead of name -> slot. It mirrors bound_names's traversal exactly (only
fn and class bodies are scopes; if/while/for/try are not), and
reproduces the one precedence rule that is not optional to get right: when
deciding whether a name a function's body binds becomes that function's own
local or refers outward, a candidate is skipped — and resolves to the
enclosing binding instead — if it is already bound in the module scope or any
enclosing function scope. Skipping this check would misresolve the single most
common shape of Korrin closure: count = count + 1 inside a function that
reads a module-level count would incorrectly point at itself rather than at
the module-level assignment. A test pins exactly this case.
Single-file only for v1. A name from an imported module —
utils.foo()'s foo — is not resolved into utils.kor. Doing so means the
server tracking a file the editor never opened, parsing and caching it, and
keeping that cache correct across edits to either file: a real,
separately-scoped feature (workspace-wide file awareness), not an incremental
extension of a single-file walker. A use of the imported name itself
(utils in utils.foo()) still resolves, back to its own import "./x" as utils statement in the current file — binding it is ordinary same-file
scoping and needs nothing special.
An unresolvable name (a builtin with no source location, a name the walker
genuinely cannot find, or a file that fails to lex or parse at all) returns
null over the protocol, not an error. "I don't know where this is defined"
is a legitimate, expected answer for a request like this — an error would
incorrectly suggest something went wrong with the server itself.
Consequences
- One accepted, narrow gap: the walker's "already bound elsewhere" check
covers module-level and enclosing-function bindings, but not the prelude's
builtin names (
print,str,Error, ...), which the real compiler'sglobalsset does include. A function that reassigns a builtin (fn f(): str = "hi") is treated by this pass as introducing its own fresh local, where the compiler would treat it as writing the same global the builtin occupies. The two views land on the identical span whenever nothing else in the file binds that name — which is every realistic case — and diverge only in a contrived one with no practical effect on navigation. Threading prelude names through would cost real complexity (they have noSpanto put in aScopemap, so the check becomes a second, differently shaped lookup) for a case not worth it yet. - A syntax error means no definitions resolve anywhere in the file until it's fixed, the same all-or-nothing acceptance ADR 0027 already made for diagnostics, for the same reason: there is no tree to search.
- The walker is read afresh from the document's current text on every request rather than cached, the same as diagnostics and hover — Korrin files are small enough that re-lexing and re-parsing on each request costs nothing worth optimizing away yet.
Alternatives considered
- Expose the compiler's resolution as a public API and adapt it. Rejected in ADR 0022's own reasoning restated here: the compiler's job is slot allocation for bytecode, not source navigation, and the two should not share an implementation just because they ask a similar-sounding question.
- Resolve every name, including into imported files, from the start. Correct in principle, and the more complete feature. Deferred because it is a genuinely different scope of work — the server would need to track files the editor never opened — and shipping single-file resolution now is not a half-measure that has to be undone later; it composes cleanly with cross-file resolution whenever that lands.
- Treat an unresolvable name as an error response. Rejected: it would train a client (and a user reading its error output) to see "go to definition on a builtin" as a malfunction rather than the expected, correct answer that there is nowhere in source to jump to.
30. Node and TypeScript, scoped to the VS Code client only
- Status: Accepted
- Date: 2026-09-05
Context
editors/vscode/ has existed since before this milestone as a grammar-only
extension: a package.json manifest, a TextMate grammar, and a language
configuration file — no build step, no scripts, no devDependencies, no
CI coverage. Every other part of this repository, including the website
(korrin-web/, hand-written HTML/CSS plus mdbook, a Rust tool) and the docs
site build, is Rust or plain static files. CI is, and through three milestones
has been, entirely cargo commands.
A real VS Code extension client that starts korrin-lsp and shows what it
sends — live diagnostics, hover, go-to-definition — is written against the
vscode extension API, which is JavaScript/TypeScript only; there is no
alternative. This is not a preference to weigh against a Rust option, the way
ADR 0026 weighed lsp-server against tower-lsp — VS Code extensions cannot
be written in Rust today, so Node and TypeScript enter this repository's
tooling for the first time out of genuine necessity, not convenience.
Decision
Node and TypeScript are scoped to editors/vscode/ only, with its own
package.json, package-lock.json, and build step, kept as separate and
self-contained from the Rust workspace as korrin-web/'s mdbook build
already is from it.
- TypeScript, strict mode (
strict: trueplusnoUnusedLocals,noUnusedParameters,noImplicitReturns,noFallthroughCasesInSwitch), compiled withesbuildrather thantscfor the actual output —esbuildbundlesvscode-languageclientand its own dependencies into a singleout/extension.js, so the packaged extension has nonode_modulesto ship and no runtime dependency resolution to fail.tsc --noEmitstill runs, in CI and by hand, purely for type-checking. vscode-languageclientis the standard client library for exactly this: starting a language server as a subprocess and speaking the LSP over its stdio, translating betweenvscode's own API types and the protocol's wire types. Writing that translation layer by hand would be reimplementing a solved, boring problem for no benefit.- A dedicated CI job (
vscode-extension, usingactions/setup-node+npm ci+tsc --noEmit+ theesbuildcompile), separate from every Rust job. None of the existingtest/lint/msrv/docsjobs touch this directory or would ever catch a TypeScript error; a broken extension client should fail its own check, not slip through unnoticed because nothing looked. - No dependency here is added to the Rust workspace's dependency policy
(ADR 0013) or
docs/internals/dependencies.md— that policy governs Cargo dependencies of Rust crates, andnpm's dependency tree is a genuinely separate concern with its own separate ecosystem norms. It is not silently exempt from scrutiny; it is scoped to one small, isolated corner of the repository and reviewable there on its own terms.
Consequences
- Building or contributing to
crates/korrin,crates/korrin-cli, orcrates/korrin-lspneeds nothing butcargo— Node is only required for someone touching the VS Code client specifically, the same waymdbookis only required for someone touching the docs site. - The repository now has two package managers with two different sets of
norms (Cargo's committed
Cargo.lockfor a workspace that ships binaries, npm's committedpackage-lock.jsonfor reproducible CI installs) rather than one. A real, ongoing cost, accepted because a VS Code extension has no Rust-only path around it. - The packaged
.vsixdoes not bundle a prebuiltkorrin-lspbinary for any platform (per ADR 0026's Milestone 4 scope) — a user installs the language server themselves viacargo install. Multi-platform binary packaging is real, separate work for a later milestone if this project ever wants a from-the-marketplace "it just works" install experience.
Alternatives considered
- A webview-based or LSP-free extension avoiding TypeScript entirely.
Not viable:
textDocument/publishDiagnostics, hover, and go-to-definition are integrated into VS Code's own editor chrome (the Problems panel, the hover popup,F12) through the extension API, which has no non-JS/TS surface. There is no version of "show live diagnostics in the editor" that avoids this. - Hand-rolled JSON-RPC in the client, skipping
vscode-languageclient. Symmetric tokorrin-lspnot hand-rolling its own transport (ADR 0026) — the framing, cancellation, and API-translation work is exactly the kind of solved problem worth depending on a library for. - Fold the Node/TypeScript check into an existing CI job rather than a
new one. Rejected: none of the existing jobs' names, caching strategy, or
failure semantics have anything to do with this, and a Rust contributor
reading a red
testjob should not have to wonder whether their Rust change broke something or a TypeScript file did.
31. Package distribution: git, no registry
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0018 built the module system and deferred this
exact question on the spot: "A package manifest / registry. Way beyond M2.
Path-based resolution needs no configuration and is enough to build with; a
package story can come later without changing import's surface." Milestone
5 is that later point — a Korrin codebase should be able to depend on someone
else's code, not just its own files.
The decisions in play:
- Where does a package's code live, and how does a consumer get it onto disk?
- What identifies a package and a version of it?
- What does a manifest look like, and what does it pin?
- Where does a fetched package land locally, and is that shared across projects or private to one?
Constraints that shape the answer: the project has no backend anywhere today
— every deliverable is either a Rust binary or a static site on Cloudflare
Pages (ADR 0030's editors/vscode/ and
korrin-web/ are the two precedents, and neither talks to a server the
project runs). ADR 0013 asks that a new
dependency be justified and that large transitive trees be avoided. Milestone
6 — a native-module system — is the next milestone specifically because
there is no way today to write a Korrin module in Rust and ship it, so a
package in Milestone 5 can only be Korrin source.
Decision
Distribution is git, with no central hosting
A package is a git repository. There is no upload step, no package registry
service, and nothing this project hosts or runs. korrin pkg clones or
fetches a package directly from wherever its manifest says it lives.
The manifest: korrin.toml
[package]
name = "requests"
version = "0.3.0"
[dependencies]
json = { git = "https://github.com/alice/korrin-json", tag = "v1.2.0" }
testkit = { git = "https://github.com/bob/korrin-testkit", branch = "main" }
scratch = { git = "https://github.com/carol/korrin-scratch", rev = "9f2a1c3b" }
[package].nameand.versionare a package's self-declared identity — shown in diagnostics, inkorrin pkg list, and on the community index (ADR 0034). They play no role in resolving a dependent'simportstatements; see the next point.- A
[dependencies]table key is the name a dependentimports it by, independent of what the upstream package calls itself.import "json"means whatever this manifest callsjson, regardless of whatalice/korrin-json's own[package].namesays. This is the same principle ADR 0018 already gave files (import "./a/config" as cfgrenames at the use site) applied to packages, and it avoids a separate rename field. - Each dependency needs a
gitURL and exactly one oftag,branch, orrev. Zero or more than one is an error fromkorrin pkg add/install, not a silently-picked default. - A package's entry file is the fixed path
src/lib.kor— mirroring Cargo'ssrc/lib.rs— not derived from[package].name, since that name can legitimately differ from what a dependent calls the package.
Packages are leaf-only in Milestone 5
If a package's own korrin.toml declares a non-empty [dependencies] table,
korrin pkg add/install refuses it: "json declares dependencies of its
own — packages with dependencies are not supported in Milestone 5." A
package may only depend on nothing.
The lockfile: korrin.lock
# @generated by `korrin pkg` — do not edit by hand.
version = 1
[[package]]
name = "json"
git = "https://github.com/alice/korrin-json"
rev = "a1b2c3d4e5f6..."
path = ".korrin/packages/json"
Every dependency resolves to a pinned commit, even one specified by tag or
branch — a build stays reproducible even if the upstream ref later moves.
korrin pkg update is the only thing that ever moves a pin.
An application (any korrin.toml with a non-empty [dependencies] table
run via korrin run) commits korrin.lock, for the same reproducibility
reason Cargo asks binaries to commit Cargo.lock. A package meant to be
depended on does not — mirroring Cargo's own binary/library split, and
costing nothing to state now even though leaf-only packages give this rule
little practical bite yet.
No versioning solver
Because a dependency is an exact git ref, not a version range, there is
nothing to solve. korrin pkg add pins a commit at add time. Combined with
leaf-only packages, there is no transitive dependency graph to flatten either
— resolution is: read [dependencies], fetch each entry once, write one
lockfile line per entry. No semver crate is added; it would have no
load-bearing job under this model.
Local storage: project-local, no shared cache
A fetched package lands at .korrin/packages/<name>/, a real checkout (not a
symlink), next to the korrin.toml that declared it — gitignored. Manifest
and lockfile discovery walks upward from the entry file's own directory,
not the process's working directory, matching the principle ADR 0018 already
set for ./ resolution.
Consequences
- Nobody has to trust, fund, or operate a registry for Korrin packages to exist. Anyone with a git host (or a bare local repo) can publish one.
- A package author who wants to depend on another package cannot, yet — that is real functionality given up, not an oversight. It is the direct price of landing packaging with zero new hosted infrastructure and no dependency solver; a transitive story is real future work, not blocked by anything chosen here.
- No global package cache means repeated
korrin pkg installacross different projects re-clones the same repository each time. Acceptable at Milestone 5's scale; a shared cache is legitimate future work (see "Alternatives considered"). korrin.lock's commit-or-not convention needs restating, and possibly revisiting, once transitive dependencies exist and a package can plausibly want its own lock for its own dependencies.- A package pinned by
branchsilently becomes stale untilkorrin pkg updateis run — this is the intended tradeoff (an explicit update step, not automatic tracking), matching howrev/tagpins already work.
Alternatives considered
- A hosted registry (crates.io-style: upload, storage, an API). Real functionality — search, download counts, a single canonical source — but needs a backend this project has never had, funded and operated indefinitely. Far more than packaging needs to exist at all.
- Vendoring only (copy a dependency's source into your own repo, no live reference). Simplest possible, and needs nothing this ADR designs — but it doesn't solve "depend on someone else's code" in any lasting way; every update is a manual re-copy with no record of where it came from.
- A shared global cache (
~/.korrin/cache/, à la~/.cargo/registry, reusable across projects viagit worktree addor a maintained bare mirror). More bandwidth- and disk-efficient, and the design Cargo/uv actually use — but it is real, ongoing machinery: locking against concurrentkorrin pkgruns, a staleness/GC policy, and a first cross-platform cache-directory decision. On Windows specifically, the natural cheap sharing mechanism — a symlink — needs Developer Mode or elevated privileges, a concrete wrinkle worth avoiding for a first cut. Legitimate future work once the project-local approach's cost is actually felt. - Transitive dependencies from the start, with a real solver. The standard shape for a package manager, but a dependency-graph solver is substantial, separately-scoped work, and nothing about Milestone 5's actual goal — letting one person depend on another's code at all — needs it yet.
32. Bare-name imports merge installed packages with built-ins
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0018 split every import specifier two ways: a
./- or ../-prefixed specifier is a file, anything else is a built-in
module, looked up by exact name with no filesystem probe.
ADR 0031 adds a third kind of thing a bare
name can mean — an installed package. This ADR decides how import "name"
tells a built-in from a package, without reopening import's syntax
(a decision already locked in with the user) and without weakening ADR
0018/0019's "a specifier's meaning is knowable with no filesystem probe"
guarantee.
ADR 0019 already faced a similarly-shaped problem — telling a built-in from a
file — and rejected adding a prefix (std:math) for it: "Explicit, but
ugly for the common case and a new bit of syntax." This ADR is not free to
wave that reasoning away without saying why the two cases differ.
Decision
One merged namespace, no new syntax
import "name" (no ./) checks the built-in module table and the installed-
package table together. There is no prefix distinguishing them — import "math" and import "requests" are written identically, whichever kind each
turns out to be.
Built-ins win, checked once, before it can ever matter
A package's local import name (the key it is given in [dependencies],
ADR 0031) is checked against
korrin::stdlib::module_names() at korrin pkg add/install time, not at
import time. A colliding name is refused outright: "math collides with a
built-in module of the same name; choose a different local name, e.g. korrin pkg add ... --name my_math."
Because of that gate, the two tables a bare specifier could resolve against are disjoint by construction by the time any Korrin program runs. Runtime dispatch needs no new mechanism to enforce precedence — it already tries the small, fixed built-in table first; a package lookup is a new fallback reached only on a miss, and in practice a miss on the built-in table always means "not a built-in," never "shadowed by a package."
Why this doesn't reopen ADR 0019's rejected prefix
ADR 0019's problem was: a specifier's meaning must be knowable without
consulting what files exist on disk. ./ vs. bare-name already solves that
completely, unconditionally, and this ADR does not touch it. The problem
here is different in kind: bare names now have two tables instead of
one, and the question is not "how do we know which table without probing
the filesystem" (dispatch order already answers that, for free) but "how do
we guarantee the two tables never actually collide." A prefix would answer
that question too, but at the cost ADR 0019 already named and rejected once
— a new bit of syntax for the common case — for no benefit a one-time,
off-the-hot-path install check doesn't already provide more cheaply.
Consequences
import "whatever"never needs a human (or a docs page) to explain which of two syntaxes to use depending on whatwhateverturns out to be. There is one syntax.- A package cannot shadow a built-in, ever — not silently, not by accident.
If the stdlib later grows a module whose name an existing installed
package already uses, that specific package's next
korrin pkg installfails loudly with the same collision message, rather than the program's behavior silently changing underneath it. This is a real, if narrow, forward-compatibility cost the stdlib's authors should keep in mind when naming a new built-in module. - A hand-edited
korrin.lockthat smuggles a builtin-colliding name past the install-time gate still resolves to the built-in, never the package — the safe failure direction, and one that needs no extra runtime code, since it's exactly what the existing "built-in table first" dispatch order already does.korrin.lockis a machine-managed, "do not edit by hand" artifact (likeCargo.lock); a runtime re-validation pass guarding against someone deliberately corrupting a generated file was judged not worth adding. - No new grammar, no new error code, no new diagnostic path for the ordinary
case — the entire cost of this decision is one new check inside
korrin pkg add/install.
Alternatives considered
- A prefix for packages (
import "pkg:requests") or for built-ins (import "std:math", reopening ADR 0019). Explicit and unambiguous by construction, at the cost of a new bit of syntax ADR 0019 already argued against for a materially identical-looking case, for a problem an install-time check already solves without it. - Packages checked first, built-ins second. Same no-new-syntax shape, opposite precedence. Rejected: it means a newly installed package could silently shadow an existing program's use of a built-in the moment it's added, which is a worse failure mode than the chosen direction (a collision is caught at the point something changes, not silently absorbed by it).
- A filesystem/table probe with no install-time gate at all — just try
packages, fall back to built-ins (or vice versa), at import time, forever.
Rejected on the same grounds ADR 0019 already used against probing: the
meaning of an
importline would depend on state that can silently change later (a package added, a stdlib module added), rather than being fixed the moment the dependency was added.
33. The korrin-pkg crate and its dependency tier
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0031 and
ADR 0032 settled what packages are and
how import finds them. This ADR is the mechanical question every new
crate in this workspace has answered once already
(ADR 0026 for korrin-lsp): where does the code
that implements this live, what does it depend on, and what dependency tier
does it sit in under ADR 0013?
Three sub-questions, each with a real choice:
- Does
korrin pkg ...live on the existingkorrinbinary, or a new one? - What parses
korrin.toml/korrin.lock, and does that dependency belong in the publishedcrates/korrinlibrary? - How does package-fetching talk to git — a C library, a pure-Rust
reimplementation, or the system
gitbinary?
Decision
A new library crate, crates/korrin-pkg/, with no [[bin]]
Manifest parsing, lockfile handling, git access, and dependency resolution
live in crates/korrin-pkg/, a library korrin-cli depends on — not inside
crates/korrin itself, and not inside korrin-cli directly.
This follows the mechanical pattern docs/internals/architecture.md already
documents from adding korrin-lsp in Milestone 4: a new crates/<name>/,
added to the workspace's members, its own [lints]\nworkspace = true, a
korrin = { path = "../korrin" } dependency, a new tier section in
docs/internals/dependencies.md. No existing crate needs to change, and no
CI job needs to change (--workspace jobs pick it up automatically).
A separate crate rather than folding this into korrin-cli directly: it
keeps manifest/lockfile/git logic unit- and integration-testable against
local fixture git repos without spawning the CLI binary through
assert_cmd; it keeps korrin-cli's own dependency list from growing a TOML
parser and a git-access strategy it would otherwise need directly; and it
leaves room for korrin-lsp to eventually resolve installed packages for
hover or go-to-definition without depending on the whole CLI binary crate.
crates/korrin, the published library, is unaffected — it gains a
set_packages(HashMap<String, PathBuf>) seam (already-resolved paths in,
nothing about manifest or lockfile formats known to it) and no new
dependency at all.
korrin pkg is a subcommand of the existing korrin binary
korrin pkg add/install/remove/update/list are new variants on
korrin-cli's existing Command enum, the same shape as run/tokens/
ast/bytecode. This is different from korrin-lsp, which is a separate
program — a persistent server an editor spawns and speaks a wire protocol
to over stdio, never typed directly by a person (ADR 0026). korrin pkg add ... is something a developer types at a prompt; a second binary would only
fragment the CLI for no benefit.
Manifest/lockfile parsing: toml_edit
toml_edit, not plain toml, specifically for in-place, comment-preserving
edits — korrin pkg add/remove rewrite korrin.toml without disturbing
whatever else is in it, the same reason Cargo itself uses toml_edit for
cargo add/cargo remove rather than the plain toml crate it also
depends on elsewhere.
Git access: shell out to the installed git binary
Package fetching runs git as a subprocess (std::process::Command) rather
than linking a git implementation.
shell out to git | git2 (libgit2) | gix | |
|---|---|---|---|
| New dependency weight | none | a C library behind unsafe FFI bindings, a heavier transitive tree | pure Rust, no C dependency, but large and comparatively immature |
Against unsafe_code = "forbid" | no tension | genuinely awkward — this workspace's own crates never write unsafe, and a dependency whose entire purpose is unsafe FFI to a C library sits against that identity, even though the lint itself only fires on this workspace's own code | no tension |
| New environmental assumption | none — this project's own contributor workflow already assumes a working git on PATH | an entirely separate git implementation alongside the one already on every contributor's machine | same concern as git2 |
| Auth for private repos | inherits the user's already-configured git auth (SSH agent, credential helpers) for free | needs its own auth story, or falls back to delegating to system git anyway | same concern as git2 |
Shelling out costs zero new dependencies, introduces no unsafe anywhere in
this feature's dependency graph, and inherits the user's already-working git
configuration — a real advantage for private package repositories that
either library alternative would otherwise need to reimplement or delegate
back to system git for anyway. The cost is requiring git on PATH, already
true of every contributor to this repository.
Concretely: a tag/branch-pinned dependency uses git clone --depth 1 --branch <ref> <url> <dest> (shallow, fast). An explicit rev (a commit
SHA) first tries a shallow git fetch --depth 1 origin <rev> (works on hosts
that allow fetching an arbitrary SHA — GitHub and GitLab both do, with that
server-side option) and falls back to a full git clone plus git checkout <rev> if the shallow fetch is rejected — a documented, real git-hosting
inconsistency handled explicitly rather than assumed away.
semver is not added
Dependency versions are exact git refs, not ranges (ADR 0031).
Nothing in this design compares or constrains version numbers, so semver
has no load-bearing job. Noted here, and in
docs/internals/dependencies.md, so a later reader doesn't wonder whether it
was simply missed.
Consequences
crates/korrinstays exactly as dependency-lean as ADR 0013 asks — this entire feature adds nothing to itsCargo.toml.korrin-cligains one new dependency (korrin-pkg, path-local) rather than three or four (a TOML parser, a git library, error-handling glue).- Every
korrin pkgoperation that touches the network depends on the user's localgitbehaving the way this design assumes (shallow clones, shallow SHA fetches where supported). A git host with unusual restrictions could need a narrower fallback path added later; the two-step shallow/full fallback already anticipates the most common case. - If Milestone 6 or later ever wants Rust-native package logic embedded
directly into
korrin-lspor another consumer,korrin-pkgis already shaped as a library ready to be reused, not something to extract out of a binary-only crate later.
Alternatives considered
- A separate
korrin-pkgbinary. Rejected — see "Decision" above.korrin-lsp's separateness comes from being a different kind of program (a server, not a CLI a person types); packaging commands have no such property. - Folding manifest/lockfile/git logic directly into
korrin-cli. Simpler on paper, but couples CLI argument parsing to file-format and subprocess logic that has nothing to do with argument parsing, and loses the ability to test that logic without going throughassert_cmd. git2orgixfor git access. See the comparison table above — both are reasonable engineering choices in general; neither earns its cost against a workspace this dependency-conscious when shelling out solves the same problem for free.
34. The community package index: a second static site
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0031 makes packages discoverable only by already knowing their git URL. Something has to let people find out a package exists in the first place — "a second site alongside this one indexing shared libraries, fed by a submission process," per the project's own roadmap. The user locked in, ahead of any design work here: no submission-form backend, no Cloudflare Pages Function, no KV/D1 — a GitHub-PR curated list instead. This ADR designs the concrete shape of that, following ADR 0031's own governing constraint: no new hosted infrastructure.
korrin-web/ (the existing docs/landing site) is the direct precedent for
what "static site on Cloudflare Pages" already looks like in this project —
scripts/deploy-site.sh, wrangler.toml (pages_build_output_dir = "korrin-web"), a hand-written index.html plus style.css. editors/vscode/
is the precedent for "a self-contained, non-Rust tool in its own directory,
with its own build and its own review scope" (ADR 0030).
Decision
A new sibling directory, korrin-packages/, deployed the same way korrin-web/ is
korrin-packages/
index.html # hand-written, vanilla JS — mirrors korrin-web/index.html's style
style.css
packages.json # generated: the merged, machine-readable index
data/packages/
requests.json # one file per submission
json.json
wrangler.toml
CONTRIBUTING.md # what a submission PR needs to contain
A second instance of the exact deploy pattern korrin-web/ already uses (a
static directory, its own wrangler.toml, pushed with wrangler pages deploy) — not a new kind of infrastructure. It is not built with
mdBook: mdBook is the right tool for docs/'s prose, the wrong one for a
searchable, tabular listing with no chapters. The only build step is a small,
dependency-free script that globs data/packages/*.json into packages.json;
index.html fetches and renders that client-side — simpler than
korrin-web/'s own mdbook build phase, not more complex.
One JSON file per package, not one shared array
{
"name": "requests",
"git": "https://github.com/alice/korrin-requests",
"description": "HTTP client for Korrin, built on io.",
"tags": ["http", "network"]
}
data/packages/<name>.json, one file per submission. This keeps a
submission PR's diff to exactly one new file, avoids merge conflicts between
two people submitting packages around the same time, and keeps a reviewer's
job trivial — the same reason curated-list projects (Awesome-lists,
deno.land/x) generally adopt this shape over one shared array everyone
edits.
JSON, not TOML, deliberately: this is data a static page's own client-side
JavaScript consumes, not a Rust-ecosystem manifest. Matching korrin.toml's
format for consistency's sake would only add a conversion step for no
benefit — the data is authored directly as the format the page needs.
Submission is a PR, moderation is review
korrin-packages/CONTRIBUTING.md states the requirement: a submission PR
adds exactly one data/packages/<name>.json file with the fields above; the
package it names must have a korrin.toml at its git root and a working
src/lib.kor. A lightweight CI job in this directory validates that the JSON
parses and the required fields are present — schema validation, not
moderation. Whether a package is a good fit for the index is a human PR
review question, same as any other contribution to this project.
Consequences
- No new infrastructure exists anywhere: no server, no database, no form
endpoint. The entire "backend" is Cloudflare Pages serving static files,
identical to how
korrin-web/already works. - Submitting a package is exactly as much friction as any other open-source contribution — a PR — which is a real barrier compared to a self-serve form, and a deliberate one: it puts a human reviewer between "anyone can type a URL" and "it appears on the index," with no code required to enforce that.
- The index can go stale — a listed package can be deleted, renamed, or abandoned upstream with nothing here noticing automatically, since there is no backend to periodically check anything. A "does this still resolve" CI job is realistic future work; not built now.
- Two entirely separate Cloudflare Pages projects (
korrin-langforkorrin-web/, a new one forkorrin-packages/) means two deploy targets to remember, documented inkorrin-packages/'s ownDEPLOY.md, mirroringkorrin-web/DEPLOY.md.
Alternatives considered
- A submission form with a backend. Explicitly ruled out by the user
ahead of this ADR — would need a Cloudflare Pages Function, a loosened
_headersCSP (the current one blocks all form submissions viaform-action 'none'), and a storage backend (KV or D1) that doesn't exist anywhere in this project today. - One shared
packages.jsonarray, edited in place by every submission PR. Simpler to read as a single file, but every concurrent submission becomes a merge conflict against the same line range, and a reviewer has to diff a bigger file to see what one submission actually changed. - A new page on the existing
korrin-web/docs/mdBook site, rather than a second site. Would need no newwrangler.toml/deploy target, but forces a searchable/filterable listing through a prose-book renderer built for chapters, not tables — the wrong tool, and it conflates "the language's own documentation" with "a community-maintained list of third-party code," which are different trust levels worth keeping visibly separate. - Building this on mdBook anyway, generating one page per package from
the same
data/packages/*.jsonfiles. Would give consistent site chrome withkorrin-web/for free, but the JSON-per-package shape doesn't map cleanly to mdBook's chapter/heading model without real per-page templating workkorrin-web/doesn't already have a pattern for — not worth it for a first cut when a plain fetch-and-render page does the whole job in far less code.
35. Native modules load by compile-time linking, not dynamic loading or WASM
- Status: Accepted
- Date: 2026-09-05
Context
math and io are wired into the module loader by hand today — a closed,
compile-time match in crates/korrin/src/stdlib/mod.rs, editable only by
changing this crate's own source. Milestone 6 opens that up: a third party
should be able to write a Korrin module in Rust and have a Korrin program
import it, without editing crates/korrin itself. Milestone 7 (GameKorr,
a Pygame-style graphics library) is the concrete, already-planned first
consumer — a Window object wrapping a real windowing crate (minifb),
making real OS syscalls.
The question this ADR settles: once a native module exists as compiled Rust
code somewhere, how does it actually get into the running korrin process?
Three real candidates, and constraints already on record in this project that bear directly on the choice:
- The workspace enforces
unsafe_code = "forbid"on every crate (ADR 0013), and there is zerounsafeanywhere in this codebase today. - The one prior time a "need a native capability" problem came up —
korrin-pkgneeding to talk to git — the project explicitly rejected anunsafe-FFI dependency (git2) on identity grounds and shelled out to a subprocess instead (ADR 0033): "a dependency whose entire purpose isunsafeFFI to a C library sits against that identity, even though the lint itself only fires on this workspace's own code." - Milestone 7's own plan names a specific real dependency (
minifb) making real syscalls — windowing cannot be faked or sandboxed away without changing what GameKorr is.
Decision
A native module is a Rust crate. Using one means rebuilding the korrin
binary from source with that crate compiled in. There is no dlopen/
LoadLibrary, no cdylib plugin loading, and no WASM sandbox — the
mechanism is a real cargo build, mechanically detailed in
ADR 0037.
This has real, named precedent outside Rust: nginx's own --add-module
recompilation model for third-party modules is exactly this trade — no
runtime plugin loading, a rebuild instead.
Consequences
- Zero
unsafeanywhere in this feature. The entire mechanism is ordinary Cargo dependency resolution andrustccompilation — nothing here needs an exception tounsafe_code = "forbid", and no new ADR is ever needed to relax it for this milestone. - No Rust ABI-instability problem. A native module and the
korrinruntime it links against are compiled together, in onerustcinvocation, by construction — there is no cross-compiler-version or cross-crate-version ABI to get wrong, which a dynamic-loading design would have needed to solve from scratch (Rust has no stable ABI). - "Installing" a native module costs a rebuild, not a drop-in binary
swap. A project depending on one needs its own project-specific
korrinbinary — the generic one on$PATHcannot run a program that imports it (see ADR 0037, ADR 0038). This is the real, accepted price of this decision, named plainly rather than glossed over. - A project with any native-module dependency now also needs a working
Rust toolchain to
korrin pkg install/build, on top of M5's existing "gitonPATH" assumption.
Alternatives considered
- Dynamic loading (
libloading/dlopen/LoadLibrary, acdylibbuilt separately and loaded at runtime). The conventional sense of "plugin," and what "distributed like any other package" might suggest at first read. Rejected: needs a new ADR carving out anunsafeexception for at least one loader crate — directly reversing the identity ADR 0033 already stated in rejectinggit2for the same reason — and needs a defined, hand-rolledextern "C"ABI boundary to sidestep Rust's own unstable ABI across compiler versions, real engineering weight this milestone does not need to take on for a first cut. - WASM sandboxing (compile native modules to
wasm32-unknown-unknown, run via a pure-Rust runtime). Fully safe, real portability, nounsafeneeded with a suitable runtime (e.g.wasmi). Rejected specifically because it does not fit the already-stated plan for GameKorr: a WASM guest cannot make real OS syscalls, so windowing would have to move into Korrin's own runtime and be exposed to the guest as a curated host import — GameKorr's own choice ofminifbwould then be Korrin's choice, not GameKorr's. That is a materially more restrictive vision than "third parties bring their own native dependencies," and forecloses the actual next milestone this one exists to unblock. - Do nothing until M7 forces the question. Rejected on the same
reasoning every prior milestone plan used: the loading model shapes
nearly everything else in this milestone's design (the object model, the
manifest shape, what
korrin pkgneeds to do) — deciding it first, in its own ADR, is what let ADR 0036-0038 be written without re-litigating it.
36. Value::NativeObject: one opaque object type per module, dispatched through the existing method-lookup path
- Status: Accepted
- Date: 2026-09-05
Context
Korrin has no mechanism today for "a native object with private Rust state
and its own methods." The closest existing thing, BoundNative
(ADR 0016), binds a receiver to a &'static
Rust function for exactly three Value variants — Str, List, Map —
via a compile-time-closed 3-arm match in methods::lookup
(crates/korrin/src/interpreter/methods.rs). Every stdlib function today
(math.sqrt, io.read_file, ...) is a flat, stateless Value::Native —
nothing in the codebase constructs an object carrying opaque Rust-side
state (a file handle, a buffer, an open window) that Korrin methods then
operate on.
Milestone 7's Window needs exactly that: a minifb::Window plus a pixel
buffer, held behind methods like poll_events/draw_rect/present. A
third-party type cannot simply become a new Value enum variant — Value
lives in the published crates/korrin library, and a module author's own
type is defined outside it.
Decision
One new Value variant, NativeObject, carrying opaque state plus a
method table the module itself supplies — not a fully general,
multi-type, inheritance-capable object-type registry.
pub struct NativeObject {
pub type_name: &'static str, // what type() reports — the module's own choice
state: RefCell<Box<dyn Any>>, // opaque to Korrin and to the interpreter alike
pub methods: &'static [NativeMethod],
}
impl NativeObject {
pub fn downcast_ref<T: 'static>(&self) -> Option<Ref<'_, T>>;
pub fn downcast_mut<T: 'static>(&self) -> Option<RefMut<'_, T>>;
}
impl Value {
pub fn native_object(type_name: &'static str, state: impl Any, methods: &'static [NativeMethod]) -> Value;
}
methods::lookup gains exactly one new arm:
Value::NativeObject(obj) => obj.methods,
Everything downstream — BoundNative construction in get_attribute, the
arity check, the call dispatch in both engines — is unchanged. A module's
method table reuses the existing NativeMethod/NativeMethodFn/Arity
types verbatim, built with the same const fn method(...) helper
methods.rs already uses for str/list/map. A NativeMethodFn
downcasts its receiver internally
(recv.as_native_object().unwrap().downcast_mut::<WindowState>().unwrap())
— the same "dispatch already guarantees the variant; a mismatch would be a
bug in lookup, not a Korrin program's fault" invariant recv_str/
recv_list/recv_map already rely on.
type_name(), repr()/Debug, and values_equal each gain the forced new
arm: type_name() returns obj.type_name (like Instance returning its
class name, not a fixed string); repr()/Debug render <native {type_name}>, never the opaque state; equality is Rc::ptr_eq only — the
same identity-only stance already taken for Instance/Class/Function.
Consequences
- Ships the smallest real thing that actually unblocks GameKorr, matching this project's own established discipline (M1's tree-walker before M3's VM; M5's leaf-only packages before a real dependency graph).
- One object type per module, not several. A module wanting two
distinct native object kinds (say, a
Windowand aFont) gets two separateValue::native_object(...)call sites with two separate&'static [NativeMethod]tables — there is no registry keyed by type name, no way for Korrin code to ask "what native types exist," and no inheritance between native object types. This is a real, deliberate limitation, not an oversight — a fully general registry is named and rejected below. downcast_mut/downcast_refreturningNoneon a type mismatch is only ever a bug in the native module itself (its own method table pointing a method at the wrong constructor's state) — never a condition a Korrin program can trigger, sincelookuponly ever returns a method from the table already attached to the receiver. Native code is expected to.expect()this, not propagate it as a catchable Korrin exception.get_attribute,BoundNative, and both engines' call-dispatch code need zero changes — the entire cost of this decision is one newValuevariant, its forced exhaustive-match arms, and one new line inmethods::lookup.
Alternatives considered
- A fully general type registry — multiple native types per module,
real extensibility hooks (a name-to-table
HashMappopulated at runtime, method resolution across a type hierarchy). Closer to a real object system for native types, and more future-proof. Rejected for v1: real upfront interpreter-surface design and risk (how do two modules' types interact? what doestype()report for a registry-dispatched value? does it need its own diagnostics?) that nothing in this milestone — or in GameKorr's own stated shape — actually needs yet. Revisit once a second native module with its own object type exists and the one-type- per-module limit is felt for real, not hypothetically. - Representing native state as an
Instance(Korrin's existing class/object mechanism) with a special "native" class flag. Rejected:Instance's fields are aHashMap<String, Value>— Korrin-level values set byself.x = ...— with no slot for opaque, non-ValueRust data (aminifb::Windowcannot be aValue). RetrofittingInstanceto carry an optional native payload would complicate a type every existing class-related code path already handles, for a case that needs none ofInstance's actual class machinery (inheritance,super, user-definedinit).
37. The native-module rebuild mechanism: a generated project, korrin-cli as a library, and a git-rev-pinned korrin
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0035 settled that native modules load
by compile-time linking. This ADR is the mechanical question that decision
leaves open, with zero precedent anywhere in this codebase to draw on:
given a third-party crate implementing NativeModule
(ADR 0036), how does "a crate implements a
trait" become "the korrin binary a developer actually runs knows about
it"?
There is no dynamic loading, so this has to resolve at Rust compile time —
concretely, a second cargo build, separate from however the generic
korrin binary itself was built, producing a binary specific to one
project's set of native dependencies.
Decision
korrin-cli gains a library target
crates/korrin-cli is bin-only today (Cargo.toml has [[bin]] name = "korrin", doc = false to avoid colliding with the korrin library
crate's own docs, no [lib]). It gains src/lib.rs:
pub fn main_with_native_modules(
native_modules: Vec<(String, Box<dyn korrin::NativeModule>)>,
) -> std::process::ExitCode { /* today's main() body, parameterized */ }
main.rs shrinks to fn main() -> ExitCode { korrin_cli::main_with_native_modules(Vec::new()) }. The generic korrin
binary's behavior is unchanged — an empty vec means the existing three-way
import dispatch never reaches the native-module branch at all.
This was weighed against hand-writing a second, slimmed-down CLI inside the
generated project (below) and rejected: two implementations of "parse
argv, run a file, print diagnostics" that must agree forever is exactly
the failure ADR 0021 already refused to create for
builtins across engines. Reusing korrin-cli as a library means a
project's custom binary supports run --vm, tokens, ast, bytecode,
pkg, and the REPL identically to the generic one, for free.
The generated project
korrin pkg install/add/remove/update, on finding a native = true
dependency, generate (or refresh) a project at .korrin/native-runner/ —
gitignored, @generated by \korrin pkg` — do not edit by hand, the same convention korrin.lock` already established:
# .korrin/native-runner/Cargo.toml
[package]
name = "korrin-native-runner"
publish = false
[[bin]]
name = "korrin"
path = "src/main.rs"
[dependencies]
korrin-cli = { git = "<embedded, see below>", rev = "<embedded, see below>" }
gamekorr = { git = "https://github.com/korrin-lang/gamekorr", tag = "v0.1.0" }
// .korrin/native-runner/src/main.rs
fn main() -> std::process::ExitCode {
korrin_cli::main_with_native_modules(vec![
("gamekorr".to_string(), gamekorr::native_module()),
])
}
One line per native dependency, in manifest order, generated by hand as a
plain Vec literal — this is ADR 0036's
"hardcoded dispatch," just materialized as generated data instead of
hand-typed source, exactly as stdlib::load's own match is hand-typed
data today. linkme/inventory-style compile-time-registration crates
(populating a registry via custom linker sections) were considered and
rejected: neither literally requires a user-written unsafe block, but
both depend on linker/section behavior outside what unsafe_code = "forbid"'s own stated purpose — auditability of this workspace's own
memory-safety surface — was ever meant to police, and neither buys
anything a five-line generated Vec literal doesn't already do more
simply and more legibly for a reviewer.
Pinning which korrin the generated project builds against
The generated project must depend on a korrin/korrin-cli source-
compatible with whatever korrin binary is doing the generating (the same
NativeModule/Value shapes). Two real options:
- A crates.io version pin (
korrin-cli = "0.x"). Clean, but requires publishingkorrin/korrin-clito crates.io on some cadence — every existing cross-crate dependency in this workspace is a path dependency; this project has never needed to publish anything, and no other ADR anywhere commits to doing so. - A git-rev pin, embedded at build time. A
build.rsinkorrin-clirunsgit rev-parse HEAD(when building from a real checkout) and exposes it viaenv!; that rev is what the generatedCargo.tomlpins against.
Decision: the git-rev pin, for exactly the reason above — Korrin is pre-1.0 with no crates.io publication commitment anywhere else in this project. Revisit once/if Korrin ever does publish; the crates.io pin becomes strictly better then (faster resolution, no full git clone of the language itself as a build dependency of every native-module project).
The pinned URL comes from the checkout's own origin remote, not from
Cargo.toml's static repository field. The same build.rs that reads
git rev-parse HEAD also reads git remote get-url origin, falling back to
Cargo.toml's repository field only when a checkout has no origin
remote configured. This matters concretely, not just in principle: a fork's
korrin pkg build must link the generated project against that fork's
commit, not silently pull the upstream korrin-lang/korrin repository at a
revision the fork's own changes were never pushed to, which is exactly what
a hardcoded canonical URL would do. Both values are exposed as
korrin_cli::GIT_URL/GIT_REV, mirroring each other in shape and origin.
What triggers the rebuild
Not every korrin pkg install — a cargo build pulling in korrin-cli's
full dependency tree plus a native crate (e.g. minifb) is genuinely slow,
and the overwhelming majority of installs (plain .kor packages, which
remain the common case) must stay exactly as fast as they are today. The
rebuild — exposed as its own subcommand, korrin pkg build — runs
automatically only when the set of native dependencies actually changed
since korrin.lock last recorded one. A stale pinned-korrin-cli-rev
(the generated project's pin no longer matching the currently-installed
korrin) is surfaced as a plain warning to re-run korrin pkg build
manually, not an automatic rebuild — the same "explicit update step, not
automatic tracking" philosophy ADR 0031
already chose for branch-pinned dependencies going stale.
Manifest and korrin-pkg changes
A native = true boolean on an existing [dependencies] entry, not a
second table:
[dependencies]
gamekorr = { git = "https://github.com/korrin-lang/gamekorr", tag = "v0.1.0", native = true }
Weighed against a Cargo-[build-dependencies]-style separate table and
rejected: native-vs-plain dependencies never need independent ordering or
a different resolution algorithm — they still fetch identically (git/
tag/branch/rev), only what happens after fetching differs. A
boolean is proportionate to the actual distinction. Dependency and
LockedPackage each gain pub native: bool, default false, omitted
(not written as false) for a plain package — every existing korrin.toml
and test stays byte-for-byte unaffected, and no lockfile version bump is
needed.
check_no_builtin_collision needs no change — a native module's local
name lives in the same [dependencies] table a plain package's does, so
the existing stdlib::module_names().contains(&name) check already
covers it, run once per dependency regardless of native. This mirrors
ADR 0032's own finding that
package/built-in disjointness needed no new machinery beyond one existing
check.
validate_fetched_package gains a parallel native path: requires
Cargo.toml at the checkout root (the one thing checkable without
invoking rustc); still applies the leaf-only rule uniformly if the
fetched crate carries its own korrin.toml. It does not attempt to
verify the crate actually exposes pub fn native_module() -> Box<dyn korrin::NativeModule> — Rust has no reflection to check that without
compiling; a mismatch surfaces as an ordinary compile failure from korrin pkg build instead.
Errors
PkgError gains NativeModuleMissingCargoToml, CargoNotFound (a new
environmental assumption this milestone adds on top of M5's "git on
PATH": a working Rust toolchain), and NativeBuildFailed { stderr } —
cargo's own stderr printed verbatim under the existing korrin pkg: ...
prefix, never re-parsed, since that output belongs to cargo/rustc, not
to Korrin. No new Korrin diagnostic (E0xxx) code is needed —
import "gamekorr" through the generic binary (which was never given
this module) fails exactly the way an unresolved package import already
does, E0314, the same category ADR 0031/
0032 already unified.
Consequences
- A native-module dependency is the first thing in this project that
requires a second, real
cargo buildas part ofkorrin pkg's own work — a genuinely new kind of operation, slower and more failure-prone (real compile errors) than anything M5's package system ever needed to handle. - Every project with a native-module dependency effectively vendors a
pinned copy of
korrin-cli's source (via the git-rev pin) inside its own build — real disk/bandwidth cost per project, accepted the same way ADR 0031 already accepted "no shared package cache" as a real cost of a simpler design. korrin-clibeing both a binary and a library is a small, permanent addition to its public surface — itslib.rsAPI (main_with_native_ modules) is now something other crates (specifically, every generated.korrin/native-runner/) depend on and must not break carelessly.- See ADR 0038 for how a project's generated binary actually gets invoked.
Alternatives considered
- A crates.io pin instead of a git-rev pin. See "Pinning" above — not rejected forever, just premature given no crates.io publication commitment exists anywhere else in this project yet.
- Rebuilding on every
korrin pkg install, unconditionally. Simpler, but makes the common case (installing a plain.korpackage) pay a multi-minutecargo buildtax it never needed before. Rejected for cost alone. - Hand-writing a slimmed CLI for the generated project instead of
reusing
korrin-clias a library. See "the generated project" above — rejected to avoid two CLIs that must agree forever.
38. No auto-detect/re-exec: a project's custom binary is invoked explicitly, via a generated wrapper
- Status: Accepted
- Date: 2026-09-05
Context
ADR 0037 settled that a project depending
on a native module gets its own custom-built korrin binary, distinct
from whatever generic korrin is installed on $PATH. That leaves one
question genuinely open: how does a developer actually end up running the
right one?
Two real designs, and this is the one place in Milestone 6's design this project's own plan was genuinely torn on.
Decision
No magic. korrin pkg build prints the custom binary's path on success
and writes a small, gitignored, project-root wrapper script (./korrin
on Unix, ./korrin.cmd on Windows) that forwards argv to it. The
generic korrin run needs no new mechanism for the failure path at
all — it already, today, raises the ordinary E0314 ImportError the
moment a program imports a name only a native-module registry (which the
generic binary was never given) would satisfy.
Consequences
- A developer working on a project with a native-module dependency must
remember to invoke the generated wrapper (or the custom binary directly)
instead of whatever generic
korrinhappens to be on$PATH. This is a real, accepted cost — directly comparable to a C project needing its own custom-built toolchain rather than the system compiler — named plainly here rather than hidden behind implicit re-exec magic. - Running
korrin run game.korwith the wrong (generic) binary fails loudly and immediately with a diagnostic already fully explained by existing behavior (E0314, "no built-in module and no installed package/native module named ..."), not silently or confusingly. - No platform-specific process-spawning code is needed for this milestone
— a real re-exec (spawning a child, forwarding stdio and exit code by
hand, since Windows has no
execve) is deferred until, if ever, it's actually wanted.
Alternatives considered
- Auto-detect and re-exec. The generic
korrinbinary notices a project has a native-module dependency, checks a freshness marker, and transparently spawns the project's custom binary in its place. Rejected: it needs real, fiddly, platform-specific process-spawning code (Windows has noexecve) for a first cut that doesn't need it, and it is exactly the kind of implicit magic this project has already rejected once on identical grounds — ADR 0019 refused a filesystem probe forimportresolution specifically because "an import line's meaning would depend on which files exist." Which binary runs a program is at least as consequential a question as which module an import resolves to, and deserves no less explicitness. A silent delegation to a separately-built, possibly-stale, possibly differently-versioned binary is also a materially worse debugging experience than a loud, immediate failure.
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.
Dependencies
Policy: ADR 0013. Every runtime
dependency of the published korrin crate is listed here with its justification.
crates/korrin (published library)
ariadne — diagnostic rendering
- Does: turns a
Diagnostic(code, message, labelled spans, notes) into an annotated terminal snippet with carets, line numbers, and colour. - Why not std: aligning carets under multi-byte characters, rendering
multi-line spans, and handling tab expansion are genuinely intricate. A
hand-rolled version would be larger than the rest of the
diagnosticsmodule and worse. - Cost to replace: contained. Only
diagnostics::rendertouches it; the publicDiagnostictype does not expose it. Swapping tocodespan-reportingor a custom renderer is a one-file change. (ADR 0008)
thiserror — error type boilerplate
- Does: derives
std::error::Error/Displayfor internal error enums. - Why not std: hand-written
DisplayandErrorimpls for every internal error enum are pure noise and drift out of sync with their variants. - Cost to replace: mechanical.
thiserrorgenerates code you could write by hand; removing it is find-and-replace with manual impls.
stacker — on-demand native stack growth
- Does:
stacker::maybe_growchecks the remaining Rust stack and allocates a fresh segment before it runs out. - Why not std: the tree-walking interpreter recurses in Rust for every nested
Korrin expression, so a moderately recursive Korrin program can exhaust a
fixed-size stack and abort the process.
stackerturns that into graceful growth (and theRECURSION_LIMITbackstop turns truly unbounded recursion into a cleanE0313). - Still needed? Yes, but only for one engine. The bytecode VM runs Korrin
calls in a loop over its own frame stack, so it needs no native-stack growth
and caps
framesinstead.stackerstays as long as the interpreter does, which is for good (ADR 0024). - Why not a big fixed stack thread: Korrin values are
Rc-based and notSend, so the interpreter cannot be moved to a worker thread with a large stack.stackeris the approach rustc itself uses for the same reason. - Cost to replace: contained — one helper (
interpreter::grow_stack) wraps it. Removing it means picking a fixed stack strategy and living with its limits.
unicode-ident — identifier character classification
- Does:
is_xid_start/is_xid_continueper Unicode UAX #31, the standard for programming-language identifiers. - Why not std:
char::is_alphanumericis not the right set and would let Korrin's notion of "identifier" drift from the Unicode standard the spec cites. - Cost to replace: low, but only downward — restricting identifiers to ASCII would remove it at the cost of a spec change.
crates/korrin-cli (binary only — not published, not depended upon)
Per policy, the CLI may use mature crates for CLI concerns.
clap— argument parsing and--helpgeneration forkorrin run/ the REPL. Reimplementing arg parsing well is not a good use of effort.rustyline— line editing, history, and multi-line input for the REPL. A usable REPL needs readline-style editing;rustylineis the standard pure-Rust choice.korrin-pkg(path dependency, workspace-internal) — everything behindkorrin pkg ...and the manifest/lockfile discoverykorrin rundoes before setting up either engine. Kept as a separate crate specifically sokorrin-cli's ownCargo.tomlnever needs a TOML parser or a git-access strategy directly (ADR 0033).
crates/korrin-pkg (manifest/lockfile/git — not published, not depended upon by korrin)
Per policy, and per ADR 0033,
korrin-pkg sits in the same freer tier as korrin-cli/korrin-lsp: not
published, a tool crate, free to use mature crates for its genuine concern
(reading/writing korrin.toml/korrin.lock, fetching git repositories).
toml_edit— parses and, critically, edits in placekorrin.toml(korrin pkg add/removemust not disturb formatting or comments elsewhere in the file) and serializeskorrin.lock. Chosen over the plainertomlcrate for exactly that round-trip-preserving editing, the same reason Cargo itself usestoml_editforcargo add/cargo removerather than its owntomldependency.thiserror— same justification as incrates/korrin: derivesDisplay/ErrorforPkgErrorinstead of hand-written boilerplate.- Git access has no dependency at all. Every git operation
(
clone/fetch/checkout/rev-parse) runs the installedgitbinary as a subprocess (std::process::Command), not a linked library. Considered and rejected:git2(a C library behindunsafeFFI, at odds with a workspace whose own crates never writeunsafe) andgix(pure Rust, but a much larger and less mature dependency than shelling out). Shelling out also inherits whatever git auth (SSH agent, credential helpers) is already configured on the machine, which either library would otherwise need to reimplement or delegate back to system git for anyway. See ADR 0033 for the full comparison. semverwas considered and not added. Dependency versions are exact git refs (a tag, a branch's current tip, or a commit), never a version range — nothing here compares or constrains version numbers, so a semver crate would have no load-bearing job (ADR 0031).- Building a native module's generated project has no dependency at
all either.
korrin pkg build(Milestone 6) runs the installedcargobinary as a subprocess, exactly the same shelling-out stance already taken forgitabove, for the same reasons: no build-orchestration library to vet, and it inherits whatever toolchain (stable/nightly, target) is already configured on the machine. A project with anative = truedependency gains a new baseline assumption on top of "gitis on PATH": a working Rust toolchain is too — surfaced asPkgError::CargoNotFoundif it isn't. A plain, all-.korproject needs neithercargonor a Rust toolchain to install its packages, only to buildkorrinitself once.
crates/korrin-lsp (language server — not published, not depended upon)
Per policy, and per ADR 0026, korrin-lsp
sits in the same freer tier as korrin-cli: not published, a tool crate,
free to use mature crates for its genuine concern (speaking the Language
Server Protocol).
lsp-server— the JSON-RPC-over-stdio connection and message types (Connection,Message,Request/Notification/Response). Synchronous, no async runtime — this is the reason it was chosen overtower-lsp, see ADR 0026. What rust-analyzer itself is built on.lsp-types— the protocol's own vocabulary as serde types (Position,Diagnostic,Hover,Location, capability structs). Needed regardless of transport; reimplementing the LSP's JSON schema by hand would be pure busywork with no payoff.serde/serde_json—lsp-serverandlsp-typesboth build on these;korrin-lspuses them directly to (de)serialize request/response payloads at the edges of the dispatch loop.
Dev-dependencies (do not ship)
insta— snapshot testing for token streams and AST dumps.proptest— property tests (lexer/parser never panic; round-trips).assert_cmd/predicates— black-box tests of thekorrinbinary.
How to add a builtin or a method
Korrin's core vocabulary is a set of functions in the global scope (print,
range, str, …) plus methods on the built-in types str / list / map
(xs.push(v), text.trim()). The rule for which to add
(ADR 0016): a function constructs or
converts a value or does I/O; a method operates on its receiver.
Adding a function
- Implement it in
crates/korrin/src/builtins.rsagainst theNativeFnPtrsignature,fn(&mut dyn Host, &[Value], Span) -> Exec<Value>. TheHostis whichever engine is running: take output, arguments, input, and re-entrant calls from it, and expect nothing else of it. - Add a row to the
BUILTINStable (name,Arity, function). - Document it as a
###heading in../spec/08-builtins.md§8.1 — the coverage test (crates/korrin/tests/builtins.rs) fails if you skip this, and fails again if the name and the table disagree. - Add or extend a golden test under
crates/korrin/tests/programs/. - Add a
CHANGELOG.mdentry.
Adding a method
- Implement it in
crates/korrin/src/interpreter/methods.rsagainst theNativeMethodFnsignature — the receiver arrives as&Value, separate from the argument slice. - Add a
method(...)row toSTR_METHODS,LIST_METHODS, orMAP_METHODS. TheAritycounts arguments after the receiver. - Document it in a table row in
../spec/08-builtins.md§8.2–8.4 as`type.name(args)`. The coverage test matches that form against the method tables both ways. - Add or extend a golden test and an interpreter unit test.
- Add a
CHANGELOG.mdentry.
Adding to a standard-library module
A member of a built-in module (math, later io) — see
ADR 0019.
- Add the function to the module's file in
crates/korrin/src/stdlib/, against theNativeFnPtrsignature, and list it in that module's table (and itsnames()). - Document it in a table row in
../spec/11-modules.mdas`math.name(args)`.crates/korrin/tests/builtins.rschecks the module'snames()against that section, both ways. - Add an interpreter unit test and, where it fits, a line in
tests/programs/module_math.kor. - Add a
CHANGELOG.mdentry.
Adding a whole standard-library module
- New file
crates/korrin/src/stdlib/<name>.rswith apub(crate) fn module() -> Environmentand anames(). - Register it in
stdlib::loadandstdlib::module_names. - New section in
docs/spec/11-modules.md; a guide mention; an ADR if the module makes a design choice (e.g.ioand sandboxing). - A
tests/programs/golden or atests/modules.rscase.
How to add a keyword
Adding a keyword is a language change and therefore needs an ADR first (ADR 0006 is the current keyword set, amended by ADR 0017 for the exception keywords). Once the decision is made, the mechanical steps are:
- Add the variant to
lexer::token::Keywordand its entries in bothfrom_identandas_str. Update the "N reserved words" counts in the module docs. - Handle it in the parser: a dispatch arm in
parse_statement(or the expression parser), aparse_*method, and — if it starts a statement — an entry insynchronize's recovery set. - Add an
ExprKind/StmtKindvariant inast.rsand adump_*arm. - Visit the new node in
resolver.rsand execute it ininterpreter::exec_stmt/eval. If it adds aSignalvariant, the compiler will point out every match that must handle it. - Update the grammar and prose in
docs/spec/02-grammar.md, the relevant otherdocs/spec/section, the guide, and the keyword list indocs/spec/01-lexical-structure.mdanddocs/guide/README.md. - Amend ADR 0006 (or supersede it), and write the feature's own ADR.
- Tests: lexer test, parser tests (success + the malformed-syntax error),
resolver test, interpreter tests, a golden program under
crates/korrin/tests/programs/, thekorrin_ish()property generator, and aCHANGELOG.mdentry.