Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Declares a runtime function rt_write via the @rt directive
  2. Defines a main machine with a single branch
  3. Calls rt_write with a file descriptor (1 = stdout), a string, and its length

Program Structure

Every Lake program consists of:

  1. Directives — compiler attributes that declare runtime functions (@rt)
  2. 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:

TypeDescription
i6464-bit signed integer
strImmutable string literal (fat pointer: start + end addresses)
bufRuntime byte buffer (fat pointer: address + length)
pidProcess 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

CodeDescription
E020Record constructor arity mismatch (wrong number of arguments)
E021Record constructor argument type mismatch
E022Named record construction missing required field
E023Named record construction contains unknown field
E024Named record construction has duplicate field
E025Field access to non-existent field
E026Destructure pattern arity mismatch
E027Destructure pattern type mismatch on nested field
E028Record 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 destructurelet { method = m } = r is 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 by i64. 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:

PrecedenceOperatorsDescription
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

PrecedenceOperatorsDescription
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:

OperatorsDescription
<< >>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

FunctionArgumentsDescription
rt_writefd data sizeWrite size bytes from data to file descriptor fd
rt_exitcodeExit the program with the given exit code
rt_allocatesizeAllocate size bytes on the heap, returns fat pointer
rt_storectx value size offsetWrite value to memory at offset
rt_load_u64ctx offsetRead a 64-bit value from memory at offset
rt_mmapaddr size prot flags fd offRaw mmap syscall
rt_syscallvariesRaw 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

ModuleRole
std.ioprint / println / eprint / eprintln (plus _buf variants) over stdout/stderr
std.optionOption[T] enum + is_some / is_none / unwrap_or
std.resultResult[T E] enum + is_ok / is_err / unwrap_or
std.vecVec[T] growable vector + vec_new / vec_push / vec_get / vec_len
std.stringString owned, growable byte string
std.stringsstring/buf algorithms: slice, search, trim, int_to_buf, parse_int, line scanning
std.hashmapIntMap[V] integer-keyed hash map
std.bytesbounded byte access over a buf: at / set, at64 / set64, big-endian helpers, size
std.mathabs / mod / min / max
std.processper-actor allocation (arena_alloc, alloc_or_die) and die
std.sysprocess-global exit(code)
std.envargc / argv_at / envp_at
std.tcpTCP server primitives: listen / accept / send / recv / close
std.crypto.sha256sha256 digest
std.crypto.hmachmac_sha256
std.crypto.pbkdf2pbkdf2_sha256
std.encoding.base64encode / decode
std.postgresPostgreSQL client (connect / query / prepare / execute)
std.experimental.sysraw 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") }
    }
  }
}
FunctionSignatureDescription
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) -> Telement at index
vec_len(Vec[T]) -> i64element 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
  }
}
FunctionSignatureDescription
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) -> Vvalue for key (check has first; a miss returns 0)
intmap_has(IntMap[V] i64) -> i641 if present, else 0
intmap_len(IntMap[V]) -> i64entry 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 %; use std.math.mod or bitwise masking (x & (n - 1) when n is a power of two).
  • No !=. Compare with == inside a when and put the “not equal” case in a wildcard or _ arm.
  • Generic inference sometimes needs a hint. Annotate intermediate let bindings (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.