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 (
Resultwith 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:
| Requirement | Versions |
|---|---|
"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
| Command | What 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 PATH | Create a project |
brim update [PATH] | Update git dependencies in .brimlock |
brim gen ts [PATH] | Write TypeScript types for the program's API |
brim lsp | Run 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
| Function | Result | |
|---|---|---|
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
| Function | Result | |
|---|---|---|
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:
| Function | Result | |
|---|---|---|
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
| Function | Result | |
|---|---|---|
to_string(x) | string | a number in decimal, or a bool as "true" or "false" |
to_float(n) | f64 | an i64 as the nearest f64 |
to_int(x) | i64 | an 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
| Function | Result | |
|---|---|---|
abs(x) | i64 or f64 | the absolute value; abs of the smallest i64 stops the program |
sqrt(x), floor(x), ceil(x), round(x) | f64 | round rounds halves away from zero |
pow(a, b) | i64 or f64 | a 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 type | for numbers and strings |
clamp(x, low, high) | the argument type | x moved into the range |
PI, E | f64 | the 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 +.
| Method | Result | |
|---|---|---|
len() | i64 | the 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) | string | bytes start up to end |
trim(), trim_start(), trim_end() | string | without surrounding whitespace |
to_upper(), to_lower() | string | ASCII letters only |
replace(from, to) | string | every 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() | i64 | the 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.
| Method | Result | |
|---|---|---|
len(), is_empty() | i64, bool | |
get(i) | Option[T] | |
first(), last() | Option[T] | |
contains(x) | bool | for lists of i64, bool and string |
join(sep) | string | for List[string] |
push(x) | appends | |
pop() | Option[T] | removes the last element |
insert(i, x) | inserts before index i | |
remove(i) | T | removes 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() | T | for 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.
| Method | Result | |
|---|---|---|
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.
| Method | Result | |
|---|---|---|
add(x), remove(x) | , bool | remove 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 method | Result | |
|---|---|---|
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, body | the 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, bool | Results |
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.
| Function | Result | |
|---|---|---|
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() | bool | whether 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
\nor\r\n. A lone\ris 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 (seedocs/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(007is errorE0005), to avoid confusion with octal notation in other languages. - Letters directly after a literal (
10px,0xFF) are errorE0006. 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 ani32parameter,x + 1withx: u64), andi64otherwise. Its value must fit that type: at most 9223372036854775807 fori64, 18446744073709551615 foru64. A literal directly after unary-is one negative value, so-9223372036854775808isi64::MINand-1is not au64. Out-of-range literals are errorE0215. Literals that do not even fit in 64 bits are errorE0004.
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-7and2e10aref64literals;_separators are allowed as in integers.- There must be digits on both sides of the point:
1.and.5are not literals.1..5is a range, andpoint.0a field. - A literal too large for
f64(1e999) is errorE0007. The value is thef64nearest 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\nfor line breaks. \u{...}must denote a Unicode scalar value: not a surrogate and at most10FFFF.- 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:
| Level | Operators | Associativity |
|---|---|---|
| 1 | || | left |
| 2 | && | left |
| 3 | == != | none (no chaining) |
| 4 | < <= > >= | none (no chaining) |
| 5 | + - | left |
| 6 | * / % | left |
| 7 | unary - ! | prefix |
| 8 | call 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
letthere isE0103(a constant is aconst). Items other than functions (struct,enum,trait,impl,const) cannot be declared inside functions (E0103). - In the condition of an
iforwhile,Name {starts the body, not a struct literal: writeif 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). letrequires 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() = 1is errorE0104. - The body of
if,elseandwhilemust be a block in braces.elsemust be followed by{orif. - 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 isE0107(orE0214for a variable). - Blocks and expressions may nest at most 128 levels deep, counting long operator chains and
else ifchains (E0105). This is an implementation limit that protects the compiler's stack.
5. Types
| Type | Values |
|---|---|
i64 | 64-bit signed integers, two's complement |
i32 | 32-bit signed integers |
u8 | integers from 0 to 255, used for bytes |
u64 | integers from 0 to 2^64 - 1 |
f64 | 64-bit floating-point numbers (IEEE 754 double precision) |
bool | true, false |
string | immutable sequences of Unicode text (UTF-8) |
void | no values |
| structs | declared with struct (section 5.1) |
| enums | declared 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) |
voidmay only appear as a function return type. Variables and parameters of typevoidare errorE0213.- Unknown type names are error
E0212. - There are no implicit conversions between types, ever:
to_float(n)turns an integer into anf64andto_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(u8250 + 10,i322147483647 + 1,u640 - 1);u8andu64cannot be negated.to_int(x),to_i32(x),to_u8(x)andto_u64(x)convert between integer types, withR0001when the value does not fit.i32andu8values currently take as much memory as ani64. - Bytes are a
List[u8]:s.bytes()gives a string's UTF-8 bytes andstring_from_bytes(b)the text back, orNonewhen 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 typevoid(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 aList,MaporBox, 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)andShape.Emptycreate values. The number of values must match the variant (E0228), and an unknown variant isE0227.- An enum is a value like a struct, and cannot contain itself directly (
E0221), but it can through aList,MaporBox:enum Json { Null, Number(i64), Array(List[Json]), Object(Map[string, Json]) }, orenum Expr { Num(i64), Add(Box[Expr], Box[Expr]) }(section 5.7). - Enums can be compared with
==and!=and shown withprintandto_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
letannotation, a parameter type, a return type). Arguments that need the expected type themselves, such asNone, are inferred last. When a type argument cannot be inferred, it isE0226; 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 callShape's methods on aT,T: Eqallows==and!=, andT: Showallowsprintandto_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):
| On | Method | Result | |
|---|---|---|---|
Option[T] | is_some(), is_none() | bool | |
Option[T] | unwrap_or(fallback) | T | the 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) | T | the 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 elementi, counting from 0. An index outside the list is run-time errorR0005.xs[i] = vandxs[i] += vchange an element. Indexing anything other than a list isE0234. -
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:
Method Result len()i64the number of elements is_empty()boolget(i)Option[T]the element, or Noneoutside the listfirst(),last()Option[T]contains(x)boolonly for lists of i64,boolandstringjoin(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 iclear()sorted()List[T]a sorted copy, smallest first; stable; Tmust implementOrd(section 5.11)sorted_by(less)List[T]a copy sorted by less: fn(T, T) -> bool; stablereversed()List[T]a copy in the opposite order slice(start, end)List[T]the elements from startup toend; indices outside the list are moved to its ends, soslicenever failsindex_of(x)Option[i64]the index of the first element equal to x;Tmust implementEqmap(f)List[U]f(x)for each element, withf: fn(T) -> Ufilter(keep)List[T]the elements for which keep: fn(T) -> boolis trueany(test),all(test)boolwhether testholds for some, or every, element (allof an empty list istrue)fold(init, f)Af(...f(f(init, x0), x1)..., xn), withf: fn(A, T) -> Afind(test)Option[T]the first element for which testholdseach(f)calls f: fn(T)with each elementmin(),max()Option[T]the smallest or largest element; Tmust implementOrdsum()Tthe elements added up, 0 for an empty list; only for lists of numbers, and integer overflow stops the program The methods from
pushtoclearchange the list, so they need a variable declared withlet mut(or a field or element of one); calling them on a temporary isE0104. An unknown method isE0231. -
Lists can be compared with
==and shown withprintandto_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 errorR0006(usegetwhen the key may be missing).m[key] = vadds or replaces an entry;m[key] += 1and other changes throughm[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:
Method Result len()i64the number of entries is_empty()boolget(k)Option[V]contains_key(k)boolkeys()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,removeandclearchange the map and need alet mutvariable. To loop over a map, loop overkeys()orvalues().
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.
| Method | Result | |
|---|---|---|
add(x) | adds x if it is not there yet | |
remove(x) | bool | removes 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) | bool | whether 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, andprintshows 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
NaNis 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
selfis called on a value:stack.size().selfis a copy of the value, like any parameter. - With
mut self, the method changes the caller's value:stack.push(1)replacesstackwith the changedselfwhen the method returns. The value must be alet mutvariable, or a field or element of one (E0218,E0104). The value is moved into the method, so changing a list insideselfdoes not copy it. - A method without
selfis called on the type:Stack.new(). - Calling a method the wrong way is
E0240; an unknown method isE0231. 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), andselfoutside animplisE0239. selfis 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 afnexpression. Builtins cannot either. - A
fnexpression:fn(x: i64) -> i64 { return x * 2 }. Its parameters need types, and its body is checked like a function's (E0207when a non-voidone can end withoutreturn).
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
fnexpression 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), ormake_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
fnexpression shows it in the call stack asenclosing.<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(notmut self) and is called on a value.Selfin its types is the type that implements the trait (E0246for malformed traits, unknown traits and duplicates). impl Trait for Typeimplements a trait for a struct or enum declared in the same module. It must have every method of the trait, with the same signature (Selfreplaced by the type), and nothing else; other methods go in a plainimpl Typeblock (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 aThas the bounds' methods. - The built-in traits
EqandShoware implemented by every type whose values can all be compared with==, or shown withprint(section 5.8), and by type parameters bounded by them.Ordallows<,<=,>and>=and is implemented by the integer types,f64andstring.Keyallows a type to be a map key or set element, and is implemented by the integer types,boolandstring.Dataallows a type to be turned into JSON and back (json.encode,json.decode) and, likeEq, 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
sortedon lists, live in the modulestd.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
Listholds 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.1reads into a nested tuple. Fields of a tuple in alet mutvariable can be assigned (t.0 = 5). A field past the end isE0224.(p, q)is a pattern inmatch.let (a, b) = valueandlet mut (a, b) = valuetake 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:
==,printandto_stringwork when their values do ((36, "ada")), and they can be map values, list elements and type arguments.brim gen tswrites 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 anif,elseorwhile; -
between the
}of anifblock and a followingelse; -
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.
breakleaves the innermost enclosing loop.continuejumps to the next evaluation of the innermost loop's condition.breakorcontinueoutside a loop is errorE0217.
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 justVariant(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,stringorbool. 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,breakandcontinuediverge;- a block diverges if any statement in it diverges;
if/elsediverges if both branches diverge; anifwithoutelsenever diverges;while true { ... }diverges if its body contains nobreakfor that loop. Any otherwhiledoes not diverge, because its body may run zero times.matchdiverges 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 isE0216. - 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 areE0204. A nested block may shadow an outer variable. - A
letbinding is visible from the statement after it until the end of its block. Inlet x: i64 = x, thexon the right refers to an outerx. - 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
| Operator | Operand types | Result | Notes |
|---|---|---|---|
+ - * | two integers of the same type (i64, i32, u8, u64) | that type | a result outside the type is a run-time error |
/ | two integers of the same type | that type | truncates toward zero; division by zero and i64::MIN / -1 are run-time errors |
% | two integers of the same type | that type | result has the sign of the left operand; % 0 is a run-time error |
+ - * / | f64, f64 | f64 | IEEE 754: never a run-time error; 1.0 / 0.0 is inf, 0.0 / 0.0 is NaN |
% | f64, f64 | f64 | remainder with the sign of the left operand (like C's fmod) |
+ | string, string | string | concatenation |
< <= > >= | two integers of the same type, two f64, two string, or two values of a type parameter bounded by Ord | bool | strings 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 values | bool | strings compare by content; NaN == NaN is false and NaN != NaN true; other values compare structurally (section 5.8) |
&& || | bool, bool | bool | short-circuit: the right side is evaluated only if needed |
unary - | i64, i32 or f64 | same | -i64::MIN is a run-time error; u8 and u64 cannot be negated |
unary ! | bool | bool |
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 whenmainreturns 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:
| Code | Condition |
|---|---|
R0001 | integer overflow in +, -, *, /, % or unary - |
R0002 | division or remainder by zero |
R0003 | call depth limit exceeded (10,000 nested calls, in the interpreter and in native executables) |
R0004 | writing program output failed |
R0005 | a list index outside the list |
R0006 | a map key that is not in the map |
R0007 | a string slice outside the string or not on a character boundary |
R0008 | a failed assert or assert_eq |
R0009 | the step limit an embedder of the interpreter set (never in brim run or native executables) |
R0010 | to_int of NaN or of a number outside the i64 range |
R0011 | a call to an extern fn in the interpreter |
R0012 | join of a task that was already joined |
R0013 | starting 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.bmakes the module usable asb:b.function(...),b.Type,b.Type { ... },b.Enum.Variant(...),b.Type.method(...).import a.b as cuses the namecinstead.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 asthis.Xor 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 isE0241. Methods are private unless declaredpub fninside theimplblock (methods ofimpl Trait for Typeare 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) isE0241. 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, isE0243. - Modules may import each other in cycles. Every module is loaded once.
mainmust 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.ximports a module of the standard library, which is built into the compiler (section 10.2). The namestdis 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:
| Brim | TypeScript |
|---|---|
i64, i32, u8, u64, bool, string | number, 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 payloads | a 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).
| Request | Response |
|---|---|
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 fit | 400, {"error": "at $.text: expected a string, found a number"}; a missing parameter is null |
| the body is not a JSON object | 400, {"error": "..."} |
| no API function has the name | 404, {"error": "no API function \"name\""} |
another method than POST | 405, 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):
| Brim | C |
|---|---|
i64, i32, u8, u64 | int64_t, int32_t, uint8_t, uint64_t |
f64 | double |
bool | bool |
string | const 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 result | void |
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() -> Twaits for it and returns its result. A task is joined once: joining it again is the run-time errorR0012.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 whosesendwaits whilecapacityvalues are queued.Channel[T].send(value) -> boolqueues a value (falseonce the channel is closed),receive() -> Option[T]waits for the next one (Noneonce the channel is closed and every value sent before was received),close()ends it. Copies of a channel, in afn, a struct or another channel, refer to the same channel.- Tasks share no changeable memory. Every value is a value: a
fncopies 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;
exitin any task ends the program. The program ends whenmainreturns, while detached tasks run or wait. printwrites 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.
| Builtin | Forms | Effect |
|---|---|---|
print | print(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 |
eprint | eprint(string) | writes the text and a newline to standard error |
to_string | to_string(i64) -> string, to_string(f64) -> string, to_string(bool) -> string, and structs, enums, lists and maps | the decimal representation of a number, "true"/"false", or the text form of section 5.8 |
to_float | to_float(i64) -> f64, and the other integer types | the nearest f64 |
abs | abs(i64) -> i64, abs(f64) -> f64 | the absolute value; abs of i64::MIN is run-time error R0001 |
sqrt, floor, ceil, round | (f64) -> f64 | IEEE 754; round rounds halves away from zero, sqrt of a negative number is NaN |
pow | pow(i64, i64) -> i64, pow(f64, f64) -> f64 | a 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, max | min[T: Ord](a: T, b: T) -> T | from the prelude; a when the two are equal |
clamp | clamp[T: Ord](x: T, low: T, high: T) -> T | from the prelude: min(max(x, low), high) |
to_int | to_int(f64) -> i64, to_int(i32) -> i64, to_int(u8) -> i64, to_int(u64) -> i64 | rounds 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_u64 | to_i32(x) -> i32 and so on, for any integer x | the same number in another integer type; R0001 if it does not fit |
string_from_bytes | string_from_bytes(List[u8]) -> Option[string] | the text of UTF-8 bytes, or None if they are not valid UTF-8 |
from_code_point | from_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_line | read_line() -> Option[string] | the next line of standard input without its line ending (\n or \r\n); None at the end of input |
read_file | read_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_file | write_file(path: string, text: string) -> Result[i64, string] | creates or replaces a file; Ok with the number of bytes written |
args | args() -> List[string] | the program's command-line arguments, without the program name |
env | env(name: string) -> Option[string] | an environment variable |
exit | exit(code: i64) | ends the program with an exit code (its low 8 bits); a statement calling exit diverges |
assert | assert(bool) | stops the program with run-time error R0008 if the condition is false |
assert_eq | assert_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.
| Method | Result | |
|---|---|---|
len() | i64 | the 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) | string | bytes start..end; outside the string or not on a character boundary is run-time error R0007 |
trim(), trim_start(), trim_end() | string | without spaces, tabs, line feeds, carriage returns, vertical tabs and form feeds |
to_upper(), to_lower() | string | ASCII letters only |
replace(from, to) | string | every non-overlapping from; an empty from matches before every character and at the end |
repeat(n) | string | empty 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() | i64 | the 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.
| Module | Contents |
|---|---|
std.net | TCP: 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.time | now() -> 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.log | info(message), warn(message), error(message): a line on standard error with the time and level |
std.fs | exists, 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.process | run(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.crypto | sha256(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.base64 | encode(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.postgres | A 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.task | Tasks 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.random | int(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.json | enum 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.http | HTTP/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.
- Type inference.
lettakes the type of its value. Types are not inferred from later uses (let mut xs = []followed byxs.push(1)needsList[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). ifandmatchas values. Both are expressions (section 6.4); at the start of a line they are statements.- Integer types.
i64,i32,u8andu64(section 5).i64is the default. More widths (i8,i16,u16,u32) may be added as new types. - 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. - Strings. Immutable UTF-8 text, reference-counted. Positions and lengths count bytes (section 10.1);
chars,char_countandcode_pointswork on characters. Case conversion covers ASCII letters; full Unicode case mapping and grapheme clusters belong in a library. - Errors. Expected failures are
Resultvalues, 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 (Rcodes) that stop the program. There are no exceptions and no catching of run-time errors. - Statement termination. A newline ends a statement, except after an operator, inside brackets and before a line that starts with
.(section 6.1). main.fn main()takes nothing and returns nothing; arguments come fromargs()and the exit code fromexit. AmainreturningResultmay be added later.- Identifiers. ASCII letters, digits and
_. Unicode identifiers may be allowed later. - Modules. A file is a module; items and fields are private unless
pub;pub importre-exports named items. Packages come from the registry by version, from directories or from git (Appendix A). - Loops.
while,loopandforover lists, sets and ranges, withbreakandcontinuefor the innermost loop. Labeled loops are not in 1.0. - Struct literals. Every field is written out:
Point { x: x, y: y }. Shorthand and default values are not in 1.0. - Patterns. Literals, names,
_, variants, tuples,|and guards, nested to any depth, checked for exhaustiveness (section 6.10). Struct patterns, ranges andname @ patternare not in 1.0. - Traits. Methods with
self, bounds on type parameters, and the built-in traitsEq,Show,Ord,KeyandData(section 5.11). Default method bodies, static trait methods, traits for built-in types and trait objects are not in 1.0. - Memory. Automatic reference counting with copy-on-write, for every value. Values cannot refer to themselves except through
Box,ListandMap, which hold copies, so there are no cycles to collect and no garbage collector. - 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, amainthat returnsResult, Unicode identifiers. selectover several channels,receivewith 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"orrouter = { version = "1.2" }; seedocs/registry.mdfor the forms), from a directory with a.brimproj(path, relative to this manifest), or from a git repository (git, with at most one oftag,branchandrev; without them, the default branch). Apathdependency may also have aversion, whichbrim publishkeeps;versioncannot be combined withgit(M0006). - Every registry package has one version in a project: the newest non-yanked version that meets every requirement on it (
M0014when none does;M0013for 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 geometryis the module insrc/lib.brimof the packagegeometry, andimport geometry.shapesthe module insrc/shapes.brimnext 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.brimbut with a library root is a library:brim checkandbrim testwork on its library root. brim testruns 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 updatepicks the newest commits and versions. Commit.brimlockfor 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] memberslists member directories relative to the manifest; a last component*stands for every subdirectory with a.brimproj(M0012for 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,buildanddocwork on every member;-p NAMEpicks one, as does running in a member's directory. brim run,brim check,brim buildandbrim testwithout a path use the nearest.brimprojin the current directory or its parents.brim fmtformats the project's source files (all.brimfiles under the manifest's directory, except intarget).brim new PATHcreates a project: a.brimproj, asrc/main.brimwith 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) orwarning(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
helpwhen 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)
| Code | Meaning |
|---|---|
| E0001 | Unexpected character (including single & or ` |
| E0002 | Unterminated string literal (strings must end on the same line) |
| E0003 | Invalid escape sequence in a string |
| E0004 | Integer literal does not fit in 64 bits |
| E0005 | Integer literal with leading zeros |
| E0006 | Invalid suffix on an integer literal (10px, 0xFF) |
| E0007 | Floating-point literal too large for f64 (1e999) |
Syntax errors (brim-parser)
| Code | Meaning |
|---|---|
| E0100 | Unexpected token ("expected X, found Y"), including unclosed blocks and parentheses |
| E0101 | A reserved word (async, await, type, use) used as a name |
| E0102 | Missing type annotation on a parameter or a struct field |
| E0103 | Something other than a declaration at the top level, or a struct, enum or const declared inside a function |
| E0104 | Invalid 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) |
| E0105 | Blocks or expressions nested too deeply (implementation limit: 128) |
| E0106 | Chained comparison such as a < b < c |
| E0107 | Call of an expression that is not a function value |
Semantic errors (brim-semantic)
| Code | Meaning |
|---|---|
| E0200 | Undefined variable |
| E0201 | Undefined function |
| E0202 | Function or constant defined more than once |
| E0203 | Variable declared twice in the same scope |
| E0204 | Duplicate parameter name |
| E0205 | Wrong number of arguments |
| E0206 | Mismatched types, including a non-bool condition and a builtin called with an unsupported argument type |
| E0207 | Non-void function can reach its end without returning |
| E0208 | return without a value in a non-void function, or with a value in a void function |
| E0209 | Operator applied to unsupported operand types |
| E0210 | No main function |
| E0211 | main has parameters or a return type |
| E0212 | Unknown type name, including an unknown struct in a struct literal |
| E0213 | void used as a variable, parameter or field type |
| E0214 | Function, struct or enum used as a value, or a variable called as a function |
| E0215 | Integer literal out of range for i64 |
| E0216 | Function redefines a builtin (including Some, None, Ok, Err and the I/O functions) |
| E0217 | break or continue outside of a loop |
| E0218 | Assignment to an immutable variable, a parameter, or a field of one |
| E0219 | Type 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 |
| E0220 | Field, variant or type parameter declared more than once |
| E0221 | Struct or enum contains itself, directly or through other structs and enums (through List and Map is allowed) |
| E0222 | Struct literal is missing fields |
| E0223 | Field given more than once in a struct literal |
| E0224 | Unknown field, or field access on a value that is not a struct |
| E0225 | Wrong number of type arguments for a generic type (or in an impl), or type arguments on a type that takes none |
| E0226 | A type argument of a generic function, struct or variant cannot be inferred |
| E0227 | Unknown enum variant |
| E0228 | Wrong number of values for an enum variant, in a constructor or a pattern |
| E0229 | match does not cover every value |
| E0230 | Pattern does not fit the type of the matched value |
| E0231 | Unknown 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) |
| E0234 | Indexing a value that is not a list or map |
| E0235 | for loop over a value that is not a list or range |
| E0236 | Map key type other than i64, bool or string |
| E0237 | impl block for a built-in type |
| E0238 | Method defined twice for a type, or named like one of the enum's variants |
| E0239 | self parameter outside an impl block |
| E0240 | Method with self called on the type, or method without self called on a value |
| E0241 | Use of another module's item that is not pub |
| E0242 | Module not found, or imported item not found in its module |
| E0243 | Two imports with the same name, or an import whose name is already defined |
| E0244 | A test function (fn test_...) with parameters or a return type (only reported by brim test) |
| E0245 | The alternatives of a p | q pattern bind different names, or a name with different types |
| E0246 | A malformed trait (a method without self, or with mut self), a duplicate trait or trait method, or an unknown trait |
| E0247 | An 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 |
| E0248 | A type argument that does not implement a bound of its type parameter |
| E0249 | A constant whose value is not computed from literals, or that is defined in terms of itself |
| E0250 | An if used as a value without else, or a block used as a value that does not end with one |
| E0251 | A let pattern that does not match every value |
| E0255 | An API function (a pub fn of a module served with serve_api) that is generic, or whose parameters or result do not implement Data |
| E0260 | An extern fn with a type C does not have, type parameters or self, or an extern fn main |
Warnings
| Code | Meaning |
|---|---|
| W0001 | Unreachable code after return, break, continue or a diverging if/while |
| W0002 | Value of a non-call expression statement is unused |
| W0003 | Variable declared mut but never reassigned |
| W0004 | Unreachable match arm, or alternative of |: earlier patterns already cover it |
Manifest errors (brim-manifest)
| Code | Meaning |
|---|---|
| M0001 | Invalid TOML, a missing required field, or an unknown key |
| M0002 | Invalid package name |
| M0003 | Invalid package version |
| M0004 | Unsupported edition |
| M0005 | Invalid [build] entry or lib path |
| M0006 | Invalid dependency: no path or git, both, unknown keys, or a version requirement |
| M0007 | Dependency name that cannot be imported (it contains -) |
| M0008 | Dependency not found, or its git repository cannot be fetched |
| M0009 | A dependency's package has another name, or two packages have the same name |
| M0010 | Invalid .brimlock |
| M0011 | A workspace manifest without [package] where a package is needed |
| M0012 | Invalid workspace member |
| M0013 | A registry package that does not exist, or whose archive does not match |
| M0014 | No version of a registry package meets every requirement |
| M0015 | The 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)
| Code | Meaning |
|---|---|
| R0001 | Integer overflow |
| R0002 | Division or remainder by zero |
| R0003 | Call depth limit exceeded (stack overflow) |
| R0004 | Writing program output failed |
| R0005 | List index out of bounds |
| R0006 | Map key not found |
| R0007 | Invalid string slice |
| R0008 | Failed assert or assert_eq (with the two values as notes) |
| R0009 | Step limit exceeded; only for embedders that set brim_interp::Limits::max_steps, never in brim run or native executables |
| R0010 | to_int of NaN or of a number outside the i64 range |
| R0011 | A call to an extern fn in the interpreter (brim test, or brim run without a native backend) |
| R0012 | join of a task that was already joined |
| R0013 | Starting 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)
| Code | Meaning |
|---|---|
| G0001 | Two exported types have the same name |
| G0002 | brim gen ts --angular for a program that serves no module with serve_api |
Exit codes of brim
| Code | Meaning |
|---|---|
| 0 | Success (warnings may have been printed) |
| 1 | The program has syntax or semantic errors; for brim fmt --check, a file is not formatted |
| 2 | Invalid command line (unknown command or option, missing input) |
| 3 | Input problem: file missing or unreadable, not UTF-8, wrong extension, invalid .brimproj |
| 4 | Requested feature unavailable: brim build in a brim compiled without the llvm feature, or no C compiler to link with |
| 70 | Internal compiler error (a bug in Brim) |
| 101 | The 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)orErr(error). The compiler makes callers deal with it: aResultis not aT, so the value can only be used after amatchor the?operator, which returns theErrfrom 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
Rerrors 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
| Crate | Responsibility | Depends on |
|---|---|---|
brim-diagnostics | SourceFile, Span, Location, Diagnostic, terminal rendering | none |
brim-lexer | UTF-8 text to Tokens; lexical errors (E00xx) | diagnostics |
brim-ast | Syntax tree types; fully parenthesized Display for tests | diagnostics |
brim-parser | Tokens to AST; syntax errors (E01xx); error recovery | lexer, ast, diagnostics |
brim-runtime | Runtime ABI (RuntimeFn, AbiType), checked arithmetic, print and string functions, RuntimeError (R0xxx) | none |
brim-stdlib | Catalogue of builtin functions and their overloads, each mapped to a RuntimeFn | runtime |
brim-semantic | Symbol tables, scopes, type checking (E02xx, W0xxx); produces CheckedProgram | ast, diagnostics, runtime, stdlib |
brim-ir | IR data structures, text form, validator | diagnostics (spans), runtime (RuntimeFn) |
brim-lower | CheckedProgram to IR | ir, runtime, semantic |
brim-interp | Reference interpreter for IR | diagnostics, ir, runtime |
brim-codegen | Backend 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 feature | diagnostics, ir, runtime; inkwell (optional) |
brim-manifest | .brimproj parsing and validation (M0xxx) | diagnostics, serde, toml |
brim-package | Dependency graphs: path and git dependencies, the git cache, .brimlock | diagnostics, manifest, serde, toml |
brim-playground | The interpreter for the browser: run(source, input) and its WebAssembly exports | cli, diagnostics |
brim-doc | HTML documentation from doc comments (brim doc) | ast, diagnostics |
brim-tsgen | TypeScript declarations for a checked program's pub types (brim gen ts) | diagnostics, semantic |
brim-fmt | The formatter (brim fmt): works on tokens so comments and line breaks survive, and checks that the tokens are unchanged | diagnostics, lexer, parser |
brim-driver | Loading a program: the root file and the modules it imports, from its package and dependencies, through a file reader | ast, diagnostics, package, parser, semantic |
brim-lsp | The language server (brim lsp): diagnostics, formatting, symbols, definitions and hover over stdio | driver, fmt, lexer, manifest, package, semantic; serde_json |
brim-cli | The brim binary: argument parsing, commands, exit codes | everything 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,xis 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 nextfnat the top level. An unclosed block is detected when afnkeyword or end of file is reached. - Nesting limit. Blocks and expressions together are limited to 128 levels of nesting (
E0105). Long left-associative chains andelse ifchains 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. Thebrimbinary also runs the compiler on a thread with a 64 MB stack.
Semantic analysis
Two passes:
- Collect every function signature into a function table, so declaration order does not matter.
- 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
ModuleholdsFunctions and an entryFuncId. - A
Functionhas slots (typed local storage, like LLVMalloca; parameters are the firstparam_countslots), values (SSA-style temporaries), and blocks. - Each
Blockholds instructions and exactly oneTerminator(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
mem2regpass 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:
| Symbol | Signature | Purpose |
|---|---|---|
brim_rt_print_i64 | (i64) -> void | print(i64) |
brim_rt_print_bool | (bool) -> void | print(bool) |
brim_rt_print_str | (str) -> void | print(string) |
brim_rt_str_concat | (str, str) -> str | string + |
brim_rt_str_eq | (str, str) -> bool | string == / != |
brim_rt_i64_to_str | (i64) -> str | to_string(i64) |
brim_rt_bool_to_str | (bool) -> str | to_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
BrimStrcarries a reference count. String literals are static objects with count-1, which retain and release ignore. Strings from concatenation andto_stringstart 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, seedocs/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 usesArcfor the same reason. - With
BRIM_RT_CHECK=1in the environment, the runtime checks memory instead of freeing it: retaining or releasing a freed string aborts the program, and strings still allocated whenmainreturns 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.
| Option | For | Against |
|---|---|---|
| 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 GC | Handles 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 / borrowing | Zero 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-everything | Trivial 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>; onlybrim_entryandbrim_max_call_depthare exported, for the runtime'smain. - Slots become
allocas in a dedicated entry block, which also stores the parameters and jumps to IR block 0. LLVM'smem2reg(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. boolisi1inside functions and is widened toi8at runtime calls, as the ABI specifies.- String literals become constant
BrimStrobjects (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
| Decision | Rationale | Revisit when |
|---|---|---|
| Hand-written lexer and parser, no generator | Best control over error messages and recovery. The grammar is small. | Grammar grows much larger |
| Newline-terminated statements | Requested: no semicolons in ordinary code. Continuation after operators keeps it predictable. | Method chains (. at line start) |
| Separate checked tree instead of annotating the AST | Later 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 slots | Trivial validation and interpretation, and maps directly onto alloca + mem2reg. | An optimizer needs real SSA |
| Iterative interpreter with a heap-allocated call stack | Deep Brim recursion produces a clean R0003 error instead of crashing the host. | Never |
| Checked integer arithmetic | Silent wraparound is a common source of bugs in server software. | Performance data from the native backend |
| LLVM behind an opt-in Cargo feature | Contributors 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 time | Native 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 time | Native 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 calls | Gives 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 manifest | TOML parsing is complex and well solved. Versions are pinned. | Never |
Development Guide
Prerequisites
- A Rust toolchain installed with rustup. The repository's
rust-toolchain.tomlselects the stable channel withrustfmtandclippy. The minimum supported Rust version is 1.88 (let-chains). - No LLVM installation is needed for the compiler frontend,
brim checkandbrim run. - For native builds (
brim build) and their tests: LLVM 22 and a C compiler. See Native builds.
macOS / Homebrew note: if
rustcwas also installed through Homebrew (brew install rust), the Homebrew binaries may come first onPATHand ignorerust-toolchain.toml. Homebrew'srustccan also break when Homebrew upgrades LLVM (dyld: Symbol not found ... libLLVM). Prefer rustup's toolchain: put rustup's proxy directory first on yourPATH. That is~/.cargo/binfor a standard rustup install, or$(brew --prefix rustup)/binfor Homebrew'srustupformula. Alternatively, runrustup 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
| Location | What 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/diagnostics | Programs that must fail brim check, with the exact expected stderr in a .stderr file next to each program |
tests/integration | Programs run with brim run. Expected stdout in .stdout, expected stderr (if any) in .stderr |
crates/brim-cli/tests/fixtures.rs | The 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.rs | Runs the real brim executable: exit codes, help, manifests, dependencies, test, fmt, gen ts |
crates/brim-cli/tests/fuzz_smoke.rs | A 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.rs | Every 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
checksuites are expected to exit with 1; integration programs with 0. - A first line
// exit: Noverrides the expected exit code. - A missing
.stdoutor.stderrfile 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
- 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. - Lexer: new tokens or keywords. If the keyword was reserved, move it from
RESERVEDtoKEYWORDS. - AST and parser: new nodes, parse functions and tests (valid and invalid input).
- Semantic analysis: checking rules, new error codes with tests, and the checked-tree representation.
- IR: new instructions if needed, plus validator rules and tests.
- Lowering, then the interpreter, then the LLVM backend (
brim-codegen/src/llvm.rs, andruntime/brim_rt.cfor new runtime functions). Run the tests with--features brim-cli/llvmso the differential tests compare both. - Fixtures in
tests/, an entry indocs/error-handling.mdfor each new code, and an entry inCHANGELOG.md.
Adding a diagnostic
- Pick the next free code in the right range (
E00xxlexer,E01xxparser,E02xxsemantic,Wxxxxwarnings,Mxxxxmanifest,Rxxxxrun time). - Point the primary span at the cause. Explain with secondary labels and notes, and offer
helpwhen 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.
unsafecode 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.mddescribes it. - The standard library: the
pubitems of thestdmodules, 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
brimand their meaning, and its exit codes (seeerror-handling.md). .brimprojand.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 fmtwhere 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:
| Archive | Contents |
|---|---|
brim-X.Y.Z-linux-x86_64, -linux-aarch64, -macos-aarch64, -windows-x86_64 | brim 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-native | the 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
-
Make sure
mainis green, including the native jobs. -
Set the version in the workspace
Cargo.toml([workspace.package] version) and runcargo checkto updateCargo.lock. -
In
CHANGELOG.md, rename## [Unreleased]to## [X.Y.Z] - YYYY-MM-DDand start a new, empty## [Unreleased]section above it. -
Update the version in
editors/vscode/package.json. -
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 -
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;ifas an expression is an open question).whileloops withbreakandcontinue.- Mutable variables (
let mut), assignment and compound assignment (+=,-=,*=,/=,%=). - Divergence-based "returns on all paths" analysis and unreachable-code warnings;
while truewithoutbreakcounts as never ending. - The
to_stringbuiltin fori64andbool.
Phase 4: Native code generation (done, first version)
-
LlvmBackendbehind the opt-inllvmCargo feature, usinginkwell0.10 with LLVM 22 (seearchitecture.md, "Code generation"). -
A small C runtime, embedded in the compiler and linked into every executable with the system C compiler.
-
brim buildproduces an executable (-o,--releasefor LLVM's O2 pipeline). Projects build totarget/<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 fndeclarations,[build] c-sourcesand[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]andResult[T, E]as built-in enums. - Done: recoverable error handling:
Resultvalues with the?operator (also forOption). Error type conversion is still open. - Done:
List[T]with literals, indexing, methods and copy-on-write value semantics, andforloops over lists and ranges. - Done:
Map[K, V]withi64,boolorstringkeys, insertion-ordered, with the same value semantics. - Done: methods in
implblocks, withself,mut selfand 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 typedencodeanddecode),std.postgres,std.cryptoandstd.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.postgresand HTTP servers;selectover channels andreceivewith a timeout.
Phase 6: Package manager and project tooling (done, first version)
- Done: multi-file programs: every file is a module,
importwith aliases and item lists,pubvisibility, diagnostics across files. - Done:
brim new, andbrim testwithassertandassert_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 indocs/registry.md) withbrim publish,brim loginandbrim 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 tswrites TypeScript types for a program'spubtypes. - Done:
json.encodeandjson.decodewith the same mapping,serve_api(module, request)for a module'spub fns, andbrim gen ts --angularfor 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.