The Lake Programming Language
Lake is a process-oriented programming language built around machines, branches, and state transitions.
Programs in Lake are composed of machines — lightweight processes that define behavior through pattern-matched branches with typed parameters. Machines communicate by spawning new processes or transitioning their own state via self(). A cooperative scheduler manages concurrent execution of all spawned machines.
@rt(rt_write)
counter is {
n i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1) }
}
}
}
main is {
_ i64.0 -> {
counter(5)
counter(3)
counter(7)
}
}
This program spawns three independent counter processes that run concurrently. Each counter decrements until it reaches zero, then prints “done”.
Key Ideas
- Machines are the core abstraction — each machine call spawns a new cooperatively-scheduled process
- Branches define behavior through pattern matching on argument types
self(args)performs a state transition within the current process (no new process is spawned)- Calling another machine spawns it as a new concurrent process
- Cooperative scheduling — each process runs a quantum of work before yielding to the scheduler
- O(1) branch dispatch — branches are selected by hashing the argument types at compile time
Status
Lake is in active development. This book documents the features that are currently implemented and working in the native compiler.
Getting Started
Building the Compiler
The Lake native compiler is written in Rust (edition 2024) and uses Cranelift as its code generation backend:
git clone https://github.com/morphqdd/lake-native-compiler.git
cd lake-native-compiler
cargo build
Running an Example
cargo run -- examples/counter.lake -o counter
./counter
First Program
Here is a minimal Lake program:
@rt(rt_write)
main is {
_ i64.0 -> {
rt_write(1 "hello, lake!\n" 14)
}
}
This program:
- Declares a runtime function
rt_writevia the@rtdirective - Defines a
mainmachine with a single branch - Calls
rt_writewith a file descriptor (1= stdout), a string, and its length
Program Structure
Every Lake program consists of:
- Directives — compiler attributes that declare runtime functions (
@rt) - Machines — the program logic, defined with
is
@rt(rt_write) # 1. directive
main is { # 2. machine
_ i64.0 -> {
rt_write(1 "done\n" 5)
}
}
Machines can be declared in any order — forward references are resolved automatically.
Machines
A machine is the core abstraction in Lake. Every machine call spawns a new cooperatively-scheduled process.
Defining a Machine
name is {
# branches
}
The is keyword separates the machine name from its body. The body is enclosed in curly braces and contains one or more branches.
A Simple Machine
A counter that recursively decrements until it reaches zero:
@rt(rt_write)
counter is {
n i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1) }
}
}
}
main is {
_ i64.0 -> {
counter(5)
}
}
When main calls counter(5), a new process is spawned running the counter machine. The self(n-1) call does not spawn a new process — it transitions the current process to a new state.
Concurrent Execution
Calling a machine always spawns a new process. Multiple spawns create concurrent processes managed by the cooperative scheduler:
@rt(rt_write)
worker is {
steps i64 acc1 i64 acc2 i64 -> {
when 1 <= steps {
true -> { self(steps-1 acc2 acc1+acc2) }
false -> { rt_write(1 ".\n" 2) }
}
}
}
main is {
_ i64.0 -> {
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
}
}
Here main spawns four worker processes. They execute concurrently — each process runs a quantum of work (256 blocks) before yielding to the scheduler.
Ping-Pong Example
Machines can spawn each other:
@rt(rt_write)
pong is {
_ i64.0 -> {
rt_write(1 "pong\n" 5)
}
}
ping is {
_ i64.0 -> {
rt_write(1 "ping\n" 5)
pong()
}
}
main is {
_ i64.0 -> {
ping()
ping()
ping()
}
}
main spawns three ping processes. Each ping prints “ping” and then spawns a pong process that prints “pong”.
Declaration Order
Machines can be declared in any order. Forward references work:
main is {
_ i64.0 -> {
worker(10)
}
}
worker is {
n i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1) }
}
}
}
Branches & Patterns
Branches define a machine’s behavior. Each branch describes which arguments are accepted and what happens when they are received.
Branch Syntax
pattern+ -> { body }
pattern+— one or more patterns (parameters){ body }— branch body containing expressions
Patterns
A pattern declares a parameter with a name, a type, and an optional default value:
ident Type # typed parameter
ident Type.default # parameter with default value
_ # wildcard — ignored parameter
Typed Parameters
sum is {
n i64 acc i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1 acc+n) }
}
}
}
This branch takes two i64 parameters: n and acc.
Default Values
A default value is specified after the type, separated by a dot (.):
main is {
_ i64.0 -> {
counter(5)
}
}
_ i64.0 — a wildcard parameter of type i64 with default value 0. Parameters with defaults do not participate in branch signature matching.
Wildcard
The _ symbol means the argument is ignored. It does not bind a variable:
pong is {
_ i64.0 -> {
rt_write(1 "pong\n" 5)
}
}
Branch Dispatch
When a machine is called, the compiler selects the matching branch based on the types of the provided arguments. Branch dispatch is O(1) — argument types are hashed at compile time.
Only non-default, non-wildcard pattern types participate in the hash. For example:
counter is {
n i64 -> { ... }
}
A call counter(5) matches this branch because 5 is i64 and the branch expects i64.
Multiple Parameters
Parameters are separated by spaces:
worker is {
steps i64 acc1 i64 acc2 i64 -> {
when 1 <= steps {
true -> { self(steps-1 acc2 acc1+acc2) }
false -> { rt_write(1 ".\n" 2) }
}
}
}
Three parameters: steps, acc1, and acc2, all of type i64.
Scope
Each branch has its own scope. Variables declared through patterns are only accessible within that branch’s body.
Types
Primitive Types
Lake’s primitive types are:
| Type | Description |
|---|---|
i64 | 64-bit signed integer |
str | Immutable string literal (fat pointer: start + end addresses) |
buf | Runtime byte buffer (fat pointer: address + length) |
pid | Process identifier (fat pointer to process context) |
All values are represented as i64 at the machine code level. str literals
are stored as read-only data with fat pointers. A buf is a heap-allocated
byte buffer used for runtime data (file reads, network bytes, growable string
backing) — see the Standard Library std.bytes module for
bounded access. Process identifiers are heap pointers that remain stable for
the lifetime of the process.
Beyond the primitives, you can define your own records, enums, and generic types.
Type Annotations
Types appear in patterns:
n i64 # 64-bit integer
And in default values:
_ i64.0 # i64 with default value 0
Type-Based Dispatch
Types are central to branch dispatch. When a machine is called, the compiler matches the argument types against branch signatures:
counter is {
n i64 -> { ... } # matches calls with one i64 argument
}
The hash of argument types determines which branch receives the call. This dispatch is O(1) at runtime.
Process Identifiers (pid)
The pid type represents a handle to a spawned process. When a machine is called, it returns a pid that can be used to send messages to that process:
receiver is {
_ i64.0 -> {
wait { n i64 -> { rt_write(1 "got message\n" 12) } }
}
}
main is {
_ i64.0 -> {
let p pid = receiver()
p(42) # send message to receiver
}
}
A pid is a stable heap pointer that identifies the process for its entire lifetime. It can be stored in variables, passed as arguments, and sent through mailboxes.
Records
Records provide a way to group related data together with named fields. They are declared using the is keyword and consist of field declarations (no branches).
Record Declaration
A record is declared similarly to a machine, but with a block containing only field declarations:
Request is {
method buf
path buf
body buf
}
Each field has a name and a type. Records are nominal — two records with identical fields are distinct types. Records are read-only in v1; changes require constructing a new record.
Construction
Records can be constructed in two ways: positionally or by named fields.
Positional Construction
Fields are provided in the order they are declared:
let r = Request("GET" "/" empty_buf)
The number and types of arguments must match the declared fields exactly.
Named Construction
Fields are provided by name, in any order:
let r = Request { method = "GET" path = "/" body = empty_buf }
Named construction is reordered to the declaration order internally. This form is useful when field order is not obvious or when constructing with clarity in mind.
Field Access
Access a field using the dot (.) operator:
r.method # : buf
r.path # : buf
r.body # : buf
Field access returns the type of the field.
Destructuring
Extract fields from a record using a let binding with a pattern:
let { method path body } = r
# now method, path, and body are available as local variables
Wildcards (_) can be used to ignore fields:
let { method _ _ } = r
# only method is bound; path and body are ignored
Patterns are positional (in declaration order). Nested patterns are supported for complex destructuring.
Example
+std.io.{ println print_buf }
Greeting is {
who buf
count i64
}
main is {
_ -> {
let g = Greeting("world" 3)
pin println(g.who)
let { who count } = g
pin print_buf(who)
pin println("!")
}
}
Generic Records
Records can take type parameters in square brackets, so the same shape works for many element types:
Box[T] is { val T }
Pair[K V] is { k K v V }
See the Generics chapter for construction and use-site annotations.
Error Codes
| Code | Description |
|---|---|
| E020 | Record constructor arity mismatch (wrong number of arguments) |
| E021 | Record constructor argument type mismatch |
| E022 | Named record construction missing required field |
| E023 | Named record construction contains unknown field |
| E024 | Named record construction has duplicate field |
| E025 | Field access to non-existent field |
| E026 | Destructure pattern arity mismatch |
| E027 | Destructure pattern type mismatch on nested field |
| E028 | Record name collides with machine, constant, or FFI declaration |
Limitations
Records have the following limitations:
- Read-only fields — no field mutation; reconstruct the whole record for changes. This is why the standard library’s collections (
Vec,String,IntMap) thread an updated value out of every mutating call. - No recursive types — a record cannot have a field of its own type.
- No field-rename destructure —
let { method = m } = ris not allowed; use positional patterns instead. - No type-tag matching — to match on which kind of value you have, use an enum rather than a record.
Enums
An enum is a type that holds exactly one of several named variants. Each variant may carry a payload (zero or more fields). Enums give you tagged unions: a value knows which variant it is, and pattern matching lets you recover the payload safely.
Declaration
Enums are declared with is enum { ... }. Each variant is a name, optionally
followed by a parenthesized list of payload types:
Result is enum {
Ok(i64)
Err(buf)
}
Range is enum {
Empty
Bounded(i64 i64)
}
Empty is a nullary variant — it carries no payload. Bounded(i64 i64)
carries two integers.
Construction
Build a value with EnumName.Variant(args). Nullary variants drop the
parentheses:
let good = Result.Ok(42)
let bad = Result.Err("oops")
let e = Range.Empty
let b = Range.Bounded(3 10)
Pattern Matching
Use when to match a value against its variants. Each arm binds the
variant’s payload to fresh names; use _ to ignore a payload slot:
unwrap is {
r Result -> ret i64 {
when r {
Ok(n) -> { ret n }
Err(_) -> { ret 0 - 1 }
}
}
}
Multi-field payloads bind each slot in order:
width is {
r Range -> ret i64 {
when r {
Empty -> { ret 0 }
Bounded(lo hi) -> { ret hi - lo }
}
}
}
A nullary variant matches by bare name (Empty -> { ... }).
Exhaustiveness
A when over an enum must cover every variant. If you leave one out and
provide no wildcard arm, the compiler rejects the program:
Shape is enum {
Circle(i64)
Square(i64)
Triangle(i64 i64 i64)
}
area_kind is {
s Shape -> ret i64 {
when s {
Circle(_) -> { ret 1 }
Square(_) -> { ret 2 }
-- ERROR: missing `Triangle` arm (no wildcard)
}
}
}
Add a _ catch-all to make the match exhaustive without listing every
variant:
kind_of is {
s Shape -> ret i64 {
when s {
Circle(_) -> { ret 1 }
Square(_) -> { ret 2 }
_ -> { ret 99 }
}
}
}
Generic Enums
Enums can take type parameters in square brackets (see the Generics chapter for the full picture). The two most important generic enums ship in the standard library:
Option[T] is enum {
Some(T)
None
}
Result[T E] is enum {
Ok(T)
Err(E)
}
A generic machine can match on them just like a concrete enum:
unwrap_or[T] is {
o Option[T] d T -> ret T {
when o {
Some(v) -> { ret v }
None -> { ret d }
}
}
}
When you construct a None (or any variant whose payload doesn’t pin down
the type parameters), annotate the binding so the compiler can pick the
concrete instantiation:
main is {
_ -> {
let x = Option.Some(42) -- T = i64 inferred from 42
let y Option[i64] = Option.None -- annotation needed: payload-free
let a = unwrap_or(x 0)
let b = unwrap_or(y 99)
}
}
Option[T] and Result[T E] are also available — with helpers like
is_some, is_none, is_ok, is_err, and unwrap_or — from
std.option and std.result. See the
Standard Library chapter.
Generics
Generics let you write a record, enum, or machine once and reuse it across many element types. Type parameters appear in square brackets after the name. Lake resolves them by monomorphisation: every concrete use site is compiled into its own specialized copy, so there is no runtime cost.
Generic Records
Add [T] (or several parameters) to a record declaration. Fields may then
use the type parameter:
Box[T] is { val T }
Pair[K V] is { k K v V }
Construction infers the type parameters from the argument types:
main is {
_ -> {
let b = Box(42) -- Box[i64]
let p = Pair(1 "hello") -- Pair[i64 buf]
}
}
Generic Machines
A machine can be generic too. Its parameters are bound from the call
arguments and may appear in the parameter types and the ret type:
unbox[T] is {
b Box[T] -> ret T {
ret b.val
}
}
main is {
_ -> {
let b = Box(42)
let v = unbox(b) -- T = i64, v : i64
}
}
A trivial identity machine shows the shape clearly:
id[T] is {
x T -> ret T { ret x }
}
Generic Enums
Enums accept type parameters in exactly the same way. Option[T] and
Result[T E] are the canonical examples (covered in the
Enums chapter):
Option[T] is enum {
Some(T)
None
}
Result[T E] is enum {
Ok(T)
Err(E)
}
unwrap_or[T] is {
o Option[T] d T -> ret T {
when o {
Some(v) -> { ret v }
None -> { ret d }
}
}
}
Use-Site Types and Annotations
Write a concrete instantiation by filling the brackets: Vec[i64],
Box[i64], Result[i64 buf]. You often need this on let bindings whose
right-hand side does not, on its own, pin down the type parameters — for
example a constructor that returns an empty collection, or a payload-free
enum variant:
main is {
_ -> {
let v Vec[i64] = vec_new() -- vec_new() has no element to infer from
let y Option[i64] = Option.None -- None carries no payload
let r Result[i64 buf] = Result.Ok(7)
}
}
When the arguments already determine the type — like Box(42) or
Option.Some(42) — the annotation is optional.
Two Generics, Same Name
Because dispatch is by argument type, two generic machines that share a
name can coexist as long as they live in different modules and take
distinguishable argument types. The compiler picks the right one from the
call’s argument types. This is how unwrap_or works for both Option[T]
(in std.option) and Result[T E] (in std.result).
Notes and Limitations
- Monomorphisation happens at compile time; each distinct instantiation becomes its own specialized function. There is no boxing or dynamic dispatch.
- Value/return inference sometimes needs an explicit annotation on an
intermediate binding. If the compiler can’t infer a type parameter,
annotate the
let(as above). IntMap[V]is keyed byi64. A fully generic hash map (arbitrary key types) is not available yet — see the Standard Library chapter.
Expressions
Expressions make up the body of branches.
Literals
42 # number (i64)
0 # number (i64)
100000 # number (i64)
0x7fffffff # hexadecimal number (i64)
"hello\n" # string literal (str)
b"GET " # byte-string literal (buf)
'A' # char literal (i64 — the byte value, 65)
true # boolean
false # boolean
Numbers are i64 and may be written in decimal or hex (0x...). String
literals (str) support escape sequences: \n, \t, \r, \\, \".
A byte-string literal b"..." has type buf instead of str — useful
where an API wants a runtime byte buffer rather than a static string span.
A char literal 'X' is sugar for the byte’s integer value (there is no
dedicated char type): 'A' is 65, '\n' is 10, '\xff' is 255. It
reads more clearly than a bare number when working with byte data:
when at(b i) == '\n' {
true -> { ... }
_ -> { ... }
}
Variables
Names declared in branch patterns:
counter is {
n i64 -> {
self(n-1) # "n" is available here
}
}
Let Bindings
let x i64 = 42
Creates a local variable within the current branch.
Arithmetic
Binary operators with precedence:
| Precedence | Operators | Description |
|---|---|---|
| 10 (highest) | * / | Multiplication, division |
| 9 | + - | Addition, subtraction |
All operators are left-associative and operate on i64:
n - 1 # subtraction
acc + n # addition
acc1 + acc2 # addition
steps - 1 # subtraction
Arithmetic works in arguments:
self(n-1 acc+n) # two computed arguments
self(steps-1 acc2 acc1+acc2) # three, one with addition
Comparisons
| Precedence | Operators | Description |
|---|---|---|
| 8 | <= >= == < > | Comparison |
0 == n # equality check
1 <= steps # less-or-equal check
Lower precedence than arithmetic: a + b <= c means (a + b) <= c.
Lake has no != operator. Test for inequality by matching == in a when
and handling the non-matching case in a _ (or false) arm.
Bitwise and Shift Operators
Integers can also be combined bit by bit:
| Operators | Description |
|---|---|
<< >> | left / right shift (logical) |
& | bitwise AND |
^ | bitwise XOR |
| | bitwise OR |
These treat i64 as a raw bit pattern. They are common in byte-level code
and as a stand-in for the (absent) modulo operator when the divisor is a
power of two — x & (n - 1) computes x % n:
let slot = hash & (cap - 1)
let hi = word >> 8 & 0xff
Parenthesize freely to make precedence explicit, e.g. (a & (b + 1)).
Indexing
A buf supports byte indexing with buf[i], which reads the byte at offset
i as an i64:
let first = b[0]
When Expressions
Conditional branching based on an expression:
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1) }
}
when 1 <= steps {
true -> { self(steps-1 acc2 acc1+acc2) }
false -> { rt_write(1 ".\n" 2) }
}
Syntax:
when condition {
pattern -> { body }
pattern -> { body }
}
The condition is any expression. Arms match on literal values (numbers, booleans). If no arm matches, execution falls through silently.
Numeric pattern matching is also supported:
when some_value {
0 -> { ... }
1 -> { ... }
2 -> { ... }
}
when also matches enum variants, binding each variant’s
payload. Matching over an enum must be exhaustive (cover every variant or
include a _ arm):
when r {
Ok(n) -> { ret n }
Err(_) -> { ret 0 - 1 }
}
Wait Expression
The wait expression suspends the current process until a message arrives in its mailbox:
wait {
n i64 -> { rt_write(1 "received\n" 9) }
}
When a message arrives, it is dequeued from the mailbox and the handler body executes with the message value bound to the pattern variables.
If the mailbox is empty, the process is suspended and moved to the scheduler’s wait array. When another process sends a message, the waiting process is awakened.
Multiple messages can be handled by using wait in a loop:
receiver is {
remaining i64 -> {
when 1 <= remaining {
true -> {
wait {
n i64 -> {
rt_write(1 "." 1)
self(remaining-1)
}
}
}
}
}
}
main is {
_ i64.0 -> {
let r pid = receiver(3)
r(1)
r(2)
r(3)
}
}
Message Sending
Calling a pid-typed variable sends a message to that process:
let p pid = worker()
p(42) # send 42 to the worker process
The syntax is identical to calling a machine, but when the callee is a pid variable, it becomes a message send instead of a spawn.
Messages are enqueued in the target process’s mailbox (a ring buffer of 256 slots). If the target is waiting, it is immediately awakened and moved back to the scheduler’s active queue.
Ping-Pong Example
ponger is {
_ i64.0 -> {
wait { partner pid -> { self(partner 3) } }
}
partner pid remaining i64 -> {
when 1 <= remaining {
true -> {
wait { n i64 -> { partner(1) self(partner remaining-1) } }
}
}
}
}
pinger is {
partner pid remaining i64 -> {
when 1 <= remaining {
true -> {
partner(1)
wait { n i64 -> { self(partner remaining-1) } }
}
}
}
}
main is {
_ i64.0 -> {
let po pid = ponger()
let pi pid = pinger(po 3)
po(pi)
}
}
The ponger waits to receive the pinger’s pid, then they exchange messages in a loop.
State Transitions
State transitions are the primary control flow mechanism in Lake. There are two kinds:
self() — Internal State Transition
self(args) transitions the current process to a new state without spawning a new process. The current branch’s variables are replaced with the new arguments, and execution restarts from the matched branch.
This is the primary looping mechanism:
counter is {
n i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1) }
}
}
}
self(n-1) does not recurse on the call stack — it transitions the process state and the scheduler re-enters the machine.
Arguments can include arithmetic:
sum is {
n i64 acc i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1 acc+n) }
}
}
}
machine(args) — Spawn a New Process
Calling any machine other than self spawns a new concurrent process:
main is {
_ i64.0 -> {
counter(5)
counter(3)
counter(7)
}
}
Each counter(N) spawns an independent process. The spawning process continues immediately — it does not wait for the spawned process to finish.
Cooperative Scheduling
All spawned processes are managed by a cooperative scheduler. Each process runs a quantum of work (256 blocks) before yielding. This means concurrent processes make interleaved progress:
@rt(rt_write)
worker is {
steps i64 acc1 i64 acc2 i64 -> {
when 1 <= steps {
true -> { self(steps-1 acc2 acc1+acc2) }
false -> { rt_write(1 ".\n" 2) }
}
}
}
main is {
_ i64.0 -> {
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
worker(100000 0 1)
}
}
Eight worker processes execute concurrently, each computing 100,000 Fibonacci iterations.
pid(args) — Send a Message
Calling a pid-typed variable sends a message to that process instead of spawning:
receiver is {
_ i64.0 -> {
wait { n i64 -> { rt_write(1 "got it\n" 7) } }
}
}
main is {
_ i64.0 -> {
let p pid = receiver()
p(42) # send message, not spawn
}
}
When you call a machine, it returns a pid. You can store that pid and send messages to it later.
Messages are enqueued in a 256-slot ring buffer. If the target process is suspended (via wait), it is immediately awakened.
See Expressions for details on wait and message sending.
Runtime Functions
Calls to @rt-declared functions are inlined — they execute immediately without spawning a process:
rt_write(1 "hello\n" 6) # direct call, no process spawned
See Directives for the available runtime functions.
Directives
Directives are compiler attributes that declare runtime functions. They are placed before machines.
@rt — Runtime Function
Binds a name to a built-in runtime function:
@rt(rt_write)
After this declaration, rt_write can be called directly from any branch. Unlike machine calls, runtime function calls do not spawn a new process — they execute inline.
Available Runtime Functions
| Function | Arguments | Description |
|---|---|---|
rt_write | fd data size | Write size bytes from data to file descriptor fd |
rt_exit | code | Exit the program with the given exit code |
rt_allocate | size | Allocate size bytes on the heap, returns fat pointer |
rt_store | ctx value size offset | Write value to memory at offset |
rt_load_u64 | ctx offset | Read a 64-bit value from memory at offset |
rt_mmap | addr size prot flags fd off | Raw mmap syscall |
rt_syscall | varies | Raw syscall wrapper |
Common Usage
Writing to stdout:
@rt(rt_write)
main is {
_ i64.0 -> {
rt_write(1 "hello, lake!\n" 14)
}
}
The arguments to rt_write are: file descriptor (1 = stdout), string data, and byte length.
@cfg — Per-Target Item Filtering
@cfg(arch="...") keeps an item only when compiling for a matching target
architecture; non-matching items are dropped before name resolution. The
supported values are "x86_64" and "aarch64". This lets you declare the
same name twice — once per architecture — without a collision:
@cfg(arch="x86_64")
pub const SYS_SOCKET = 41
@cfg(arch="aarch64")
pub const SYS_SOCKET = 198
Whichever variant matches the --target survives; the other is filtered
out, so SYS_SOCKET resolves to the architecture-correct value. The
standard library uses this in std.experimental.sys to give each Linux
syscall number a single name across both architectures.
Imports
A line beginning with + imports the named public items from a module:
+std.io.{ println eprintln }
+std.vec.{ vec_new vec_push }
Module paths nest with dots (std.crypto.sha256). See the
Standard Library chapter.
Constants
const declares a module-level compile-time constant with a literal value.
It is inlined at every use site. Prefix with pub to export it:
pub const FD_STDOUT = 1
pub const O_CREAT = 64
Placement
Directives are placed at the top of the file, before any machine definitions:
@rt(rt_write)
counter is {
n i64 -> {
when 0 == n {
true -> { rt_write(1 "done\n" 5) }
false -> { self(n-1) }
}
}
}
main is {
_ i64.0 -> {
counter(5)
}
}
Standard Library
The Lake standard library lives under std/ and is written in Lake itself.
You bring symbols into scope with an import line at the top of a file:
+std.io.{ println }
+std.vec.{ vec_new vec_push vec_get vec_len }
The +module.{ names } form imports the listed public (pub) items from a
module. Modules nest with dots (std.crypto.sha256, std.postgres.types).
To compile against the stdlib, point the compiler at the std/ directory.
Either set STD_PATH (or LAKE_PATH) or pass it on the command line:
STD_PATH=/path/to/lake-stdlib/std lakec -O speed main.lake -o out
Module Overview
| Module | Role |
|---|---|
std.io | print / println / eprint / eprintln (plus _buf variants) over stdout/stderr |
std.option | Option[T] enum + is_some / is_none / unwrap_or |
std.result | Result[T E] enum + is_ok / is_err / unwrap_or |
std.vec | Vec[T] growable vector + vec_new / vec_push / vec_get / vec_len |
std.string | String owned, growable byte string |
std.strings | string/buf algorithms: slice, search, trim, int_to_buf, parse_int, line scanning |
std.hashmap | IntMap[V] integer-keyed hash map |
std.bytes | bounded byte access over a buf: at / set, at64 / set64, big-endian helpers, size |
std.math | abs / mod / min / max |
std.process | per-actor allocation (arena_alloc, alloc_or_die) and die |
std.sys | process-global exit(code) |
std.env | argc / argv_at / envp_at |
std.tcp | TCP server primitives: listen / accept / send / recv / close |
std.crypto.sha256 | sha256 digest |
std.crypto.hmac | hmac_sha256 |
std.crypto.pbkdf2 | pbkdf2_sha256 |
std.encoding.base64 | encode / decode |
std.postgres | PostgreSQL client (connect / query / prepare / execute) |
std.experimental.sys | raw Linux syscall numbers and wrappers (per-arch via @cfg) |
The sections below cover the collections you’ll reach for most often, with examples adapted from the stdlib’s own smoke tests.
io
I/O machines are ret-typed. A bare call is fire-and-forget (the scheduler
orders the output); prefix with pin to force sequential, in-order output:
+std.io.{ println println_buf }
main is {
_ -> {
pin println("hello")
pin println_buf(some_buf)
}
}
println takes a str literal; println_buf takes a runtime buf (such
as a file read or a converted integer).
Option and Result
Option[T] models presence/absence; Result[T E] models success/failure.
Both expose unwrap_or plus predicate helpers:
+std.io.{ println }
+std.option.{ Option unwrap_or is_some is_none }
+std.result.{ Result is_ok is_err }
main is {
_ -> {
let s = Option.Some(42)
let n Option[i64] = Option.None
when unwrap_or(s 0) == 42 {
true -> { println("ok some") }
_ -> { println("FAIL some") }
}
when unwrap_or(n 99) == 99 {
true -> { println("ok none") }
_ -> { println("FAIL none") }
}
let r Result[i64 buf] = Result.Ok(7)
when r {
Ok(v) -> { println("ok result") }
Err(_) -> { println("FAIL result") }
}
let e Result[i64 buf] = Result.Err("boom")
when is_err(e) == 1 {
true -> { println("ok is_err") }
_ -> { println("FAIL is_err") }
}
}
}
Note is_some / is_ok etc. return 1 or 0 — compare against 1 (Lake
has no !=; invert with a when == instead).
Vec
Vec[T] is a growable vector. It is value-threaded: vec_push may
reallocate, so it returns a (possibly new) Vec that you must bind and
carry forward:
+std.io.{ println }
+std.vec.{ vec_new vec_push vec_get vec_len }
fill is {
v Vec[i64] i i64 -> ret Vec[i64] {
when i >= 100 {
true -> { ret v }
_ -> { self(vec_push(v i * 7) i + 1) }
}
}
}
main is {
_ -> {
let v Vec[i64] = vec_new()
let v2 Vec[i64] = fill(v 0)
when vec_len(v2) == 100 {
true -> { println("ok") }
_ -> { println("FAIL") }
}
}
}
| Function | Signature | Description |
|---|---|---|
vec_new | () -> Vec[T] | empty vector (annotate the binding) |
vec_push | (Vec[T] T) -> Vec[T] | append; returns the threaded vector |
vec_get | (Vec[T] i64) -> T | element at index |
vec_len | (Vec[T]) -> i64 | element count |
String
String is an owned, growable byte string (as opposed to immutable str
literals). Like Vec, it is value-threaded:
+std.io.{ println println_buf }
+std.string.{ String str_from str_push str_append str_len str_eq str_to_buf }
+std.strings.{ int_to_buf }
main is {
_ -> {
let a = str_from("hello" 5)
let b = str_push(a 33) -- push byte 33 ('!')
pin println_buf(int_to_buf(str_len(b))) -- 6
pin println_buf(str_to_buf(b)) -- hello!
let d = str_append(str_from("foo" 3) str_from("bar" 3))
pin println_buf(str_to_buf(d)) -- foobar
}
}
str_from(s n) copies the first n bytes of literal s. str_to_buf
returns a fresh buf of exactly the live bytes — handy for println_buf.
IntMap
IntMap[V] is an integer-keyed hash map (open addressing, linear probing),
generic over the value type. It is value-threaded — thread the result of
intmap_put:
+std.io.{ println_buf }
+std.hashmap.{ IntMap intmap_new intmap_put intmap_get intmap_has intmap_len }
+std.strings.{ int_to_buf }
fill is {
m IntMap[i64] i i64 -> ret IntMap[i64] {
when i >= 50 {
true -> { ret m }
_ -> { self(intmap_put(m i i * 100) i + 1) }
}
}
}
main is {
_ -> {
let m0 IntMap[i64] = intmap_new()
let m IntMap[i64] = fill(m0 0)
pin println_buf(int_to_buf(intmap_len(m))) -- 50
pin println_buf(int_to_buf(intmap_get(m 7))) -- 700
pin println_buf(int_to_buf(intmap_has(m 123))) -- 0
}
}
| Function | Signature | Description |
|---|---|---|
intmap_new | () -> IntMap[V] | empty map (annotate the binding) |
intmap_put | (IntMap[V] i64 V) -> IntMap[V] | insert/update; thread the result |
intmap_get | (IntMap[V] i64) -> V | value for key (check has first; a miss returns 0) |
intmap_has | (IntMap[V] i64) -> i64 | 1 if present, else 0 |
intmap_len | (IntMap[V]) -> i64 | entry count |
Keys are i64. A fully generic key type (string/buf keys) is not
available yet.
PostgreSQL
std.postgres is a from-scratch PostgreSQL client: it speaks the wire
protocol over a raw socket, authenticates with SCRAM-SHA-256, and uses
binary codecs for typed columns. The surface is connect / query /
prepare / execute / close, returning Conn, Rows, and Stmt
records.
+std.postgres.{ connect close query Rows ip_v4 row_count row_field }
+std.postgres.types.{ dec_int4 }
+std.io.{ println println_buf }
+std.strings.{ int_to_buf }
main is {
_ -> {
let host = ip_v4(127 0 0 1)
let c = connect(host 5432 user db password)
when c.ok {
1 -> {
let rows = query(c "SELECT 1::int4")
when rows.ok {
1 -> {
let f0 = row_field(rows 0 0)
pin println_buf(int_to_buf(dec_int4(f0 0)))
}
_ -> { pin println("query error") }
}
close(c)
}
_ -> { pin println("connect failed") }
}
}
}
The host argument is a 4-octet IPv4 buf built with ip_v4(...). The
user / db / password arguments are buf values. Prepared statements
go through prepare(c name sql) and execute(c stmt args), with parameter
encoders such as enc_int4 from std.postgres.types and args2 / args3
helpers to bundle the arguments. See examples/postgres_smoke in the
stdlib repo for a complete prepared-statement round-trip.
Current Limitations
A few rough edges to keep in mind while writing against today’s stdlib:
- No modulo operator. There is no
%; usestd.math.modor bitwise masking (x & (n - 1)whennis a power of two). - No
!=. Compare with==inside awhenand put the “not equal” case in a wildcard or_arm. - Generic inference sometimes needs a hint. Annotate intermediate
letbindings (let v Vec[i64] = vec_new()) when the right-hand side doesn’t pin down the type parameters. - Hash-map keys are
i64.IntMap[V]covers id-to-value tables; a fully generic-key map is not available yet. - Collections (
Vec,String,IntMap) are value-threaded — always bind and reuse the value a mutating call returns.