Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Syntax

The surface syntax of blr source files (.blr). This page is a description only — rationale, research, and decisions live in the knowledge vault (knowledge/design/syntax); the migration plan lives in knowledge/plans/syntax-rewrite.

Comments

  • // starts a line comment, running to end of line.
  • A // comment placed directly above an item, with no blank line between, is the item’s doc comment.

Identifiers

One class of identifiers. Case is a convention enforced by lint, not by the grammar:

  • snake_case — values, variables, functions, modules, record fields, parameters.
  • PascalCase — user-defined types and sum case labels.
  • 'a — type variables (OCaml style); ' followed by a lowercase identifier.

Keywords

pub, fn, type, let, import, match, extern, resource, linear, select, from

true and false are built-in identifiers (constants of bool), not keywords.

Punctuation

TokenMeaning
{ ... }Block; record literal/type; match arms (match {). The role is fixed by position or by the introducing form.
[ ... ]List literal (value position); sum type (type position).
( ... )Call / argument list, parameter list, grouping.
,Sibling separator (arguments, list elements, record fields, sum cases). Trailing comma allowed.
;Statement separator. Exactly one per statement; no trailing ;.
.Field / member access.
:name : type annotation; record field.
->Function result: in the function type, the closure, and the definition.
=>Match arm.
=Binding (let, type).
==Equality.
> <Comparison.
|Pipe.
+ - * /Arithmetic.
? (after select)The open row of a select form.
..Row concatenation: p .. {c: 3}.
::Path separator: module::item (std::df, df::filter); sum-label qualification in injections ('T::A) and match arms (T::A(_)).
#{ ... }Quote: the enclosed expression is quoted as data. # only appears as the #{ introducer.
\Function-quote shorthand prefix: \r -> body / \r,l -> body (see expressions).
'Type-variable prefix: 'a. Sum-injection prefix: 'T::A(v), 'A(v).
"String literal.

Items

Items appear at the top level of a file. A .blr file is a module: items plus at most one top-level expression. The top-level expression, when present, is the module’s main (the script form); pub fn main is the named form of the same entry point.

FormSyntax
importimport std::df
functionpub fn main() -> i64 { ... }
extern functionpub extern fn id_bool(b: bool) -> i64;
type aliaspub type P = {x: f64, y: f64}

Modifiers precede the item keyword; the order is fixed: pub extern fn, never extern pub fn.

Types

ConceptSpellingExample
primitivesi64, f64, bool, string, uniti64
resource (row-typed)DataFrame, CachedDataFrame, DataTableDataFrame<'a>
named resourceresource [linear] Name (top-level item)resource Widget
record{field: type, ...}{x: f64, y: f64}
list[]T (prefix; nests)[]i64, [][]f64
sum[ Case type, Case type ][ A unit, Pt P ]
functionfn (param: type) -> typefn (x: i64) -> i64
type variable'a'a

In a sum definition, case names are written bare; they are defined as T::Case for the sum T. A label that is a blr keyword is written with a leading backtick: [ Let unit, Match unit ].

DataFrame<'a> is a data frame typed by the row (record) type of its data; the row parameter is a phantom — a frame value is the same opaque resource handle regardless of its row type. CachedDataFrame<'a> is the typed form of a cached data frame: a materialized table that serves fresh frames without re-executing the pipeline that built it. DataTable<'a> is the materialized columnar result of consuming a frame. The untyped handles behind these — Frame, CachedFrame, and Table — are ordinary named resources declared by the df module itself (see Frames and caches).

Named resources

A module can declare its own resource types with a top-level resource declaration:

pub resource Widget
pub resource linear Ticket

A resource declaration is an item, like an extern fn or a native fn, not a type alias: the declaration itself is the type’s identity, and the name is what identifies the resource to the host (converted to kebab-case, so Widget crosses the boundary as the WIT widget resource). A declared name may still be used anywhere a type is written (an extern fn signature, an alias body), but it cannot be re-aliased to a different type.

A named resource is an opaque handle that only has meaning across the component boundary: the host module that provides the extern fns naming the type owns the representation. The type is nominal — two declarations with the same name in the same module are the same type. A resource value cannot be constructed in blr; it only arrives from an extern fn result and flows back to one.

A bare resource is shared: values of the type may be passed around and re-used freely (at the boundary they cross as borrow handles in parameter position and own handles in result position). Adding linear opts the type into use-exactly-once, exactly like frames: each value must be consumed by one extern fn call.

The df standard-library module declares its own boundary resources — a linear Frame and shared CachedFrame and Table — with exactly this syntax; they are not special to the compiler. resource and resource linear therefore cover every resource, including the data-frame family.

Linearity

Linearity is a property of types, not of names: a value is linear iff its type contains a frame. Linear values must be used exactly once; using one zero times or two times is a compile-time error.

TypeLinear?
Frame, DataFrame<'a>yes (linear leaves)
named resource declared resource linearyes (linear leaf)
named resource declared resource (bare)no — a shared handle, reusable
[]Twhen T is linear
{...} / [...] (record/sum)when any field is linear
CachedFrame, CachedDataFrame<'a>no — a cache is a shared source of fresh linear frames
fn typesno — function values are reusable; the obligation rides on the argument and result types
Quote<T>no
everything elseno

Rules, checked after type inference:

  1. Exactly once. Each variable bound to a linear type occurs exactly once in its binding scope. let bindings and function parameters alike.
  2. Whole-value movement. Linear values may be constructed (in lists, records, sums), passed as arguments, and returned freely — these move the value whole.
  3. Destructuring.
    • Projection (r.f) of a record that contains a linear field is legal only when the rest of the record is statically empty; projecting {f1: DataFrame<'a>, n: i64} as r.f1 is an error, because n’s sibling fields are discarded without being consumed.
    • Branching (match) on a sum containing a linear field is forbidden: the unchosen variant would be silently discarded.
  4. No linear captures. A linear value may not be referenced inside a nested closure. (Full linear-closure support — where a closure’s linearity depends on its captures — is a deliberate later phase.)
// error: `f` is used twice
let f = select {a, b, c} from df::fromcsv("test.csv");
df::count(f) + df::count(f)

// error: `f` is never used
let f = select {a, b, c} from df::fromcsv("test.csv");
0

Lists

A list is a fixed-size, element-major sequence built with [v, ...]. The std::list module provides the basic operations:

OperationSpellingResult
lengthstd::list::length(l)element count (i64)
indexl[i] (sugar for std::list::get(l, i))the ith element, 0-based
mapstd::list::map(l, f)new list of f(x) for each element
foldstd::list::fold(l, init, f)f(...f(f(init, l[0]), l[1])...)

map and fold take closures, which may be inline (fn (x) -> x * 2) or function items (double), so generic functions are monomorphized per list element type as with any application.

l[i] is bounds-checked at runtime: an out-of-range index (negative or >= length) aborts the program with a runtime error (see below).

Indexing a list of linear elements is a compile-time error. l[i] discards every element other than the ith, and unselected linear values may not be dropped (see Linearity):

import std::df

// error: indexing a list of linear elements is not allowed
pub fn main() -> i64 {
    let l = [df::fromcsv("a.csv"), df::fromcsv("b.csv")];
    df::count(l[0])
}

Selecting all elements through a map or fold closure is fine: every element is consumed by the closure, none is silently dropped.

Runtime errors

std::runtime::error(msg: string) -> unit signals an unrecoverable runtime error: the message is printed to stderr and the program aborts. It never returns, so it is used as the final expression of a -> unit function.

The compiler uses it for its own runtime checks, so a known failure reports a meaningful message instead of a bare trap:

pub fn main() -> i64 {
    [1, 2, 3][5] // aborts: "list index out of bounds"
}

Frames and caches

A data frame is linear: it is an uncomputed pipeline (read CSV, filter, limit, …), and consuming it means executing it exactly once. There is no implicit memoization — reuse of a frame is a type error, so “compute once, use many” must be written as an explicit copy:

import std::df

pub fn main() -> i64 {
    let d = select {a, b, c} from df::fromcsv("test.csv") | df::filter(#{ fn (r) -> r.a > 0 }) | df::cache();
    let a = d | df::to_df() | df::filter(#{ fn (r) -> r.b > 0 }) | df::count();
    let b = d | df::to_df() | df::count();
    a + b
}
  • df::cache(frame) consumes the linear frame, executes its pipeline, and materializes the result into a cached table. The result, CachedDataFrame<'a>, is non-linear: it may be named and re-used as often as you like.
  • df::to_df(cached) derives a fresh linear frame from the cache. Each derived frame scans the cached table — the original pipeline never re-executes — and is consumed exactly once like any frame.

Caching is the one place a frame’s data is copied; it is deliberate and observable in the source, which is why the rest of the language can stay linear without hidden cost.

Frame sources: select

A frame source is an item that reads a table into a fresh frame — today df::fromcsv (and influxdb::query for query sources). A source call names the row the frame carries with a select form:

// typed row: the columns this frame is expected to carry
select {name: string, total: int} from df::fromcsv("sales.csv")

// shorthand: bare labels leave the field types free (each is `label: _`)
select {name, total} from df::fromcsv("sales.csv")

// mixed: typed and free fields in one row
select {name: string, total, region} from df::fromcsv("sales.csv")

// open row: nothing declared, nothing validated
select ? from df::fromcsv("sales.csv")

The select … from … form is a single expression — the source — and a pipeline continues from it:

pub fn main() -> DataTable<'a> {
    select {a, b} from df::fromcsv("groups.csv")
        | df::filter(\r -> r.a > 1)
        | df::aggregate(\r -> {a: r.a}, \c -> {total: sum(c.b)})
        | df::collect()
}
  • The declared row is ? (the open row) or a row literal whose fields are either label: type or a bare label (a free field type, the surface spelling of a type hole). A field type must be concrete: it may not name type or row variables.
  • The operand of from must be a single source application — a call whose head is a source item. Anything else (a bare reference, a non-source call, a parenthesized expression, a pipeline) is a compile error: `select` requires a source (e.g. `df::fromcsv`, `influxdb::query`). The rule also rules out the pipeline ambiguity of select {a} from X | df::aggregate(…): there is no reading in which the pipeline is the operand, so the form parses exactly one way.
  • The declared row is the frame’s view/schema: the closed set of fields nameable downstream. The underlying table may carry more columns; a field not in the declared row is a type error (reference to non-existent field b on {a: i64}). With the open row (select ?) nothing is declared and nothing is validated.

A source call without a select form is a compile error: the open mode is always visible at the source, never implicit.

Expressions

ConceptSpellingExample
integer literaldecimal digits2
float literaldecimal point1.5
bool literalbuilt-in identifierstrue
string literaldouble-quoted"test.csv"
variableidentifierx
let bindinglet name = expr;let a = 2 * 2;
applicationf(x)df::filter(f)
field accessvalue.fieldr.a
pathmodule::itemselect {a, b, c} from df::fromcsv("test.csv")
list literal[v, ...][true, false]
list indexl[i]xs[0]
record literal{field: value, ...}{x: 1.5, y: 2.5}
row concatenationrow .. rowp .. {c: 3} .. {d: 4}
sum injection'T::Case(value) / 'Case(value)'T::Pt({x: 1.5, y: 2.5}), 'A(unit)
closurefn (param) -> exprfn (x) -> x
select (frame source)select <row> from <source-call> — row is ? or {label: type, label, ...}select {name, total} from df::fromcsv("sales.csv"), select ? from df::fromcsv("sales.csv")
quoted expression#{ expr }#{ fn (r) -> r.b > r.a }
function-quote shorthand\param, ... -> expr (one or more parameters; pure sugar for #{ fn (...) -> expr })\r,l -> r.a == l.a, \r -> r.a > 1
matchmatch expr { T::Case(binding) => expr, ... } (anonymous arm: Case(binding))match t { T::A(_) => 1, B(n) => n }
pipeexpr | fnselect {a, b, c} from df::fromcsv("test.csv") | df::filter(f)
equality / comparison== > <a == b, r.b > r.a

A quoted expression captures its contents as data. Only static structure is quotable for now: quoting an expression that references a value from the enclosing scope is not yet supported (the general “what is quotable” design is tracked separately).

The tick on a sum injection marks it as a value construction. The label is the final segment of the path; the owner (T::, import::T::) is for the reader, and the anonymous form 'Case(v) is the same value without it. The injected value is type-checked like any other value against the declared type of its position: where type T is expected, the case must exist in T with a compatible payload, and a missing case is a row-mismatch type error, like a bad match arm.

Example

// Filter the frame to rows where b > a, then show it.
import std::df

pub fn main() -> i64 {
    let f = #{ fn (r) -> r.b > r.a };
    select {a, b, c} from df::fromcsv("test.csv") | df::filter(f) | df::show()
}

pub type Tiny = [ A unit, B i64 ]

pub fn classify(t: Tiny) -> i64 {
    match t {
        Tiny::A(_) => 0,
        Tiny::B(n) => n,
    }
}