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

Brim

Brim is a small, statically typed programming language by Ordnary, for command-line tools and backend services. It compiles to native executables through LLVM, and comes with one brim command for everything else: running, testing, formatting, managing dependencies and editor support.

fn main() {
    let names: List[string] = ["Ada", "Grace", "Linus"]
    for name in names {
        print("Hello, " + name + "!")
    }
}
Hello, Ada!
Hello, Grace!
Hello, Linus!

Brim aims to be:

  • Explicit. Every parameter, result and field has a declared type, a variable has the type of its value, values are never converted implicitly, and errors are values (Result with the ? operator), not exceptions.
  • Predictable. Structs, lists and maps are values: assigning one copies it (cheaply, by copy-on-write). Memory is managed by reference counting, without a garbage collector.
  • Helpful. Every diagnostic has a code, points at the source and usually says how to fix the problem. Run-time errors show the call stack.

Brim is heading for 1.0, edition 2026: from 1.0 the language, the standard library and the brim command are stable (Releasing). The roadmap lists what comes next.

Start with Getting started, or take the tour.

Getting started

Installing

On macOS or Linux:

curl -fsSL https://brimlang.com/install.sh | sh

On Windows, in PowerShell:

irm https://brimlang.com/install.ps1 | iex

The script picks the build for your platform, checks its checksum and puts brim in /usr/local/bin (or ~/.local/bin). You can also download an archive from the releases yourself and put brim on your PATH. The -native archives can also compile native executables with brim build; they need a C compiler to link: cc on Linux and macOS, and on Windows clang (from LLVM) with the Visual Studio C++ build tools.

Or build it from source with Rust 1.88 or newer:

git clone https://github.com/ordnary-com/brim.git
cd brim
cargo install --path crates/brim-cli

For native builds from source, see the development guide.

A first program

Save this as hello.brim:

fn main() {
    print("Hello, world!")
}

and run it:

brim run hello.brim

brim check hello.brim only reports errors, and brim build hello.brim writes a native executable hello next to it.

A first project

brim new greeter
cd greeter
brim run
brim test

brim new creates a .brimproj, a src/main.brim with a test, and a .gitignore. Inside a project, commands find the .brimproj themselves. Continue with the tour.

A tour of Brim

Every example on this page is a complete program; the block under it is its output. The language specification has the details.

Values and variables

fn main() {
    let name: string = "Brim"
    let year: i64 = 2026
    let ready: bool = true
    print(name + " " + to_string(year))
    print(ready)

    let mut count: i64 = 0
    count += 5
    count = count * 2
    print(count)
}
Brim 2026
true
10

Variables are immutable unless declared with let mut, and always have a type. The basic types are i64, f64, bool and string. Integer arithmetic is checked: an overflow stops the program with a run-time error instead of wrapping around.

Numbers

fn main() {
    let price: f64 = 19.95
    let count: i64 = 3
    let total: f64 = price * to_float(count)
    print(total)
    print(0.1 + 0.2)
    print(to_int(total))
    match "2.5e3".parse_float() {
        Some(x) => print(x / 1000.0)
        None => print("not a number")
    }
}
59.849999999999994
0.30000000000000004
59
2.5

i64 is for counting and f64 for measuring. They never mix by accident: price * count is an error until you convert one with to_float or to_int. f64 follows IEEE 754, so 0.1 + 0.2 is not exactly 0.3, and 1.0 / 0.0 is inf rather than an error.

For bytes and other fixed sizes there are u8, i32 and u64. A literal takes the type it needs, and a result that does not fit stops the program instead of wrapping around:

fn main() {
    let data: List[u8] = "Hé".bytes()
    print(data)
    let mut level: u8 = 250
    level += 5
    print(level)
    let big: u64 = 18446744073709551615
    print(big / 3)
    print(to_i32(level) - 300)
    print(string_from_bytes(data))
}
[72, 195, 169]
255
6148914691236517205
-45
Some("Hé")

Functions

fn area(width: i64, height: i64) -> i64 {
    return width * height
}

fn greet(name: string) {
    print("Hello, " + name)
}

fn main() {
    greet("Ada")
    print(area(3, 4))
}
Hello, Ada
12

Functions without -> return nothing. A function can be used before its declaration. Statements end at the end of the line.

Functions as values

fn apply(f: fn(i64) -> i64, x: i64) -> i64 {
    return f(x)
}

fn double(x: i64) -> i64 {
    return x * 2
}

fn main() {
    print(apply(double, 21))
    let offset: i64 = 100
    let shift: fn(i64) -> i64 = fn(x: i64) -> i64 {
        return x + offset
    }
    print(apply(shift, 1))
}
42
101

fn(i64) -> i64 is a function type. A fn expression copies the variables it uses when it is made, so it never changes them.

Control flow

fn main() {
    let n: i64 = 7
    if n % 2 == 0 {
        print("even")
    } else if n > 5 {
        print("odd and large")
    } else {
        print("odd")
    }

    let mut i: i64 = 0
    while i < 3 {
        i += 1
    }
    print(i)

    for k in 1..4 {
        print(k * k)
    }
}
odd and large
3
1
4
9

for runs over a range a..b (without b) or over a list. break and continue work in both kinds of loop.

Structs

struct Point {
    x: i64,
    y: i64,
}

fn main() {
    let a: Point = Point { x: 1, y: 2 }
    let mut b: Point = a
    b.x = 10
    print(a.x)
    print(b.x + b.y)
}
1
12

Structs are values: let mut b = a copies a, so changing b leaves a alone.

== compares structs, enums, lists and maps by their contents, and print shows them:

struct Point {
    x: i64,
    y: i64,
}

fn main() {
    let a: Point = Point { x: 1, y: 2 }
    print(a == Point { x: 1, y: 2 })
    print([a, Point { x: 3, y: 4 }])
    print(Some("ada"))
}
true
[Point { x: 1, y: 2 }, Point { x: 3, y: 4 }]
Some("ada")

Enums and match

enum Shape {
    Circle(i64),
    Rect(i64, i64),
    Empty,
}

fn describe(s: Shape) -> string {
    match s {
        Shape.Circle(r) => return "circle with radius " + to_string(r)
        Shape.Rect(w, h) => return to_string(w) + " by " + to_string(h)
        Shape.Empty => return "nothing"
    }
}

fn main() {
    print(describe(Shape.Circle(2)))
    print(describe(Shape.Rect(3, 4)))
    print(describe(Shape.Empty))
}
circle with radius 2
3 by 4
nothing

match must cover every variant; _ matches the rest. It also works on integers, strings and booleans.

Patterns nest, | joins alternatives, and if adds a condition to an arm:

fn classify(o: Option[i64]) -> string {
    match o {
        Some(0) => return "zero"
        Some(1) | Some(2) | Some(3) => return "small"
        Some(n) if n < 0 => return "negative"
        Some(n) => return "large: " + to_string(n)
        None => return "nothing"
    }
}

fn main() {
    print(classify(Some(2)))
    print(classify(Some(-5)))
    print(classify(Some(40)))
    print(classify(None))
}
small
negative
large: 40
nothing

A lowercase name in a pattern binds the value; a guard that is false moves on to the next arm.

An enum can contain itself through a Box, which keeps the value on the heap:

enum Expr {
    Num(i64),
    Add(Box[Expr], Box[Expr]),
}

fn eval(e: Expr) -> i64 {
    match e {
        Num(n) => return n
        Add(a, b) => return eval(a.value()) + eval(b.value())
    }
}

fn main() {
    let e: Expr = Expr.Add(Box(Expr.Num(2)), Box(Expr.Num(3)))
    print(e)
    print(eval(e))
}
Add(Num(2), Num(3))
5

Lists and maps

fn main() {
    let mut scores: List[i64] = [7, 3, 9]
    scores.push(5)
    let mut total: i64 = 0
    for s in scores {
        total += s
    }
    print(to_string(scores.len()) + " scores, total " + to_string(total))
    print(scores[2])

    let mut ages: Map[string, i64] = ["Ada": 36]
    ages["Grace"] = 85
    for name in ages.keys() {
        print(name + ": " + to_string(ages[name]))
    }
    print(ages.contains_key("Linus"))
}
4 scores, total 24
9
Ada: 36
Grace: 85
false

Lists and maps are values too. Indexing outside a list, or with a key a map does not have, stops the program; get returns an Option instead.

Methods

struct Counter {
    count: i64,
}

impl Counter {
    fn new() -> Counter {
        return Counter { count: 0 }
    }

    fn add(mut self, n: i64) {
        self.count += n
    }

    fn twice(self) -> i64 {
        return self.count * 2
    }
}

fn main() {
    let mut c: Counter = Counter.new()
    c.add(3)
    c.add(4)
    print(c.twice())
}
14

A method with mut self changes the value it is called on; one with self gets a copy. Methods without self are called on the type.

Generics

struct Pair[A, B] {
    first: A,
    second: B,
}

fn swap[A, B](p: Pair[A, B]) -> Pair[B, A] {
    return Pair { first: p.second, second: p.first }
}

fn main() {
    let p: Pair[string, i64] = Pair { first: "answer", second: 42 }
    let q: Pair[i64, string] = swap(p)
    print(q.first)
    print(q.second)
}
42
answer

Type arguments are inferred from the arguments and the expected type.

Traits

trait Shape {
    fn area(self) -> f64
}

struct Square {
    side: f64,
}

impl Shape for Square {
    fn area(self) -> f64 {
        return self.side * self.side
    }
}

fn largest[T: Shape](shapes: List[T]) -> f64 {
    let mut best: f64 = 0.0
    for s in shapes {
        if s.area() > best {
            best = s.area()
        }
    }
    return best
}

fn count[T: Eq](xs: List[T], x: T) -> i64 {
    let mut n: i64 = 0
    for y in xs {
        if y == x {
            n += 1
        }
    }
    return n
}

fn main() {
    print(largest([Square { side: 2.0 }, Square { side: 3.0 }]))
    print(count(["a", "b", "a"], "a"))
}
9.0
2

A bound such as T: Shape lets generic code call the trait's methods. The built-in traits Eq and Show allow == and print on a T.

Errors: Option, Result and ?

fn parse_age(text: string) -> Result[i64, string] {
    match text.parse_int() {
        Some(n) => {
            if n < 0 {
                return Err("an age cannot be negative")
            }
            return Ok(n)
        }
        None => return Err("not a number: " + text)
    }
}

fn total_age(a: string, b: string) -> Result[i64, string] {
    return Ok(parse_age(a)? + parse_age(b)?)
}

fn main() {
    match total_age("36", "85") {
        Ok(n) => print(n)
        Err(e) => print("error: " + e)
    }
    match total_age("36", "old") {
        Ok(n) => print(n)
        Err(e) => print("error: " + e)
    }
}
121
error: not a number: old

? takes the value out of an Ok or Some, or returns the Err or None from the function at once.

Strings and the standard library

An f-string puts values in text:

fn main() {
    let name = "Ada"
    let langs = ["en", "fr"]
    print(f"{name} speaks {langs.len()} languages: {langs}")
}
Ada speaks 2 languages: ["en", "fr"]
fn main() {
    let line: string = "  Brim is small, Brim is fast  "
    let text: string = line.trim()
    print(text.to_upper())
    print(text.replace("Brim", "it"))
    for word in text.split(", ") {
        print(word.len())
    }
    print(text.starts_with("Brim"))
}
BRIM IS SMALL, BRIM IS FAST
it is small, it is fast
13
12
true

The standard library also reads input (read_line), files (read_file, write_file), arguments (args) and environment variables (env), and sets the exit code (exit).

Modules

A program can be split into files. Every file is a module; pub makes an item visible to other modules:

src/main.brim
src/geometry.brim
// src/geometry.brim
pub fn square(side: i64) -> i64 {
    return side * side
}
// src/main.brim
import geometry

fn main() {
    print(geometry.square(4))
}

Tasks

std.task runs functions at the same time. A task gets copies of what its fn uses, so tasks never change each other's values; they hand results back with join and talk through channels:

import std.task

fn main() {
    let total = task.spawn(fn() -> i64 {
        let mut sum = 0
        for i in 0..1000 {
            sum += i
        }
        return sum
    })
    let words: task.Channel[string] = task.channel()
    task.detach(fn() {
        words.send("hello")
        words.close()
    })
    print(total.join())
    print(words.receive())
}
499500
Some("hello")

The next chapter covers projects, tests and dependencies.

Projects, tests and dependencies

Projects

A project is a directory with a .brimproj:

[package]
name = "greeter"
version = "0.1.0"
edition = "2026"

The program starts in src/main.brim; a library's root is src/lib.brim. Other modules live next to them: import text.format is src/text/format.brim. Inside the project, brim run, brim build, brim check, brim test and brim fmt need no path.

Tests

Functions whose names start with test_ are tests. assert and assert_eq stop a test when something is wrong:

fn add(a: i64, b: i64) -> i64 {
    return a + b
}

fn test_add() {
    assert_eq(add(2, 3), 5)
    assert(add(-1, 1) == 0)
}

brim test runs every test of the project and reports each one. A failed assert_eq shows both values:

running 1 test
test test_add ... FAILED

failures:

---- test_add ----
error[R0008]: assertion failed: the values are not equal
 --> src/main.brim:6:5
  |
6 |     assert_eq(add(2, 3), 6)
  |     ^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: left:  5
  = note: right: 6

test result: FAILED. 0 passed; 1 failed

brim test parse runs only the tests whose names contain parse.

Dependencies

Packages can use other packages: from the package registry by version, from a directory, or from a git repository:

[dependencies]
router = "1.2"
geometry = { path = "../geometry" }
http = { git = "https://github.com/example/brim-http.git", tag = "v1.0.0" }

import geometry then refers to the library root of geometry (src/lib.brim), and import geometry.shapes to its src/shapes.brim.

Versions

router = "1.2" means any version from 1.2.0 up to, but not including, 2.0.0: a newer version that promises to be compatible. Other forms:

RequirementVersions
"1.2.3" or "^1.2.3"1.2.3 up to 2.0.0
"0.3"0.3.0 up to 0.4.0 (before 1.0, the second number is the major one)
"~1.2.3"1.2.3 up to 1.3.0
"=1.2.3"exactly 1.2.3
">=1.2, <1.5"the comparisons, all of them
"*"any version

Pre-releases such as 2.0.0-beta.1 are only picked when the requirement names one. Every package has one version in a project: the newest that meets all requirements on it, from the project and from other packages. When there is none, brim says who requires what.

.brimlock

The picked versions and the commits of git dependencies are recorded in .brimlock, so every build uses the same versions, also without a network; commit it with your program. brim update picks the newest versions and commits again. Downloads are cached in ~/.brim (or $BRIM_HOME).

Publishing

brim login            # paste an API token for the registry
brim publish --dry-run
brim publish

brim publish checks the package, packs .brimproj, src/ and the README, LICENSE and CHANGELOG files, and uploads them. Give the package a description and a license in [package]. Every dependency needs a version; a path dependency can have one too, which is what the published package keeps:

[dependencies]
shared = { path = "../shared", version = "0.1" }

A published version never changes. brim yank router@1.2.0 stops new projects from picking a broken version; projects whose .brimlock has it keep working.

Workspaces

A workspace is a directory with packages that are developed together. Its .brimproj lists them:

[workspace]
members = ["apps/*", "shared"]

