Learn Landin in Y minutes
Ada, but small. Zig, but sweeter. One systems language from 32KB to 32TB. Move fast, keep the pointers, and let the compiler tell you when you are being an idiot.
Named after Peter Landin, who coined the term "syntactic sugar" and wrote "The Next 700 Programming Languages". This one is the 701st.
Version 0.1.0 — the first version of the specification proper. It was arrived at over seventeen pre-release revisions, four prototype programs and two outside reviews. That history is kept in a separate archive; the part of it with lasting value is at the end of this file, under WHAT WAS TRIED AND DROPPED.
Numbering: [NNNN] is stable, which is the point of it — an insert never renumbers anything, so the order things are read in and the order of the numbers need not agree. They agree today, at 0.1.0, and they will drift, and that is the numbering working rather than failing. Gaps of ten leave room. Sections carry titles and no numbers, because nothing cites a section. Refer to decisions by number.
This file explains the language and does not decide it. spec.md is the normative document: it holds the grammar of the kernel the compiler enables today, the rules this file left unsaid, and the register of decisions taken while implementing them. Where the two could be read differently, spec.md decides. Everything still open, every implementation dependency and every disposition is owned by ROADMAP.md, not by a second list here.
COMMENTS
These three are shown by being written, so each is kept exactly as it appears in a program: the marker is the comment.
DECLARATIONS
Full form: name, type, value
Full form: name, type, value. Immutable by default.
greeting: utf8 = "hello"
Two questions about a reference
Two questions about a reference, and they are independent: may the name be pointed somewhere else, and may the thing it points at be written? mut on the binding answers the first. The type answers the second, with mut inside it, as the POINTERS section shows — and nothing else answers either.
mut cursor: ptr u32 = addr first -- re-pointable, reads knob: ptr mut u32 = addr setting -- fixed, writes gpioa: volatile ptr mut port = ptr(0x4002_0000) -- fixed, writes
Exported from the module
Exported from the module. Without it, module-internal.
public version: u32 = 3
Several names may share one declaration
Several names may share one declaration, which is the same form field lists already use.
public red, green, blue: u8 north, south, east, west: atom
Left of ':' is always the name being introduced
Left of ':' is always the name being introduced. Right of ':' is what fills it: a type, or (with '=') a value.
Types are declared like any other value, with 'type'
Types are declared like any other value, with 'type'.
point: type = struct x: f32 y: f32 end point
Order inside a module does not matter
Order inside a module does not matter. Forward references are fine; the compiler collects names before resolving them.
NUMBERS AND LITERALS
Integers: u8 u16 u32 u64 u128, i8 i16 i32 i64 i128
Integers: u8 u16 u32 u64 u128, i8 i16 i32 i64 i128. Any other width exists as well — u4, u12, u23 — for the packed fields of [0730], where the datasheet decides how many bits a thing gets. Outside a packed struct one occupies the next machine width, and a one-bit field is spelt bool.
Integer literals are untyped
Integer literals are untyped. They take the type of their context, and are checked at that point.
tiny: u8 = 5 large: u64 = 5 -- bad: u8 = 300 is a compile error
With no context, an integer literal defaults to i32
With no context, an integer literal defaults to i32.
plain := 5
Float literals are always recognisable as such
Float literals are always recognisable as such. There is no silent slide between the two classes.
ratio: f32 = 5.0 -- wrong: f32 = 5 is a compile error half := 1.0 / 2.0 -- 0.5 zero := 1 / 2 -- 0, integer division
Bases, separators, exponents
Bases, separators, exponents.
hex_value := 0xDEAD_BEEF bin_value := 0b1010_0101 oct_value := 0o755 big_value := 1_000_000 sci_value := 1.5e10 neg_exp := 1.0e-6
Hex float literals express every representable value exactly
Hex float literals express every representable value exactly, including subnormals.
pi_exact: f64 = 0x1.921fb54442d18p+1 smallest: f32 = 0x1.0p-126
IEEE special values
IEEE special values. The sign of a literal is preserved by constant folding. The sign of a NaN produced by arithmetic is not specified.
neg_zero: f64 = -0.0 pos_inf: f64 = f64.infinity neg_inf: f64 = -f64.infinity quiet_nan: f64 = f64.nan neg_nan: f64 = -f64.nan
Character literal is a codepoint, typed u32
Character literal is a codepoint, typed u32.
letter_a := 'a'
Text literals are untyped too
Text literals are untyped too. They take utf8, []u8, utf16 or cstring from context; with no context, utf8. Every literal carries a trailing NUL that the length does not count, so passing one to C costs nothing.
name: utf8 = "hello" cname: cstring = "hello"
A literal lives in read-only storage, so a reference to one cannot be written through by [0070] however the binding is declared. Writable text is a copy into storage you asked for.
-- greeting: []u8 = "abc" -- greeting[0] = 0x78 -- error, the literal is static
The escape set is closed and small
The escape set is closed and small: \n \r \t \e newline, return, tab, escape \\ \" \' the characters themselves \xNN one byte, exactly two hex digits \u{...} one codepoint, any number of digits There is no octal form and no \0; write \x00. An unknown escape is a compile error, never silently the character. \xNN is only valid where bytes are meant, \u{...} only where text is meant, so a literal can never be invalid UTF-8.
line := "col\tsep\n" smile := "\u{1F600}" byte := "\xFF" -- []u8 context only
Raw literals: N quotes on each side, N at least three
Raw literals: N quotes on each side, N at least three. If the content contains three, use four. Nothing is escaped. The indentation of the closing delimiter is stripped from every line, so a block can sit indented in the code and still start at the left margin.
help := """ usage: tool [options] -v verbose """
OPERATORS
Arithmetic
Arithmetic: + - * / % Integer division truncates toward zero; the remainder takes the sign of the dividend.
q := -7 / 2 -- -3 r := -7 % 2 -- -1
No implicit conversion
No implicit conversion. Conversion is a type applied to a value. If the value is known at compile time, an impossible conversion is a compile error; otherwise it traps.
-- bad: u8 = u8(300) compile error runtime_narrow := u8(measured) -- traps if out of range
Shifts fill with zeros beyond the width, for any amount
Shifts fill with zeros beyond the width, for any amount. Signed >> keeps the sign. One form only.
z := 1 << 40 -- 0 in a u32 context
Bitwise
Bitwise: & | ^ ~ << >> These bind TIGHTER than comparisons, unlike C.
flag := status & 1 == 0 -- means (status & 1) == 0
Logical: and, or, not
Logical: and, or, not. Words, so '!' stays free for the error channel.
ok := a < b and not done
Ranges
Ranges, inclusive unless narrowed: 0..9 inclusive 0..<10 half-open A range is an ordinary value from core that satisfies iterable, not a concept of its own. Left-exclusive forms are gone; step(0, 10, 2) and friends are library calls.
sizeof, alignof and lenof
sizeof, alignof and lenof. On an array the length is a compile-time value; on a slice it is a run-time value; on a literal it is compile-time.
w1 := sizeof u32 a1 := alignof u64 n1 := lenof grid
addr takes an address
addr takes an address, .val names the pointee — reading on the right of an assignment, written on the left.
Assignment is a statement
Assignment is a statement, never an expression: = += -= *= /= %= &= |= ^= <<= >>= and the wrapping forms +%= -%= *%=
No ++ and no --
No ++ and no --. There are inc and dec statements, though 'x += 1' says the same thing.
inc total dec total
Evaluation order is fixed
Evaluation order is fixed, because a language whose point is that costs are visible cannot leave where they happen to the compiler. Operands and call arguments evaluate left to right. Aggregate fields initialise in the order they are written, whatever order the layout puts them in. 'and' and 'or' short-circuit, left to right. An assignment evaluates its destination place first, then the value. A deferred call is the one thing that does not evaluate where it is written, and [1100] says when it does instead.
The dot is not only field access
The dot is not only field access. All of these are member selection: p.val (pointer target), T.less (concept entry), http.get (module member), f64.nan (named value of a type). Separate tokens: .. (range), ... (inferred error set), and the decimal point.
POINTERS
Pointer type, address, dereference
Pointer type, address, dereference. All words, no sigils. mut inside the type says the pointee may be written; without it the pointee is read-only. .val names the pointee, so it reads on the right of an assignment and, with mut, is an ordinary target on the left.
mut value: u32 = 42 p: ptr mut u32 = addr value v := p.val p.val = 43 ro: ptr u32 = addr value -- same address, read-only through ro -- ro.val = 1 -- error
A mut reference satisfies a plain one, never the reverse
A mut reference satisfies a plain one, never the reverse. That is not a conversion and [0310] is not bent: no bit changes and nothing is lost, a permission is forgotten. It is the one relaxation the language has, it goes one way, and it is what makes a read-only parameter usable at all.
add_up: (xs: []i32) -> (total: i32) = ... end add_up(writable_slice) -- fine, []mut i32 relaxes to []i32
Permission is shallow
Permission is shallow: it comes from the reference type and from nowhere else. An immutable binding or an 'in' parameter protects the value it holds, not what that value points at — so a list handed over as 'in' still yields writable elements if its storage is writable. Deeper protection would mean knowing which references a value owns and which it merely borrows, and this language does not track ownership [0910]. Where a read-only view is wanted, hand one out: that is what an accessor is for, and the caller relaxes what it gets by [0440].
Address literal
Address literal. The pointee type comes from context, and a register is written through, so it is a mut pointee sitting in an immutable binding — which is exactly the pair [0070] separates.
gpio: volatile ptr mut u32 = ptr(0x4001_0000)
Both directions between a pointer and an integer are the
Both directions between a pointer and an integer are the ordinary conversion of [0700], a type applied to a value. No special word: this is the one conversion the language marks by what it costs rather than by how it is spelled.
dma_source := u32(addr port.val.dr)
What it costs: an integer that used to be a pointer has no origin. Build a pointer back out of one and you get something that borrows nothing, whose lifetime nobody knows, and about which the compiler will say nothing ever again. It is the one place inside the language where the lifetime system is deliberately left behind, on the same footing as the C boundary. Taking the address of a volatile field is unremarkable by comparison: volatility is a property of the access path, not of the number.
There is no null
There is no null. "maybe a pointer" is an ordinary union of an atom and a pointer type; the compiler represents it as a plain pointer with 0 for the empty case.
none_found: atom maybe_ptr: type = none_found | ptr mut u32
Pointers are a system tool
Pointers are a system tool: hardware, the C boundary, and library internals. Everyday code uses slices, handles and indices.
Three operations sit between pointers and slices
Three operations sit between pointers and slices, and core has them where nothing else does. Every allocator needs all three, and none can be written in the language proper.
mem.offset: (p: ptr mut u8, n: usize) -> (q: ptr mut u8) mem.base_of: (T: type, s: []T) -> (p: ptr u8) mem.base_of: (T: type, s: []mut T) -> (p: ptr mut u8) mem.slice_from: (T: type, p: ptr mut u8, n: usize) -> (s: []mut T)
slice_from is where uninitialised storage is smuggled in
slice_from is where uninitialised storage is smuggled in, and calling that an answer was too kind to it. It hands back a []mut T over memory holding no T, so the type says more than is true from the allocation until the write, and nothing checks the gap. Containers hold the invariant themselves — a growing array by its length, a hash table by its state array — and that works, for a container whose author is careful. Where it does not work is inline storage. small(T, N) holds an [N]T that is not full yet, and there is no honest value to put in the empty slots: [0540] forbids zeroed for a T with no zero image, and ptr is such a T. So until there is a way to say uninitialised in a type, that shape is restricted to a T that has a zero image, and general uninitialised generic storage is not supported. The answer remains a distinct raw-storage type — one that tracks capacity apart from the initialised count and admits one slot at a time. Its exact operations and invariants are decided from executable container cases in ROADMAP.md at R3.20 and R3.30, not guessed as a prerequisite to the first front end.
ARRAYS, SLICES AND TEXT
Array: a value
Array: a value. Assignment copies. Size is part of the type.
grid: [4]f32 = [1.0, 2.0, 3.0, 4.0]
The length may be inferred from the literal
The length may be inferred from the literal.
triple := [1.0, 2.0, 3.0] -- [3]f32
zeroed is the all-bits-zero image of a type
zeroed is the all-bits-zero image of a type. Two separate properties decide where it may appear. A type HAS a zero image when all-zero is a valid value for it; that is what lets a surrounding struct or array be zeroed. A type ACCEPTS the word zeroed only when that image has one obvious reading. Numbers, bool, ranges containing zero and aggregates of those accept it. Named value sets do not: write the name. Pointers and 'any' have no zero image at all, because there is no null.
buffer: [256]u8 = zeroed irqs: set(irq) = zeroed -- fine: a set is bools, see [0730] -- mode: clock_mode = zeroed -- error, write 'internal' -- p: ptr u32 = zeroed -- error, no zero image
The first of those two properties is also a concept
The first of those two properties is also a concept, so generic code can ask for it. It is supplied by the compiler rather than declared, which is the only kind of conformance that is: the compiler is the only thing that knows a type's bit patterns.
zeroable: type = concept (T: type) end zeroable
Which is what lets a shape that needs a value for storage it has not filled yet say so, instead of pretending — see small at [1350] and the reason at [0510].
Repetition, for static patterns that live in flash
Repetition, for static patterns that live in flash.
pattern: [256]u8 = [256 of 0xFF] filled: [256]u8 = [of 0xFF] -- length from the type header: [8]u8 = [0x7F, 0x45, of 0]
Slice: a view
Slice: a view. Pointer plus length. Copies nothing. mut inside the type says the elements may be written.
view: []f32 = grid[0..<2] -- read-only elements edit: []mut f32 = grid[0..<2] -- writable, since grid is mut
An empty slice still has a base
An empty slice still has a base. It is a canonical aligned address that is not null and may not be dereferenced, so base_of on an empty slice yields that and nothing pretends otherwise. Indexing checks the length before it computes an address, so an empty slice cannot produce one.
Fixed arrays are the vector type
Fixed arrays are the vector type. Arithmetic and comparison on them are element-wise, so there is no second type with the same shape and different rules. The first backend lowers them to scalar loops; real vector instructions come later, as an optimisation, and never on a target without them.
va: [4]f32 = [1.0, 1.0, 1.0, 1.0] vb := va * va sc := va * 2.0 -- a scalar on either side is fine s := reduce_add(vb)
Arithmetic applies element by element between arrays of the same element type and the same length; differing lengths do not broadcast, they are a type error. Overflow traps per element, +% wraps per element. Comparison is deliberately not element-wise: == and <> ask about the whole array and yield one bool, and the ordering operators are not defined on arrays. Masks come from named library functions, so no operator changes result type with its operands. [4096]f32 + [4096]f32 is a loop and a 16 KB temporary. That is visible in the type, but worth saying out loud.
Text types are distinct views
Text types are distinct views, not one string type: utf8 distinct []u8 text, UTF-8 by convention utf16 distinct []u16 cstring distinct ptr u8 no length, NUL terminated
Indexing utf8 by an integer yields the bytes of one
Indexing utf8 by an integer yields the bytes of one codepoint and is a linear scan. Indexing by a position is O(1). Both conformances exist; the argument type decides.
DEFERRED to a later version, kept here as a design record
DEFERRED to a later version, kept here as a design record. It touches aliasing, generics, slicing, addr, layout, debug information and the optimiser all at once, and no program has yet proved it necessary. A collection may be stored transposed: one array per field instead of one array of structs. That is a property of the collection, not of the struct, so it is not a layout attribute. Field access through an index goes straight into that field's array, reading a whole element gathers a copy, and the address of a whole element does not exist. The point of it is the last line: one field, contiguous.
world: type = struct items: soa [4096]entity count: usize end world advance: (inout w: world, dt: f32) -> none = for i in 0..<w.count do w.items[i].x += w.items[i].vx * dt end for xs: []f32 = w.items.x shift_all(xs, dt) first := w.items[0] -- a copy -- p := addr w.items[0] -- error: the fields are apart end advance
Only for structs without a variant part. Layout attributes do not apply, because there is no single element layout.
TYPES YOU DECLARE
Atoms: one declaration, one value, its own type
Atoms: one declaration, one value, its own type. This is the one place where a declaration introduces a type and a value at once, because they coincide.
not_found: atom no_access: atom
An enumeration is a union of atoms
An enumeration is a union of atoms. north, south, east and west were declared at [0100].
compass: type = north | south | east | west
Distinct type
Distinct type: same representation, different type, no operations inherited.
meter: type = distinct f32 second: type = distinct f32
meter + second is a compile error.
Range subtype: checked at assignment and conversion
Range subtype: checked at assignment and conversion.
percent: type = u8 range 0..100
Struct, block form and inline form
Struct, block form and inline form. Same thing. The inline form is also the parameter, return and payload list.
size2: type = (w: u32, h: u32)
Struct with a variant part
Struct with a variant part. Common fields need no ceremony.
figure: type = struct label: utf8 area: f32 kind: variant circle: (radius: f32) | rectangle: (width: f32, height: f32) end kind end figure
A case with no payload is written bare
A case with no payload is written bare. It is an atom, and [1700] holds wherever atoms appear, so a variant reads as the union it is.
node_kind: type = struct kind: variant leaf | branch: (first: node_id, count: u32) end kind end node_kind
Construction and conversion use the same form
Construction and conversion use the same form: a type applied to arguments.
origin := point(x: 0.0, y: 0.0) metres := meter(1.5)
A variant case is built the same way: the case name applied to its payload. The tour showed variants being matched long before it showed one being built.
mut f: figure = (label: "disc", area: 0.0, kind: circle(radius: 2.0)) f.kind = rectangle(width: 3.0, height: 4.0)
A struct literal is untyped
A struct literal is untyped, the way a number literal is, and takes its type from the context — named or anonymous. What does not convert is a value already typed as an anonymous struct, a return list for instance: two anonymous types match when field names, types and order all match, and neither ever becomes a same-shaped named type.
here: point = (x: 1.0, y: 2.0) pair: (quot: i32, rem: i32) = divide(10, 3) -- named: point_pair = divide(10, 3) -- no, that is named
'of' fills every field the literal did not name
'of' fills every field the literal did not name, which is the same word and the same place array literals use it in at [0560]. It has to typecheck for each of them, so 'of false' works where the rest are bool and 'of zeroed' works wherever the rest have a zero image. This is not a default value: [0980] refused those on declarations, because a new parameter would then fit every existing call silently. Here the choice is made at each literal by whoever writes it. The trade is still real and belongs to that writer — a literal with 'of' picks up a field added later without a word, one without it breaks loudly.
cfg: control = (enable: true, mode: external, of zeroed)
A register has three kinds of field
A register has three kinds of field, and confusing them is the classic driver bug. A flag is one bit. A selection is a set of mutually exclusive named values, encoded as the datasheet says. A set is independent flags at once. And a plain number is a plain number. A set earns its place when its members mean something. A numbered bank of lines does not: sixteen output pins are [16]bool at 0..15, not sixteen invented atoms.
irq_rx: atom irq_tx: atom irq_err: atom internal: atom external: atom pll: atom
The encoding belongs to the union, not to the atom, so the same atom may be encoded differently in another register. The width is the smallest that holds the largest encoding, so it is usually left out; a base type is written only when the datasheet gives the field more room than it needs.
polarity: type = (active_low = 0 | active_high = 1) -- one bit clock_mode: type = (internal = 0 | external = 1 | pll = 4) divider_sel: type = u4 (by_1 = 0 | by_2 = 1) -- four bits, as given
A set is not a kind of its own. set(X) generates a packed struct of bool, one field per member of X, each sitting at the bit its encoding names — so membership is a field read, adding and removing a member is a field write, and building one is the ordinary struct literal with 'of' from [0720]. There is no set literal, no set operator and no membership operator, because none of them is needed once it is a struct. A union used as a set must carry its encodings, since the encoding is the bit number. Leaving them out would put the bit assignment back at the mercy of declaration order, which is the hole this section closed.
irq: type = (irq_rx = 0 | irq_tx = 1 | irq_err = 2) control: type = layout(packed) struct enable: bool at 0 mode: clock_mode at 4..6 irqs: set(irq) at 8..10 divider: u12 at 16..27 end control
Bit positions are written down rather than implied by the order of the fields. Every bit nobody claims is reserved by that fact alone: it cannot be named, it survives a read-modify-write untouched, and it appears in no completion list. That removes a field kind, removes the silent rule that the first field takes the low bits, and removes the dependence of the encoding on declaration order — which matters, because reordering fields of a published register would otherwise be a breaking change nobody sees. Within one packed struct it is all or nothing: if one field carries a position, all of them do. A field may be an array, which is how every real peripheral describes sixteen two-bit pin settings in one word. The element width times the count has to equal the range, and element zero takes the low bits.
moder: type = layout(packed) struct pins: [16]pin_mode at 0..31 end moder
Indexing such a field by a value known only at run time is ordinary code: it is a shift by a computed amount inside a register image, with the same bounds check any index gets. On an image, that is — never straight through the volatile pointer, for the reason [0740] gives.
set_pin: (inout m: moder, n: u32, mode: pin_mode) -> none = m.pins[n] = mode end set_pin
Access behaviour is data, not keywords
Access behaviour is data, not keywords. A register is a parameterised type carrying how it reads, how it writes and what it holds after reset, so an SVD generator can express write-one-to-clear, clear-on-read, write-once and whatever the next vendor invents without the language growing a word for each. Writing a single field through a volatile pointer stays forbidden: build the whole value, write it once.
status: register(control, read: normal, write: none, reset: 0x0000_0400) clear: register(set(irq), read: normal, write: one_clears, reset: 0x0000_0000)
A field of register(T, ...) type reads as a T and is assigned a T, and the access behaviour is checked exactly there: reading one whose read is 'none' is an error, and so is writing one whose write is 'none'. Together with the rule above that gives the shape of every driver — read the whole image, change it locally, write it back whole. 'reset' initialises nothing. It is what the datasheet says the register holds after a reset, recorded so that tools and readers know what they are starting from. The hardware puts it there, not the program.
reset_flags: (c: volatile ptr mut control) -> none = c.val = (enable: true, mode: external, irqs: (irq_rx: true, of false), of zeroed) end reset_flags read_modify_write: (c: volatile ptr mut control, n: u32) -> none = mut image := c.val image.divider = n c.val = image end read_modify_write
Fields keep the order you wrote them
Fields keep the order you wrote them, with natural alignment and padding in between, so a hexdump matches the source and the layout does not shift under a new compiler. layout(optimal) lets the compiler reorder to save padding. layout(c) applies the C rules. Byte order is per field.
packet: type = layout(c) struct kind: u8 length: big u16 id: big u32 end packet
Attributes are prefix words, from a closed set
Attributes are prefix words, from a closed set. Arguments are always parenthesised. Closed value sets are atoms; arbitrary linker names stay strings.
mut public volatile align(n) layout(c|optimal|packed)
and 'at' for a bit position, which is why a field or an entry cannot be called that — the same goes for 'from', 'of', 'with' and 'align' itself.
big little escaping caller fixed option
link(section: "...", symbol: "...", keep, weak,
inline, noinline)
extern(c|interrupt|naked|...)packed folded into layout, naked into extern, option implies fixed, and the five toolchain words became one attribute with named arguments. Register access is data now, not keywords.
LIFETIME AND ESCAPE
There is no borrow checker and there are no lifetime
There is no borrow checker and there are no lifetime annotations. Instead every pointer, slice and 'any' carries the origin of what it refers to: static, allocated, or frame. Frame-origin may not be returned, and may not be stored where something longer-lived keeps it.
bad: () -> (p: ptr u32) = x := 42 p = addr x -- error: a frame origin escapes end bad
Parameters are non-escaping by default
Parameters are non-escaping by default, so a callee may use a pointer freely but not keep it. Keeping it is declared.
push_front: (inout head: ptr mut node, escaping item: ptr mut node) -> none = item.val.next = head head = item end push_front build: (a: arena) -> (head: ptr node) = n := try mem.new(T: node, a: a) push_front(head, n) -- allocated, fine mut local: node = zeroed push_front(head, addr local) -- error: a frame origin escapes end build
The other direction
The other direction. What a returned reference was derived from is written down, because the caller cannot otherwise know that the thing it came from has to hold still now. That, and only that: whether the view may be written is the return type's business [0430].
used: (T: type, l: list(T)) -> (s: []mut T from l) = ... end
One accessor, not two. It hands out the widest permission the storage has, and a caller who wants less relaxes it by [0440] — 'xs: []T = vec.used(l)'. The pair of accessors that every language with deep const ends up needing is not needed here. Named from several parameters at once it borrows all of them. With no from clause the result is independent: it borrows nothing. That is what an allocator returns, and it is why two live allocations out of one allocator are unremarkable. Both halves are checked where the function is written. Returning something derived from a parameter without saying so is an error, and naming a parameter it did not come from is an error too, so the clause cannot drift away from the body. In obligation it is the mirror of escaping. escaping says the callee keeps a reference to what the caller handed in; from says the caller keeps a reference to what the callee handed back. It is written rather than inferred, for the reason escaping is: inference across calls lets a distant body change a signature, and local compilation is worth more. Writing it also leaves nothing to carve out — function pointers, concept entries and the C boundary need no special rule here, because nothing was ever being inferred.
The borrow of reaches across the call from there
The borrow of [0830] reaches across the call from there, with the derivation supplied by the signature instead of being visible in one expression. No new checking, and the fix is the same one: take the view again afterwards.
xs := vec.used(numbers) try vec.push(numbers, a, 5) -- error: numbers is borrowed by xs use(xs)
Nothing is written at the call. A convention is part of the declaration and appears nowhere else: the compiler has it, and a caller that misuses what it got is told so at the next use, so a marker would buy legibility rather than safety. That is an editor's job, the same way the resolved error set is. escaping already works this way — it puts an obligation on the caller and lives only in the signature.
Derivation stops at the three primitives of
Derivation stops at the three primitives of [0500]. offset, base_of and slice_from yield a reference independent of what went in, and core answers for that. It is the same privilege and the same place as the promise about uninitialised storage — one place to audit, two promises — and it is what lets an allocator hand out storage that points into itself without the storage borrowing it.
bump_alloc: (inout a: bump, size: usize, alignment: usize) -> (p: ptr mut u8) ! out_of_memory = p = mem.offset(a.base, off) -- no from: offset cut the chain end bump_alloc
arena is built in
arena is built in, both as the block below and as the type a parameter is written with at [0780]. There is nothing to import for it. A scratch arena is a block, so its extent is exact rather than guessed. Everything from it has frame origin, which is why a local may be referenced from inside it. Nothing from it may leave the block. There is no general reset: to start over, open another block. Passed on as a parameter the arena is an ordinary allocator again, and what comes out of it there is allocated rather than frame. That has to be so, or a block arena would be useless beyond the function that opened it — and it is sound for a reason worth saying rather than leaving implicit: the block is the outermost extent, so anything that would outlive it passes the block on its way out, and the check there catches it.
report: (data: []u8) -> none = arena scratch do mut buf := try mem.new_slice(T: u8, a: scratch, n: 1024) format_into(buf, data) write_out(buf) end scratch end report
A view derived from a local borrows it
A view derived from a local borrows it. While the view is still in use, that local may not be handed on as inout or sink, which is what stops a container from moving under a slice into it. The check is local to one function body, so there is nothing to annotate and the fix is always nearby: take the view again afterwards. It only applies where the storage can actually move, and unchecked turns it off with everything else.
grow_and_use: (inout l: list, v: i32) -> none = xs := l.items push(l, v) -- error: l is borrowed by xs use(xs) end grow_and_use
Origins join to the shortest-lived part
Origins join to the shortest-lived part. A struct holding one frame pointer is frame as a whole, and a value chosen between two branches takes the more restrictive of the two. Returns need no analysis across function boundaries at all: frame origin cannot leave a function, so whatever does leave is allocated or static by construction. A value that holds no references at all is unconstrained, which is another reason the idiom is handles and indices.
Volatile is exempt from the borrow rule and from every
Volatile is exempt from the borrow rule and from every aliasing assumption. Hardware routinely needs several typed windows on one address — a byte view and a word view of the same register — and that is legal, deliberately.
What this does not catch
What this does not catch, said plainly: two different arenas are indistinguishable, pointers that travel through a struct field and are read back later, and anything across the C boundary. A view taken through an accessor used to be on this list; [0790] took it off. What is left would need regions or generation counters, and both are deliberately out.
FUNCTIONS
A function is a value of a function type
A function is a value of a function type. No 'fn' keyword. '=' opens the body, 'end' closes it, always.
double: (x: i32) -> (r: i32) = r = x * 2 end double
A single-expression body still takes an end
A single-expression body still takes an end. The expression fills the named return.
triple_it: (x: i32) -> (r: i32) = x * 3 end
Nothing returned
Nothing returned, and never returns:
log_it: (m: utf8) -> none = ... end halt_it: () -> noreturn = loop do end loop end halt_it
Three parameter conventions
Three parameter conventions, and they are about the value you were handed — never about what it points at, which the type says by [0450].
| convention | what it promises |
|---|---|
in | I will not change the value. The default, unmarked. |
inout | I may replace it, exclusively, and the change comes back. Implies mut. |
sink | consumed. The place the caller named is dead afterwards — see [0910], since a place is not always a binding. |
So a function that writes registers through a pointer it will never re-point takes it as 'in' and the pointer type carries the mut. That reads as what it does, which the older rule could not say. Outputs are not a convention: returns are named. Orthogonal to all three, 'escaping' says the callee may keep what a pointer, slice or 'any' refers to beyond the call, so the caller must prove it lives long enough. In obligation it is the opposite of sink: sink ends the caller's duty, escaping extends it. On a generic parameter escaping says the right thing at both extremes with no special case. For T = u32 it is vacuous, since [0840] already leaves a value holding no references unconstrained; for T = ptr node it is exact. The origin travels with the type, so one word covers both. An inout argument need not be a binding. A pointer target, c.inner.val, is an ordinary one.
process: (source: []u8, inout target: []u8, sink owned: buffer) -> (written: u32) = written = 0 end process
sink takes a place
sink takes a place, and a field of a binding is a place, so releasing a container's storage needs no ceremony. The path has to be rooted in a binding and contain no dereference and no computed index, which is the line where the analysis is still provable: two pointers may name one place, so p.val.items is refused, and a computed index names none in particular, so xs[i].items is too. A place that was sunk is dead. Reading it before it is assigned again is an error, and that is what closes the window between releasing storage and repointing the field — the window a temporary binding would have left open. Say plainly what this is and is not. It is a use-after-consume check on one place. It is not ownership: the value is copyable, so a copy made before the sink is refused nothing, and consuming through both is not caught. Making it ownership means values that cannot be copied. Affine values remain parked with their resource-prototype trigger in ROADMAP.md's inherited review register. A place sunk out of an inout parameter must be assigned again before the function returns, or the caller would get its struct back with a dead field and nobody tracking it. That is [0930]'s rule for named returns, applied to fields.
release: (T: type, A: type is allocator, inout l: list(T), inout a: A) -> none = mem.drop_slice(a, l.items) -- l.items is dead from here l.items = [] -- and live again from here l.len = 0 end release
Multiple named returns
Multiple named returns.
divide: (a: i32, b: i32) -> (quot: i32, rem: i32) = quot = a / b rem = a % b end divide
Every named return must be assigned before return
Every named return must be assigned before return. On the fail path they need not be, and the caller may not read them.
Errors: a declared set of atoms in one dedicated register
Errors: a declared set of atoms in one dedicated register.
open_file: (path: utf8) -> (handle: u32) ! not_found | no_access = fail no_access when lenof path == 0 handle = 1 end open_file
Not everything that goes wrong belongs in that channel
Not everything that goes wrong belongs in that channel, and the question to ask is whether it can be determined from what you already hold. A syntax mistake is entirely in the bytes the parser is looking at, so it is checked, reported and recovered from — a parser that stops at the first mistake hides the other twelve. Out of memory, a file that is not there, a device someone unplugged hang on the world instead of on your data, and checking first would only be a race, so those are what fail is for. Put briefly: check what you can foresee, and where you can foresee it, prefer working around it to reporting it. fail is for what cannot be foreseen or cannot be dealt with where it happens. Reporting needs somewhere to report to, and that is an ordinary parameter. A diagnostics sink is a capability by [1680]: a function that was given none cannot report, which is enforced by an argument list and nothing else.
parse: (src: utf8, inout d: diagnostics) -> (tree: ptr node) ! out_of_memory | too_deep = ... end
Propagating: try, visible at the call site
Propagating: try, visible at the call site. '...' means: plus whatever my callees can fail with. Not allowed where the set must be concrete: function pointers, concept entries [1260], anything exported to C — and anything public. A public signature is a promise, and one that a change three modules down can rewrite silently is not a promise. Inside a module, where the compiler sees every caller anyway, '...' is a convenience and stays.
read_config: (path: utf8) -> (data: []u8) ! ... = h := try open_file(path) data = [] end read_config
Calls: positional first, then named
Calls: positional first, then named. No default values.
r1 := divide(10, 3) r2 := process(source: src, target: dst, owned: buf)
A return list is an anonymous struct
A return list is an anonymous struct, so a result can be bound whole and read by field, or destructured. Binding by name, never by position.
whole := divide(10, 3) sum := whole.quot + whole.rem (quot, rem) := divide(10, 3) (quot: q2) := divide(20, 3) (quot, _) := divide(30, 3)
A function type is an ordinary type
A function type is an ordinary type, and a function is an ordinary value of it, represented as a code address. There is no separate function-pointer type and addr is not used on functions: the type is written the way the signature is.
handler: type = () -> none mut current: handler = default_handler
A callback is therefore a pair of that and a state pointer, written out because nothing is captured.
on_byte: type = struct call: (state: ptr u8, b: u8) -> none state: ptr u8 end on_byte
Anonymous functions
Anonymous functions. No capture: they see only their parameters. State travels as an explicit parameter.
less_i32 := (a: i32, b: i32) -> (yes: bool) = a < b end
Handling
Handling, not just propagating: an else clause on the call, binding the error atom. It either yields a value or leaves the function. try is sugar for the second line.
h1 := open_file(path) else 0 h2 := open_file(path) else (e) fail e end h3 := open_file(path) else (e) match e not_found: create(path) _: fail e end match end open_file
The else clause yields a value, or transfers control out of the block that encloses it: return, fail, break, continue. That is the same rule an if-expression arm follows, so an arm that leaves needs no value and nobody has to invent a placeholder to satisfy the type. It does not assign to the binding it is initialising, which would need a rule about writing an immutable binding inside its own initialiser. Being an expression, it also works in argument position:
use(read_config(path) else (e) default_config() end)
A call that can fail and whose result is discarded is an error. Write 'try f()' or discard through an else. else is for the error channel only, not for unions.
A caller parameter is filled in by the compiler with the
A caller parameter is filled in by the compiler with the site of the call, so assertions and logging work without macros. It may only be passed on from another caller parameter, otherwise a wrapper would report itself.
site: type = distinct u32 assert: (cond: bool, caller where: site) -> none = if not cond then panic_handler(assertion, where) end if end assert
used as: assert(count > 0)
CONTROL FLOW
demo_flow: (x: i32, items: []i32) -> (out: i32) = out = 0
Branch
Branch. 'then' closes the condition, which must be bool.
if x > 0 then out = 1 elsif x < 0 then out = -1 else out = 0 end if
Every construct has a one-line form
Every construct has a one-line form. No newline is ever required; 'end' closes.
if x > 0 then out = 1 end if
A declaration is allowed in a condition
A declaration is allowed in a condition. It is a plain type error unless it is bool.
if ok := is_ready() then out = 1 end if
A block has the value of its last expression
A block has the value of its last expression, and that is the whole rule: if, match, else clauses, bare blocks and loops are expressions wherever one is wanted. Rust needs a semicolon to decide whether the last line is the value; here discarding is already explicit with '_ =', so a bare expression at the end is unambiguous.
sign := if x > 0 then 1 else -1 end if doubled := begin a := expensive() a * 2 end
Bare block, for scoping
Bare block, for scoping.
scope: begin tmp := x * 2 out = out + tmp end scope
defer runs at the end of its block, in reverse order
defer runs at the end of its block, in reverse order. The call is evaluated where it runs, not where it was written: it names places, and reads them then. So a defer that sinks something sinks it at the end, which is why the thing stays usable in between — and if a named place has been re-pointed by then, the defer sees what is there now. Registering one costs nothing and reserves nothing.
defer cleanup()
undo is the same machinery under a condition
undo is the same machinery under a condition. It is registered when control reaches it, runs in reverse order at the end of its block, and runs only if the block is left by fail — which includes a try that propagates and a fail arriving from deeper in. Not on return, not on break, and not on a panic, since panics do not unwind. It is its own word rather than a flavour of defer, because it is not the same thing: to defer is to do it later, and this may never be done at all. English calls it a contingency, or a compensating action.
undo release(handle)
Checks may be switched off for a region, visibly
Checks may be switched off for a region, visibly.
unchecked begin out = out + items[0] end unchecked
Conditional loop
Conditional loop.
mut i: u32 = 0 while i < 10 do inc i continue when i == 3 end while
Traversal
Traversal. Bindings default to in; inout implies mut.
for item in items do out += item end for for item, idx in items do item = item + i32(idx) -- items is []mut i32, so this writes end for for k in 0..<10 do out += k end for
There is no marker on a loop binding
There is no marker on a loop binding, because the type already decided: over a []mut T an element is a writable place, over a []T it is not. Over anything else that satisfies iterable the binding is a copy, since item at [1320] hands out a value — so assigning to it is an error rather than a silent write to nothing.
xs := vec.used(l) -- []mut i32 for k in 0..<lenof xs do xs[k] = xs[k] * 2 end for
complete runs when the loop finished without break
complete runs when the loop finished without break.
for item in items do break when item == 42 complete out = -1 end for
Labels use the ordinary name form
Labels use the ordinary name form, on loops and bare blocks only. break and continue take one.
outer: for a in items do for b in items do break outer when a == b end for end outer
break carries a value with 'with'
break carries a value with 'with', which is what makes a search an expression instead of a mutable variable and a flag. Every break of a loop used as an expression must yield the same type, and complete supplies the value for running out; loop do needs none, having no other exit. 'with' is required because a bare identifier after break could otherwise be either a label or a value.
found := for item in items do break with item when item > 40 complete break with 0 end for out += found end demo_flow
Because entries are registered where control reaches
Because entries are registered where control reaches them, the triangular cleanup of several fallible acquisitions falls out of the order instead of being written: the first failing frees nothing, the second frees one, the third frees two.
grow: (K: type, V: type, A: type is allocator, inout m: map(K, V), inout a: A, want: usize) -> none ! out_of_memory = ns := try mem.new_slice(T: slot, a: a, n: want) undo mem.drop_slice(a, ns) nk := try mem.new_slice(T: K, a: a, n: want) undo mem.drop_slice(a, nk) m.state = ns -- committed from here, and nothing m.keys = nk -- fallible follows end grow
And the discipline it asks for, which has to be said out loud: undo cleans up what is still yours. Once a resource has been handed on, its cleanup is somebody else's, but the entry is still registered. So acquire everything fallible first, commit afterwards, and let nothing fallible stand between the commit and the end of the block. Where that order cannot be had, the entry would have to be called off, and there is deliberately no way to do that — a construct for calling one off turned out to be bookkeeping for a question the block's exit already answers. The pattern this replaces is a flag and a conditional defer. That is linear rather than quadratic, so it was never about the number of lines. It is that forgetting to set a flag frees storage that is still in use, and under an arena, where free does nothing, the mistake is silent until somebody runs the same container on a real allocator.
Pattern matching
Pattern matching: constant patterns, case patterns with binding, and the wildcard. No fallthrough; list several labels instead. A missing case is a compile error.
area: (f: figure) -> (a: f32) = a = 0.0 match f.kind circle (radius): a = 3.14159 * radius * radius rectangle (width, height): a = width * height end match end area
A pattern binding carries a parameter convention
A pattern binding carries a parameter convention: in by default, inout to write into the payload that was matched. Nothing new is needed, because the conventions of [0900] are already the mechanism — and without them a [N]T payload would be copied in order to be read and could not be written at all. An inout binding borrows the matched value for the arm, by the rule at [0830]. So an arm may assign to the variant field it was bound out of, but only once the binding has had its last use, exactly as any other borrow ends.
spill: (inout s: store, v: i32) -> none = match s.kind inline (inout buf): buf[0] = v spilled (heap): use(heap) end match end spill describe: (c: compass) -> (name: utf8) = match c north, south: name = "along" _: name = "across" end match end describe
CONCEPTS AND GENERIC CODE
A concept names a bundle of requirements on a type
A concept names a bundle of requirements on a type.
ordered: type = concept (T: type) less: (a: T, b: T) -> (yes: bool) end ordered
A conformance registers one type against one concept
A conformance registers one type against one concept.
i32 is ordered (less: less_i32)
A conformance for a parameterised type binds its variables
A conformance for a parameterised type binds its variables in front, because the conformance holds for every one of them. The binder is an ordinary parameter list — the same form functions take and the same form a parameterised type takes — so a variable may carry a constraint, and a fixed value parameter may appear among them.
(T: type) list(T) is iterable (Cur: usize, Item: T, first: list_first, at_end: list_at_end, item: list_item, next: list_next) (A: type is allocator) counted(A) is allocator (alloc: counted_alloc, free: counted_free)
The functions supplying the entries are generic themselves. Instantiating the conformance supplies their type argument and leaves a function of exactly the concept's shape, so nothing beyond the binder is needed. Free variables are never quantified by inference. A name that is not in scope is a misspelling, not a new variable, which is the rule build options already follow at [1530]. Without this there is no generic container that can be traversed, sorted or handed to any other generic code.
A concept entry's error set has to be concrete
A concept entry's error set has to be concrete. The entry is reached through a table, [0960] forbids an inferred set there, and so a concept fixes the error set once for every type that will ever satisfy it. That is right for allocation — out of memory is out of memory — but it is a constraint on how concepts are designed, not a detail. The same holds one level up, for what an implementation may ask for. A concept with no allocator among its parameters means no implementation of it can allocate, however much one of them would like to. Resist widening the concept until it fits the hungriest: that hands every implementation the sum of everyone's needs. Either the hungry one picks a shape that does not need it, or there are two concepts.
The register is keyed by (type
The register is keyed by (type, concept, input types), so a type may satisfy the same concept more than once. Which concepts exist, and how finely they distinguish what an operation costs, is the standard library's business. The language checks nothing about cost and knows no vocabulary for it: a guarantee should not depend on how clever the compiler happens to be.
indexable: type = concept (T: type, Idx: type, Item: type) get: (s: T, i: Idx) -> (item: Item) end indexable utf8 is indexable (Idx: u32, Item: []u8, get: utf8_nth) utf8 is indexable (Idx: position, Item: []u8, get: utf8_at_pos)
Conformances may be declared anywhere
Conformances may be declared anywhere, and two for the same key are simply an error. There is no precedence, no orphan rule and no override: until a rule turns up that is worth its weight, the compiler reports the collision and somebody sorts it out. A wrapper through distinct, or passing the functions explicitly at the call, both work without anyone changing anyone else's library. The alternative that was tried and dropped had libraries declare weak conformances and applications override them with strong ones. It worked, but it meant an application could quietly change the behaviour of generic code inside a library, which is a strange thing for a language whose whole point is that costs and effects are visible.
A generic function takes the type as an ordinary parameter
A generic function takes the type as an ordinary parameter, constrained by 'is'. Type and fixed parameters are compile-time, and their order in the list does not matter: a parameter may be used in the type of one that comes before it, exactly as declarations inside a module may. The compiler collects the names first and resolves the types afterwards, so report: (inout d: sink(N), N: fixed u32, ...) is as good as putting N first, and at the call site N is deduced from whatever argument pins it down. Concept entries are reached through the type parameter, so two constrained parameters never collide: A.less, B.less.
sort: (T: type is ordered, data: []mut T) -> none = for k in 1..<lenof data do mut j := k while j > 0 and T.less(data[j], data[j - 1]) do tmp := data[j] data[j] = data[j - 1] data[j - 1] = tmp dec j end while end for end sort
At the call site the type is inferred from the arguments
At the call site the type is inferred from the arguments. Naming it explicitly stays possible.
sort_demo: (values: []i32) -> none = sort(values) sort(T: i32, data: values) end sort_demo
Generic code can always be compiled once and handed a table
Generic code can always be compiled once and handed a table of the concept's functions. That table is the foundation, because 'any' needs it and so do calls through function pointers. Everywhere the concrete type is known, the compiler weighs specialising against it: loop depth at the call, how many concept entries the body calls, and the size of the type on the one side, code size on the other. With a single instantiation it always specialises, since the table version then becomes dead. Optimising for size raises the bar, optimising for speed lowers it, and identical machine code from different types is folded into one copy. The build report lists what was specialised and what was not. The evidence is not only the concept's functions. It carries the size and the alignment of the type as well, because sizeof T and alignof T are constants only where the call was specialised, and a generic container that cannot ask how big its element is cannot allocate.
Traversal is a concept
Traversal is a concept. This is what 'for x in s' uses.
iterable: type = concept (T: type, Cur: type, Item: type) first: (s: T) -> (c: Cur) at_end: (s: T, c: Cur) -> (yes: bool) item: (s: T, c: Cur) -> (v: Item) next: (s: T, c: Cur) -> (c2: Cur) end iterable
A range is not a concept
A range is not a concept. '0..<10' builds an ordinary value from core that satisfies iterable, and the spelling is built in for integer-like types only. Anything else a type wants to be traversed by, it satisfies iterable for. Custom stepping is a library call, so the step is visible where it is used rather than hidden in a type.
step_range: type (T: type) = struct low: T high: T by: T end step_range step: (T: type, low: T, high: T, by: T) -> (r: step_range(T)) = ... end
used as: for i in step(0, 10, 2) do ... end for
Concepts compose
Concepts compose. A combined concept is named, so a table is generated per (type, concept) and the value stays two words. Conformance is declared explicitly, even when the body is empty.
drawable: type = concept (T: type) draw: (self: ptr T, target: ptr mut canvas) -> none end drawable clickable: type = concept (T: type) click: (self: ptr mut T, x: u32, y: u32) -> none end clickable widget: type = concept (T: type) is drawable, clickable focus: (self: ptr mut T) -> none end widget button is drawable (draw: button_draw) button is clickable (click: button_click) button is widget (focus: button_focus)
All three, because the composed declaration supplies only what it adds. Nothing is inherited by having the parts.
Types take parameters through a declaration form of their
Types take parameters through a declaration form of their own, not through a function that returns a type — that would be the compile-time evaluation this language does not have. Substitution, not execution.
list: type (T: type) = struct items: []T len: usize end list
The parameter list is the same one everywhere: a variable may be constrained, and a fixed value parameter may stand among the type parameters.
map: type (K: type is hashable, V: type) = struct ... end map small: type (T: type is zeroable, fixed N: u32) = struct ... end small
Allocation is an ordinary concept
Allocation is an ordinary concept, so the same container runs on the heap, in an arena, or on a fixed buffer with no dynamic allocation at all. A failing allocator makes the out-of-memory paths testable, which almost nobody bothers with in C because it is too awkward.
allocator: type = concept (A: type) alloc: (inout a: A, size: usize, alignment: usize) -> (p: ptr mut u8) ! out_of_memory free: (inout a: A, p: ptr mut u8, size: usize) -> none end allocator push: (T: type, A: type is allocator, inout l: list(T), inout a: A, v: T) -> none ! out_of_memory = ... end
The allocator is threaded, not stored in the container, and the reason is stronger than visibility: a stored allocator makes the type list(T, A), so a list in an arena and a list on the heap become different types and no function takes both. Threading keeps the type parameterised by T alone, and costs one argument at every call that can allocate.
RUNTIME DISPATCH
'any C' is a value of some type satisfying concept C
'any C' is a value of some type satisfying concept C, decided at run time: a data pointer plus a concept table, two words. That pair is an ordinary copyable value and can sit in a variable, a field or a slice. What it points at is never copied, because its size is unknown; the pointee has to live somewhere the pair outlives, typically an arena. The pair needs no permission marker of its own: the concept's entries already carry it. An entry declared with 'self: ptr mut T' can only be satisfied by a pointer that has the permission, so a stateful implementation behind runtime dispatch works and nothing had to be invented for it. What the pair does carry is the origin of the pointee for [0840], so an 'any' over something with frame origin cannot be put in a list that outlives the frame.
Building one is explicit
Building one is explicit. The concept comes from context where it can; otherwise name it.
screen: (a: arena) -> (items: []any widget) ! out_of_memory = b := try mem.new(T: button, a: a) b.val = button(text: "OK") items = try mem.new_slice(T: any widget, a: a, n: 1) items[0] = any(b) end screen
Calls go through the table
Calls go through the table, with the data pointer as the first argument. This is the only place that reads like a method call.
paint: (items: []any widget, target: ptr canvas) -> none = for w in items do w.draw(target) end for end paint
This is what generics cannot do
This is what generics cannot do: one array holding values of different types. []T is always one T.
MODULES
A module is a directory
A module is a directory. Every file in it sees the others with no import. Two levels of visibility: module-internal (the default) and public. No separate interface file.
An import path is a directory path, and nothing cleverer
An import path is a directory path, and nothing cleverer. 'import net/http' looks for a directory net holding a directory http, under each of the import roots the compiler was given, in order, and takes the first that has it. From that directory it takes every source file — not recursively — and follows their imports in turn. A subdirectory is a module of its own, reached by its own path. What is bound is the last segment. Which is why a segment has to be a plain identifier: lowercase letters, digits and underscore, not starting with a digit. Lowercase because a case-sensitive and a case-insensitive filesystem must both agree on what a name is; an identifier because the last segment becomes a name in the importing file, so a hyphen would need an alias every time. Nothing else — no dots, no spaces, no case, no characters that some filesystem somewhere will not carry. The separator in source is always '/', on every host. The compiler turns it into whatever the filesystem wants.
import net/http
Pull selected names into scope, by name
Pull selected names into scope, by name. No wildcard.
import net/http (get, post)
Imports are per file, so every file reads on its own
Imports are per file, so every file reads on its own.
Values at module level must be known at compile time
Values at module level must be known at compile time. Nothing runs before the entry point. Immutable ones can stay in flash; mutable ones cost RAM and are conspicuous.
table: [4]u32 = [1, 2, 4, 8] mut call_count: u32 = 0
A package is a named collection of modules with a version
A package is a named collection of modules with a version and an origin. Names have two levels, owner and package, and a directory under a search root is the package it names. Exactly one version of a package name exists in a program: duplicated code is untenable at 32KB, the types are nominal, and there is one conformance register. A version conflict is therefore a hard error and somebody upgrades.
The roots are the project
The roots are the project, then the user's landin home, then the system-wide one, and vendoring is just the first of those. Whether the leading segments of a path mean an owner and a package is a convention for people and for the companion tool; the compiler sees directories under roots, as [1420] says, and nothing else. So it receives an ordered list of roots and no more than that. Fetching, version solving, lock files and naming authority all live in a companion tool that ships alongside but stays separable — a compiler you can read is worth more than one that can download things. Arranging the roots so that only one version of anything is reachable is that tool's job, which is what makes [1470]'s one-version rule keepable. core and landin are reserved, and both are used. core is the standard library: core/mem, core/text, core/vec. landin holds the toolchain modules of [1560] — landin/compiler, landin/assembler, landin/linker — which are available without an import, and that is why the bare names compiler, assembler and linker are taken. Naming authority remains deliberately deferred to the companion tool and ecosystem successor in ROADMAP.md. The search path is project-first, so any collision can be overridden locally and no dispute is fatal.
COMPILE TIME
Conditional compilation
Conditional compilation.
fixed if compiler.arch == arm64 then word_bits: u32 = 64 elsif compiler.arch == cortex_m0 then word_bits: u32 = 32 end if
Compile-time assertion
Compile-time assertion. Not a keyword: it is a builtin call like the rest of [1560], because the compiler is what has to know it, and that leaves the word 'assert' to the library function at [1040] where it belongs.
compiler.assert(sizeof usize == 8)
A compile-time value parameter
A compile-time value parameter.
make_buffer: (fixed N: u32, T: type) -> (b: [N]T) = ... end
Your own build switches
Your own build switches. Declared, typed, with a default, settable from the build description. A misspelt name is a compile error, never a silent false. Names are global, so each is declared exactly once.
option log_level: u32 = 0
There are no compile-time loops and no compile-time
There are no compile-time loops and no compile-time function calls. That line is deliberate. The builtin modules at [1560] look like an exception and are not one: their calls are directives to the compiler, the assembler and the linker, they take only fixed arguments, and nobody can write another. Nothing of yours runs while the program is being built. What that costs is a generated table or an SoA layout, which comes from a program that writes source and is run by the build — twice so far, and a third would be worth taking seriously.
THE TOOLCHAIN, C, AND THE MACHINE
Landin has its own native backends
Landin has its own native backends. A verified, target-neutral intermediate representation takes QBE's IL as a design influence without freezing one flat or serialised stage shape before implementation evidence exists. The compiler emits deterministic assembly text and relies on the assembler and linker of the platform. Linux x86-64 comes first, native macOS arm64 second, and emulator-first Cortex-M third. The frame pointer is always set up. The whole program is compiled together [1480], with optional private caches that do not create a stable separate-compilation interface. Not LLVM and not C: LLVM is a dependency larger than the language and C loses the calling convention, the traps and the debug information that the design spends its precision on. The cost is owned deliberately — every target is work that nobody else does.
Three builtin modules are the way to the tools
Three builtin modules are the way to the tools. They are modules of the reserved landin package — landin/compiler and its siblings — and are in scope without an import, which is why their bare names are not available to anyone else.
| module | what it reaches |
|---|---|
compiler | target, word size, byte order, build mode, and the atomic and vector intrinsics |
assembler | inline assembly |
linker | libraries, sections, entry |
Their calls are builtin, take only fixed arguments, and cannot be written by hand. Where the line runs: something is builtin when the compiler has to know it. Atomics are, because opaque assembly in a hot loop wrecks the register allocation around it. Masking interrupts is not, because being opaque is exactly right there — a critical section wants nothing reordered across it. So cpu.disable_interrupts and its kin are an ordinary core module per target, written with assembler.block behind a fixed if and inlined, not a fourth builtin module whose contents would change with the target. If one of them later turns out to exist on every target and mean the same thing everywhere, the way the atomics and the vector operations do, it can be promoted into compiler then. The rule decides it, not a list.
Calling conventions are a growing set of atoms behind one
Calling conventions are a growing set of atoms behind one spelling rather than a keyword each: extern(c) today, extern(interrupt) for handlers whose entry, exit and vector placement differ, extern(naked) for no prologue at all, and room for extern(aapcs), extern(sysv), extern(win64) later. The ones worth real work eventually are Fortran, whose ABI is trivial and whose numeric libraries are everywhere, and Swift, which puts its error in a register of its own just as Landin does. Zig, Odin, Rust and Python all speak C, so there is nothing to gain there.
Importing from C
Importing from C. Declarations are written by hand; no header is ever read. A C pointer may be null and a Landin pointer may not, so the two are not the same type and a declaration says which it means. malloc returns something that may be nothing, which is the union of [0480] and costs no bits.
none_returned: atom extern(c) malloc: (n: usize) -> (p: none_returned | ptr mut u8) extern(c) free: (p: ptr mut u8) -> none
ptr(0) is refused, so null cannot be minted on this side either. Where a foreign interface hands back a pointer that may be null, it is declared as the union and matched on; the compiler represents that as the plain pointer it already was.
Linking a static library
Linking a static library, next to the declarations that need it rather than in a separate build file.
linker.library("m")
Exporting to C
Exporting to C. No error channel crosses the boundary, so the error set must be empty.
public extern(c) my_add: (a: i32, b: i32) -> (r: i32) = r = a + b end my_add
A symbol name the language's identifiers cannot spell
A symbol name the language's identifiers cannot spell.
link(symbol: "__aeabi_uidiv") udiv: (a: u32, b: u32) -> (q: u32) = ... end
Atomics are builtins
Atomics are builtins, not assembly, so the compiler knows which memory they touch and can still allocate registers around them. The ordering is a compile-time atom. The standard library wraps these into a pleasant type.
bump: (p: ptr u32) -> none = _ = compiler.atomic_add(p, 1, acq_rel) end bump
Inline assembly, for what has no builtin
Inline assembly, for what has no builtin. Opaque to the compiler, which therefore assumes it may touch any memory and must not be reordered.
extern(naked) reset_handler: () -> none = assembler.block(""" ldr r0, =_stack_top mov sp, r0 bl start """) end reset_handler
'start' and not 'main': freestanding there is no main, and the build description names the entry [1650].
Kept against section garbage collection, and placed
Kept against section garbage collection, and placed. The table is a struct, not an array of addresses: a function type is an ordinary type [1000], so a handler is written as one, and the first word is a stack pointer rather than a handler at all. 'handler' is the function type from [1000].
vector_table: type = layout(c) struct stack_top: usize reset: handler rest: [46]handler end vector_table link(section: ".isr_vector", keep) vectors: vector_table = ( stack_top: stack_top_address, reset: start, rest: [of default_handler] )
Being reachable from something kept is what keeps a handler. The table carries keep and names them, so they survive by being named. extern(interrupt) does not imply it and should not: a calling convention is what the program means, keep is an instruction to the toolchain, and [0760] separated those two on purpose.
Entry point
Entry point. Hosted, main follows the system C ABI. The no-argument form is the ordinary one, because argc and argv in the C shape cannot be indexed without slice_from, which is core's by [0500] — so the arguments come from core as a slice instead. The C form stays available for whoever wants it. Freestanding there is no main; the build description names the entry.
And this is where capabilities come from
And this is where capabilities come from. Everything below is handed what it may do — an allocator, an Io, a diagnostics log — and the entry point is the one place where a root is minted rather than passed. So the whole of main is an argument list being filled.
public main: () -> (code: i32) = args := io.args() -- []cstring, from core mut h := io.host() -- out of nothing, once, here mut w := any(addr h) arena program do mut logger := diag.to(w.err()) mut d := any(addr logger) code = run(w, program, d, args) else 1 end program end main
Which is what makes the same run testable and portable without it knowing: hand it a different root and it does not learn the difference.
test_drops_debug: () -> none = mut h := io.in_memory([(name: "in.log", body: "DEBUG a\nERROR b\n")]) mut w := any(addr h) arena scratch do mut logger := diag.new_log(N: 32) mut d := any(addr logger) kept := run(w, scratch, d, []) else 0 assert(kept == 1) end scratch end test_drops_debug
For that to work at all, Io has to be a concept and not a type, so something else can satisfy it. It travels as 'any io' rather than as a type parameter: an indirect call in front of a system call costs nothing, where an allocator is threaded generically because it sits in hot loops. Same machinery, [1690], chosen per case.
A failed check calls a fixed, never-returning symbol
A failed check calls a fixed, never-returning symbol. Two scalars, no strings: 'site' is a number the compiler assigns per check, and the file and line for it live in a side table that constrained builds simply omit.
panic_kind: type = out_of_range | overflow | bad_conversion | unreachable public panic_handler: (kind: panic_kind, site: u32) -> noreturn = loop do end loop end panic_handler
THE PRINCIPLES BEHIND THE DECISIONS
Require a capability, do not track an effect
Require a capability, do not track an effect. Where another language would record in a type that a function performs input and output, allocates, or reads the clock, Landin makes it take the thing: an allocator, an Io, a peripheral handle, a random source. This is why there is no effect system and why there does not need to be one. Said exactly, because the obvious wording claims more than is true. A function below a root can do only what it was given, and the argument list is the whole enforcement. The roots are where authority is minted rather than passed, and there are two, both nameable: the entry point, where the host capability comes from [1660], and an address literal in a driver, where a peripheral does [0460]. Nothing stops an ordinary function reaching for either. So between the roots it is enforced, and at them it is a habit. Restricting the first to the entry module would be cheap, and would turn "this subtree cannot touch the world" from a habit into a checkable claim. It would not close the second, because a driver has to be able to write ptr(0x4002_0000). That asymmetry is why this is stated rather than enforced — and it is a tightening available later rather than a repair that is owed.
One mechanism, two readings, is better than two mechanisms
One mechanism, two readings, is better than two mechanisms. Generic code is a value plus evidence that its type satisfies a concept. When the compiler knows the type it can specialise and the evidence disappears; when it does not, the evidence is carried and the type is erased, and that is exactly what 'any C' is. Static generics and runtime dispatch are one thing seen from two sides, not two features.
Atoms are the same idea wherever they appear
Atoms are the same idea wherever they appear: identity without payload. Enumerations, error sets, variant tags, register encodings and panic kinds are uses of atoms, not separate categories. There is no 'enum' in the language because there is nothing left for it to be.
How a new feature earns its place
How a new feature earns its place. When a program cannot be written cleanly, in order:
| ask, in order | then |
|---|---|
| can an existing mechanism express it? | a library |
| can the compiler work it out itself? | no syntax |
| must the programmer say it, and does saying it generalise or remove another mechanism? | a candidate |
| does it only solve this one case? | not yet |
A new mechanism should let two old ones leave the building.
What the language claims, at this version, plainly
What the language claims, at this version, plainly. It performs local checks: origins and escape, consumption [0910], reference permission [0430], bounds, conversions and arithmetic. It is not a memory-safe language and not a resource-safe one. Pointer-to-integer conversion and the C boundary leave the checked model altogether [0470], a copy taken before a sink is refused nothing [0910], and two arenas are indistinguishable [0860]. That is a smaller claim than 'safe' and a larger one than C's. The table that lets a reader see exactly which operation falls where does not exist yet; ROADMAP.md grows it alongside executable cases at R2.90 and closes the matrix at R7.40. Until then, read the claim as: deliberately unsafe, with static help that is worth having. Checks stay on by default. unchecked exists in the design [1120] and is not implemented first, because defining what an optimiser may then assume is a decision that should wait for a compiler that can be measured.
Check once, then carry the proof
Check once, then carry the proof. A successful test yields a value that stands for what was checked — a buffer that passed the alignment test becomes a dma_buffer, and the DMA interface asks for nothing else. distinct types, range subtypes and error sets are enough to do this; it needs no feature of its own, only the habit.
WHAT LANDIN DELIBERATELY DOES NOT HAVE
no classes, inheritance, methods or runtime type information no exceptions, no stack unwinding, no catchable panics no garbage collection and no reference counting no destructors no closures that capture no implicit conversions no null no positional tuples: anonymous records with named fields exist no function name overloading and no multiple dispatch no user-defined operators and no macros no compile-time execution no separate interface files no header parsing
WHAT IS STILL OPEN
ROADMAP.md, and not a second list here. spec.md is the normative authority for language semantics and this file explains them; ROADMAP.md is the sole durable authority for open work, implementation dependencies, phase gates, dispositions and completion evidence. Every inherited item is traced to the construct, prototype finding or archived review section it came from.
There is no compiler yet, but implementation begins immediately rather than waiting for every unresolved foundation. R0 establishes the Ada bootstrap chassis and R1 the executable kernel on Linux x86-64. The first major compiler milestone is R3, a complete derived parser program with evidence-table dispatch and any but without specialization. Target work continues through native macOS arm64 and emulator-first Cortex-M.
The endpoint is feature-complete pre-v1. Production status, release versioning, package acquisition, competitive optimization and self-hosting are outside this roadmap. A future roadmap may replace tested Ada stages incrementally; no self-hosting work or serialized cross-language stage protocol is scheduled now.
WHAT WAS TRIED AND DROPPED
The revision log this replaces recorded every change across a long sequence of versions. Most of it was arrival. What is worth carrying is the other kind of entry: the thing that was designed, sometimes built, and then taken out again — because a reader who does not know that will propose it back, and because a design is partly defined by what it refused.
- an ambient environment, carried in a register, holding the allocator and the diagnostics and the Io — Odin's context. Designed in full, then removed. Once the argument was that every property it needed made it visible anyway, the only thing left was its implicit flow, and that was the thing worth losing. It became an ordinary parameter and the ABI lost a reserved register [1680].
- compile-time execution, refused twice. It would have given generics, macros and configuration from one mechanism, and it costs an interpreter inside the compiler. The accepted price is that generated tables and vendor bindings come from generator programs [1540].
- witness tables as the only story, then monomorphisation as the only story: both refused. The table is the foundation and specialising is an optimisation weighed per instantiation [1310].
- cost annotations on a conformance, and the concepts that went with them. Removed entirely: how finely a concept distinguishes what an operation costs is a library's business, and no language guarantee should rest on how clever an optimiser happens to be [1270].
- a concept for ranges. A range is an ordinary value satisfying iterable, and left-exclusive forms went with it [0360].
- a second type with the shape of a fixed array and different operator rules. Fixed arrays are the vector type [0590].
- transposed collections, designed and deferred: they touch aliasing, generics, slicing, addr, layout, debug information and the optimiser at once, and no program has yet needed one [0620].
- weak conformances, built and removed. Libraries would declare weakly and applications override strongly, which worked — and meant an application could quietly change the behaviour of generic code inside a library. A collision is simply an error [1280].
- labels on if and match, removed; they earn their place on loops and bare blocks only [1180].
- errdefer, refused, and then arrived at from the other side. A cancellable defer was tried first and dropped, because the cancel always sits where the block succeeds and is hand-made bookkeeping for a question the exit already answers. defer with an argument was dropped because it reads as a call. What went in is undo, its own word, because to defer is to do it later and this may never be done at all [1110].
- read-only reference types spelled as a second form of every reference — considered twice and refused both times, then arrived at anyway from the other end. What was refused was deep const with an implicit widening; what went in is permission in the type with one stated relaxation, and it removed two mechanisms rather than adding one [0430].
- permission derived from where a reference came from, which lasted one version. It rescued the register case and could not express the commonest signature in a driver, so it was replaced by permission in the type [0430].
- inferred derivation for the from clause, refused with a counterexample rather than an argument: mechanically an allocator's result does come out of the allocator, so inference forbids a second live allocation [0790].
- braces, which were never a decision. They crept into two register examples and a set literal and were taken out again; the language has none [0730].
- a keyword for the compile-time assertion. The collision with the ordinary assertion was resolved by subtraction: it is compiler.assert now, a builtin call like the rest, and the language has one keyword fewer [1510].
- sets as a kind of their own, with a literal, three operators and a membership operator, all of which were designed and then not needed: set(X) generates a packed struct of bool, so membership is a field read [0730].
- affine values, which cannot be copied. Not refused — parked, with the condition that would bring them in, and with sink honestly described in the meantime as a use-after-consume check on one place rather than as ownership [0910].
- async and await, and the stackless coroutines under them, refused before either was built. Concurrency is not a property of a function here; it is a capability the caller hands down, so the same code blocks or does not depending on the Io it was given [1660]. One implementation of that Io ships first and it blocks, which is a scheduler not yet written rather than a limit in the language. Stackless is the refusal proper: cutting a function into a state machine is a compiler project of its own, and it colours every function type that reaches one — the same objection that removed the ambient environment above [1680]. Stackful fibres are the route to take instead, and the design already pays for them: the frame pointer is always present, the callee-saved discipline is explicit, and nothing rides in a reserved register.