Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Korrin documentation

Four kinds of documentation, each with a distinct job:

DirectoryQuestion it answersAudience
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/, then spec/ as a reference.
  • Hacking on the interpreter: internals/architecture.md, then the rustdoc, with decisions/ 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.

  1. Getting started — install, run a file, use the REPL.
  2. Values — numbers, strings, booleans, nil, lists, maps.
  3. Control flow and functionsif, loops, fn, closures.
  4. Classes — objects, methods, inheritance, super.
  5. 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. No let.
  • Every string interpolates: "hi {name}"; {{ is a literal brace.
  • Operations on str / list / map are methods: xs.push(v), text.trim(), m.keys(), x.len(). print, str, int, range, … stay functions.
  • Only nil and false are 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 false0, "", 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.
  • init is the constructor. You call the class itself (Dog("Rex"), no new) and Korrin runs init for 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
01Lexical structure
02Grammar
03Values and types
04Expressions
05Statements
06Functions
07Classes
08Built-in functions and methods
09Errors and diagnostics
10Execution model

Conventions

  • Grammar fragments use the notation defined in section 02, which also gives the complete grammar.

  • A fenced code block tagged korrin is 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: E0304 line 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:

TokenMeaning
NEWLINEThe end of a logical line that contained at least one token.
INDENTThe following logical line opens a more-indented block.
DEDENTThe end of an indented block. One is emitted per level closed.
EOFEnd 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:

  1. any bracket — (, [, or { — opened earlier is still unclosed; or
  2. 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: push w; emit one INDENT.
  • w < t: pop while the top is greater than w, emitting one DEDENT per pop. If the resulting top is not equal to w, 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: e or E, 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:

EscapeCharacter
\nline feed
\ttab
\rcarriage 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 "{}" is E0103. A { not closed before the end of the line is E0002.
  • 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:

FormMeaning
A = B ;Rule: A is defined as B.
"x"A terminal: the literal source text x.
UPPERCASEA token produced by the lexer (§1), e.g. NAME, INT, NEWLINE.
A , BA followed by B.
A | BEither 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 expression rule 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 < c is a syntax error.
  • Assignment targets. The grammar allows assignable = NAME | attribute | index, but the parser actually parses a full expression on the left of = and then checks that it is one of those three forms, so that a mistake like a + b = 1 gets 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

TypeLiteral(s)Notes
nilnilThe single "no value". A function with no return yields it.
booltrue, false
int0, 42Signed 64-bit (ADR 0010).
float3.14, 1e9IEEE-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.
functionfn definitions; builtinsFirst-class; type() reports "function".
classclass definitionsCallable — calling it constructs an instance.
instanceClassName(...)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:

TypeForm
nilnil
booltrue / false
intdecimal digits
floatdecimal, always with a fractional part (3.0, not 3); inf, -inf, nan
strthe 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:

PrecedenceOperatorsFormAssociativity
1ora or bleft
2anda and bleft
3notnot aprefix
4== != < <= > >=a == bnon-associative
5+ -a + bleft
6* / %a * bleft
7--aprefix
8f(...) a.b a[i]postfixleft

"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 b evaluates a; if a is falsy it is the result, otherwise b is evaluated and is the result.
  • a or b evaluates a; if a is truthy it is the result, otherwise b.
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:

  • on an instance, a field or a method (§7);
  • on a str, list, or map, a built-in method (§8).

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): if name already 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 boolfalse 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).

MethodResult
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.

MethodResult
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

MethodResult
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

CodeMeaning
E0001A character that cannot begin any token (includes a lone !).
E0002A string literal with no closing " before the end of the line.
E0003A \ escape that Korrin does not define.
E0004A malformed number literal (1.2.3, 10abc).
E0005A number literal outside the range of its type.
E0006A tab character in a line's leading indentation.
E0007A dedent to a column that matches no enclosing block.
E0008An opening (, [, or { with no matching close.

Syntax — E01xx

CodeMeaning
E0101A token where the grammar does not allow it (includes a chained comparison).
E0102A required token (:, ), in, a name, end of line, …) was missing.
E0103An expression was required and none was found.
E0104The left side of = is not a name, attribute, or index.
E0105(reserved) An indented block with no statements.
E0106A for header not of the form for NAME in EXPR:.

Static — E02xx

CodeMeaning
E0201return outside any function.
E0202break outside any loop.
E0203continue outside any loop.
E0204(reserved — self is an ordinary name)
E0205super outside any method.
E0206super in a class with no parent.
E0207Two parameters of one function share a name.
E0208A class names itself as its own parent.

Runtime — E03xx

CodeMeaning
E0301Read of a name with no binding in scope.
E0302Call of a value that is not a function or class.
E0303A call with the wrong number of arguments.
E0304An operator, builtin, or method got a value of the wrong type.
E0305Integer or float division/modulo by zero.
E0306An integer operation overflowed the 64-bit range.
E0307A list or string index outside its bounds (includes list.pop / list.remove_at / list.insert out of range).
E0308Indexing a value that does not support it.
E0309Access of an attribute a value does not have (an unknown instance field/method, or an unknown method on str / list / map).
E0310A map[key] lookup for an absent key.
E0311A value that is not hashable used as a map key.
E0312(reserved for the future error-handling feature)
E0313Function-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 / or evaluate 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

#TitleStatus
0000Architecture Decision Records: the processAccepted
0001Indentation defines blocksAccepted
0002Documentation is enforced by CIAccepted
0003Newlines terminate statementsAccepted
0004A tree-walking interpreter firstAccepted
0005Methods take an explicit self parameterAccepted
0006The Milestone 1 keyword setAccepted
0007INDENT / DEDENT tokenization, spaces onlyAccepted
0008Byte-offset spans, one diagnostic type, ariadneAccepted
0009Only nil and false are falsyAccepted
0010Numeric model: int/float split, / true division, checked overflowAccepted
0011Bare assignment, lexical block scope, nearest-binding ruleAccepted
0012Cargo workspace: library plus thin CLIAccepted
0013Dependency policyAccepted
0014Built-in collection operations are free functions (M1)Accepted — amended by 0016
0015String interpolation — every string, {expr}Accepted
0016Operations 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 if int(a / b) proves insufficient in practice.
  • Arbitrary-precision integers. Whether int should stop being i64.
  • 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:

  1. One decision per file. If a discussion produces two decisions, write two ADRs.
  2. Numbers are permanent and never reused. 0007 is 0007 forever.
  3. ADRs are immutable once Accepted, except to change their Status line 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 to Superseded by NNNN.
  4. Status is one of: Proposed, Accepted, Superseded by NNNN, Deprecated.
  5. 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).
  6. 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 pass keyword 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".
  • end keyword (Ruby/Lua style). Still redundant with indentation, adds vertical noise, and creates its own "stack of ends" 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:

  1. API documentation — every public item has a doc comment.
  2. The language specification — its described behaviour matches the implementation.
  3. Design rationale — every meaningful decision has an ADR.

Decision

CI enforces all three:

  1. API docs. Every crate sets #![warn(missing_docs)] and CI builds with RUSTFLAGS=-Dwarnings and runs cargo 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.
  2. 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 in docs/spec/08-builtins.md.
  3. 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 suppresses NEWLINE while 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 = 2 on 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 resolver pass between parsing and execution — in Milestone 1 it validates (placement of return / 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 / Interpreter API, 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/super only 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 self as 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 this keyword. Saves typing, but introduces an invisibly-injected binding and the "unqualified name: is it a field or a local?" ambiguity that causes real bugs.
  • @field sigil for fields (Ruby, CoffeeScript). New punctuation for something self. already expresses.
  • new keyword 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:

GroupKeywords
Functionsfn return
Conditionalsif elif else
Loopswhile for in break continue
Boolean logicand or not
Literalstrue false nil
Classesclass super
Empty blockpass

Rationale for the debatable members:

  • fn over def/func/function — shortest unambiguous choice.
  • elif over else if — one token, no nesting ambiguity, matches the single-keyword-per-concept style.
  • and/or/not as words, not &&/||/! — they read as English, they cannot be confused with future bitwise operators, and Korrin has no C heritage to honour.
  • self is 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 find self" 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.
  • super is a keyword, because super.method is special syntax: there is no value to bind a plain identifier to.
  • pass exists 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/raise land 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 not participates 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.
  • def for functions. Fine, but fn is 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 DEDENT per pop. If no stack entry matches the new level exactly ⇒ E0007 inconsistent dedent.
  • Equal ⇒ emit nothing.
  • NEWLINE is emitted at the end of each non-blank logical line.
  • At end of file, emit a final NEWLINE then one DEDENT per 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 NEWLINE is emitted (see 0003).

Consequences

  • The parser sees INDENT/DEDENT as ordinary tokens and its block rule is INDENT 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:

  1. How is a source position represented on tokens and AST nodes?
  2. How are errors from different stages (lexer, parser, resolver, runtime) represented?
  3. How are they rendered to a terminal?

Decision

  1. 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. u32 keeps a Span at 8 bytes and caps source size at 4 GiB.
  2. One [Diagnostic] type for every stage. It carries a stable [ErrorCode] (E00xx lexer … E03xx runtime), a message, ordered labelled spans (first = primary), notes, and an optional help line. Stages differ only in which codes they raise.
  3. Rendering uses the ariadne crate, configured for byte indexing. Korrin owns the Diagnostic type; ariadne is an implementation detail of the render function 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 code and span, not on rendered text, so message wording can be improved without breaking tests.
  • ariadne counts in characters by default; Korrin passes byte offsets and sets IndexType::Byte, so the two never disagree even with non-ASCII source.
  • If ariadne is ever swapped out, only diagnostics::render changes.
  • 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 to ariadne; 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 false are falsy; everything else, including 0 and "", is truthy.
  • Extended (Python, JavaScript, C): 0, 0.0, "", [], {}, and nil are 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: where x might be 0 does what a reader expects (0 is 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/or returning operands enables name or "default", which is idiomatic and does not misfire on 0/"" 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. int is a signed 64-bit integer. float is an IEEE-754 double. A literal with no . and no exponent is an int; otherwise a float.
  • Arithmetic promotes. int op int stays int (except /); if either operand is float, the other is converted to float and the result is float.
  • / always produces a float. 7 / 2 is 3.5. 10 / 2 is 5.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. All int arithmetic uses checked operations internally.
  • % is the remainder with the sign of the dividend; x % 0 is E0305.
  • Equality across types: 1 == 1.0 is true (compared by mathematical value). Ordering likewise.

Consequences

  • int and float are visibly different (type(3) vs type(3.0), str(3) = "3" vs str(3.0) = "3.0"), which matches user expectation for "is this a whole number".
  • / never surprises a beginner with 1/2 == 0. The cost is that a / b on two ints you wanted floored needs int(a / b) (or a future //).
  • Overflow-as-error trades raw speed and C-like wraparound for predictability. A program that overflows i64 is 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). The 1/2 == 0 footgun; 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 int by default. No overflow at all, but a performance and implementation cost that M1 does not need; leaves i64 semantics 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 = value is 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 / for blocks 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 = value first looks for an existing binding of x, 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 x uses the same nearest-binding search; an unresolved read is E0301.
  • A for loop 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 by print(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 for variable 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 resolver pass 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 the UnboundLocalError surprise is a classic beginner trap.
  • let for 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 / for bodies. 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 the run / Interpreter embedding API. No dependency on any CLI or terminal crate. This is what gets published to crates.io.
  • crates/korrin-cli — the korrin binary. Depends on korrin plus clap and rustyline. 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.lock is 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-cli may 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 now x.len(). print / type / str / int / float / bool / range / input remain 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:

FunctionEffect
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) or x.len()?" question.
  • No method chaining on collections (sort(filter(xs)), not xs.filter(...).sort()). Accepted for M1.
  • append mutating its first argument and returning nil is slightly unusual; the alternative (returning the list) invites xs = 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's format!, .NET's older String.Format, many template languages).
  • A dedicated functionformat("total: {}", count) (Rust format!, 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() / print would 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 runStrStart, alternating StrText and StrExprStart … StrExprEnd bracketed hole tokens, then StrEnd — rather than one Str token. The hole tokens are ordinary tokens with real source spans, so the parser reuses parse_expression unchanged and a diagnostic inside a hole points at the right byte in the file. Plain strings (no hole) still emit one Str token 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 written split(trim(" a, b "), ",") — inside-out, and the reader parses it backwards.
  • A crowded global scope. append, pop, keys, values, has, get, remove are 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. Having list operations be append(xs, v) while a user's own collection type uses xs.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: appendlist.push, poplist.pop, keys / values / has / get / remove → the same-named map methods. 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, input stay free functions. They construct a value, convert between types, or do I/O — they are not operations on a receiver. type(x) and str(x) in particular must work uniformly on every value, including nil and int, 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 list methods (push, insert, remove_at, reverse, sort) change the receiver in place and return nil; pop and remove_at return the element removed. This matches how a mutable, shared list already behaves and avoids the xs = xs.push(v) misreading 0014 warned about.
  • Implementation: a NativeMethod table per type in crates/korrin/src/interpreter/methods.rs, a Value::BoundNative for 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 existing attribute and call productions.
  • The method set for M2 is specified in §8.2–8.4.
  • crates/korrin/tests/builtins.rs now 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-type Value machinery beyond BoundNative.
  • len is now x.len() everywhere, including inside interpolation ("{items.len()} left"). Slightly more to type than len(items); consistent with every other operation, and len was the only builtin that read as an operation on its argument.
  • Future built-in types (a set, a bytes) 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 — len reads well and Python does this. Rejected for consistency: len is an operation on its argument (unlike type or str), 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 for str. str gains 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

ModuleResponsibilityKey ADRs
spanSpan (byte offsets) and Spanned<T>.0008
diagnosticsDiagnostic, ErrorCode, SourceMap, rendering.0008
lexerBytes → tokens; indentation state machine; bracket-depth tracking for newline suppression.0001, 0003, 0006, 0007
astNode types for expressions and statements. Spans on every node.
parserRecursive-descent statements + Pratt-parsed expressions; error recovery.0003, 0006
resolverStatic checks (return/break/self placement, duplicate params, self-inheritance) and name resolution to scope depth.0005, 0011
interpreterTree walk. Value, Environment, control-flow Signal enum, arithmetic.0004, 0009, 0010, 0011
builtinsThe built-in function and method table.

Cross-cutting conventions

  • Errors are values. A stage returns Result<T, Vec<Diagnostic>> (or accumulates into a sink). No panic! on user input, ever — panics are for internal invariant violations only.
  • Control flow is a value too. return / break / continue are an interpreter::Signal enum threaded through Result, not Rust panic/catch and 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, no std::process::exit. That is korrin-cli's job. (ADR 0012)

How to…

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 diagnostics module and worse.
  • Cost to replace: contained. Only diagnostics::render touches it; the public Diagnostic type does not expose it. Swapping to codespan-reporting or a custom renderer is a one-file change. (ADR 0008)

thiserror — error type boilerplate

  • Does: derives std::error::Error / Display for internal error enums.
  • Why not std: hand-written Display and Error impls for every internal error enum are pure noise and drift out of sync with their variants.
  • Cost to replace: mechanical. thiserror generates code you could write by hand; removing it is find-and-replace with manual impls.

stacker — on-demand native stack growth

  • Does: stacker::maybe_grow checks 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. stacker turns that into graceful growth (and the RECURSION_LIMIT backstop turns truly unbounded recursion into a clean E0313).
  • Why not a big fixed stack thread: Korrin values are Rc-based and not Send, so the interpreter cannot be moved to a worker thread with a large stack. stacker is 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_continue per Unicode UAX #31, the standard for programming-language identifiers.
  • Why not std: char::is_alphanumeric is 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 --help generation for korrin 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; rustyline is 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 the korrin binary.

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

  1. Implement it in crates/korrin/src/builtins.rs against the NativeFnPtr signature — fn(&mut Interpreter, &[Value], Span) -> Exec<Value>.
  2. Add a row to the BUILTINS table (name, Arity, function).
  3. 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.
  4. Add or extend a golden test under crates/korrin/tests/programs/.
  5. Add a CHANGELOG.md entry.

Adding a method

  1. Implement it in crates/korrin/src/interpreter/methods.rs against the NativeMethodFn signature — the receiver arrives as &Value, separate from the argument slice.
  2. Add a method(...) row to STR_METHODS, LIST_METHODS, or MAP_METHODS. The Arity counts arguments after the receiver.
  3. 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.
  4. Add or extend a golden test and an interpreter unit test.
  5. Add a CHANGELOG.md entry.

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:

  1. Add the variant to lexer::token::Keyword and its entries in from_ident and as_str.
  2. Handle it in the parser.
  3. Update the grammar and prose in docs/spec/02-grammar.md and the relevant other docs/spec/ section.
  4. Update ADR 0006 (or supersede it).
  5. Tests: lexer snapshot, parser snapshot, a golden program, CHANGELOG.md.