apps/* stands for every directory in apps with a .brimproj. The members share one .brimlock, at the root, and every dependency has one version in the whole workspace. At the root, brim check, brim test, brim build and brim doc work on every member; -p NAME picks one, as does running brim in a member's directory. Members depend on each other with path.

Calling C

An extern fn is a C function, declared without a body:

extern fn crc32(data: string, len: i64) -> u64

fn main() {
    print(crc32("hello", 5))
}

The C code comes from your package, the libraries it names, or the C library:

[build]
c-sources = ["c/checksum.c"]
link = ["z"]

extern fns take and return i64, i32, u8, u64, f64, bool and string (passed as a const char *), and may return nothing. They run in native executables: brim build, and brim run, which builds such a program for you. The interpreter cannot call C, so brim test stops at a call with R0011.

Tools and editors

CommandWhat it does
brim run [PATH] [ARGS...]Check a program and run it with the interpreter
brim build [PATH]Compile a native executable (--release to optimise, -o for the path)
brim check [PATH]Report errors without running
brim test [PATH] [FILTER]Run the tests
brim fmt [PATH...]Format source files (--check to only report)
brim new PATHCreate a project
brim update [PATH]Update git dependencies in .brimlock
brim gen ts [PATH]Write TypeScript types for the program's API
brim lspRun the language server

brim help COMMAND explains each command.

Formatting

brim fmt sets the indentation (four spaces), the spaces between tokens and the blank lines, and keeps comments and line breaks. There are no options: every Brim program looks the same.

API types for TypeScript

A backend written in Brim can share its API types with a TypeScript frontend. brim gen ts writes a declaration for every pub struct and enum:

pub struct Task {
    pub id: i64,
    pub title: string,
    pub assignee: Option[string],
}

pub enum Priority {
    Low,
    High,
}

becomes

export interface Task {
  id: number;
  title: string;
  assignee: string | null;
}

export type Priority = "Low" | "High";

Section 9.4 of the specification has the whole mapping. json.encode and json.decode use the same shapes.

An Angular client

A module whose pub fns are the API is served with the built-in serve_api(module, request): POST /api/add_note with {"text": "milk"} calls add_note(text: "milk"), and the result is the JSON response.

// notes.brim
pub fn add_note(text: string) -> Result[Note, NoteError] {
    ...
}

// main.brim
let response = serve_api(notes, request)
request.respond(response)?

brim gen ts --angular -o client.ts then writes, next to the types, an Angular service with a method per function:

@Injectable({ providedIn: 'root' })
export class NotesClient {
  addNote(text: string): Observable<Note> { ... }
}

A function that returns Err(error) answers with status 422, and the HttpErrorResponse holds the error. Provide API_BASE_URL when the server is on another origin. examples/http/api is a complete service with its generated client.

Documentation: brim doc

brim doc writes HTML documentation for a program's own modules: a page per module with every pub struct, enum, trait, constant, function and method, its signature and the // comment right above it, and an index with a search box. The comment at the top of a file documents the module. In comments, code is code, and lines indented by two or more spaces are code examples:

// Notes, kept in a file.
//
//   let note = notes.add_note("milk")?

// Adds a note and returns it with its `id`.
pub fn add_note(text: string) -> Result[Note, NoteError] {
    ...
}

A project's documentation goes to target/doc (-o DIR picks another place); brim doc --std documents the standard library.

Build cache

brim build keeps what it can reuse in ~/.brim/cache (or $BRIM_HOME/cache): the compiled runtime, and for each executable what it was built from. A build whose source files, options, C compiler and brim are all unchanged keeps the executable and says (up to date). Set BRIM_NO_CACHE=1 to build everything again; deleting the directory is always safe.

Debugging

A program built without --release carries debug information (DWARF), so lldb and gdb show Brim source lines, function names and variables:

$ brim build app.brim -o app
$ lldb ./app
(lldb) breakpoint set --file app.brim --line 12
(lldb) run
(lldb) bt
  * frame #0: app`brim.area at app.brim:12:12
    frame #1: app`brim.main at app.brim:20:17
(lldb) frame variable
(long) width = 3
(long) height = 4

Variables of the types i64, f64 and bool can be printed; strings, lists and other values not yet. On macOS the debug information is in app.dSYM next to the executable.

Editors

brim lsp is a language server: it shows errors as you type, completes names (after value. the fields and methods of the value's type), goes to definitions, finds references, renames across files, shows signatures while you type a call, offers quick fixes for errors that say what to write, formats, lists a file's declarations and shows types on hover. The Brim repository has:

support for VS Code, Neovim, Vim, Helix, Zed, Emacs, Sublime Text, JetBrains IDEs, Kate, Kakoune, nano, micro and Notepad++ in editors/, each with its own instructions. Other editors can use the TextMate grammar (editors/vscode/syntaxes/brim.tmLanguage.json) or the tree-sitter grammar (editors/tree-sitter-brim), and start brim lsp from any LSP client.

Standard library

Everything here is always available: there is nothing to import. The language specification (sections 5.4 to 5.6 and 10) is the precise reference, and the API reference, made with brim doc --std, lists every function and type of the std modules with its documentation.

Output and input

FunctionResult
print(x)writes x and a newline to standard output; x is an i64, bool or string
eprint(text)writes text and a newline to standard error
read_line()Option[string]the next line of standard input, without its line ending; None at the end
args()List[string]the program's command-line arguments, without the program name
env(name)Option[string]an environment variable
exit(code)ends the program with an exit code
fn main() {
    print("to standard output")
    eprint("to standard error")
    print(42)
    print(1 < 2)
    match env("BRIM_SURELY_UNSET") {
        Some(value) => print("set to " + value)
        None => print("not set")
    }
    print(args().len())
}
to standard output
42
true
not set
0

A program that reads its input line by line:

fn main() {
    let mut count: i64 = 0
    while true {
        match read_line() {
            Some(line) => count += 1
            None => break
        }
    }
    print(to_string(count) + " lines")
}

Files

FunctionResult
read_file(path)Result[string, string]the file's contents
write_file(path, text)Result[i64, string]creates or replaces the file; the number of bytes written

Errors are messages such as cannot read file "notes.txt": file not found. Relative paths are relative to the current directory.

fn main() {
    match read_file("does-not-exist.txt") {
        Ok(text) => print(text)
        Err(message) => print(message)
    }
}
cannot read file "does-not-exist.txt": file not found

Directories and more: std.fs

std.fs has the rest of what a program does with files:

FunctionResult
exists(path), is_dir(path), is_file(path)bool
list(dir)Result[List[string], string]the names in a directory, sorted
create_dir(path)Result[bool, string]with any missing parents; Ok(false) if it existed
remove(path)Result[bool, string]a file or an empty directory; Ok(false) if there was none
remove_all(path)Result[bool, string]a directory with everything in it
rename(from, to)Result[bool, string]also moves
size(path)Result[i64, string]in bytes
modified(path)Result[time.Time, string]when it last changed
read(path), write(path, text), append(path, text)read and write are read_file and write_file
import std.fs

fn main() {
    match fs.list("no-such-dir") {
        Ok(names) => print(names)
        Err(message) => print(message)
    }
}
cannot list directory "no-such-dir": not found

Numbers and conversion

FunctionResult
to_string(x)stringa number in decimal, or a bool as "true" or "false"
to_float(n)f64an i64 as the nearest f64
to_int(x)i64an f64 rounded toward zero; NaN or a number outside i64 stops the program
s.parse_int()Option[i64]a decimal integer with an optional sign, and nothing else
s.parse_float()Option[f64]a decimal number such as -1.5e3

Arithmetic on i64 is checked: overflow, and division or remainder by zero, stop the program with a run-time error. Arithmetic on f64 follows IEEE 754 and never stops the program: 1.0 / 0.0 is inf. An f64 is written with the fewest digits that read back exactly (0.1, 2.0, 1e21).

fn main() {
    print(to_string(7) + to_string(true))
    match "-42".parse_int() {
        Some(n) => print(n * 2)
        None => print("not a number")
    }
    match "4x2".parse_int() {
        Some(n) => print(n)
        None => print("not a number")
    }
}
7true
-84
not a number

Math

FunctionResult
abs(x)i64 or f64the absolute value; abs of the smallest i64 stops the program
sqrt(x), floor(x), ceil(x), round(x)f64round rounds halves away from zero
pow(a, b)i64 or f64a to the power b; on integers a negative power divides (pow(2, -1) is 0) and overflow stops the program
min(a, b), max(a, b)the argument typefor numbers and strings
clamp(x, low, high)the argument typex moved into the range
PI, Ef64the constants
fn main() {
    let r = 2.0
    print(round(PI * r * r))
    print(pow(2, 10))
    print(sqrt(2.0))
    print(clamp(150, 0, 100))
    print(max("pear", "apple"))
}
13.0
1024
1.4142135623730951
100
pear

Strings

Positions and lengths count bytes of the UTF-8 text. Strings compare with ==, !=, <, <=, > and >=, and join with +.

MethodResult
len()i64the length in bytes
is_empty()bool
contains(t), starts_with(t), ends_with(t)bool
find(t)Option[i64]the position of the first t
slice(start, end)stringbytes start up to end
trim(), trim_start(), trim_end()stringwithout surrounding whitespace
to_upper(), to_lower()stringASCII letters only
replace(from, to)stringevery occurrence
repeat(n)string
split(sep)List[string]an empty separator splits into characters
lines()List[string]without line endings
chars()List[string]each character as a string
char_count()i64the number of characters
code_points()List[i64]the Unicode code point of each character
bytes()List[u8]the UTF-8 bytes
parse_int()Option[i64]
fn main() {
    let s: string = "  Hello, Brim  "
    let t: string = s.trim()
    print(t.len())
    print(t.to_lower())
    print(t.replace("Brim", "world"))
    print(t.slice(0, 5))
    match t.find(",") {
        Some(i) => print(i)
        None => print("no comma")
    }
    print("ab".repeat(3))
    print("a,b,,c".split(",").len())
    print("x\ny\n".lines().len())
    print("héllo".chars().join("-"))
    print("apple" < "banana")
}
11
hello, brim
Hello, world
Hello
5
ababab
4
2
h-é-l-l-o
true

from_code_point(n) is the character with a code point, or None. To build a long string from many pieces, use a StringBuilder: it joins the pieces once, where + in a loop copies the text each time.

fn main() {
    let mut out = StringBuilder.new()
    for cp in "HAL".code_points() {
        match from_code_point(cp + 1) {
            Some(c) => out.add(c)
            None => out.add("?")
        }
    }
    out.add_line("!")
    print(out.build())
}
IBM!

Lists

List[T] holds values of one type, in order. Indexing outside the list stops the program; get returns an Option instead.

MethodResult
len(), is_empty()i64, bool
get(i)Option[T]
first(), last()Option[T]
contains(x)boolfor lists of i64, bool and string
join(sep)stringfor List[string]
push(x)appends
pop()Option[T]removes the last element
insert(i, x)inserts before index i
remove(i)Tremoves element i
clear()
sorted(), reversed()List[T]a sorted or reversed copy
sorted_by(less)List[T]a copy sorted by fn(a, b) -> bool
slice(a, b)List[T]elements a up to b, clamped to the list
index_of(x)Option[i64]where x is first
map(f), filter(keep)List
any(test), all(test)bool
fold(init, f)combines the elements, left to right
find(test), min(), max()Option[T]
sum()Tfor lists of numbers; 0 when empty
each(f)calls f with each element

push up to clear change the list, so it needs let mut. The others return a new list and leave the original as it was:

fn main() {
    let xs = [3, 1, 2]
    print(xs.sorted())
    print(xs.reversed().slice(0, 2))
    print(xs.index_of(2))
    print(["bb", "a", "ccc"].sorted_by(fn(a: string, b: string) -> bool {
        return a.len() < b.len()
    }))
}
[1, 2, 3]
[2, 1]
Some(2)
["a", "bb", "ccc"]

Functions passed to map, filter and the others are fn expressions or named functions. A chain can put one call per line:

fn main() {
    let words = "the quick brown fox".split(" ")
    let long = words
        .filter(fn(w: string) -> bool { return w.len() > 3 })
        .map(fn(w: string) -> string { return w.to_upper() })
    print(long)
    print(words.fold(0, fn(n: i64, w: string) -> i64 { return n + w.len() }))
}
["QUICK", "BROWN"]
16
fn main() {
    let mut xs: List[i64] = [3, 1, 4]
    xs.push(1)
    xs.insert(0, 9)
    print(xs.len())
    print(xs[0] + xs[4])
    print(xs.contains(4))
    print(xs.remove(1))
    match xs.pop() {
        Some(x) => print(x)
        None => print("empty")
    }
    match xs.get(10) {
        Some(x) => print(x)
        None => print("no element 10")
    }
    let words: List[string] = ["a", "b", "c"]
    print(words.join("+"))
}
5
10
true
3
1
no element 10
a+b+c

Maps

Map[K, V] looks up values by key; keys are i64, bool or string. Entries keep the order in which their keys were first added.

MethodResult
len(), is_empty()i64, bool
get(k)Option[V]
contains_key(k)bool
keys(), values()List[K], List[V]in entry order
insert(k, v)adds or replaces
remove(k)Option[V]
clear()
fn main() {
    let mut stock: Map[string, i64] = ["apples": 3]
    stock["pears"] = 5
    stock["apples"] += 2
    stock.insert("plums", 0)
    for name in stock.keys() {
        print(name + ": " + to_string(stock[name]))
    }
    match stock.remove("plums") {
        Some(n) => print("removed " + to_string(n))
        None => print("no plums")
    }
    print(stock.contains_key("plums"))
}
apples: 5
pears: 5
plums: 0
removed 0
false

Sets

Set[T] holds distinct values, in the order they were first added. Set() makes an empty one and Set(list) one from a list.

MethodResult
add(x), remove(x), boolremove tells whether x was there
contains(x)bool
len(), is_empty(), to_list()
union(s), intersection(s), difference(s)Set[T]
is_subset(s)bool
fn main() {
    let mut tags: Set[string] = Set()
    tags.add("rust")
    tags.add("brim")
    tags.add("rust")
    print(tags)
    let other = Set(["brim", "go"])
    print(tags.intersection(other))
    for tag in tags.union(other) {
        print(tag)
    }
}
{"rust", "brim"}
{"brim"}
rust
brim
go

Option and Result

Option[T] is a value that may be absent (Some(x) or None); Result[T, E] is the outcome of something that can fail (Ok(x) or Err(e)). Inspect them with match, or pass a failure on with ?. Their methods: is_some(), is_none(), unwrap_or(fallback) and map(f) on an Option; is_ok(), is_err(), unwrap_or(fallback), ok() and map(f) on a Result.

fn half(n: i64) -> Result[i64, string] {
    if n % 2 != 0 {
        return Err(to_string(n) + " is odd")
    }
    return Ok(n / 2)
}

fn quarter(n: i64) -> Result[i64, string] {
    return half(half(n)?)
}

fn main() {
    match quarter(12) {
        Ok(n) => print(n)
        Err(e) => print("error: " + e)
    }
    match quarter(6) {
        Ok(n) => print(n)
        Err(e) => print("error: " + e)
    }
}
3
error: 3 is odd

Networking: std.net

import std.net gives TCP connections. Everything that can fail returns a Result whose error says what went wrong, such as cannot connect to example.com:81: connection refused.

Function or methodResult
net.listen(host, port)Result[Listener, string]port 0 picks a free port
net.connect(host, port)Result[Conn, string]
listener.accept()Result[Conn, string]waits for a connection
listener.next_ready(idle_millis)Result[Conn, string]a new connection, or one given back with listener.keep(conn) that has something to read
listener.port()i64
conn.read_line()Result[string, string]without the line ending
conn.read_until(delimiter)Result[string, string]the delimiter is dropped
conn.read_exact(count)Result[string, string]count bytes
conn.read_all()Result[string, string]until the other side closes
conn.write(text)Result[i64, string]the number of bytes
conn.set_timeout(millis)Result[i64, string]0 waits forever
close()on a listener or a connection
import std.net

fn main() {
    match greet() {
        Ok(_) => print("done")
        Err(e) => print("error: " + e)
    }
}

// Answers one connection on port 7000 with a greeting.
fn greet() -> Result[i64, string] {
    let listener: net.Listener = net.listen("127.0.0.1", 7000)?
    let conn: net.Conn = listener.accept()?
    let name: string = conn.read_line()?
    conn.write("Hello, " + name + "!\n")?
    conn.close()
    return Ok(0)
}

HTTP: std.http

import std.http gives an HTTP/1.1 server and client for text, such as HTML and JSON. The server handles one request at a time: ask for the next request, decide what to answer, and respond. Connections stay open for the client's next request (keep-alive), and a quiet one keeps no other client waiting.

import std.http

fn main() {
    match serve() {
        Ok(_) => print("stopped")
        Err(e) => print("error: " + e)
    }
}

fn serve() -> Result[i64, string] {
    let server: http.Server = http.serve("0.0.0.0", 8080)?
    while true {
        let request: http.Request = server.next()?
        request.respond(route(request))?
    }
}

fn route(request: http.Request) -> http.Response {
    if request.method == "GET" && request.path == "/hello" {
        match request.param("name") {
            Some(name) => return http.text(200, "Hello, " + name + "!")
            None => return http.text(200, "Hello!")
        }
    }
    if request.method == "POST" && request.path == "/echo" {
        return http.json(200, request.body)
    }
    return http.not_found()
}
Server
http.serve(host, port)Result[Server, string]
server.next()the next Request; malformed requests get a 400 automatically
request.method, path, query, headers, bodythe request; header names in lower case, query parameters decoded
request.param(name), request.header(name)Option[string]
request.respond(response)sends the response and closes the connection
http.text(status, body), http.json(...), http.html(...)responses with a content type
http.response(status, content_type, body), http.redirect(url), http.not_found()
response.with_header(name, value)the response with one more header
Client
http.get(url)Result[Response, string]
http.post(url, content_type, body)
http.request(method, url, headers, body)any method, with extra headers
response.status, headers, body
response.ok()whether the status is 2xx
response.header(name)Option[string]

serve_api(module, request) answers requests for the pub fns of a module (POST /api/<name>, parameters as a JSON object); see Tools for the generated Angular client. http.new_request(method, path, body) makes a request for testing a handler.

The client supports http:// and https:// URLs. HTTPS goes through libcurl and checks certificates; redirects are not followed. http.encode and http.decode percent-encode and decode parts of URLs.

JSON: std.json

import std.json reads and writes JSON. A value is a json.Json: Null, Bool, Number (kept as the text it was written with), Str, Array or Object.

import std.json

fn main() {
    match age_next_year("{\"name\": \"Ada\", \"age\": 36}") {
        Ok(n) => print(n)
        Err(e) => print("error: " + e)
    }
    let reply: json.Json = json.object([
        "ok": json.from_bool(true),
        "items": json.array([json.from_int(1), json.from_string("two")]),
    ])
    print(json.stringify(reply))
}

fn age_next_year(text: string) -> Result[i64, string] {
    let user: json.Json = json.parse(text)?
    match user.get("age") {
        Some(age) => {
            match age.as_int() {
                Some(n) => return Ok(n + 1)
                None => return Err("age is not a whole number")
            }
        }
        None => return Err("no age")
    }
}
json.parse(text)Result[Json, string]; errors say where: expected ':' at character 6
json.stringify(value), json.pretty(value)compact, or indented by two spaces
json.null(), from_bool(b), from_int(n), from_float(x), from_string(s), array(items), object(members)build values
value.get(key), value.at(index)Option[Json]: a member, an element
value.as_string(), as_int(), as_float(), as_bool(), as_array(), as_object()Option of the contents
value.is_null(), value.kind()"null", "bool", "number", "string", "array" or "object"

Typed values

json.encode(value) writes any value as JSON, and json.decode(text) reads it back into the type that is expected. Structs become objects, Option is the value or null, enums are "Red" or {"kind": "Circle", "value": 1.5}, and lists, sets and tuples are arrays: the same shapes brim gen ts declares for TypeScript.

import std.json

struct Note {
    id: i64
    text: string
    tags: List[string]
}

fn main() {
    let text = json.encode(Note { id: 1, text: "milk", tags: ["shop"] })
    print(text)
    match read("{\"id\": 2, \"text\": \"tea\", \"tags\": [7]}") {
        Ok(note) => print(note)
        Err(e) => print(e)
    }
}

fn read(text: string) -> Result[Note, string] {
    let note: Note = json.decode(text)?
    return Ok(note)
}
{"id":1,"text":"milk","tags":["shop"]}
at $.tags[0]: expected a string, found a number

Every type without functions in it implements the built-in trait Data; generic code that encodes or decodes a T needs T: Data. json.to_json(value) and json.from_json(json) do the same with a json.Json instead of text.

examples/http/notes.brim combines std.http and std.json into a small JSON API.

Databases: std.postgres

std.postgres talks to a PostgreSQL server. Parameters go in the SQL as $1, $2, ... and travel separately, so values never become SQL text. query_as reads each row into a struct whose fields are the columns.

import std.postgres

struct Note {
    id: i64
    text: string
}

fn notes(url: string) -> Result[List[Note], string] {
    let db = postgres.connect(url)?
    db.execute("insert into notes (text) values ($1)", [postgres.text("milk")])?
    let found: List[Note] = db.query_as("select id, text from notes where id > $1", [postgres.int(0)])?
    db.close()
    return Ok(found)
}
postgres.connect(url)postgres://user:password@host:port/database
db.query(sql, params)Result[Rows, string]: columns, rows, affected
db.execute(sql, params)the number of rows changed
db.query_as(sql, params)Result[List[T], string] for any T: Data
db.transaction(work)runs work(db); commits on Ok, rolls back on Err
row.get(column)Option[string]; None for NULL
row.text(column), int, float, boolResults
postgres.null(), int(n), float(x), text(s), bool(b), json_value(v)parameters

Logins use SCRAM-SHA-256, MD5 or a plain password. The connection is not encrypted: connect over localhost, a private network or a tunnel. Errors read like postgres: ERROR: relation "x" does not exist [42P01].

Hashes and encodings: std.crypto, std.base64

crypto.sha256(bytes), crypto.sha256_hex(text)SHA-256
crypto.hmac_sha256(key, message)HMAC-SHA-256
crypto.pbkdf2_sha256(password, salt, iterations, length)a key from a password, also for storing passwords
crypto.md5(bytes)only where another system needs it
crypto.random_bytes(n)secure random bytes, for keys and tokens
crypto.hex(bytes), crypto.equal(a, b)hex digits; a comparison that does not leak where secrets differ
base64.encode(bytes), base64.decode(text)RFC 4648; encode_text and decode_text for strings

Bytes are List[u8]: text.bytes() and string_from_bytes(bytes) convert.

import std.base64
import std.crypto

fn main() {
    print(crypto.sha256_hex("abc"))
    print(base64.encode_text("hello"))
}
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
aGVsbG8=

Random numbers: std.random

import std.random

fn main() {
    random.seed(42)
    let die = random.int(1, 7)
    print(die >= 1 && die <= 6)
    print(random.shuffle([1, 2, 3]).sorted())
    print(random.choice(["a", "b"]).is_some())
}
true
[1, 2, 3]
true

int(low, high) is like the range low..high: high itself never comes up. float() is from 0 up to 1; bool(), choice(list) and shuffle(list) do what they say. Without seed(n) the numbers differ on every run; with it they are the same, which tests need. The generator is not meant for passwords or keys.

Running programs: std.process

process.run(cmd, args) starts a program, waits until it has finished and gives a process.Output: the exit code and everything it wrote to standard output and standard error, as strings. The program is looked up in PATH unless its name contains a /; the arguments reach it as they are, without a shell in between. It gets no input.

FunctionResult
run(cmd, args)Result[Output, string]Err only if the program cannot start or writes text that is not UTF-8
output(cmd, args)Result[string, string]the standard output if the exit code is 0; otherwise an error with the standard error
out.success()boolwhether out.code is 0

out.code is the exit code, or 128 plus the number of the signal that stopped the program (137 for kill -9).

import std.process

fn main() {
    match process.run("sh", ["-c", "echo hello; exit 3"]) {
        Ok(out) => print(f"{out.code} {out.stdout.trim()}")
        Err(message) => print(message)
    }
    match process.output("no-such-program", []) {
        Ok(text) => print(text)
        Err(message) => print(message)
    }
}
3 hello
cannot run "no-such-program": not found

Time and logging: std.time, std.log

import std.log
import std.time

fn main() {
    let start: i64 = time.millis()
    print(time.now().iso())
    print(time.from_unix_seconds(0).http_date())
    time.sleep(10)
    log.info("took " + to_string(time.millis() - start) + " ms")
}
time.now()the current Time, from the system clock, in UTC
time.from_unix_millis(ms), time.from_unix_seconds(s)a Time
t.iso(), t.date(), t.http_date()2026-10-15T08:30:01.123Z, 2026-10-15, Thu, 15 Oct 2026 08:30:01 GMT
t.parts()year, month, day, hour, minute, second, millisecond, weekday (1 is Monday)
t.unix_seconds(), t.add_millis(n), t.add_seconds(n)
time.millis()milliseconds on a clock that only moves forward, for durations
time.sleep(ms)waits
log.info(m), log.warn(m), log.error(m)2026-10-15T08:30:01.123Z INFO m on standard error

Tasks: std.task

Tasks run functions at the same time, each on a thread of its own. They share no changeable memory: a fn copies what it uses, and a value a task changes is its own copy. Tasks work together through join and channels.

import std.task

fn square_all(numbers: List[i64]) -> i64 {
    let jobs: task.Channel[i64] = task.channel()
    let results: task.Channel[i64] = task.channel()
    // Four workers take jobs until the channel is closed.
    for _ in 0..4 {
        task.detach(fn() {
            loop {
                match jobs.receive() {
                    Some(n) => results.send(n * n)
                    None => break
                }
            }
        })
    }
    for n in numbers {
        jobs.send(n)
    }
    jobs.close()
    let mut total = 0
    for _ in numbers {
        total += results.receive().unwrap_or(0)
    }
    return total
}

fn main() {
    let sum = task.spawn(fn() -> i64 { return square_all([1, 2, 3, 4]) })
    print(sum.join())
}
30
task.spawn(f)runs f, a fn() -> T, on a new task: a Task[T]
t.join()waits for the task and returns its result (once)
task.detach(f)runs f, a fn(), on a task that is never joined
task.channel()a Channel[T] without a size limit
task.bounded(n)a Channel[T] whose send waits while n values are queued
c.send(value)queues a value; false once the channel is closed
c.receive()waits for the next value: Some(value), or None once the channel is closed and empty
c.close()no more values

A service handles every request on a task of its own:

import std.http
import std.log
import std.task

fn serve(port: i64) -> Result[bool, string] {
    let server = http.serve("127.0.0.1", port)?
    loop {
        let request = server.next()?
        task.detach(fn() {
            let response = http.text(200, f"Hello from {request.path}")
            match request.respond(response) {
                Ok(_) => {}
                Err(e) => log.warn(e)
            }
        })
    }
}

fn main() {
    match serve(8080) {
        Ok(_) => {}
        Err(e) => log.error(e)
    }
}

A run-time error in a task stops the whole program, and the program ends when main returns, also while detached tasks still run.

Tests

Function
assert(condition)stops the program when the condition is false
assert_eq(a, b)stops the program when the values differ, and shows both; for i64, bool and string

brim test runs every function whose name starts with test_; see Projects, tests and dependencies.

Brim Language Specification

Version: 1.0, edition 2026 Status: describes exactly what the reference compiler in this repository accepts. Anything not described here is not part of the language. The design decisions behind it are in section 11, and what 1.0 leaves out in section 12.

This document is normative for the language. The implementation lives in crates/, and where the two disagree, that is a bug in one of them.

1. Overview

Brim is a statically typed, compiled, general-purpose language from Ordnary, aimed at backend services, APIs, CLI tools and developer tooling. Its values are values: structs, enums, lists, maps and sets are copied on assignment (cheaply, with copy-on-write), there is no null (Option) and there are no exceptions (Result and ?), match must cover every case, and integer overflow is an error rather than a wrap. Programs run in an interpreter and compile to native code through LLVM, with the same behaviour. Generics, traits, closures, modules, packages and tasks (section 9.7) are part of the language; section 10 lists the builtins and the standard library.

fn fizzbuzz(n: i64) -> string {
    if n % 15 == 0 {
        return "FizzBuzz"
    } else if n % 3 == 0 {
        return "Fizz"
    } else if n % 5 == 0 {
        return "Buzz"
    }
    return to_string(n)
}

fn main() {
    let mut i: i64 = 1
    while i <= 15 {
        print(fizzbuzz(i))
        i += 1
    }
}

2. Source text

  • A source file is UTF-8 text with the extension .brim. A leading byte order mark (U+FEFF) is ignored. Files that are not valid UTF-8 are rejected.
  • Line terminators are \n or \r\n. A lone \r is treated as whitespace.
  • Positions in diagnostics are reported as line:column, both 1-based. Columns count Unicode scalar values, not bytes. Internally the compiler stores byte offsets (see docs/architecture.md).

3. Lexical structure

3.1 Whitespace and comments

Spaces, tabs and carriage returns separate tokens and are otherwise ignored. Newlines are significant: they terminate statements (section 6.1).

A line comment starts with // and extends to the end of the line. The newline that ends a comment is still a newline token. There are no block comments.

3.2 Identifiers

identifier = ( letter | "_" ) { letter | digit | "_" } ;
letter     = "A".."Z" | "a".."z" ;
digit      = "0".."9" ;

Identifiers are ASCII-only and case-sensitive. A word that is a keyword or a reserved word is never an identifier.

3.3 Keywords

Keywords:

fn  let  mut  return  if  else  while  break  continue  true  false  struct
enum  match  for  in  impl  trait  const  loop  self  import  pub  as  extern

Reserved words for planned features. They cannot be used as identifiers, and using one produces error E0101:

async  await  type  use

The type names i64, bool, string and void are not keywords. They are predeclared type names, resolved during semantic analysis.

3.4 Integer literals

integer_literal = digit { digit | "_" } ;
  • Decimal only. _ may be used as a separator (1_000_000) and is ignored.
  • A literal with more than one digit may not start with 0 (007 is error E0005), to avoid confusion with octal notation in other languages.
  • Letters directly after a literal (10px, 0xFF) are error E0006. Hexadecimal, octal and binary literals are not supported yet.
  • A literal has the integer type the context expects (let b: u8 = 200, f(3) with an i32 parameter, x + 1 with x: u64), and i64 otherwise. Its value must fit that type: at most 9223372036854775807 for i64, 18446744073709551615 for u64. A literal directly after unary - is one negative value, so -9223372036854775808 is i64::MIN and -1 is not a u64. Out-of-range literals are error E0215. Literals that do not even fit in 64 bits are error E0004.

3.4.1 Floating-point literals

float_literal = digit { digit | "_" } ( "." digit { digit | "_" } [ exponent ] | exponent ) ;
exponent      = ( "e" | "E" ) [ "+" | "-" ] digit { digit | "_" } ;
  • 1.5, 0.25, 6.02e23, 1.5E-7 and 2e10 are f64 literals; _ separators are allowed as in integers.
  • There must be digits on both sides of the point: 1. and .5 are not literals. 1..5 is a range, and point.0 a field.
  • A literal too large for f64 (1e999) is error E0007. The value is the f64 nearest to the decimal number.

3.5 String literals

string_literal = '"' { string_char | escape } '"' ;
string_char    = ? any Unicode scalar value except '"', '\' and newline ? ;
escape         = '\' ( 'n' | 'r' | 't' | '0' | '\' | '"' )
               | '\u{' hex_digit { hex_digit } '}' ;   (* 1 to 6 hex digits *)
  • A string literal must end on the line where it starts (E0002). Use \n for line breaks.
  • \u{...} must denote a Unicode scalar value: not a surrogate and at most 10FFFF.
  • Any other escape is error E0003.

3.5.1 f-strings

fstring = 'f"' { string_char | escape | '{{' | '}}' | '{' expression '}' } '"' ;

An f-string puts values in text: f"Hello, {name}! Next year you are {age + 1}.". Each {expression} is shown as print shows it: strings as they are, numbers and booleans as to_string writes them, and structs, enums, lists and maps as in section 5.8. A value that cannot be shown (a function, or a call that returns nothing) is E0206. {{ and }} are a { and } in the text, and a lone } is E0001. The expressions may contain strings and other f-strings, and the whole f-string must end on its line. A string without the f keeps every { as it is, so JSON in a string needs no escaping.

3.6 Operators and punctuation

+  -  *  /  %  ==  !=  <  <=  >  >=  &&  ||  !
=  +=  -=  *=  /=  %=
(  )  {  }  [  ]  ,  :  ;  ->  .  ..  =>  ?

Any other character outside a string or comment is error E0001. Single & and | are errors too, with a hint to use && and ||.

4. Grammar

The grammar below uses EBNF. NEWLINE is a newline token; EOF is the end of input.

program     = { separator } { ( import | item ) { separator } } EOF ;
import      = [ "pub" ] "import" path [ "." "{" identifier { "," identifier } [ "," ] "}" | "as" identifier ] ;
item        = [ "pub" ] ( function | extern_fn | struct | enum | trait | const ) | impl ;
extern_fn   = "extern" "fn" identifier "(" [ param { "," param } [ "," ] ] ")" [ "->" type ] ;
impl        = "impl" [ path "for" ] identifier [ type_params ] "{" { separator } { [ "pub" ] function { separator } } "}" ;
trait       = "trait" identifier "{" { separator } { trait_fn { separator } } "}" ;
trait_fn    = "fn" identifier "(" "self" { "," param } [ "," ] ")" [ "->" type ] ;
const       = "const" identifier [ ":" type ] "=" expression ;
separator   = NEWLINE | ";" ;
path        = identifier { "." identifier } ;

struct      = "struct" identifier [ type_params ] "{" { field_sep } [ field { field_sep { field_sep } field } ] { field_sep } "}" ;
field       = [ "pub" ] identifier ":" type ;
field_sep   = NEWLINE | ";" | "," ;

enum        = "enum" identifier [ type_params ] "{" { field_sep } [ variant { field_sep { field_sep } variant } ] { field_sep } "}" ;
variant     = identifier [ "(" type { "," type } [ "," ] ")" ] ;

type_params = "[" type_param { "," type_param } [ "," ] "]" ;
type_param  = identifier [ ":" path { "+" path } ] ;

function    = "fn" identifier [ type_params ] "(" [ params ] ")" [ "->" type ] block ;
params      = ( receiver [ "," param { "," param } ] | param { "," param } ) [ "," ] ;
receiver    = [ "mut" ] "self" ;
param       = identifier ":" type ;
type        = path [ "[" type { "," type } [ "," ] "]" ]
            | "(" type "," type { "," type } [ "," ] ")"                 (* a tuple *)
            | "fn" "(" [ type { "," type } [ "," ] ] ")" [ "->" type ] ;  (* a function *)

block       = "{" { separator } [ statement { terminator statement } ] { separator } "}" ;
terminator  = separator { separator } ;     (* see 6.1; "}" also ends a statement *)

statement   = let_stmt | assign_stmt | if_stmt | while_stmt | loop_stmt | for_stmt | match_stmt
            | "break" | "continue" | return_stmt | block | expr_stmt ;
let_stmt    = "let" [ "mut" ] ( identifier [ ":" type ] | tuple_pat ) "=" expression ;
assign_stmt = place assign_op expression ;
place       = identifier { "." ( identifier | integer_literal ) | "[" expression "]" } ;
assign_op   = "=" | "+=" | "-=" | "*=" | "/=" | "%=" ;
if_stmt     = "if" expression block [ "else" ( if_stmt | block ) ] ;
while_stmt  = "while" expression block ;
loop_stmt   = "loop" block ;
for_stmt    = "for" identifier "in" expression [ ".." expression ] block ;
match_stmt  = "match" expression "{" { arm_sep } [ arm { arm_sep { arm_sep } arm } ] { arm_sep } "}" ;
arm         = pattern [ "if" expression ] "=>" ( block | statement ) ;
arm_sep     = NEWLINE | ";" | "," ;
pattern     = single_pat { "|" single_pat } ;
single_pat  = "_" | [ "-" ] integer_literal | string_literal | "true" | "false" | tuple_pat
            | [ identifier "." ] identifier [ "(" pattern { "," pattern } [ "," ] ")" ] ;
tuple_pat   = "(" pattern "," pattern { "," pattern } [ "," ] ")" ;
return_stmt = "return" [ expression ] ;
expr_stmt   = expression ;

expression  = or_expr ;
or_expr     = and_expr { "||" and_expr } ;
and_expr    = eq_expr { "&&" eq_expr } ;
eq_expr     = cmp_expr [ ( "==" | "!=" ) cmp_expr ] ;
cmp_expr    = add_expr [ ( "<" | "<=" | ">" | ">=" ) add_expr ] ;
add_expr    = mul_expr { ( "+" | "-" ) mul_expr } ;
mul_expr    = unary { ( "*" | "/" | "%" ) unary } ;
unary       = ( "-" | "!" ) unary | postfix ;
postfix     = primary { call | "." ( identifier | integer_literal ) | struct_lit | "[" expression "]" | "?" } ;
call        = "(" [ args ] ")" ;
struct_lit  = "{" [ field_init { "," field_init } [ "," ] ] "}" ;   (* only directly after a type's name *)
field_init  = identifier ":" expression ;
args        = expression { "," expression } [ "," ] ;
primary     = integer_literal | float_literal | string_literal | fstring | "true" | "false" | "self"
            | identifier | "(" expression ")" | tuple_lit | list_lit | map_lit
            | if_stmt | match_stmt | fn_expr ;
tuple_lit   = "(" expression "," expression { "," expression } [ "," ] ")" ;
list_lit    = "[" [ expression { "," expression } [ "," ] ] "]" ;
map_lit     = "[" ":" "]" | "[" expression ":" expression { "," expression ":" expression } [ "," ] "]" ;
fn_expr     = "fn" "(" [ param { "," param } [ "," ] ] ")" [ "->" type ] block ;

4.1 Precedence and associativity

From loosest to tightest binding:

LevelOperatorsAssociativity
1||left
2&&left
3== !=none (no chaining)
4< <= > >=none (no chaining)
5+ -left
6* / %left
7unary - !prefix
8call f(...), field .x, index [i], struct literal S { ... }, ?postfix

Comparison operators at the same level cannot be chained: a < b < c and a == b == c are error E0106. Comparisons at different levels compose: a < b == c > d means (a < b) == (c > d).

4.2 Restrictions enforced by the parser

  • Only imports and items may appear at the top level; a let there is E0103 (a constant is a const). Items other than functions (struct, enum, trait, impl, const) cannot be declared inside functions (E0103).
  • In the condition of an if or while, Name { starts the body, not a struct literal: write if p == (Point { x: 0, y: 0 }) {, with parentheses, to use a literal there.
  • A line that starts with . continues the expression on the line above it (section 6.1).
  • let requires an initial value (E0100); parameters and fields require a type (E0102).
  • Only a variable or a field path starting at a variable (p.from.x) can be assigned to: f() = 1 is error E0104.
  • The body of if, else and while must be a block in braces. else must be followed by { or if.
  • A call's callee is a function name, a variable or field holding a function value, or any expression of a function type: f(1), button.on_click(3), make_adder(1)(2). Calling a value that is not a function is E0107 (or E0214 for a variable).
  • Blocks and expressions may nest at most 128 levels deep, counting long operator chains and else if chains (E0105). This is an implementation limit that protects the compiler's stack.

5. Types

TypeValues
i6464-bit signed integers, two's complement
i3232-bit signed integers
u8integers from 0 to 255, used for bytes
u64integers from 0 to 2^64 - 1
f6464-bit floating-point numbers (IEEE 754 double precision)
booltrue, false
stringimmutable sequences of Unicode text (UTF-8)
voidno values
structsdeclared with struct (section 5.1)
enumsdeclared with enum (section 5.2); Option[T] and Result[T, E] are built in (section 5.4)
List[T]ordered sequences of T (section 5.5)
Map[K, V]values of type V looked up by keys of type K (section 5.6)
Set[T]distinct values of type T (section 5.6.1)
Box[T]one value of type T on the heap, so a type can contain itself (section 5.7)
(A, B, ...)tuples: two or more values of the given types (section 5.13)
  • void may only appear as a function return type. Variables and parameters of type void are error E0213.
  • Unknown type names are error E0212.
  • There are no implicit conversions between types, ever: to_float(n) turns an integer into an f64 and to_int(x) the other way (section 10).
  • Integer types. Arithmetic and comparisons work on two integers of the same type, and the result has that type. An operation whose result does not fit the type stops the program with run-time error R0001 (u8 250 + 10, i32 2147483647 + 1, u64 0 - 1); u8 and u64 cannot be negated. to_int(x), to_i32(x), to_u8(x) and to_u64(x) convert between integer types, with R0001 when the value does not fit. i32 and u8 values currently take as much memory as an i64.
  • Bytes are a List[u8]: s.bytes() gives a string's UTF-8 bytes and string_from_bytes(b) the text back, or None when the bytes are not valid UTF-8.
  • Two types are compatible only if they are identical.

5.1 Structs

struct Point {
    x: i64
    y: i64
}

A struct declaration introduces a new type with named, typed fields. Fields are separated by newlines or commas. Struct names share the type namespace with the built-in types (struct string {} is E0219) and may not be used by a function as well (E0219). A struct can be used before its declaration.

  • Field names within a struct must be unique (E0220). A field cannot have type void (E0213).
  • A struct cannot contain itself, directly or through other structs or enums (E0221): structs are values, so that would make it infinitely large. It can contain itself through a List, Map or Box, whose contents live on the heap: struct Node { name: string, children: List[Node] } is a tree.

Values. Point { x: 1, y: 2 } creates a struct value. Every field must be given exactly once (E0222 for a missing field, E0223 for a repeated one, E0224 for an unknown one), in any order. The field values are evaluated in the order they are written. p.x reads a field; reading a field of a non-struct is E0224.

Value semantics. Structs are values, like integers: assignment, passing an argument and returning copy the whole struct, and changing a copy never changes the original. There are no references to structs.

Field assignment. p.x = 3, p.from.y += 1 change one field of a variable. The variable must be declared with let mut (E0218); its fields are as mutable as the variable itself.

Visibility. A field is visible outside the struct's module only when declared pub (section 9.2).

Operations. Two struct types are compatible only if they are the same declaration. Structs can be compared with == and != and shown with print and to_string (section 5.8). Methods are declared in impl blocks.

5.2 Enums

enum Shape {
    Circle(i64)
    Rect(i64, i64)
    Empty
}

An enum declaration introduces a type whose values are exactly one of its variants. A variant may carry values of the listed types (its payload). Variants are separated by newlines or commas, and their names must be unique (E0220).

  • Shape.Circle(3) and Shape.Empty create values. The number of values must match the variant (E0228), and an unknown variant is E0227.
  • An enum is a value like a struct, and cannot contain itself directly (E0221), but it can through a List, Map or Box: enum Json { Null, Number(i64), Array(List[Json]), Object(Map[string, Json]) }, or enum Expr { Num(i64), Add(Box[Expr], Box[Expr]) } (section 5.7).
  • Enums can be compared with == and != and shown with print and to_string (section 5.8).

5.3 Generics

Functions, structs and enums can take type parameters:

struct Pair[A, B] {
    first: A
    second: B
}

fn swap[A, B](p: Pair[A, B]) -> Pair[B, A] {
    return Pair { first: p.second, second: p.first }
}
  • A generic type is used with type arguments: Pair[i64, string]. The number of arguments must match (E0225).
  • Type arguments of calls, struct literals and variants are inferred, from the arguments and from the type the context expects (a let annotation, a parameter type, a return type). Arguments that need the expected type themselves, such as None, are inferred last. When a type argument cannot be inferred, it is E0226; add an annotation.
  • Inside a generic function, a type parameter can be any type, so values of that type can only be stored, passed and returned, unless the parameter has bounds (section 5.11): fn total[T: Shape](xs: List[T]) may call Shape's methods on a T, T: Eq allows == and !=, and T: Show allows print and to_string.
  • Generic code is checked once and compiled once per combination of type arguments it is used with (monomorphization): there is no run-time cost.
  • Type parameter names must be unique (E0220) and cannot be built-in type names (E0219).

5.4 Option and Result

Two generic enums are always available:

enum Option[T] { Some(T), None }
enum Result[T, E] { Ok(T), Err(E) }

Option[T] is Brim's replacement for null: a value that may be absent. Result[T, E] is the result of an operation that can fail. Their variants can be used without the enum's name: Some(1), None, Ok(x), Err(message). The names Option, Result, Some, None, Ok and Err cannot be redefined.

The ? operator. value? on a Result[T, E] evaluates to the T inside an Ok. On an Err, the enclosing function returns that Err immediately, so the function must return a Result[U, F] (E0233). When F is E, the error is returned unchanged. Otherwise F must be an enum with exactly one variant whose only value is an E, and ? wraps the error in that variant: with enum ConfigError { Parse(ParseError), Missing(string) }, a ParseError returned through ? becomes Err(ConfigError.Parse(error)). Two such variants make the conversion ambiguous, which is also E0233. On an Option[T], value? evaluates to the value inside Some, and None makes the function return None; the function must return an Option[U]. ? on any other type is E0232. A call followed by ? may be used as a statement, to only propagate its error.

fn total(a: string, b: string) -> Result[i64, string] {
    return Ok(parse(a)? + parse(b)?)
}

Methods (from the prelude):

OnMethodResult
Option[T]is_some(), is_none()bool
Option[T]unwrap_or(fallback)Tthe value, or fallback
Option[T]map(f)Option[U]Some(f(x)), or None
Result[T, E]is_ok(), is_err()bool
Result[T, E]unwrap_or(fallback)Tthe Ok value, or fallback
Result[T, E]ok()Option[T]the Ok value; the error is dropped
Result[T, E]map(f)Result[U, E]Ok(f(x)), or the same Err

5.5 Lists

List[T] holds any number of values of type T, in order. [1, 2, 3] creates one; [] is an empty list and needs a known element type (let xs: List[i64] = [], E0226).

  • xs[i] is element i, counting from 0. An index outside the list is run-time error R0005. xs[i] = v and xs[i] += v change an element. Indexing anything other than a list is E0234.

  • Lists are values, like structs: assignment, passing and returning give an independent copy. Copies are cheap: the elements are only copied when one of the copies is changed while it is shared (copy-on-write).

  • Methods:

    MethodResult
    len()i64the number of elements
    is_empty()bool
    get(i)Option[T]the element, or None outside the list
    first(), last()Option[T]
    contains(x)boolonly for lists of i64, bool and string
    join(sep)stringonly for List[string]
    push(x)appends
    pop()Option[T]removes and returns the last element
    insert(i, x)inserts before index i (0 <= i <= len)
    remove(i)Tremoves and returns element i
    clear()
    sorted()List[T]a sorted copy, smallest first; stable; T must implement Ord (section 5.11)
    sorted_by(less)List[T]a copy sorted by less: fn(T, T) -> bool; stable
    reversed()List[T]a copy in the opposite order
    slice(start, end)List[T]the elements from start up to end; indices outside the list are moved to its ends, so slice never fails
    index_of(x)Option[i64]the index of the first element equal to x; T must implement Eq
    map(f)List[U]f(x) for each element, with f: fn(T) -> U
    filter(keep)List[T]the elements for which keep: fn(T) -> bool is true
    any(test), all(test)boolwhether test holds for some, or every, element (all of an empty list is true)
    fold(init, f)Af(...f(f(init, x0), x1)..., xn), with f: fn(A, T) -> A
    find(test)Option[T]the first element for which test holds
    each(f)calls f: fn(T) with each element
    min(), max()Option[T]the smallest or largest element; T must implement Ord
    sum()Tthe elements added up, 0 for an empty list; only for lists of numbers, and integer overflow stops the program

    The methods from push to clear change the list, so they need a variable declared with let mut (or a field or element of one); calling them on a temporary is E0104. An unknown method is E0231.

  • Lists can be compared with == and shown with print and to_string (section 5.8).

5.6 Maps

Map[K, V] maps keys of type K to values of type V. Keys must be integers, bool or string, or a type parameter bounded by the built-in trait Key (E0236). ["ada": 36, "alan": 41] creates one; [:] is an empty map and needs known types (E0226).

  • m[key] is the value for a key; a missing key is run-time error R0006 (use get when the key may be missing). m[key] = v adds or replaces an entry; m[key] += 1 and other changes through m[key] need the key to exist.

  • Entries keep the order in which their keys were first added. Replacing a value keeps the entry's position; removing a key closes the gap.

  • Maps are values with copy-on-write, like lists.

  • Methods:

    MethodResult
    len()i64the number of entries
    is_empty()bool
    get(k)Option[V]
    contains_key(k)bool
    keys()List[K]in entry order
    values()List[V]in entry order
    insert(k, v)adds or replaces
    remove(k)Option[V]removes the entry, returning its value
    clear()

    insert, remove and clear change the map and need a let mut variable. To loop over a map, loop over keys() or values().

5.6.1 Sets

Set[T] holds distinct values, with the same element types as map keys. Set() is an empty set of the type the context expects (E0226 without one), and Set(list) holds the distinct elements of a list. Like a map, a set keeps the order in which its elements were first added; for x in set goes through them in that order.

MethodResult
add(x)adds x if it is not there yet
remove(x)boolremoves x; whether it was there
contains(x)bool
len(), is_empty()i64, bool
to_list()List[T]in order
clear()
union(other), intersection(other), difference(other)Set[T]new sets
is_subset(other)boolwhether every element is in other

add, remove and clear change the set and need a let mut variable. Two sets are equal when they have the same elements, in any order; print shows {1, 2}.

5.7 Boxes

Box[T] holds one value of type T on the heap. It is how a struct or enum contains itself directly:

enum Expr {
    Num(i64)
    Add(Box[Expr], Box[Expr])
}

fn eval(e: Expr) -> i64 {
    match e {
        Num(n) => return n
        Add(a, b) => return eval(a.value()) + eval(b.value())
    }
}
  • Box(value) creates one; b.value() is the value inside. A box is a value like any other: it is immutable, and copying it shares the contents, which is safe because they cannot change.
  • == compares the values inside, and print shows the value inside without the box: Add(Num(1), Num(2)).
  • Freeing a long chain of boxes does not use the call stack, so a list built from a million links is fine.

5.8 Comparing and printing values

Structs, enums, lists and maps support == and !=, and print and to_string show them as text, when every value they hold does: integers, floats, booleans, strings, or other such types. A value of a type parameter does not, so inside generic code a Box[T] cannot be compared or printed (E0209, E0206).

  • Equality is structural. Two structs are equal when their fields are; two enum values when they are the same variant with equal values; two lists when they have the same length and equal elements in order; two maps when they have the same keys with equal values, in any order. Floats follow IEEE 754 inside other values too, so a value holding NaN is not equal to itself.
  • Text looks like the code that builds the value, with strings in quotes and escaped: Point { x: 1, y: 2 }, Circle(Point { x: 1, y: 2 }, 1.5), Some("ada"), None, [1, 2, 3], ["ada": 36] and [:]. Variants are shown without their enum's name, structs without their type arguments.
let a: Point = Point { x: 1, y: 2 }
print(a == Point { x: 1, y: 2 })   // true
print([a])                         // [Point { x: 1, y: 2 }]

The right side of == and != is checked with the left side's type in mind, so o == None needs no annotation.

5.9 Methods

impl Stack[T] {
    fn new() -> Stack[T] { return Stack { items: [] } }
    fn size(self) -> i64 { return self.items.len() }
    fn push(mut self, item: T) { self.items.push(item) }
}

An impl block adds methods to a struct or enum declared in the same file. A generic type's impl lists its type parameters (impl Stack[T]), which the methods can use; methods may also have their own.

  • A method whose first parameter is self is called on a value: stack.size(). self is a copy of the value, like any parameter.
  • With mut self, the method changes the caller's value: stack.push(1) replaces stack with the changed self when the method returns. The value must be a let mut variable, or a field or element of one (E0218, E0104). The value is moved into the method, so changing a list inside self does not copy it.
  • A method without self is called on the type: Stack.new().
  • Calling a method the wrong way is E0240; an unknown method is E0231. Methods of one type need different names, and an enum's methods cannot share a name with its variants (E0238). Built-in types cannot get methods (E0237), and self outside an impl is E0239.
  • self is a keyword; inside a method it is a variable like a parameter.

5.10 Functions as values

fn(A, B) -> R is the type of functions taking an A and a B and returning an R; fn(A) returns nothing. Values of such a type are made two ways:

  • A named function used without calling it: apply(double, 21). Generic functions cannot be used this way (E0214); wrap them in a fn expression. Builtins cannot either.
  • A fn expression: fn(x: i64) -> i64 { return x * 2 }. Its parameters need types, and its body is checked like a function's (E0207 when a non-void one can end without return).
fn adder(n: i64) -> fn(i64) -> i64 {
    return fn(x: i64) -> i64 {
        return x + n
    }
}

fn main() {
    let add10: fn(i64) -> i64 = adder(10)
    print(add10(5))          // 15
}
  • A fn expression can use the variables around it. It copies each one when the expression is evaluated (it captures it), like every other value in Brim, so later changes to the variable do not reach the function, and the function cannot change the variable (E0218). To change something, return the new value.
  • A function value is called like a function: f(3), button.on_click(3) for a field (when the struct has no method of that name), or make_adder(1)(2).
  • Function values can be stored in variables, fields, lists and enums, passed and returned. They cannot be compared with == or printed (E0209, E0206).
  • A run-time error inside a fn expression shows it in the call stack as enclosing.<fn>.

5.11 Traits

A trait names methods that several types can have:

trait Shape {
    fn area(self) -> f64
    fn scaled(self, factor: f64) -> Self
}

struct Circle {
    r: f64
}

impl Shape for Circle {
    fn area(self) -> f64 {
        return 3.14 * self.r * self.r
    }
    fn scaled(self, factor: f64) -> Circle {
        return Circle { r: self.r * factor }
    }
}

fn total_area[T: Shape](shapes: List[T]) -> f64 {
    let mut total: f64 = 0.0
    for s in shapes {
        total = total + s.area()
    }
    return total
}
  • A trait method is a signature without a body. It takes self (not mut self) and is called on a value. Self in its types is the type that implements the trait (E0246 for malformed traits, unknown traits and duplicates).
  • impl Trait for Type implements a trait for a struct or enum declared in the same module. It must have every method of the trait, with the same signature (Self replaced by the type), and nothing else; other methods go in a plain impl Type block (E0247). A type implements a trait at most once. The methods are also ordinary methods of the type.
  • A type parameter of a function can have bounds, [T: Shape + Show]: the type arguments must implement every bound (E0248), and inside the function a T has the bounds' methods.
  • The built-in traits Eq and Show are implemented by every type whose values can all be compared with ==, or shown with print (section 5.8), and by type parameters bounded by them. Ord allows <, <=, > and >= and is implemented by the integer types, f64 and string. Key allows a type to be a map key or set element, and is implemented by the integer types, bool and string. Data allows a type to be turned into JSON and back (json.encode, json.decode) and, like Eq, is implemented by every type without function values in it. They cannot be implemented by hand.
  • Methods of built-in types that are written in Brim itself, such as sorted on lists, live in the module std.prelude, which every program has.
  • Generic code is compiled per type, so a call through a bound is an ordinary call of the type's method. Traits are not types: a List holds values of one type, and there are no trait objects.

5.12 Constants

const NAME: T = value at the top level of a module names a value; the type may be left out, as with let. The value is computed from literals, operators, other constants, and struct, enum, list, map and box values; calls and variables are not allowed (E0249, also for constants defined in terms of themselves). A constant is used by name, and a pub const from another module as module.NAME. Two constants with the same name are E0202. const inside a function is E0103: use let.

const MAX_TRIES: i64 = 5
const ORIGIN: Point = Point { x: 0, y: 0 }

5.13 Tuples

A tuple groups two or more values without naming them: (i64, string) is the type, ("ada", 36) a value (a single value in parentheses is just that value).

fn divmod(a: i64, b: i64) -> (i64, i64) {
    return (a / b, a % b)
}

let (q, r) = divmod(17, 5)
  • t.0, t.1, ... are the values; t.0.1 reads into a nested tuple. Fields of a tuple in a let mut variable can be assigned (t.0 = 5). A field past the end is E0224.
  • (p, q) is a pattern in match. let (a, b) = value and let mut (a, b) = value take a tuple apart; the pattern must match every value (E0251), so it can use names, _ and nested tuples but not literals that could fail.
  • Tuples are values like structs: ==, print and to_string work when their values do ((36, "ada")), and they can be map values, list elements and type arguments. brim gen ts writes them as TypeScript tuples.

6. Statements

6.1 Statement termination

A statement ends at a newline, a ;, or the } that closes its block. Two statements on one line must be separated by ;. Empty statements, such as ;; or blank lines, are allowed.

Newlines do not end a statement:

  • inside parentheses, so argument and parameter lists may span lines;

  • directly after a binary operator, an assignment operator (=, +=, ...), -> or ,;

  • before the { that opens a function body or the block of an if, else or while;

  • between the } of an if block and a following else;

  • before a line that starts with ., which continues the expression above it, so method chains can put one call per line:

    let names = people
        .filter(fn(p: Person) -> bool { return p.age > 30 })
        .map(fn(p: Person) -> string { return p.name })
    

To continue a long expression, end the line with the operator:

let total: i64 = first +
    second +
    third

A line that starts with a binary operator is a new statement, which is normally an error.

6.2 let

let name: T = expr evaluates expr, which must have type exactly T, and binds it to name in the current scope. The binding is immutable.

let name = expr leaves out the type: the variable gets the type of expr. Values whose type cannot be known on their own, such as [], [:] and None, need the annotation (E0226), and so does an integer literal of a type other than i64 (let b: u8 = 200). A call of a function that returns nothing has no value to store (E0213).

let mut name: T = expr declares a mutable variable that may be reassigned. A mut variable that is never reassigned produces warning W0003.

6.3 Assignment

name = expr evaluates expr and stores it in the variable name, which must be declared with let mut (E0218). Function parameters are never mutable. The value must have the variable's type exactly (E0206).

The compound forms name op= expr (with op one of + - * / %) mean name = name op expr, with the same operand rules as the binary operators (section 8.1). For example, s += "x" appends to a mutable string.

Assignment is a statement, not an expression: it produces no value.

6.4 if / else

if cond { ... } runs the block when cond is true. cond must be a bool (E0206); there is no implicit truthiness, so write n != 0 instead of n. An optional else { ... } runs otherwise, and else if chains are allowed. Each block opens a new scope.

if as a value. In an expression, if cond { a } else { b } is the value of the branch that runs: let sign = if n < 0 { -1 } else { 1 }. The else is required (E0250), and both branches give values of the same type. A block used as a value ends with an expression (or an if or match used as a value), after any other statements; a block that ends otherwise is E0250, unless it returns or breaks first, in which case it needs no value:

let n = match text.parse_int() {
    Some(n) => n
    None => {
        print("not a number")
        return
    }
}

At the start of a line, if and match are statements, and their blocks' values are unused.

6.5 while

while cond { ... } evaluates cond (which must be a bool) and runs the block as long as it is true. The condition is evaluated before every iteration.

  • break leaves the innermost enclosing loop.
  • continue jumps to the next evaluation of the innermost loop's condition.
  • break or continue outside a loop is error E0217.

loop { ... } runs the block until a break (or return); it is the same as while true { ... }, and diverges when it contains no break for it.

6.6 return

return expr returns from the enclosing function. expr must have the function's return type. A plain return is allowed only in void functions (E0208).

6.7 Expression statements

Evaluates an expression for its effects. A non-call expression used as a statement (1 + 2) produces warning W0002. Discarding the result of a call is allowed.

6.8 Blocks

{ ... } groups statements and opens a new scope.

6.9 for

for name in names {
    print(name)
}
for i in 0..10 {
    print(i)
}

for x in list { ... } runs the body once for every element of the list, in order. The list is evaluated once, before the loop, so changing the variable it came from inside the loop does not change which elements the loop visits. for i in a..b { ... } runs it for every integer from a up to, but not including, b. The loop variable is immutable and lives in the body's scope. break and continue work as in while (continue moves on to the next element). Looping over anything else is E0235. A for loop never diverges.

6.10 match

match shape {
    Shape.Circle(r) => print(r)
    Rect(w, h) => {
        print(w * h)
    }
    _ => {}
}

match value { pattern => body ... } runs the body of the first arm whose pattern matches value. A body is a block or a single statement. Arms are separated by newlines or commas. Patterns:

  • _ matches anything.
  • A name binds the matched value to a new immutable variable in the arm. A name that is a variant of the matched enum (None, Empty) is a variant pattern instead, and a capitalized name that is not one is an error (E0227, E0230), so a misspelled variant never silently matches everything.
  • A variant pattern, Enum.Variant(p, q) or just Variant(p, q), matches that variant of an enum when each of its values matches the pattern in that position. Patterns nest to any depth: Some(Shape.Circle(r)), Ok(0), Pair(_, Leaf(n)). The pattern must have exactly as many patterns as the variant has values (E0228).
  • An integer (possibly negative), string or boolean literal matches that value of an i64, string or bool.
  • p | q | ... matches when any alternative matches. Every alternative must bind the same names, with the same types (E0245).

An arm may have a guard, pattern if condition => body: the arm only runs when the pattern matches and the condition, a bool that can use the pattern's names, is true. Otherwise the next arm is tried.

A pattern must fit the type of the matched value (E0230). The arms must be exhaustive (E0229): together they must match every value, so every variant of an enum (at every depth of nesting), both true and false, and some arm that matches any integer, string or value of another type. Arms with a guard do not count towards this, since the guard may be false. The error lists examples of values no arm matches. An arm, or an alternative of |, that can never match because earlier arms cover everything it matches produces warning W0004.

A match statement diverges (section 6.11) when it is exhaustive and every arm diverges. In an expression, match is a value, like if (section 6.4): every arm's body ends with a value of one type, or diverges.

6.11 Reachability and returns

A statement diverges when control can never continue to the statement after it:

  • return, break and continue diverge;
  • a block diverges if any statement in it diverges;
  • if/else diverges if both branches diverge; an if without else never diverges;
  • while true { ... } diverges if its body contains no break for that loop. Any other while does not diverge, because its body may run zero times.
  • match diverges if every arm diverges.

A function with a return type other than void must not be able to reach the end of its body: the body must diverge (E0207). Statements after a diverging statement are unreachable and produce warning W0001.

7. Names and scopes

  • Types: the built-in types, Option, Result, List, Map, Set, Box, and the structs, enums and traits of the module, which are visible everywhere in the module, together with the types it imports (section 9.2). Type parameters are visible in their declaration.
  • Functions and constants of a module form one namespace, together with the items it imports by name. They are visible everywhere in the module, including before their declaration, so mutual recursion works. Defining two with the same name is E0202. Defining a function with a builtin's name is E0216.
  • Variables live in lexical scopes. A function's parameters and its body's top-level statements share one scope. Each nested block opens a new scope.
  • Declaring a variable twice in the same scope is E0203, and duplicate parameter names are E0204. A nested block may shadow an outer variable.
  • A let binding is visible from the statement after it until the end of its block. In let x: i64 = x, the x on the right refers to an outer x.
  • Variables are immutable unless declared with let mut (section 6.2).
  • Variables and functions are separate namespaces. Using a function name as a value is E0214, and so is calling a variable.

8. Expressions

Operands are evaluated left to right. Function arguments are evaluated left to right before the call.

8.1 Operators

OperatorOperand typesResultNotes
+ - *two integers of the same type (i64, i32, u8, u64)that typea result outside the type is a run-time error
/two integers of the same typethat typetruncates toward zero; division by zero and i64::MIN / -1 are run-time errors
%two integers of the same typethat typeresult has the sign of the left operand; % 0 is a run-time error
+ - * /f64, f64f64IEEE 754: never a run-time error; 1.0 / 0.0 is inf, 0.0 / 0.0 is NaN
%f64, f64f64remainder with the sign of the left operand (like C's fmod)
+string, stringstringconcatenation
< <= > >=two integers of the same type, two f64, two string, or two values of a type parameter bounded by Ordboolstrings compare byte by byte; comparisons with NaN are false
== !=two values of the same type that implements Eq: numbers, bool, string, and structs, enums, tuples, lists, maps and sets of such valuesboolstrings compare by content; NaN == NaN is false and NaN != NaN true; other values compare structurally (section 5.8)
&& ||bool, boolboolshort-circuit: the right side is evaluated only if needed
unary -i64, i32 or f64same-i64::MIN is a run-time error; u8 and u64 cannot be negated
unary !boolbool

Any other combination is error E0209; in particular i64 and f64 never mix (1.5 * 2 is an error, 1.5 * 2.0 is not).

8.2 Calls

f(a, b) calls the function f. The number of arguments must match the number of parameters (E0205), and each argument must have exactly its parameter's type (E0206). The call's type is f's return type. A call to a void function cannot be used as a value.

9. Program structure and execution

  • A program is one source file, its root module, plus the modules it imports (section 9.2).
  • The program must define fn main() with no parameters and no return type, or -> void (E0210, E0211).
  • Execution evaluates main's body. The program ends when main returns or a run-time error occurs, also while tasks it started still run (section 9.7).

9.1 Run-time errors

These stop the program immediately, after the output produced so far has been flushed:

CodeCondition
R0001integer overflow in +, -, *, /, % or unary -
R0002division or remainder by zero
R0003call depth limit exceeded (10,000 nested calls, in the interpreter and in native executables)
R0004writing program output failed
R0005a list index outside the list
R0006a map key that is not in the map
R0007a string slice outside the string or not on a character boundary
R0008a failed assert or assert_eq
R0009the step limit an embedder of the interpreter set (never in brim run or native executables)
R0010to_int of NaN or of a number outside the i64 range
R0011a call to an extern fn in the interpreter
R0012join of a task that was already joined
R0013starting a task where there are no threads (the interpreter compiled to WebAssembly)

Integers never wrap silently. docs/error-handling.md lists every code with an example.

9.2 Modules

// main.brim
import geometry
import text.format.{pad, Report}

fn main() {
    print(geometry.area(geometry.square(3)))
}

// geometry.brim
pub fn square(side: i64) -> Shape { ... }
fn helper() { ... }          // private to geometry

Every .brim file is a module. import a.b loads the module in a/b.brim, relative to the directory of the root file (src/ in a project).

  • import a.b makes the module usable as b: b.function(...), b.Type, b.Type { ... }, b.Enum.Variant(...), b.Type.method(...). import a.b as c uses the name c instead.
  • import a.b.{X, y} brings the named items (functions, types and constants) into the module's scope under their own names.
  • pub import a.b.{X, y} also re-exports them: modules that import this one can use them as this.X or import them by name, as if they were declared here. Only item lists can be re-exported.
  • A module's functions, structs, enums, traits and constants are private unless declared pub; using a private item of another module is E0241. Methods are private unless declared pub fn inside the impl block (methods of impl Trait for Type are as visible as the trait).
  • Fields are private too unless declared pub name: type: outside the struct's module, reading, assigning or giving a private field (so creating the struct with a literal) is E0241. A struct with private fields is made by functions of its module.
  • Methods can only be added in the module that declares the type (E0237).
  • A missing module or item is E0242; importing two modules under the same name, or an item whose name is already taken, is E0243.
  • Modules may import each other in cycles. Every module is loaded once.
  • main must be in the root module. Function names of other modules appear qualified (geometry.area) in messages and run-time call stacks.
  • Modules can also come from other packages: see [dependencies] in Appendix A.
  • import std.x imports a module of the standard library, which is built into the compiler (section 10.2). The name std is reserved for it.

9.3 Tests

fn add(a: i64, b: i64) -> i64 {
    return a + b
}

fn test_add() {
    assert_eq(add(2, 3), 5)
}

brim test runs the program's tests: the functions whose names start with test_, declared outside impl blocks, in any module. A test takes no parameters and returns nothing (E0244 otherwise). It passes when it returns and fails when it stops with a run-time error, such as a failed assert_eq, or calls exit. For brim test, main is optional; it is never run. Other commands ignore tests, so a program still needs main.

9.4 API types for TypeScript

brim gen ts writes TypeScript declarations for the pub structs and enums of a program's own modules and the types they use, so a web frontend and a Brim backend share one definition of their API. The declarations describe values as JSON:

BrimTypeScript
i64, i32, u8, u64, bool, stringnumber, boolean, string
List[T]T[]
Map[string, V], Map[i64, V], Map[bool, V]Record<string, V>, Record<number, V>, Partial<Record<"true" | "false", V>>
Option[T]T | null
Result[T, E]{ kind: "Ok"; value: T } | { kind: "Err"; value: E }
struct P { x: i64 }interface P { x: number }
enum without payloadsa union of the variant names: "Low" | "High"
enum with payloads{ kind: "Name" }, { kind: "Name"; value: T } or { kind: "Name"; value: [T, U] } per variant

Generic types stay generic (Page[T] becomes Page<T>). Exported types need different names, even when they come from different modules (G0001). examples/api.brim and examples/api.ts show the result. json.encode and json.decode (section 10.2) write and read values in exactly these shapes; map keys of any integer type are Record<number, V>.

9.5 API functions and an Angular client

serve_api(module, request), a built-in function, answers an http.Request with an http.Response by calling one of the pub fns of module, an imported module: its API functions. A call is POST /api/<name> with the parameters as a JSON object ({"text": "milk"} for fn add_note(text: string); an empty body is {}). Parameters and results are read and written with json.decode and json.encode, so they must implement Data, and API functions cannot be generic or methods (E0255).

RequestResponse
the function returns value, or Ok(value)200, value as JSON (null for a function without a result)
the function returns Err(error)422, error as JSON
a parameter does not fit400, {"error": "at $.text: expected a string, found a number"}; a missing parameter is null
the body is not a JSON object400, {"error": "..."}
no API function has the name404, {"error": "no API function \"name\""}
another method than POST405, with allow: POST
a path that does not start with /api/404

API functions share no state: they keep it in files or a database. http.new_request(method, path, body) makes a request without a connection, for testing a handler.

brim gen ts --angular adds an Angular service per module served with serve_api: notes_api becomes NotesApiClient, @Injectable({ providedIn: 'root' }), with a method per API function in camel case. add_note(text: string) -> Result[Note, NoteError] becomes addNote(text: string): Observable<Note>, which posts { text }; a 422 response arrives as an HttpErrorResponse whose error is the NoteError. The injection token API_BASE_URL (default "", the page's own origin) says where the server is. Without a module served with serve_api, --angular is an error (G0002). examples/http/api shows a service and its generated client.ts.

9.6 Calling C

An extern fn declares a C function, without a body; calls to it call the C function of that name:

extern fn cos(x: f64) -> f64
extern fn crc32(data: string, len: i64) -> u64

Parameters and the result have types C has too, and are passed as C expects (E0260 for others, for generics, for self and for an extern fn main):

BrimC
i64, i32, u8, u64int64_t, int32_t, uint8_t, uint64_t
f64double
boolbool
stringconst char *: a NUL-terminated copy for the call (text after a NUL inside the string is not seen); a returned string is copied, and NULL is "". The C side keeps what it returns.
no resultvoid

The C code comes from the files in [build] c-sources (compiled with the C compiler that links the program, where they are, so #include "..." works) and the libraries in [build] link (-l<name>), for every package of the program, or from the C library. Native builds only: the interpreter stops at a call with R0011. brim run therefore builds a program that has extern fns and runs the executable, when the native backend is available. brim test runs in the interpreter, so tests cannot call C yet.

9.7 Tasks

std.task runs functions at the same time, each on a thread of its own:

import std.task

fn main() {
    let a = task.spawn(fn() -> i64 { return 6 * 7 })
    let lines: task.Channel[string] = task.channel()
    task.detach(fn() {
        lines.send("hello")
        lines.close()
    })
    print(a.join())             // 42
    print(lines.receive())      // Some("hello")
    print(lines.receive())      // None
}
  • spawn(f: fn() -> T) -> Task[T] starts a task; Task[T].join() -> T waits for it and returns its result. A task is joined once: joining it again is the run-time error R0012. detach(f: fn()) starts a task that is never joined.
  • channel[T]() -> Channel[T] is a channel without a size limit, bounded[T](capacity) one whose send waits while capacity values are queued. Channel[T].send(value) -> bool queues a value (false once the channel is closed), receive() -> Option[T] waits for the next one (None once the channel is closed and every value sent before was received), close() ends it. Copies of a channel, in a fn, a struct or another channel, refer to the same channel.
  • Tasks share no changeable memory. Every value is a value: a fn copies what it captures, and a list, map or struct a task changes is its own copy, made when it changes it. So no task sees another task change something, and there are no data races. Values passed between tasks are shared until one side changes them, as within a task.
  • A run-time error in any task stops the program, with that task's call stack; exit in any task ends the program. The program ends when main returns, while detached tasks run or wait.
  • print writes a line at a time: the lines of different tasks do not mix, in an order that depends on how the tasks run.
  • A task waiting for a value no task sends waits forever: close a channel to end a loop over receive.

10. Builtins

Builtins are always in scope and cannot be redefined.

BuiltinFormsEffect
printprint(string), print(i64), print(f64), print(bool), and structs, enums, lists and maps (section 5.8)writes the value and a newline to standard output
eprinteprint(string)writes the text and a newline to standard error
to_stringto_string(i64) -> string, to_string(f64) -> string, to_string(bool) -> string, and structs, enums, lists and mapsthe decimal representation of a number, "true"/"false", or the text form of section 5.8
to_floatto_float(i64) -> f64, and the other integer typesthe nearest f64
absabs(i64) -> i64, abs(f64) -> f64the absolute value; abs of i64::MIN is run-time error R0001
sqrt, floor, ceil, round(f64) -> f64IEEE 754; round rounds halves away from zero, sqrt of a negative number is NaN
powpow(i64, i64) -> i64, pow(f64, f64) -> f64a to the power b. On integers a result outside i64 is R0001, and a negative power divides, rounding toward zero (pow(2, -1) is 0, pow(0, -1) is R0001)
min, maxmin[T: Ord](a: T, b: T) -> Tfrom the prelude; a when the two are equal
clampclamp[T: Ord](x: T, low: T, high: T) -> Tfrom the prelude: min(max(x, low), high)
to_intto_int(f64) -> i64, to_int(i32) -> i64, to_int(u8) -> i64, to_int(u64) -> i64rounds an f64 toward zero; NaN and numbers outside the i64 range are run-time error R0010; a u64 above the i64 range is R0001
to_i32, to_u8, to_u64to_i32(x) -> i32 and so on, for any integer xthe same number in another integer type; R0001 if it does not fit
string_from_bytesstring_from_bytes(List[u8]) -> Option[string]the text of UTF-8 bytes, or None if they are not valid UTF-8
from_code_pointfrom_code_point(i64) -> Option[string]the character with a Unicode code point, or None for a surrogate or a number outside 0..=10FFFF (hex)
read_lineread_line() -> Option[string]the next line of standard input without its line ending (\n or \r\n); None at the end of input
read_fileread_file(path: string) -> Result[string, string]a file's contents; Err with a message if it cannot be read or is not UTF-8
write_filewrite_file(path: string, text: string) -> Result[i64, string]creates or replaces a file; Ok with the number of bytes written
argsargs() -> List[string]the program's command-line arguments, without the program name
envenv(name: string) -> Option[string]an environment variable
exitexit(code: i64)ends the program with an exit code (its low 8 bits); a statement calling exit diverges
assertassert(bool)stops the program with run-time error R0008 if the condition is false
assert_eqassert_eq(i64, i64), assert_eq(bool, bool), assert_eq(string, string)stops the program with R0008 if the values differ; the message shows both

The prelude also has the constants PI and E (f64). Its functions and constants can be used by name everywhere, unless a module defines its own with the same name. A program may also define its own abs, sqrt, floor, ceil, round or pow, which it then uses instead of the builtin; redefining other builtins is E0216.

The prelude's struct StringBuilder builds a string from pieces: StringBuilder.new(), b.add(text), b.add_line(text), b.is_empty() and b.build(). Joining with + in a loop copies the text so far every time; a builder joins once.

An f64 is written with the fewest digits that read back as exactly the same number: 0.1, 0.30000000000000004. Numbers from 0.00001 up to (not including) 1e16 are written as decimals, with .0 when whole (2.0); others in scientific notation (1e16, 1.5e-7). The special values are NaN, inf and -inf, and negative zero is -0.0.

A builtin selects its form by the argument's type. User functions cannot be overloaded, and builtins cannot be redefined (E0216).

File error messages have the form cannot read file "PATH": REASON, where the reason is file not found, permission denied, is a directory, file is not valid UTF-8 or input/output error. Relative paths are relative to the current directory.

10.1 String methods

Positions and lengths count bytes of the UTF-8 text.

MethodResult
len()i64the length in bytes
is_empty()bool
contains(t), starts_with(t), ends_with(t)bool
find(t)Option[i64]the position of the first t
slice(start, end)stringbytes start..end; outside the string or not on a character boundary is run-time error R0007
trim(), trim_start(), trim_end()stringwithout spaces, tabs, line feeds, carriage returns, vertical tabs and form feeds
to_upper(), to_lower()stringASCII letters only
replace(from, to)stringevery non-overlapping from; an empty from matches before every character and at the end
repeat(n)stringempty for n <= 0
split(sep)List[string]the parts between separators ("a,,b" gives three parts); an empty separator splits into characters
lines()List[string]without line endings; a final line ending does not start another line
chars()List[string]each character as a string
char_count()i64the number of characters (code points); len() counts bytes
code_points()List[i64]the Unicode code point of each character
parse_int()Option[i64]a decimal integer with an optional + or -, and nothing else
parse_float()Option[f64]a decimal number with an optional sign, fraction and exponent (-1.5e3, 12); the nearest f64, or inf for numbers too large. NaN, inf, 1. and .5 are not accepted

Strings can be compared with <, <=, > and >=, byte by byte.

10.2 Standard library modules

Some of the standard library is written in Brim and built into the compiler. Its modules are imported like any other: import std.http makes http.get(...), http.Request and so on available.

ModuleContents
std.netTCP: listen(host, port) -> Result[Listener, string], connect(host, port) -> Result[Conn, string]; Listener.accept(), next_ready(idle_millis) (the next connection with something to read: a new one or a kept one), keep(conn) (give a connection back for next_ready, at most 256), port(), close(); closed(), a connection that is not open; Conn.read_line(), read_until(delimiter), read_exact(count), read_all(), write(text), read_bytes(count) -> Result[List[u8], string] and write_bytes(bytes) for binary protocols, set_timeout(millis), port(), close()
std.timenow() -> Time (the system clock), from_unix_millis, from_unix_seconds; Time.iso() (2026-10-15T08:30:01.123Z), date(), http_date(), parts() -> DateTime (year, month, day, hour, minute, second, millisecond, weekday), unix_seconds(), add_millis, add_seconds; millis() (a monotonic clock for durations), sleep(millis). All in UTC
std.loginfo(message), warn(message), error(message): a line on standard error with the time and level
std.fsexists, is_dir, is_file; list(dir) -> Result[List[string], string] (sorted names); create_dir(path) (with parents; Ok(false) if it existed), remove(path) (a file or empty directory; Ok(false) if there was none), remove_all(path), rename(from, to), each -> Result[bool, string]; size(path) -> Result[i64, string], modified(path) -> Result[time.Time, string]; read, write and append(path, text). Errors are messages such as cannot list directory "out": not found; the reasons are not found, permission denied, already exists, directory not empty, not a directory, is a directory and input/output error
std.processrun(cmd, args: List[string]) -> Result[Output, string] runs a program and waits for it; Output has code: i64 (128 plus the signal for a program a signal stopped), stdout and stderr (strings) and success(). The program is looked up in PATH unless its name contains /, gets no input and runs in the working directory. Err only when it cannot start (cannot run "x": not found) or writes text that is not UTF-8. output(cmd, args) -> Result[string, string] is the standard output of a program that exits with 0, and otherwise an error with its standard error
std.cryptosha256(bytes), sha256_hex(text), hmac_sha256(key, message), pbkdf2_sha256(password, salt, iterations, length), md5(bytes), each on List[u8]; random_bytes(n) from the operating system's secure source (/dev/urandom); hex(bytes); equal(a, b), which takes as long whatever the first difference
std.base64encode(bytes) -> string and decode(text) -> Result[List[u8], string] (RFC 4648, standard alphabet, = padding; spaces and line breaks are skipped), encode_text(text) and decode_text(text) for strings
std.postgresA PostgreSQL client over TCP, without TLS. connect(url) -> Result[Client, string] for postgres://user:password@host:port/database (port 5432 and the user's database unless given), connect_to(host, port, user, password, database); Client.query(sql, params) -> Result[Rows, string], execute(sql, params) -> Result[i64, string] (the rows changed), query_as[T: Data](sql, params) -> Result[List[T], string] (each row as an object read with json.from_json), transaction(work) (BEGIN, then COMMIT if work returns Ok and ROLLBACK if Err), close(). Parameters are $1, $2, ... and a List[Value]: null(), int(n), float(x), text(s), bool(b), json_value(v), sent separately from the SQL. Rows has columns, rows and affected; a Row has get(column) -> Option[string], text, int, float and bool (Results), is_null and to_json(), which writes bool, integer, float8, numeric and json/jsonb columns as themselves. Logins use SCRAM-SHA-256, MD5 or a plain password, as the server asks. Errors: postgres: ERROR: relation "x" does not exist [42P01]
std.taskTasks and channels (section 9.7): spawn(f) -> Task[T], Task.join(), detach(f); channel[T](), bounded[T](capacity); Channel.send(value) -> bool, receive() -> Option[T], close()
std.randomint(low, high) (from low up to, not including, high; low when high <= low), float() (0 up to 1), bool(), choice(list) -> Option[T], shuffle(list) -> List[T], seed(n). The generator is xoshiro256**: fast, not for passwords or keys. After seed(n) the numbers are the same on every run, in the interpreter and native code alike; without it they come from the operating system's randomness
std.jsonenum Json { Null, Bool(bool), Number(string), Str(string), Array(List[Json]), Object(Map[string, Json]) }; parse(text) -> Result[Json, string], stringify(value), pretty(value), quote(text); building: null(), from_bool, from_int, from_float, from_string, array, object; reading: Json.get(key), at(index), as_string(), as_int(), as_float(), as_bool(), as_array(), as_object(), is_null(), kind(); typed values (T: Data): encode(value) -> string, decode(text) -> Result[T, string], to_json(value) -> Json, from_json(json) -> Result[T, string]
std.httpHTTP/1.1. Server: serve(host, port) -> Result[Server, string], Server.next() -> Result[Request, string], Request.respond(Response), Request.header(name), Request.param(name). Client: get(url), post(url, content_type, body), request(method, url, headers, body), each -> Result[Response, string]. Responses: text, json, html, response, redirect, not_found, Response.with_header, header, ok. URLs: encode, decode. new_request(method, path, body) for tests; the built-in serve_api(module, request) serves API functions (section 9.5)

Network data is text: what a connection reads must be valid UTF-8. Network errors are messages such as cannot connect to example.com:80: connection refused; the reasons are connection refused, connection reset, connection closed, timed out, host not found, address in use, address not available, permission denied, invalid port and network error, and for https:// also certificate not trusted, TLS handshake failed and invalid URL.

The HTTP server handles a request at a time; a task per request (task.detach, section 9.7) handles them at the same time, and Server.next() and Request.respond may be called from different tasks. It keeps a connection open after the response for the client's next request (HTTP keep-alive) when the request is HTTP/1.1 without connection: close, or HTTP/1.0 with connection: keep-alive, and the response has no connection: close header; the response says connection: keep-alive or connection: close. Server.next() waits for a new connection and for the kept ones at once, so a quiet client keeps no one else out; a kept connection that stays quiet for five seconds, or that the client closes, is closed. It protects itself against clients that send too much: request heads over 64 KiB get 431, bodies over the limit (10 MiB, or Server.with_max_body(bytes)) get 413, and a client gets ten seconds to send its request. Conn.read_until_max(delimiter, max) gives the same protection to programs that use std.net directly. Header names are kept in lower case; query parameters and paths are percent-decoded. The client reads bodies with a content-length, chunked bodies and bodies that last until the connection closes; https:// URLs go through libcurl, which checks certificates; a native program that uses the HTTP client links with libcurl (-lcurl, so building it needs libcurl's development files on Linux), and other programs, servers included, do not. Only the functions a program can reach from main (or its tests) are compiled. Redirects are not followed: the 3xx response is returned.

std.json follows RFC 8259. Numbers keep the text they were written with (Number("-2.5e3")), so no precision is lost; as_int reads whole numbers and as_float any number. from_float writes NaN and infinities, which JSON cannot hold, as null. Object members keep their order, and a repeated key keeps its last value. Parsing rejects lone surrogates ("\ud83d"), which are not characters, and values nested more than 128 deep. Parse errors name the problem and the character position: expected ':' at character 6.

encode and decode use the shapes brim gen ts declares: numbers, booleans and strings as themselves (a NaN or infinite f64 as null); a struct as an object with every field; Option as its value or null (a missing member decodes as None); an enum whose variants have no payloads as the variant's name ("Red"), other enums, Result among them, as {"kind": "Circle", "value": 1.5} with an array for several payload values and no "value" for none; Box[T] as the value; tuples, lists and sets as arrays; maps as objects, with integer and bool keys written as text ({"1": "one"}). decode also accepts a variant name alone for a variant without a payload. Its errors give the path to the value: at $.tags[1]: expected a string, found a number, at $[0]: Shape has no variant "Triangle", at $.n: 300 does not fit in u8. Decoding a u64 accepts values up to the largest i64. The type to decode comes from the expected type, which may come through ?: let note: Note = json.decode(text)?.

The standard library modules use builtins whose names start with __ (__net_listen, ...). Programs cannot call them (E0201).

11. Design decisions

The questions that were open before 1.0, and how 1.0 answers them. Each answer is part of the stable language (see docs/releasing.md); where 1.0 leaves something out, adding it later does not break programs written for 1.0.

  1. Type inference. let takes the type of its value. Types are not inferred from later uses (let mut xs = [] followed by xs.push(1) needs List[i64]): a declaration says everything about the variable. Type arguments of calls and literals are inferred from arguments and the expected type (section 5.3).
  2. if and match as values. Both are expressions (section 6.4); at the start of a line they are statements.
  3. Integer types. i64, i32, u8 and u64 (section 5). i64 is the default. More widths (i8, i16, u16, u32) may be added as new types.
  4. Overflow. Integer operations are checked in every build, debug and release, in the interpreter and in native code: a result that does not fit is R0001. Wrapping arithmetic, if it comes, will be functions with their own names, never a change to the operators.
  5. Strings. Immutable UTF-8 text, reference-counted. Positions and lengths count bytes (section 10.1); chars, char_count and code_points work on characters. Case conversion covers ASCII letters; full Unicode case mapping and grapheme clusters belong in a library.
  6. Errors. Expected failures are Result values, passed on with ?, which converts between error enums (section 5.4). Bugs (overflow, an index out of range, a failed assertion) are run-time errors (R codes) that stop the program. There are no exceptions and no catching of run-time errors.
  7. Statement termination. A newline ends a statement, except after an operator, inside brackets and before a line that starts with . (section 6.1).
  8. main. fn main() takes nothing and returns nothing; arguments come from args() and the exit code from exit. A main returning Result may be added later.
  9. Identifiers. ASCII letters, digits and _. Unicode identifiers may be allowed later.
  10. Modules. A file is a module; items and fields are private unless pub; pub import re-exports named items. Packages come from the registry by version, from directories or from git (Appendix A).
  11. Loops. while, loop and for over lists, sets and ranges, with break and continue for the innermost loop. Labeled loops are not in 1.0.
  12. Struct literals. Every field is written out: Point { x: x, y: y }. Shorthand and default values are not in 1.0.
  13. Patterns. Literals, names, _, variants, tuples, | and guards, nested to any depth, checked for exhaustiveness (section 6.10). Struct patterns, ranges and name @ pattern are not in 1.0.
  14. Traits. Methods with self, bounds on type parameters, and the built-in traits Eq, Show, Ord, Key and Data (section 5.11). Default method bodies, static trait methods, traits for built-in types and trait objects are not in 1.0.
  15. Memory. Automatic reference counting with copy-on-write, for every value. Values cannot refer to themselves except through Box, List and Map, which hold copies, so there are no cycles to collect and no garbage collector.
  16. Concurrency. Tasks on threads, joined or detached, with channels between them (section 9.7). There is no shared changeable memory, so there are no data races; there is no async/await.

12. Not in 1.0

These are not part of the language. The reserved words async, await, type and use keep room for some of them; using one is E0101.

  • Type aliases (type), and the items of section 11 that 1.0 leaves out: labeled loops, struct patterns, field shorthand, default method bodies, trait objects, a main that returns Result, Unicode identifiers.
  • select over several channels, receive with a timeout, and shared mutable state between tasks.
  • Macros, reflection, operator overloading, and an ownership or borrowing system.

docs/roadmap.md says what comes after 1.0.

Appendix A. The .brimproj manifest

Brim projects are described by a .brimproj file, in TOML. Its format is part of the stable interface (see docs/releasing.md). The lock file next to it is .brimlock. Before 1.0 the two were called brim.toml and brim.lock: a project with a brim.toml and no .brimproj still works, with a warning to rename it, and a brim.lock is read and replaced by a .brimlock the next time the lock file is written.

[package]
name = "hello"          # required: lowercase letter, then [a-z0-9_-], max 64 chars
version = "0.1.0"       # required: MAJOR.MINOR.PATCH with an optional -prerelease
edition = "2026"        # required: language edition; only "2026" exists
description = "..."     # optional; for the registry
license = "MIT"         # optional; an SPDX expression, for the registry
repository = "https://..." # optional; for the registry

[build]                 # optional
entry = "src/main.brim" # optional; default "src/main.brim"; relative, inside the package
lib = "src/lib.brim"    # optional; default "src/lib.brim": the library root
c-sources = ["c/glue.c"] # optional; C files for `extern fn`s (see 9.6)
link = ["z"]            # optional; libraries for `extern fn`s: -lz

[dependencies]          # optional
router = "1.2"          # from the registry
geometry = { path = "../geometry" }
shapes = { path = "../shapes", version = "0.3" }
http = { git = "https://example.com/http.git", tag = "v1.0.0" }

[workspace]             # optional; [package] may then be left out
members = ["apps/*", "shared"]
  • Unknown tables and keys are errors, to catch typos.
  • A dependency comes from the package registry (a version requirement: router = "1.2" or router = { version = "1.2" }; see docs/registry.md for the forms), from a directory with a .brimproj (path, relative to this manifest), or from a git repository (git, with at most one of tag, branch and rev; without them, the default branch). A path dependency may also have a version, which brim publish keeps; version cannot be combined with git (M0006).
  • Every registry package has one version in a project: the newest non-yanked version that meets every requirement on it (M0014 when none does; M0013 for a package the registry does not have).
  • A dependency's name is the name of its package, and is used in import, so it contains no - (M0007). All packages of a project have different names (M0009).
  • A package is used as a library through its library root: import geometry is the module in src/lib.brim of the package geometry, and import geometry.shapes the module in src/shapes.brim next to it. Inside the package, its modules import each other as usual (import shapes). The project's own modules cannot have the name of a dependency.
  • A package without src/main.brim but with a library root is a library: brim check and brim test work on its library root.
  • brim test runs only the tests of the project's own modules.
  • .brimlock. The first build writes the commit of every git dependency and the version and checksum of every registry dependency to .brimlock, next to .brimproj (at the root of a workspace); later builds use those, without the network when they are cached. brim update picks the newest commits and versions. Commit .brimlock for programs, so every build uses the same versions. Git repositories are cached in $BRIM_HOME/git (by default ~/.brim/git) and only fetched when a commit is missing.
  • Workspaces. [workspace] members lists member directories relative to the manifest; a last component * stands for every subdirectory with a .brimproj (M0012 for invalid members). The manifest may also have a [package], which is then a member too. Members share the root's .brimlock. At the root, check, test, build and doc work on every member; -p NAME picks one, as does running in a member's directory.
  • brim run, brim check, brim build and brim test without a path use the nearest .brimproj in the current directory or its parents.
  • brim fmt formats the project's source files (all .brim files under the manifest's directory, except in target).
  • brim new PATH creates a project: a .brimproj, a src/main.brim with a test, and a .gitignore.

Error Handling

This document covers three things: how the compiler reports problems (diagnostics), how the brim tool signals failure (exit codes), and how errors work inside Brim programs (run-time errors, plus the open design for recoverable errors).

Diagnostics

Every problem the toolchain finds is a Diagnostic (crates/brim-diagnostics) with:

  • a severity: error (the program is rejected) or warning (accepted);
  • an optional stable code such as E0200;
  • a message;
  • an optional primary label: a span marked with ^, plus a short label;
  • any number of secondary labels, marked with -, for related locations;
  • notes (= note:) for context and help (= help:) for suggested fixes.

Rendered output:

error[E0206]: mismatched types
 --> tests/diagnostics/type_mismatch.brim:6:25
  |
6 |     let label: string = 42
  |                ------ expected due to this type
  |                         ^^ expected `string`, found `i64`
  |
  = note: Brim never converts between types implicitly

Colors are used when stderr is a terminal and NO_COLOR is unset or empty.

Guidelines for writing diagnostics:

  • State the problem in the message and point at the exact cause with the primary span. Use secondary labels to explain why something was expected.
  • Suggest a concrete fix in help when there is a likely one, such as a "did you mean" for typos or the correct syntax.
  • Never let one mistake produce a cascade. The parser recovers at statement boundaries and skips tokens the lexer already reported. The type checker uses an internal error type that suppresses follow-up errors.
  • Every new code gets a fixture in tests/ and an entry below. Codes are never reused for a different problem.

Diagnostic codes

Lexical errors (brim-lexer)

CodeMeaning
E0001Unexpected character (including single & or `
E0002Unterminated string literal (strings must end on the same line)
E0003Invalid escape sequence in a string
E0004Integer literal does not fit in 64 bits
E0005Integer literal with leading zeros
E0006Invalid suffix on an integer literal (10px, 0xFF)
E0007Floating-point literal too large for f64 (1e999)

Syntax errors (brim-parser)

CodeMeaning
E0100Unexpected token ("expected X, found Y"), including unclosed blocks and parentheses
E0101A reserved word (async, await, type, use) used as a name
E0102Missing type annotation on a parameter or a struct field
E0103Something other than a declaration at the top level, or a struct, enum or const declared inside a function
E0104Invalid assignment target, or a list method that changes the list called on something other than a variable (only variables, their fields and list elements can be changed)
E0105Blocks or expressions nested too deeply (implementation limit: 128)
E0106Chained comparison such as a < b < c
E0107Call of an expression that is not a function value

Semantic errors (brim-semantic)

CodeMeaning
E0200Undefined variable
E0201Undefined function
E0202Function or constant defined more than once
E0203Variable declared twice in the same scope
E0204Duplicate parameter name
E0205Wrong number of arguments
E0206Mismatched types, including a non-bool condition and a builtin called with an unsupported argument type
E0207Non-void function can reach its end without returning
E0208return without a value in a non-void function, or with a value in a void function
E0209Operator applied to unsupported operand types
E0210No main function
E0211main has parameters or a return type
E0212Unknown type name, including an unknown struct in a struct literal
E0213void used as a variable, parameter or field type
E0214Function, struct or enum used as a value, or a variable called as a function
E0215Integer literal out of range for i64
E0216Function redefines a builtin (including Some, None, Ok, Err and the I/O functions)
E0217break or continue outside of a loop
E0218Assignment to an immutable variable, a parameter, or a field of one
E0219Type name already used: by another struct or enum, a built-in type (including Option and Result) or a function; or a type parameter named like a built-in type
E0220Field, variant or type parameter declared more than once
E0221Struct or enum contains itself, directly or through other structs and enums (through List and Map is allowed)
E0222Struct literal is missing fields
E0223Field given more than once in a struct literal
E0224Unknown field, or field access on a value that is not a struct
E0225Wrong number of type arguments for a generic type (or in an impl), or type arguments on a type that takes none
E0226A type argument of a generic function, struct or variant cannot be inferred
E0227Unknown enum variant
E0228Wrong number of values for an enum variant, in a constructor or a pattern
E0229match does not cover every value
E0230Pattern does not fit the type of the matched value
E0231Unknown method
E0232? applied to a value that is not an Option or Result
E0233? in a function whose return type cannot carry the Err or None (an error type that no variant of the function's error enum holds, two variants that both could, or not a Result/Option)
E0234Indexing a value that is not a list or map
E0235for loop over a value that is not a list or range
E0236Map key type other than i64, bool or string
E0237impl block for a built-in type
E0238Method defined twice for a type, or named like one of the enum's variants
E0239self parameter outside an impl block
E0240Method with self called on the type, or method without self called on a value
E0241Use of another module's item that is not pub
E0242Module not found, or imported item not found in its module
E0243Two imports with the same name, or an import whose name is already defined
E0244A test function (fn test_...) with parameters or a return type (only reported by brim test)
E0245The alternatives of a p | q pattern bind different names, or a name with different types
E0246A malformed trait (a method without self, or with mut self), a duplicate trait or trait method, or an unknown trait
E0247An impl Trait for Type that misses a method of the trait, has a method with another signature or one the trait does not declare, implements the trait twice, or implements Eq or Show
E0248A type argument that does not implement a bound of its type parameter
E0249A constant whose value is not computed from literals, or that is defined in terms of itself
E0250An if used as a value without else, or a block used as a value that does not end with one
E0251A let pattern that does not match every value
E0255An API function (a pub fn of a module served with serve_api) that is generic, or whose parameters or result do not implement Data
E0260An extern fn with a type C does not have, type parameters or self, or an extern fn main

Warnings

CodeMeaning
W0001Unreachable code after return, break, continue or a diverging if/while
W0002Value of a non-call expression statement is unused
W0003Variable declared mut but never reassigned
W0004Unreachable match arm, or alternative of |: earlier patterns already cover it

Manifest errors (brim-manifest)

CodeMeaning
M0001Invalid TOML, a missing required field, or an unknown key
M0002Invalid package name
M0003Invalid package version
M0004Unsupported edition
M0005Invalid [build] entry or lib path
M0006Invalid dependency: no path or git, both, unknown keys, or a version requirement
M0007Dependency name that cannot be imported (it contains -)
M0008Dependency not found, or its git repository cannot be fetched
M0009A dependency's package has another name, or two packages have the same name
M0010Invalid .brimlock
M0011A workspace manifest without [package] where a package is needed
M0012Invalid workspace member
M0013A registry package that does not exist, or whose archive does not match
M0014No version of a registry package meets every requirement
M0015The registry's checksum of a locked version differs from .brimlock: a published version never changes, so the registry, a mirror or the network may be compromised (brim update records the new archive if it is trusted)

Run-time errors (brim-runtime)

CodeMeaning
R0001Integer overflow
R0002Division or remainder by zero
R0003Call depth limit exceeded (stack overflow)
R0004Writing program output failed
R0005List index out of bounds
R0006Map key not found
R0007Invalid string slice
R0008Failed assert or assert_eq (with the two values as notes)
R0009Step limit exceeded; only for embedders that set brim_interp::Limits::max_steps, never in brim run or native executables
R0010to_int of NaN or of a number outside the i64 range
R0011A call to an extern fn in the interpreter (brim test, or brim run without a native backend)
R0012join of a task that was already joined
R0013Starting a task where there are no threads (the interpreter compiled to WebAssembly)

Run-time errors are reported like compile-time diagnostics, with the source location of the failing operation and the call stack:

error[R0001]: integer overflow in `*`
 --> tests/integration/overflow.brim:3:12
  |
3 |     return n * 2
  |            ^^^^^
  |
  = note: call stack: main -> double

Code generation errors (brim-tsgen)

CodeMeaning
G0001Two exported types have the same name
G0002brim gen ts --angular for a program that serves no module with serve_api

Exit codes of brim

CodeMeaning
0Success (warnings may have been printed)
1The program has syntax or semantic errors; for brim fmt --check, a file is not formatted
2Invalid command line (unknown command or option, missing input)
3Input problem: file missing or unreadable, not UTF-8, wrong extension, invalid .brimproj
4Requested feature unavailable: brim build in a brim compiled without the llvm feature, or no C compiler to link with
70Internal compiler error (a bug in Brim)
101The Brim program stopped with a run-time error; for brim test, a test failed

Internal errors

The compiler must never panic on any input. Fuzz-style tests (garbage_input_does_not_panic, deep nesting tests) guard the lexer and parser. Invalid IR from lowering is caught by the IR validator and reported as an internal error (exit 70). A panic anywhere is caught at the top of brim, reported as an internal compiler error, and exits with 70.

Errors inside Brim programs

Brim has two kinds of failures:

  • Recoverable errors are values. A function that can fail returns Result[T, E]: Ok(value) or Err(error). The compiler makes callers deal with it: a Result is not a T, so the value can only be used after a match or the ? operator, which returns the Err from the calling function unchanged:

    fn read_config(path: string) -> Result[Config, string] {
        let text: string = read_file(path)?
        return parse_config(text)
    }
    

    Absent values use Option[T] the same way; there is no null.

  • Run-time errors abort. The R errors above (overflow, division by zero, stack overflow) are bugs, not conditions to handle: output written so far is flushed, the error is reported on stderr, and the process exits with 101.

  • Errors convert into larger error enums. When the function's error type is an enum with exactly one variant holding the operand's error type, ? wraps the error in that variant:

    enum ConfigError {
        Parse(ParseError)
        Missing(string)
    }
    
    fn port(text: string) -> Result[i64, ConfigError] {
        let n: i64 = parse_port(text)?   // a ParseError becomes ConfigError.Parse(...)
        return Ok(n)
    }
    

Not in 1.0: a standard error type with context, and returning a Result from main (see section 11 of the specification).

Brim has no exceptions with stack unwinding: they complicate the native runtime and make control flow invisible.

Architecture

This document describes how the Brim compiler is structured, and why. The language itself is specified in language-spec.md.

Pipeline

 source text (.brim)
      │  brim-lexer        tokens + lexical diagnostics
      ▼
   tokens
      │  brim-parser       recursive descent + precedence climbing
      ▼
    AST  (brim-ast)        untyped, mirrors the source, carries spans
      │  brim-semantic     name resolution, type checking, entry point
      ▼
 checked program           typed, name-resolved tree; no error types left
      │  brim-lower
      ▼
  Brim IR (brim-ir)        typed CFG; validated by brim_ir::validate
      ├──► brim-interp     reference interpreter      (`brim run`)
      └──► brim-codegen    LLVM backend + C runtime   (`brim build`, opt-in `llvm` feature)

A program can consist of several files, also from other packages: the driver (brim-driver, used by the command line and the language server) asks brim-package for the project's dependency graph, and an import whose first name is a dependency loads that package's module. Imports in a dependency are rewritten to full module names (import shapes in geometry becomes import geometry.shapes), so semantic analysis sees one set of uniquely named modules and knows nothing about packages. The driver (brim-cli) parses the root file, follows its imports to the other modules, and puts all files in one SourceMap: each file's spans are shifted by a base offset, so a Span alone identifies the file. Diagnostics render against the map, with labels in other files shown in their own snippet (::: file:line:col). Semantic analysis receives all modules at once.

Every stage reports problems as brim_diagnostics::Diagnostics. A stage only runs if the previous stage produced no errors. Warnings never block compilation.

Crates

CrateResponsibilityDepends on
brim-diagnosticsSourceFile, Span, Location, Diagnostic, terminal renderingnone
brim-lexerUTF-8 text to Tokens; lexical errors (E00xx)diagnostics
brim-astSyntax tree types; fully parenthesized Display for testsdiagnostics
brim-parserTokens to AST; syntax errors (E01xx); error recoverylexer, ast, diagnostics
brim-runtimeRuntime ABI (RuntimeFn, AbiType), checked arithmetic, print and string functions, RuntimeError (R0xxx)none
brim-stdlibCatalogue of builtin functions and their overloads, each mapped to a RuntimeFnruntime
brim-semanticSymbol tables, scopes, type checking (E02xx, W0xxx); produces CheckedProgramast, diagnostics, runtime, stdlib
brim-irIR data structures, text form, validatordiagnostics (spans), runtime (RuntimeFn)
brim-lowerCheckedProgram to IRir, runtime, semantic
brim-interpReference interpreter for IRdiagnostics, ir, runtime
brim-codegenBackend trait; LlvmBackend (IR to LLVM IR, object emission, linking with the C runtime in runtime/brim_rt.c). LLVM code is only compiled with the llvm featurediagnostics, ir, runtime; inkwell (optional)
brim-manifest.brimproj parsing and validation (M0xxx)diagnostics, serde, toml
brim-packageDependency graphs: path and git dependencies, the git cache, .brimlockdiagnostics, manifest, serde, toml
brim-playgroundThe interpreter for the browser: run(source, input) and its WebAssembly exportscli, diagnostics
brim-docHTML documentation from doc comments (brim doc)ast, diagnostics
brim-tsgenTypeScript declarations for a checked program's pub types (brim gen ts)diagnostics, semantic
brim-fmtThe formatter (brim fmt): works on tokens so comments and line breaks survive, and checks that the tokens are unchangeddiagnostics, lexer, parser
brim-driverLoading a program: the root file and the modules it imports, from its package and dependencies, through a file readerast, diagnostics, package, parser, semantic
brim-lspThe language server (brim lsp): diagnostics, formatting, symbols, definitions and hover over stdiodriver, fmt, lexer, manifest, package, semantic; serde_json
brim-cliThe brim binary: argument parsing, commands, exit codeseverything above

brim-lower and brim-manifest were added beyond the initially sketched layout. brim-lower keeps the IR independent of the AST and of semantic analysis: brim-ir itself depends on neither. brim-manifest keeps project configuration reusable by future tools, such as a package manager or language server, without depending on the CLI. brim-interp is separate from brim-codegen so the temporary interpreter is never confused with the native backend.

Only brim-manifest and brim-package (serde, toml; brim-package also runs the git program), brim-lsp (serde_json) and, with the llvm feature, brim-codegen (inkwell, which brings llvm-sys) have third-party dependencies, all pinned exactly in the workspace Cargo.toml. The compiler frontend is dependency-free.

Source positions

  • Span { start, end } stores byte offsets into the UTF-8 source. Spans are two integers, so they are cheap to copy into every token and AST node.
  • Line and column are computed on demand by SourceFile::location, which binary-searches a table of line-start offsets. That costs O(log lines) per lookup, and lookups only happen when rendering diagnostics.
  • Lines and columns are 1-based. Columns count Unicode scalar values (chars): in "é" x, x is at byte offset 5 but column 5, and é counts once.
  • Columns do not account for grapheme clusters or terminal display width (for example East Asian wide characters or emoji), so carets may be misaligned on such lines. Tabs are expanded to 4 columns when rendering, both in the source line and under it, so carets stay aligned.

Lexer

A hand-written single-pass scanner. Newlines are emitted as tokens because they terminate statements. Invalid characters produce an Unknown token after reporting E0001, so the parser can skip them without reporting a second, confusing error. The lexer never panics: every malformed input produces a diagnostic and lexing continues.

Parser

Recursive descent for items and statements, and precedence climbing for binary expressions (BinaryOp::precedence).

  • Newline handling. Inside parentheses a depth counter makes the parser skip newline tokens. After a binary operator, = or ->, newlines are skipped explicitly.
  • Error recovery. On an error the parser records a diagnostic and skips to the next statement boundary (newline, ; or the enclosing }), or to the next fn at the top level. An unclosed block is detected when a fn keyword or end of file is reached.
  • Nesting limit. Blocks and expressions together are limited to 128 levels of nesting (E0105). Long left-associative chains and else if chains count too, because later phases recurse over the tree. In debug builds the parser uses about 7 KB of stack per nesting level, so this limit keeps every phase within the 2 MB default thread stack used by tests. The brim binary also runs the compiler on a thread with a 64 MB stack.

Semantic analysis

Two passes:

  1. Collect every function signature into a function table, so declaration order does not matter.
  2. Check each body with a stack of scopes (Vec<HashMap<String, LocalId>>).

Types are a small enum (i64, bool, string, void) plus an internal Error type. Any expression involving Error is not reported again, which prevents cascades: one undefined variable in a long expression produces exactly one error.

The output is a checked program: a typed tree where every name is resolved to a LocalId or FnId, every builtin call is resolved to a specific runtime function, and every operator is resolved to a type-specific operation (IntAdd, StrConcat, ...). Later phases never redo lookup or type inference.

"Returns on all paths" is computed structurally, without building a control-flow graph: each statement reports whether it diverges (control never reaches the next statement). return, break and continue diverge; if/else diverges when both branches do; while true diverges when its body has no break. A non-void function body must diverge. The same flag drives the unreachable-code warning. The checker keeps a stack of enclosing loops to validate break/continue and to record whether a loop contains a break.

Mutability is tracked per local: assignments to non-mut locals are rejected, and mut locals that are never reassigned produce a warning. Compound assignments are expanded in the checked tree (x += 1 becomes x = x + 1) so lowering has a single assignment form.

Intermediate representation

Brim IR is a typed control-flow graph, designed to be easy to validate, to interpret, and to translate to LLVM.

  • A Module holds Functions and an entry FuncId.
  • A Function has slots (typed local storage, like LLVM alloca; parameters are the first param_count slots), values (SSA-style temporaries), and blocks.
  • Each Block holds instructions and exactly one Terminator (ret, jump, branch, unreachable).
  • A value is defined exactly once and may only be used later in the same block. Data crosses block boundaries only through slots. This avoids the need for phi nodes or dominance analysis in the validator and interpreter. LLVM's mem2reg pass turns slots back into SSA registers, so nothing is lost in native code.
  • Operations are fully typed (iadd, ieq, bnot). String operations and printing are calls into the runtime (call @brim_rt_str_concat(...)).
  • Instructions carry an optional source span, used for run-time error locations today and for debug information later.

&& and || lower to a branch: the left value is stored in a temporary bool slot, the right side is evaluated in its own block only when needed, and a join block loads the result.

if lowers to a branch into a then block and an else block (empty when there is no else), both jumping to a join block. If neither branch falls through, the join block keeps no predecessors and an unreachable terminator. while lowers to a header block that evaluates the condition and branches to the body or the exit block; the body jumps back to the header. break jumps to the exit block and continue to the header. Mutable variables are ordinary slots, so loops need no phi nodes.

Text form (brim check --emit ir):

; entry: main

fn add(s0 a: i64, s1 b: i64) -> i64 {
bb0:
  v0 = load s0
  v1 = load s1
  v2 = iadd v0, v1
  ret v2
}

fn main() {
  slot s0 result: i64
bb0:
  v0 = const i64 10
  v1 = const i64 32
  v2 = call add(v0, v1)
  store s0, v2
  v3 = load s0
  call @brim_rt_print_i64(v3)
  ret
}

The standard library modules (std.net, std.http) are Brim source files in crates/brim-driver/std, built into the compiler with include_str!; the driver loads them for import std.x instead of a file. They call runtime functions through builtins whose names start with __, which the checker only accepts inside std modules. Sockets live in the runtime as numbered handles, implemented twice with the same behaviour and error texts: brim-runtime/src/net.rs for the interpreter and the C runtime for native executables.

Modules only matter to semantic analysis. The checker keeps a scope per module (its own items, the items it imports by name, and the modules it imports) and switches to a module's scope while checking it; declarations happen in passes over all modules, so modules can use each other in any order. After checking, the program is one set of functions and types; functions of non-root modules carry their module's name.

Generics do not reach the IR: brim-lower monomorphizes the checked program. Non-generic functions are lowered in source order, and every generic function is lowered once per combination of concrete type arguments that is called, as an IR function named like swap[i64, string]. The checked program's TypeTable interns every Name[args] type, so equal types are equal values and substitution is cheap.

Enums are lowered to structs: field 0 holds the variant index (the tag), followed by the payload fields of all variants. A value fills its own variant's fields and puts zero values (0, false, "", or a struct of zeros) in the others; a match stores the value in a slot, compares the tag (or the literal) arm by arm and extracts the bound values. This costs some space but needs no new IR concept, so the interpreter, the LLVM backend and reference counting support enums without changes.

Lists are an IR type (L0, L1, ... in Module::lists, each with its element type) with instructions to create, read and change them. They are values with copy-on-write. Instructions that change a list or a struct (list.push, list.set, insert, ...) consume that operand: the validator rejects any later use of it. That lets a backend change the value in place when it holds the only reference. Lowering makes that the common case: to change a part of a variable, it moves the variable's value out of its slot (take), moves each part on the path out of its parent (leaving a zero value, so the parent stops referring to it), changes the innermost part, and puts everything back. xs.push(x) in a loop therefore runs in constant time, also for team.members.push(x) and grid[i].push(x).

The interpreter represents a list as Rc<Vec<Value>> and uses Rc::make_mut. It drops every value right after its last use in a block (computed per function before running it), so temporaries do not keep lists shared. The native runtime stores elements inline after a reference-counted header (null is the empty list) and copies them with memcpy; for element types that contain strings or lists, the backend generates small retain/release functions per list type that the runtime calls when it copies or frees elements.

Maps work the same way: an IR type (M0, ... in Module::maps) with instructions that find a key's position, read the key or value at a position, and insert, replace or remove entries (the changing ones consume the map). Positions are stable between changes, which lets lowering read m.keys() with a simple loop and update m[k].items in place through the value at a position. Both implementations keep entries in insertion order with a hash index on the side (HashMap in the interpreter, open addressing in the native runtime), so iteration order is the same everywhere.

Methods are ordinary functions after checking: p.len() is a call of Point.len with p as its first argument. A mut self method is different at the IR level: it is called with call.mut, which moves its first argument into the callee (marked consumes_self), and it returns its changed self (paired with its result in a small struct, if it has one). Lowering takes the value out of the caller's place, calls, and puts the returned self back, so stack.push(x) updates a list in place even through several method calls.

Structs are aggregate values in the IR: Module::structs lists every struct type (%0, %1, ...) with its fields, and three instructions work on them: make %N(v...) builds a struct from one value per field, extract v.i reads field i, and insert v.i, x returns a copy of v with field i replaced. A field assignment p.a.b = x lowers to loading p, extracting a, inserting x into it, inserting the result into p, and storing p again. Struct types never contain themselves, so every struct has a fixed layout. The interpreter represents a struct as a shared, immutable Rc<[Value]>, and the LLVM backend as a named LLVM struct type, built with insertvalue and read with extractvalue.

brim_ir::validate checks: struct field types (no void, no struct containing itself), the entry function signature, slot and value types, single definition and same-block use of values, operand and result types of every instruction, call argument counts and types (including runtime calls), return types, and branch targets. Lowering is expected to always produce valid IR, so a validation failure is reported as an internal compiler error (exit code 70).

Brim IR has no optimization passes of its own. brim build --release runs LLVM's standard default<O2> pipeline on the generated code; IR-level passes should only be added when measurements show LLVM cannot do the job.

Runtime ABI

Generated code reaches the outside world only through runtime functions listed in brim_runtime::RuntimeFn:

SymbolSignaturePurpose
brim_rt_print_i64(i64) -> voidprint(i64)
brim_rt_print_bool(bool) -> voidprint(bool)
brim_rt_print_str(str) -> voidprint(string)
brim_rt_str_concat(str, str) -> strstring +
brim_rt_str_eq(str, str) -> boolstring == / !=
brim_rt_i64_to_str(i64) -> strto_string(i64)
brim_rt_bool_to_str(bool) -> strto_string(bool)

Native representation: i64 is a 64-bit integer, bool an 8-bit 0/1, and str a pointer to a BrimStr object: a 64-bit reference count (-1 for static objects such as literals), a 64-bit byte length, then the UTF-8 bytes (not NUL-terminated).

The interpreter implements these functions with brim-runtime's Rust functions. Native executables link a small C implementation of the same symbols, crates/brim-codegen/runtime/brim_rt.c, which the compiler embeds and compiles at link time. The two must behave identically; the differential tests enforce that (see Code generation).

Besides the functions above, the C runtime has backend-internal entry points that are not part of the IR: brim_rt_enter(name, ...) and brim_rt_leave() bracket every Brim call to maintain the call stack and enforce the call-depth limit (brim_runtime::MAX_CALL_DEPTH, shared with the interpreter), brim_rt_trap(...) reports a run-time error and exits with 101, and brim_rt_retain(str) / brim_rt_release(str) manage string memory (see Memory management). The runtime also defines the process entry point main, which calls the generated brim_entry.

The standard library adds string functions, I/O and the environment to the ABI. A function that can fail (brim_rt_str_slice) takes the pre-rendered error message as extra arguments, like a trap. I/O functions that can fail without stopping the program record an error instead, which brim_rt_last_error returns; lowering turns that into a Result or Option. brim_rt_exit never returns. Both implementations produce the same error messages, so the differential tests cover them.

Integer arithmetic is not a runtime call. The backend emits the operation together with an overflow check (LLVM's llvm.s{add,sub,mul}.with.overflow intrinsics, and explicit checks for division by zero and i64::MIN / -1), and branches to a trap. The semantics are defined once, in brim_runtime::ops, and the interpreter uses those functions directly.

Memory management

Current state. The only heap-allocated values are strings. They are immutable. In the interpreter they are Rc<str>: cloning is cheap and memory is freed when the last reference goes away. Native executables use automatic reference counting, as recommended below:

  • Every BrimStr carries a reference count. String literals are static objects with count -1, which retain and release ignore. Strings from concatenation and to_string start at 1 and are freed when the count reaches 0.
  • The backend inserts the counting; the IR has no ownership information. The rules apply to every value that contains strings, including structs with string fields (counting a struct counts each string in it): every slot owns one reference (slots start as null; a store retains the new value, then releases the old one; every return releases all string slots). Every non-constant string value is owned by its block: loads retain, calls return strings at +1, and the block releases them just before its terminator. Since IR values never outlive their block, that is always after their last use. Parameters are borrowed: the caller keeps its reference during the call and the callee retains the parameter into its slot. A returned string is retained once more, so the caller receives it at +1.
  • This is correct by construction but not minimal: a load followed by a call costs a retain and a release. Eliminating redundant pairs is an optimization for later.
  • Counts become atomic once a program starts its first task (std.task, see docs/design/concurrency.md): before that, only one thread exists and plain counts are cheaper. Copy-on-write checks for a count of 1 with an acquiring load. Values are immutable or copied before they change, so sharing them between threads needs nothing else. The interpreter uses Arc for the same reason.
  • With BRIM_RT_CHECK=1 in the environment, the runtime checks memory instead of freeing it: retaining or releasing a freed string aborts the program, and strings still allocated when main returns are reported as a leak (exit code 102). The differential tests run every native program this way.

Recommendation for the native backend: automatic reference counting (ARC) of immutable heap values. For now that means strings only.

OptionForAgainst
ARC (chosen)Deterministic and predictable latency (important for services). No collector thread. Simple runtime. Easy C interop. Values never refer to themselves except through Box, List and Map, which hold copies, so reference cycles are impossible and ARC is complete.Refcount traffic costs throughput; counts are atomic once a program starts a task.
Tracing GCHandles cycles. High allocation throughput.Pause times, a much larger runtime, and harder FFI. It needs stack maps from the backend. It is the kind of elaborate runtime this phase should avoid.
Ownership / borrowingZero runtime cost and strong guarantees.It is a large language-design commitment, explicitly out of scope for the first implementation, and it would dominate the language's learning curve.
Arenas / leak-everythingTrivial to implement.Unbounded memory growth makes it unsuitable for long-running servers, a primary use case.

ARC keeps the runtime small, matches the use cases (long-running servers favor predictable memory over peak throughput), and leaves room to add escape analysis (stack allocation) or ownership-style optimizations later without changing language semantics. This decision should be revisited before aggregate types (structs, collections) are added, because that is when cycles become possible.

Safety claims. Brim programs cannot express pointers, manual memory management or unchecked indexing, and the interpreter is written in safe Rust. Native executables run generated code and a C runtime, so their memory safety rests on the backend and the runtime being correct: the differential tests run every program in both, with the runtime's memory checks on (BRIM_RT_CHECK=1). extern fn calls C code, which is outside these guarantees.

Code generation (LLVM)

brim build compiles a program to a native executable. The LLVM backend lives in brim-codegen behind the non-default Cargo feature llvm, so the frontend, its tests and the default CI jobs never need LLVM. Without the feature, brim build checks the program and reports the backend as unavailable (exit 4).

Supported toolchain. LLVM 22, through inkwell 0.10 (its llvm22-1 feature; LLVM 23 is not supported by inkwell yet). Linking uses the system C compiler: $BRIM_CC, else $CC, else cc (clang on Windows). Tested on Linux (x86-64), macOS (arm64) and Windows (x86-64) in CI.

Lowering. Each IR function is translated directly:

  • Every function becomes an internal LLVM function brim.<name>; only brim_entry and brim_max_call_depth are exported, for the runtime's main.
  • Slots become allocas in a dedicated entry block, which also stores the parameters and jumps to IR block 0. LLVM's mem2reg (part of the release pipeline) turns them into registers.
  • Values become SSA values, and IR blocks map one to one onto LLVM blocks. A checked operation splits its block: the failure edge goes to a block that calls brim_rt_trap.
  • bool is i1 inside functions and is widened to i8 at runtime calls, as the ABI specifies.
  • String literals become constant BrimStr objects (identical literals are shared).

Run-time errors. A native executable reports a run-time error exactly like brim run: the same message, code, source excerpt and call-stack notes, on stderr, with exit code 101. Everything except the call stack is known at compile time, so the backend renders the diagnostic for each trap site (trap.rs) and embeds the text. The runtime prints it and appends the call stack it tracks through brim_rt_enter / brim_rt_leave. Colors are never used.

Executables. The object file is written to a temporary directory together with the runtime source, and both are compiled and linked in one C compiler invocation (-O0 for debug builds, -O2 with --release). Code is generated for the host's generic CPU, so executables also run on other machines of the same architecture and OS.

Testing. With the feature enabled, crates/brim-cli/tests/fixtures.rs builds every tests/integration program and every example natively, in debug and release mode, and requires stdout, stderr and the exit code to be identical to the interpreter's. A separate CI job installs LLVM 22 and runs the whole test suite with --features brim-cli/llvm.

Not yet done: cross-compilation, and linking with the system linker directly instead of through the C compiler.

Decisions and trade-offs

DecisionRationaleRevisit when
Hand-written lexer and parser, no generatorBest control over error messages and recovery. The grammar is small.Grammar grows much larger
Newline-terminated statementsRequested: no semicolons in ordinary code. Continuation after operators keeps it predictable.Method chains (. at line start)
Separate checked tree instead of annotating the ASTLater phases cannot accidentally use unchecked data, and the AST stays a pure syntax model for future tooling such as formatters.Never, probably
Block-local IR values plus slotsTrivial validation and interpretation, and maps directly onto alloca + mem2reg.An optimizer needs real SSA
Iterative interpreter with a heap-allocated call stackDeep Brim recursion produces a clean R0003 error instead of crashing the host.Never
Checked integer arithmeticSilent wraparound is a common source of bugs in server software.Performance data from the native backend
LLVM behind an opt-in Cargo featureContributors and the default CI jobs need no LLVM installation; the frontend can never accidentally depend on it.LLVM is easy to install everywhere, or Brim ships prebuilt binaries
Native runtime in C, compiled at link timeNative code needs C-ABI entry points. In Rust they would need unsafe code (forbidden in the workspace) and a separately built static library that brim must find at run time. A few dozen lines of C embedded in the compiler need neither; the system C compiler is needed for linking anyway.The runtime grows (ARC, I/O, networking), or Brim links without a C compiler
Run-time error text rendered at compile timeNative and interpreted programs print byte-identical errors, which makes differential testing exact, and the runtime needs no source files or debug information. Costs some binary size per trap site.Binary size matters, or debug information lands
Call stack tracked by runtime callsGives the same call-stack notes and the same R0003 depth limit as the interpreter, without unwinding or debug information. Costs two cheap calls per Brim call.Performance data shows the cost matters
toml + serde for the manifestTOML parsing is complex and well solved. Versions are pinned.Never

Development Guide

Prerequisites

  • A Rust toolchain installed with rustup. The repository's rust-toolchain.toml selects the stable channel with rustfmt and clippy. The minimum supported Rust version is 1.88 (let-chains).
  • No LLVM installation is needed for the compiler frontend, brim check and brim run.
  • For native builds (brim build) and their tests: LLVM 22 and a C compiler. See Native builds.

macOS / Homebrew note: if rustc was also installed through Homebrew (brew install rust), the Homebrew binaries may come first on PATH and ignore rust-toolchain.toml. Homebrew's rustc can also break when Homebrew upgrades LLVM (dyld: Symbol not found ... libLLVM). Prefer rustup's toolchain: put rustup's proxy directory first on your PATH. That is ~/.cargo/bin for a standard rustup install, or $(brew --prefix rustup)/bin for Homebrew's rustup formula. Alternatively, run rustup run stable cargo ....

Everyday commands

cargo build                                   # build everything
cargo test --workspace                        # unit, fixture and CLI tests
cargo fmt --all                               # format
cargo clippy --workspace --all-targets -- -D warnings

cargo run -q -p brim-cli -- run examples/hello.brim
cargo run -q -p brim-cli -- check --emit ir examples/functions.brim

The binary is written to target/debug/brim.

Native builds (LLVM)

The LLVM backend is behind the llvm Cargo feature of brim-cli (and brim-codegen). It needs LLVM 22 (not 23) and a C compiler for linking (cc, or set BRIM_CC). Tell llvm-sys where LLVM is with LLVM_SYS_221_PREFIX:

# macOS
brew install llvm@22
export LLVM_SYS_221_PREFIX="$(brew --prefix llvm@22)"

# Debian / Ubuntu (apt.llvm.org)
wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- 22
sudo apt-get install -y libpolly-22-dev libzstd-dev
export LLVM_SYS_221_PREFIX=/usr/lib/llvm-22

Then:

cargo build --features brim-cli/llvm
cargo test --workspace --features brim-cli/llvm   # includes the differential tests
cargo clippy --workspace --all-targets --features brim-cli/llvm -- -D warnings

target/debug/brim build examples/hello.brim       # writes examples/hello
target/debug/brim build --release -o /tmp/app examples/control_flow.brim

Without LLVM_SYS_221_PREFIX, llvm-sys looks for llvm-config on PATH, which is often a different LLVM version.

On Windows, use the release archive clang+llvm-22.x-x86_64-pc-windows-msvc.tar.xz from the LLVM releases (it has llvm-config.exe), unpacked to for example C:\llvm, with C:\llvm\bin on PATH for clang, which links native programs with the Visual Studio C++ build tools. That LLVM uses the static C runtime, so build with RUSTFLAGS="-C target-feature=+crt-static"; it also names xml2s.lib and expects the system libraries (psapi, ws2_32, ...) next to its own. The native-windows job in .github/workflows/ci.yml shows each step. The C runtime has a Windows version of every system call (#ifdef _WIN32); x86_64-w64-mingw32-gcc -std=c11 -Wall -c crates/brim-codegen/runtime/brim_rt.c checks that it compiles without a Windows machine.

To debug memory management in a native executable, run it with BRIM_RT_CHECK=1: use of a freed string aborts, and leaked strings are reported when the program ends. The differential tests always set it. When changing how the backend handles strings, also check that the tests still fail when a retain or release is removed.

Test layout

LocationWhat it covers
crates/*/src (#[cfg(test)])Unit tests for each crate: lexer tokens and spans, parser precedence and recovery, type checking, IR validation, interpreter semantics, manifest validation, argument parsing
tests/lexer, tests/parser, tests/diagnosticsPrograms that must fail brim check, with the exact expected stderr in a .stderr file next to each program
tests/integrationPrograms run with brim run. Expected stdout in .stdout, expected stderr (if any) in .stderr
crates/brim-cli/tests/fixtures.rsThe harness for the four directories above, plus a check that every program in examples/ runs. With the llvm feature it also builds every integration program and example natively (debug and release) and requires output identical to the interpreter's
crates/brim-cli/tests/cli.rsRuns the real brim executable: exit codes, help, manifests, dependencies, test, fmt, gen ts
crates/brim-cli/tests/fuzz_smoke.rsA deterministic mutation fuzzer: mutants of every example and test program go through the whole frontend, the formatter and the interpreter (with a step limit); nothing may panic and checked programs must lower to valid IR
crates/brim-cli/tests/site.rsEvery complete program on the documentation site (site/src) runs and prints the output shown under it (BLESS=1 updates the pages)
fuzz/Coverage-guided cargo-fuzz targets (nightly), run weekly by .github/workflows/fuzz.yml; see fuzz/README.md

Fixture conventions:

  • Programs in the check suites are expected to exit with 1; integration programs with 0.
  • A first line // exit: N overrides the expected exit code.
  • A missing .stdout or .stderr file means that stream must be empty.

Updating expected output

After an intentional change to diagnostics or program output:

BLESS=1 cargo test -p brim-cli --test fixtures
git diff tests/          # review every change

Fuzzing

The mutation fuzzer runs 3000 mutants with every cargo test. For a longer run, or another seed:

BRIM_FUZZ_ITERATIONS=500000 BRIM_FUZZ_SEED=7 cargo test --release -p brim-cli --test fuzz_smoke -- --nocapture

It prints how many mutants parsed, passed semantic analysis and ran. A failure shows the smallest failing input; turn it into a fixture in tests/ once it is fixed.

Benchmarks

benches/ holds programs that stress one thing each: calls, loops, lists, strings, maps and JSON. scripts/bench.py builds brim in release mode with LLVM and prints, for each, the compile time, the native run time and the interpreter run time (the best of three runs):

scripts/bench.py --save before.json
# change something
scripts/bench.py --compare before.json

The native test suite also runs them, so their output stays the same in the interpreter and native code.

WebAssembly

crates/brim-playground builds the interpreter as WebAssembly, for running Brim in a browser. Build and check it with:

rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1 -p brim-playground
node crates/brim-playground/smoke.js target/wasm32-wasip1/release/brim_playground.wasm

crates/brim-playground/brim-wasm.js loads it, in Node or in a browser worker.

Documentation site

The site in site/ is an mdBook. Its guide pages are in site/src; the reference pages include the files in docs/, so those stay the single source. Build it with:

cargo install mdbook --version ^0.4
mdbook serve site --open

.github/workflows/site.yml builds it on every change and publishes it to GitHub Pages when the repository variable SITE_DEPLOY is true.

Adding a language feature

  1. Specify it in docs/language-spec.md, including grammar, typing rules and new diagnostics. Move it out of "open questions" or "future features" if applicable.
  2. Lexer: new tokens or keywords. If the keyword was reserved, move it from RESERVED to KEYWORDS.
  3. AST and parser: new nodes, parse functions and tests (valid and invalid input).
  4. Semantic analysis: checking rules, new error codes with tests, and the checked-tree representation.
  5. IR: new instructions if needed, plus validator rules and tests.
  6. Lowering, then the interpreter, then the LLVM backend (brim-codegen/src/llvm.rs, and runtime/brim_rt.c for new runtime functions). Run the tests with --features brim-cli/llvm so the differential tests compare both.
  7. Fixtures in tests/, an entry in docs/error-handling.md for each new code, and an entry in CHANGELOG.md.

Adding a diagnostic

  • Pick the next free code in the right range (E00xx lexer, E01xx parser, E02xx semantic, Wxxxx warnings, Mxxxx manifest, Rxxxx run time).
  • Point the primary span at the cause. Explain with secondary labels and notes, and offer help when there is a likely fix.
  • Add a fixture that shows the rendered output and document the code in docs/error-handling.md.

Coding conventions

  • Stable Rust only. unsafe code is forbidden workspace-wide (unsafe_code = "forbid"); lifting that requires a documented justification.
  • The compiler must not panic on any input. Use diagnostics. expect/unreachable! are reserved for invariants guaranteed by earlier phases, with a message saying which phase.
  • Keep crates independent: the IR must not depend on the AST, and nothing in the frontend may depend on LLVM.
  • New third-party dependencies need a clear justification and an exact version pin in the workspace Cargo.toml.

Releasing

Brim follows Semantic Versioning from 1.0. A minor version (1.x.0) adds features; a patch version (1.x.y) only fixes bugs. Nothing in 1.x breaks what the next section calls stable. Before 1.0, a minor version (0.x.0) could change anything.

Stability

These are stable from 1.0, for every 1.x release:

  • The language, per edition (below), as docs/language-spec.md describes it.
  • The standard library: the pub items of the std modules, the builtins and the prelude, with their behaviour. New items, methods and modules may be added. A program's own names win over the prelude's, and built-in types cannot get methods in programs, so additions do not clash with existing code.
  • The command line: the commands and options of brim and their meaning, and its exit codes (see error-handling.md).
  • .brimproj and .brimlock: newer compilers read every earlier manifest and lock file.
  • Diagnostic codes (E0201, W0003, R0001, M0014, ...) keep their meaning; new codes are added, old ones are not reused.
  • The registry protocol, version 1 (docs/registry.md).
  • Behaviour. A program prints the same and exits the same way with every 1.x compiler, in the interpreter and in native code. The exceptions are the order in which tasks run, and so the order of their output, and messages: see below.

Not stable: the text of messages (diagnostics, run-time errors and error strings such as connection refused are meant for people and may become clearer), new warnings, performance, the IR text form, the runtime ABI between the compiler and its C runtime, and the Rust crates' APIs.

Fixing a bug may make the compiler reject a program it wrongly accepted, or stop a program that relied on a wrong result. Such fixes are listed under Fixed in the changelog, and where it is possible the compiler warns in an earlier release first.

Editions

A package declares its edition in .brimproj (edition = "2026"). 1.0 has one edition, 2026.

  • Within an edition, a program that compiles keeps compiling and keeps its meaning in every later compiler.
  • Changes that would break programs, such as a new keyword or a different meaning for existing code, wait for a new edition. Editions are named after their year and come only when such a change is worth it, at most once every three years.
  • Every compiler supports every earlier edition, so a package moves to a new edition when its authors choose, not when they update brim.
  • Packages of different editions work together in one program: an edition changes how a package's source is read, not its types and functions as other packages see them.
  • A new edition comes with a migration guide, and with an automatic migration in brim fmt where the change allows one.
  • The reserved words (async, await, type, use) are already unavailable as names, so features that use them can arrive in a 1.x release without a new edition.

Release binaries

The release workflow (.github/workflows/release.yml) builds:

ArchiveContents
brim-X.Y.Z-linux-x86_64, -linux-aarch64, -macos-aarch64, -windows-x86_64brim with the interpreter and all tools (run, check, test, fmt, gen, lsp, ...), without native builds
brim-X.Y.Z-linux-x86_64-native, -macos-aarch64-native, -windows-x86_64-nativethe same, with the LLVM backend linked in for brim build (which also needs a C compiler: clang with the Visual Studio C++ build tools on Windows)

Each archive holds brim, the README, the license and the changelog. The Linux builds need glibc 2.34 or newer (Ubuntu 22.04, Debian 12, Fedora 35 and later); the native Linux build also uses the system's zlib, zstd and libstdc++. The macOS builds need nothing beyond macOS itself. brim uses the system's libcurl for HTTPS (libcurl4 on Debian and Ubuntu, installed almost everywhere); a native program that makes HTTPS requests is linked with it too, which on Linux needs its development files (libcurl4-openssl-dev). The release has a SHA256SUMS file. Running the workflow by hand (workflow_dispatch) builds the archives as workflow artifacts without publishing anything: a dry run.

Making a release

  1. Make sure main is green, including the native jobs.

  2. Set the version in the workspace Cargo.toml ([workspace.package] version) and run cargo check to update Cargo.lock.

  3. In CHANGELOG.md, rename ## [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD and start a new, empty ## [Unreleased] section above it.

  4. Update the version in editors/vscode/package.json.

  5. Commit (Release X.Y.Z), tag and push:

    git tag -a vX.Y.Z -m "Brim X.Y.Z"
    git push origin main vX.Y.Z
    
  6. The workflow publishes the GitHub release with the changelog section as notes. Versions below 1.0 and versions with a pre-release suffix (v1.0.0-rc.1) are marked as pre-releases.

Roadmap

Status legend: done, in progress, planned. Only items marked done exist in the code today.

Phase 0: Repository and language design (done)

  • Rust workspace, toolchain pin, CI, contribution and security policies.
  • Initial language specification (docs/language-spec.md) with an EBNF grammar and documented open questions.
  • Architecture and error-handling documents.

Phase 1: Lexer and parser (done)

  • Lexer with byte-offset spans, line/column mapping, string escapes, integer literals, reserved words, and recovery from invalid input.
  • Recursive-descent parser with precedence climbing, newline-terminated statements, error recovery, and a nesting limit.
  • Diagnostics with codes, labels, notes and help, rendered with source excerpts.

Phase 2: Semantic analysis (done for the 0.1 language)

  • Function table, lexical scopes, duplicate detection.
  • Type checking for i64, bool, string, void, with no implicit conversions.
  • Argument count and type checks, return checks, entry-point validation.
  • Typo suggestions, unreachable-code and unused-value warnings.

Phase 3: First executable backend (done, as a temporary interpreter)

  • Brim IR: typed CFG, text form, validator.
  • Lowering from the checked program to IR.
  • Reference interpreter over IR, with checked arithmetic, call-depth limits and located run-time errors.
  • brim check, brim run, brim check --emit tokens|ast|ir.

Phase 3b: Control flow (done)

  • if / else / else if (as statements; if as an expression is an open question).
  • while loops with break and continue.
  • Mutable variables (let mut), assignment and compound assignment (+=, -=, *=, /=, %=).
  • Divergence-based "returns on all paths" analysis and unreachable-code warnings; while true without break counts as never ending.
  • The to_string builtin for i64 and bool.

Phase 4: Native code generation (done, first version)

  • LlvmBackend behind the opt-in llvm Cargo feature, using inkwell 0.10 with LLVM 22 (see architecture.md, "Code generation").

  • A small C runtime, embedded in the compiler and linked into every executable with the system C compiler.

  • brim build produces an executable (-o, --release for LLVM's O2 pipeline). Projects build to target/<package name>.

  • Run-time errors in native executables are byte-identical to the interpreter's, including the call stack and the call-depth limit.

  • Differential testing: every integration program and example, debug and release, must match the interpreter exactly.

  • A CI job with LLVM 22 on Linux and macOS; the frontend-only jobs stay LLVM-free.

  • Done: debug information (DWARF line tables, functions and simple variables for lldb and gdb) and native builds on Windows (the runtime through Winsock, Win32 and WinHTTP; linking with clang).

  • Done: calling C: extern fn declarations, [build] c-sources and [build] link.

Still open from this phase: cross-compilation, and linking without a C compiler.

Current milestone: the road to 1.0 (below). Concurrency is built (std.task).

Phase 5: Runtime and standard library (in progress)

  • Done: memory management for strings in native executables (ARC, as recommended in architecture.md), with a runtime check mode used by the tests.
  • Done: structs: declarations, literals, field access and field assignment, with value semantics. Struct equality and printing are still open.
  • Done: enums with payloads and exhaustive match (on enums, integers, strings and booleans).
  • Done: generics: generic functions, structs and enums with inferred type arguments, compiled by monomorphization.
  • Done: Option[T] and Result[T, E] as built-in enums.
  • Done: recoverable error handling: Result values with the ? operator (also for Option). Error type conversion is still open.
  • Done: List[T] with literals, indexing, methods and copy-on-write value semantics, and for loops over lists and ranges.
  • Done: Map[K, V] with i64, bool or string keys, insertion-ordered, with the same value semantics.
  • Done: methods in impl blocks, with self, mut self and type-level methods, also for generic types.
  • Done: a first standard library: string methods and comparison, standard input and error, files, command-line arguments, environment variables and exit codes.
  • Done: standard library modules for backend services: std.fs, std.process, std.time, std.log, std.random, std.net, std.http (client, server with keep-alive, API functions), std.json (with typed encode and decode), std.postgres, std.crypto and std.base64.
  • Done: concurrency: tasks and channels (std.task, docs/design/concurrency.md), on threads in the interpreter and in native code, and HTTP servers that handle requests at the same time.
  • Open: TLS for std.postgres and HTTP servers; select over channels and receive with a timeout.

Phase 6: Package manager and project tooling (done, first version)

  • Done: multi-file programs: every file is a module, import with aliases and item lists, pub visibility, diagnostics across files.
  • Done: brim new, and brim test with assert and assert_eq.
  • Done: brim fmt, a token-based formatter that keeps comments and line breaks.
  • Done: [dependencies] from directories and git repositories, .brimlock, brim update.
  • Done: version requirements with semver resolution (one version per package in a project, conflicts explained), recorded with checksums in .brimlock.
  • Done: a package registry (registry/, protocol in docs/registry.md) with brim publish, brim login and brim yank.
  • Done: workspaces: [workspace] members, one .brimlock, -p NAME.
  • Open: accounts for publishers through Ordnary's identity service (tokens are issued by an administrator for now).
  • Done: API contracts for web frontends: brim gen ts writes TypeScript types for a program's pub types.
  • Done: json.encode and json.decode with the same mapping, serve_api(module, request) for a module's pub fns, and brim gen ts --angular for the matching Angular client.

Phase 7: Editor support and production readiness (done, first version)

  • Done: a language server, brim lsp: diagnostics for every file of the program, formatting, outline, go to definition and hover; completion (names, module. and the members of a value's type), find references, rename, signature help and quick fixes from the diagnostics.
  • Done: editor support for VS Code, Neovim, Vim, Helix, Zed, Emacs, Sublime Text, JetBrains IDEs, Kate, Kakoune, nano, micro and Notepad++ (editors/), with a tree-sitter grammar and a TextMate grammar.
  • Done: stability rules per edition, a release process and release binaries for Linux, macOS and Windows (docs/releasing.md).
  • Done: fuzzing: a mutation fuzzer in the test suite (frontend, formatter, interpreter) and cargo-fuzz targets run weekly.
  • Done: a documentation site (site/, built with mdBook): introduction, getting started, a tour, projects and tools, and the reference documents. Its examples run in the tests.
  • Done: benchmarks and performance work (benches/), incremental builds with a cache, brim doc, and the interpreter as WebAssembly.

The road to 1.0

  • Done: the specification describes the whole language, and its open questions are decided (language-spec.md, sections 11 and 12).
  • Done: the edition policy and what 1.0 keeps stable (releasing.md).
  • Done: a security review of the runtime, the package client and the registry, with its findings fixed or recorded (design/security-review.md).
  • Release candidates (1.0.0-rc.N), then 1.0.0.

Phase 8: Self-hosting (optional)

  • Rewrite parts of the toolchain in Brim once the language and native backend are mature enough. This is optional and only worthwhile if it improves the project.