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.
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. - 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.
- 19 keywords:
and break class continue elif else false fn for if in nil not or pass return super true 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. /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.
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
Two subcommands show the compiler's intermediate view of a program:
korrin tokens hello.kor # the token stream
korrin ast hello.kor # the parsed syntax tree
2. Values
Korrin has ten kinds of value. This chapter covers the everyday ones.
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 str(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. 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(str(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(str(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 1.
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 |
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 19 identifiers are reserved and may not be used as names (ADR 0006):
and break class continue elif
else false fn for if
in nil not or pass
return super true 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 in
Milestone 1.
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 Milestone 1 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 19 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 ;
simple_statement =
( assignment | return_statement | "break" | "continue" | "pass" | expression ) ,
NEWLINE ;
assignment = assignable , "=" , expression ;
assignable = NAME | attribute | index ;
return_statement = "return" , [ expression ] ;
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 ) ;
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 ten 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. |
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> |
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)). An absent attribute 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 = ...) in Milestone 1. 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. Iterables in
Milestone 1: 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.
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 in Milestone 1. 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. Milestone 1 has single
inheritance only. 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 has no import for its core vocabulary: 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 ..
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).
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", 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 error stops execution immediately at the offending expression. Output written before that point 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:. |
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 | (reserved for the future error-handling feature) |
E0313 | Function-call nesting exceeded the interpreter's recursion limit. |
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.
10.1 Program start
A program is a list of top-level statements. They execute in order, top to
bottom, in the 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).
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 environments
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 whose parent is
the scope where the function was defined (not where it was called) — this is
what makes closures work.
Name resolution — for both reads and the target of = — searches the current
scope, then each enclosing scope, out to global. = updates the nearest binding
found, or creates one in the current scope if there is none.
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. The interpreter grows its working stack on
demand, but a call depth beyond its limit (currently 10 000) is reported as
E0313 rather than aborting the process. This turns an infinite
recursion into a diagnostic.
10.6 Termination
A program ends when its last top-level statement finishes, or at the point a runtime error is raised. There is no explicit exit function in Milestone 1.
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 |
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. - Module / import system. Milestone 2.
- Error handling (
try/raise). Milestone 2.
Resolved: string interpolation (0015); methods on
built-in types (0016, amending
0014); string indexing is s[i]
for one character and s.slice(a, b) for a range.
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.
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 Milestone 1 keyword set
- Status: Accepted
- Date: 2026-09-02
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).
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.
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 pipeline
source: &str
│
▼
┌──────────┐ Vec<Token> ┌──────────┐ ast::Module ┌──────────┐
│ lexer │ ──────────────────▶ │ parser │ ────────────────▶ │ resolver │
└──────────┘ + INDENT/DEDENT └──────────┘ (tree) └──────────┘
│
ResolvedModule (tree + scope info)
│
▼
┌─────────────┐
│ interpreter │ ──▶ Output
└─────────────┘
Every stage takes the previous stage's output and either produces the next
artifact or a Vec<Diagnostic>. Nothing prints; the caller
(korrin::run or the CLI) decides what to do with output and errors.
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 (return/break/self placement, duplicate params, self-inheritance) and name resolution to scope depth. | 0005, 0011 |
interpreter | Tree walk. Value, Environment, control-flow Signal enum, arithmetic. | 0004, 0009, 0010, 0011 |
builtins | The built-in function and method table. | — |
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. - Control flow is a value too.
return/break/continueare aninterpreter::Signalenum threaded throughResult, not Rustpanic/catchand not exceptions. This keeps the interpreter honest and maps to a future VM. (ADR 0004) - Spans everywhere. If you add an AST node, it carries a
Span. If you raise a diagnostic, it has a primary label. - 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.
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). - 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.
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 Interpreter, &[Value], Span) -> Exec<Value>. - 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.
How to add a keyword
Filled in alongside the lexer and parser (Milestone 1, stages 3 and 5). Placeholder so cross-references resolve.
Adding a keyword is a language change and therefore needs an ADR first (ADR 0006 is the current keyword set). Once the decision is made, the mechanical steps will be:
- Add the variant to
lexer::token::Keywordand its entries infrom_identandas_str. - Handle it in the parser.
- Update the grammar and prose in
docs/spec/02-grammar.mdand the relevant otherdocs/spec/section. - Update ADR 0006 (or supersede it).
- Tests: lexer snapshot, parser snapshot, a golden program,
CHANGELOG.md.