l
GitHub ↗

User Manual

This manual teaches l by building small native programs. It assumes that you already know a C-like language, so it does not explain basic ideas such as functions, integer arithmetic, or lexical scope. Instead, it concentrates on the parts of l that change how you design and read a program.

In particular, you will learn that:

  • Function bodies and control-flow forms are expressions.
  • Numeric types never promote implicitly, while a numeric literal takes its type from where it is written.
  • Values and name-to-value bindings are immutable; assignment changes stored data, never a binding.
  • A let only binds its evaluated expression, without allocating, and its type annotation is optional; annotated Type at module level, it names a type alias instead.
  • Aggregate initializers allocate backing; constructor assignment updates existing backing.
  • Tuples and slices are direct values, while arrays, structs, and enums are represented by backing addresses.
  • Storage-accessing references are immutable by default; shallow mut T grants write capability at the written level.
  • volatile independently makes permitted access through the written reference level use volatile LLVM operations.
  • static declarations name external storage; definitions provide storage from compile-time data and relocations.
  • Control-flow bodies are always blocks, and a completed control form is an ordinary operand.
  • Loops can produce values through else and break.
  • Enum variants are constructed through their enum, as Event::Offline, and switches are exhaustive.
  • Function values do not capture their surroundings.
  • An impl block places functions in a type’s namespace, called as T::f(...), with no receiver; Self names the target inside the block.
  • Module-qualified paths are relative unless they start with ::.
  • use name; depends on the library name and reaches its interface as the namespace name.
  • extern marks a declaration that links to a C symbol by its bare name.
  • Raw memory is explicit but has no ownership, lifetime, bounds, or atomic guarantees.

The chapters use complete programs where a runnable example helps. Most examples return 0 when their checks succeed, so l run can validate them without requiring an output library.

Manual and specification

The manual demonstrates useful patterns and explains how the language’s concepts fit together. It deliberately does not catalog every token, grammar production, conversion, precedence level, target rule, or diagnostic phase.

The Language Specification is the normative, exhaustive description. Follow a chapter’s specification links when you need an exact rule or are investigating an edge case.

Continue with Your First Program.

Your First Program

Create monitor.l with this complete program:

// Return zero when the observed value matches the expected value.
pub fn main() i32 {
  let observed: i32 = 41;
  let expected: i32 = 42;
  expected - (observed + 1)
}

Check it, then run it:

l check monitor.l
l run monitor.l

l check validates the source without producing a binary. l run builds a host executable and starts it. The program returns 0, so a successful run has no output. To keep the executable, use:

l build -o monitor monitor.l
./monitor

Read the body as an expression

An l function body contains one expression. A block lets that expression be a sequence:

let observed: i32 = 41;
let expected: i32 = 42;
expected - (observed + 1)

Each semicolon evaluates and discards the expression before it. The final expression has no semicolon, so its value is the block’s value and therefore the result of main. Writing return is valid, but it is not needed here.

A trailing semicolon changes the block result to void. This is useful for functions that perform work without returning a useful value, but it would not satisfy the i32 result required by an exported root main.

What the declaration says

pub fn main() i32

This signature declares a function; the following body makes it a definition. i32 is the fixed-width result required for the entry point. pub exports the root symbol so the platform runtime can find main. The checker can validate a file without main, but when an exported root main exists it must have the exact resolved type fn () i32.

Function definitions are private when pub is absent. pub controls both exported linkage and whether a definition can be called through another module’s API. Private functions remain callable within their own module. A bodyless declaration names an external function and takes the same pub, which decides only whether other modules may call it: its linkage is external either way, because another object supplies the entity.

Source conventions

Source files are UTF-8, but identifiers use ASCII letters, digits, and underscores and are case-sensitive. // starts a line comment. /// is reserved for documentation comments, although generated documentation does not retain its text yet. There are no block comments.

The manual uses .l for source files and two-space indentation, matching the compiler’s canonical formatter output.

Different from C

  • The final expression returns from a function without return.
  • A semicolon discards a value instead of merely ending every statement.
  • pub controls exported linkage and cross-module function visibility; there is no header declaration in this program.
  • An exported root main returns fixed-width i32, not the type selected by @CType("int") for a C int.

For exact source, token, and item rules, see Source Text and Tokens and Module Items.

Next, Values and Types explains why the type distinction in the last bullet matters.

Values and Types

l does not use C’s integer promotions or usual arithmetic conversions. Values keep their exact types, and most operators require both operands to have the same type. Choosing a type is therefore part of writing each expression, not something left to an implicit promotion pass.

Choose the type for the job

The numeric types fall into two practical groups, with a target-dependent macro for C interfaces:

Use Types Why
Data with a fixed representation u8 through u64, i8 through i64, f32, f64 Width is stable across targets
Sizes and address-sized arithmetic usize, isize Width follows the target pointer
C declarations @CType("char"), @CType("int"), and C pointer forms Expansion follows the target ABI

@CType(...) is an ordinary macro invocation used where a type is expected. Before checking, it expands to an ordinary language type for the selected target. For example, @CType("char") becomes u8, @CType("int") becomes isize on AVR and MSP430 or i32 elsewhere, @CType("char *") becomes mut *u8, and @CType("const char *") becomes *u8.

char is a 32-bit character value. bool contains true or false, and void has the one value null. Strings are not primitive values; Slices and Text develops their [u8] type.

The fifteen names are predefined identifiers rather than keywords: the compiler binds each of them in every module, so u8 resolves like an alias you could have written yourself. Nothing may be declared with one, so fn u8() and let bool: Type = i32; are errors, and a name always means its type wherever it is written.

Types remain distinct when their representations happen to match. For example, i32 and u32 are both 32 bits, but adding an i32 value to a u32 value is invalid because their signedness and types differ. An @CType result is not distinct: when it expands to i32, it is exactly the ordinary i32 type.

Literals take their type from context

A numeric literal has no type of its own. The checker gives it the type its position expects, and the written value must fit that type:

let count: u8 = 255;
let offset: isize = -4;
let ratio: f64 = 6.022e23;
let mask: u16 = 0xFF_00;

Integers are written in decimal, 0x hexadecimal, or 0b binary, and one or more underscores may separate digits, so 1__000 is valid. A floating-point literal needs a fraction or an exponent: 1.0 and 1e3 are floating-point, while 1 is an integer and never becomes f32 or f64. Nothing turns a number into a char; character literals contain one Unicode scalar value, such as 'A', '\n', or '\u{1F980}'.

Where nothing expects a numeric type, an integer is i32 and a floating-point literal f32. A literal next to a typed operand takes that operand’s type, so if small is a u8, small + 1 adds two u8 values and 200 < small compares two. The rules that decide these types are listed under Literal Typing.

Convert deliberately

Apply the target type when two accepted numeric types need to meet:

let small: u8 = 40;
let total: u16 = u16(small) + 2;
total

Conversions can narrow, wrap into another integer representation, or lose floating-point precision. char converts to an integer, but an integer does not convert to char. bool and void do not participate in numeric conversions.

This reuses ordinary call syntax. The callee is resolved as a value first, so a parameter or local value shadows an unqualified type of the same name. Only a type path can be a conversion target; name a pointer, qualified reference, or other complex type with an alias before converting to it. A conversion takes exactly one argument.

This explicitness also applies to comparisons, shifts, and function arguments. If an API accepts usize, pass a usize or convert to it instead of relying on the source type’s width. Only literals adapt; a typed value never does.

Aliases name a shape

A type alias improves vocabulary without creating a new nominal type. It is a module-level let whose annotation is the keyword Type, saying that the name stands for a type rather than a value:

let Reading: Type = i32;
let SampleCount: Type = usize;

Reading and i32 are exactly the same type, and both are names: i32 is predefined and Reading is yours, but the grammar sees one shape. The alias helps a reader understand intent, but it does not prevent a different i32 value from being used as a reading. Type is accepted only in this position: it is not itself a type, so it cannot annotate a binding inside a function body, and an alias cannot be declared there.

Here is a complete example:

let Reading: Type = i32;

fn magnitude(value: Reading) Reading {
  if value < 0 { -value } else { value }
}

pub fn main() i32 {
  let raw: Reading = -42;
  magnitude(raw) - 42
}

The branches of if are both Reading, which resolves to i32. The final expression also remains i32, matching the required entry-point result.

Numeric operations are low-level

Integer addition, subtraction, multiplication, and negation wrap at the type’s width. Division by zero, invalid shifts, and some signed division cases have no defined result. Floating-point operations may produce infinities and NaNs. The language does not insert runtime checks for these cases.

Each eager operator applies a fixed predefined associated function of its operand type. For example, left + right applies i32::add when both operands are i32, and that function can also be called or passed as a function value directly. This does not enable overloading: defining add on another type does not make + accept that type. == and != are primitive, while && and || are shorthand for conditionals, so they keep short-circuiting.

The complete literal grammar and conversion behavior live in Source Text and Tokens, Types, Static Typing, Predefined Functions, and Numeric Behavior.

Next, Memory Model separates immutable values from the storage that can hold their representation.

Memory Model

Before using storage, separate the thing a program computes from the place that can hold its representation. In l, values and bindings are immutable. Data in storage can change through a reference explicitly qualified mut.

Six separate ideas

Idea Meaning Example
Value An immutable typed semantic entity, independent of registers or memory slots 42, an array reference
Binding An immutable association from a name to a value let answer: i32 = 42
Storage A region with an address, size, alignment, duration, and contents A struct’s backing region
Data The bytes currently stored in a region, not the source that initialized it The representation of 42
Reference An immutable value designating storage and providing access to it A struct reference or *i32
Allocation Creation of a storage region Evaluating a runtime struct initializer

The compiler might keep a value in a register, spill it, or eliminate its machine representation. Those choices do not turn a binding into source-level storage. A load reads a stored value; a store replaces data. Neither changes an existing value or rebinds a name.

Evaluate first, bind second

let answer: i32 = 42;
let same: i32 = answer;

Both names denote the same scalar value. Neither let allocates a slot, and neither name can be used as the destination of a scalar store. The annotation is optional: let same = answer; binds the same i32 value, since an unannotated binding takes the type of its value.

For an array, struct, or enum, the value is instead a reference to backing storage:

let Cell: Type = struct { value: i32 };

let cell: mut Cell = {value: 0};
let alias: Cell = cell;
let count: mut *i32 = cell.value;
let before: i32 = *count;
count = 42;
cell ----+
         +----> Cell backing region
alias ---+      +----------------------+
count --------> | value: i32 data      |
                | first 0, then 42     |
                +----------------------+
before --------> 0 (unchanged value)

{value: 0} is the allocating expression. It constructs fresh backing and yields a Cell reference with the expected outer mut capability. Only after that does let cell bind the result. alias designates the same region through an immutable view, with no copy and no allocation. Because cell is mutable, cell.value projects a mut *i32 slot reference. *count loads its contents; count = 42 stores through it. Assignment never rebinds count, cell, or alias.

References alias when they designate the same or overlapping regions. A reference to a field overlaps its parent region, so writes can be observed through another alias. A previously loaded scalar such as before does not change.

Initialization versus updating

The same aggregate syntax has two runtime roles:

Initializer = new storage. Constructor = existing storage. Assignment never rebinds a name.

Form Effect
let cell: mut Cell = {value: 1} The initializer allocates and constructs fresh backing, then let binds its reference
cell = {value: 2} The constructor updates existing backing and yields void; cell keeps its identity
let other: Cell = cell Binds an alias without copying
cell = other Invalid aggregate assignment, not a copy or a rebinding

An array or struct initializer requires an expected aggregate type. The annotation above supplies it; function arguments, nested fields, return types, and contextual control-flow results can also supply it. The compiler does not infer a struct shape from field syntax. {} is the void block expression rather than empty aggregate syntax, so an empty struct initializer cannot be written. There is no separate scalar allocator syntax: use an existing aggregate field or element when you need a writable local slot.

Nested aggregates are separate regions

let Inner: Type = struct { value: i32 };
let Outer: Type = struct { inner: mut Inner };

let outer: mut Outer = {inner: {value: 1}};
let old: Inner = outer.inner;
outer = {inner: {value: 2}};
before: outer -> [inner reference] -> [value: 1]
                   old -----------------^

after:  outer -> [new reference]   -> [value: 2]
                   old -----------> [value: 1]

The outer region stays in place. Nested initializer syntax creates a new child and replaces the reference stored in the outer field; it does not recursively overwrite the old child. In contrast, outer.inner = {value: 3} first loads the current child reference, then updates that child’s existing backing.

Inner and *Inner differ by one indirection:

Inner   -> [Inner backing]
*Inner  -> [slot containing an Inner reference] -> [Inner backing]

If slot: mut *Inner already exists, slot = {value: 4} allocates fresh immutable Inner backing, stores its reference in the mutable slot, and yields that Inner reference. It does not write an inline struct over the pointer slot. If both levels must permit writes, use slot: mut *mut Inner; then *slot = {value: 4} loads the stored mutable reference and updates the backing it designates.

Mutability is a shallow capability

let cell: mut Cell = {value: 1};
let view: Cell = cell;
cell.value = 2;
let observed: i32 = *view.value;

observed is 2. The unqualified view cannot write, but it does not prevent the mutable alias cell from modifying the region. There is no runtime flag or ABI difference. A mutable reference implicitly converts to its immutable form only at the top level; the reverse conversion and changes at deeper levels are rejected.

The modifier is shallow. Given Outer = struct { inner: mut Inner } and outer: Outer, outer.inner loads exactly the declared mut Inner reference, which permits writing the separately backed child even though the outer reference is immutable. Declare the field inner: Inner to keep child access immutable. Direct fields, including raw pointers, tuples, and slice descriptors, project mut *T from a mutable base and *T from an immutable base; loading either yields exactly the declared T, without changing deeper capability.

mut is valid on raw pointers, fixed arrays, structs, enums, and slices. It is invalid on scalars, tuples, void, and function-reference types: calling a function is not storing through it. Nested modifiers apply exactly where written, as detailed in Mutable Reference Types.

Storage duration is not lexical scope

Each runtime aggregate initializer allocates at its execution site. Repeated evaluations in a loop create distinct regions, all lasting until the current function returns. Allocation is not hoisted into one reused entry-block slot, and leaving a block or iteration does not reclaim it. Such loops grow the stack and can exhaust it. Construct reusable backing outside a loop when fresh identity is not needed; remember that nested initializers still allocate fresh children during constructor updates.

There is no ownership or lifetime analysis. Returning or retaining a reference to this stack storage does not extend its duration: using it after the allocating function returns can access dangling storage.

Static storage

Module-level static items are storage declarations or definitions, not let bindings:

let Point: Type = struct { x: i32, y: i32 };

static supplied: *u32;
static counter: mut *u32 = 0;
pub static origin: Point = {x: 0, y: 0,};

supplied is a declaration: it introduces a name and type, and another linked object must provide the storage. counter and origin are definitions: they also provide storage and its initial contents. Every definition declares an entity, but a declaration need not define it. The same distinction applies to a function signature ending in ; versus one followed by a body.

counter has type mut *u32, while its designated slot contains a u32. Its static initializer 0 describes that slot’s initial contents, a u32, not a pointer value. static counter: u32 = 0; would be invalid because the annotation must designate storage. A struct, enum, or fixed array already designates its backing, so origin does not need an extra pointer level. A direct slice or function reference does not qualify as a static-storage annotation; a pointer to a slot containing either value does.

The compiler emits bytes and relocations from static initializers. It does not execute runtime initializers, constructors, or startup allocation. Nested aggregate syntax still describes separate backing objects with references stored in their parent slots. Those emitted bytes are data; the source syntax describing them is not data.

Static definitions last for the module’s runtime lifetime. Omitting an initializer never implies zero-filled storage. pub controls visibility independently: counter is private and mutable, while origin is public and immutable through its l type. An unqualified definition is emitted in constant storage where supported; a mut definition is emitted in writable storage. An immutable external declaration only restricts access through its l type and does not assert that the externally supplied storage is physically constant.

Targets and C Interoperation develops linkage and the supported static initializer forms.

Migration note

There are no var or const declarations and no const language type qualifier; those two words are ordinary identifiers. The removed word readonly is also an ordinary identifier, because unqualified storage references are now immutable. Replace scalar storage declarations with a typed aggregate initializer and a projected slot, as in the Cell example, and write mut at each reference level that must permit stores. A type alias is let Name: Type = T;; the reserved type keyword no longer introduces one.

The normative decomposition is Memory, Bindings, and Storage. Continue with Functions and Expressions, then Storage and Loops.

Functions and Expressions

Functions in l look familiar, but their bodies are expression trees. Blocks, bindings, conditions, assignments, and later even loops and switches can contribute values to a larger expression.

Define the result once

Parameter types are always written. The result type follows the parameter list:

fn maximum(left: i32, right: i32) i32 {
  if left > right { left } else { right }
}

The selected branch becomes the value of if, the block, and the function. Both yielding branches must have compatible types. Only the selected branch runs.

Omitting the result type means void:

fn perform_notification();

fn notify() {
  perform_notification();
}

A trailing semicolon supplies the void value null for this body.

Bind intermediate values

let evaluates its expression once, then binds the resulting value directly without allocating storage:

let distance: i32 = right - left;
let doubled: i32 = distance * 2;
doubled

An annotation is optional. Without one, the binding takes the type of its value, so let doubled = distance * 2; is also an i32, and let count = 0; is an i32 because nothing asks the literal for another type. Write the annotation when the value alone does not say what you mean, such as let count: u8 = 0;. A name is not visible in its own initializing expression, and an inner binding may shadow an outer one. The new name remains available through the rest of its block.

When a sequence itself must be used as a value, put it in a block:

let adjusted: i32 = {
  let delta: i32 = 2;
  value + delta
};
adjusted

{} is the empty block, which yields the void value null, and it is accepted wherever an expression is. It is never aggregate syntax, so there is no empty struct initializer.

Leave early when it clarifies the path

return value exits immediately. Because it never reaches the surrounding expression, it can appear in a branch whose other path produces a value:

fn clamp(value: i32, low: i32, high: i32) i32 {
  if value < low { return low };
  if value > high { return high };
  value
}

A bare return returns null from a void function. Source after a return still has to parse and type-check.

return, break, and continue have the internal bottom type Never: they have no normal completion and fit any expected value position. Strict expressions propagate it. If consume returns bool, consume(return 1) is Never, because the argument exits before the call can run. The same applies to eager operators, projections, tuples, initializers, sequences, and binding initializers. Source after the jump is still checked.

Short-circuiting remains visible in the type: a Never left side makes && or || Never, while a Never right side still leaves the path that skips it. This is static propagation, not full control-flow or constant-value analysis.

If the callee itself is Never, the call is Never and no signature gives its arguments parameter types: each argument is still checked on its own, so array or struct syntax as an argument is rejected. See Static Non-Yielding Classification and Calls, Constructors, and Conversions.

Compose a complete program

fn clamp(value: i32, low: i32, high: i32) i32 {
  if value < low { return low };
  if value > high { return high };
  value
}

fn score(reading: i32) i32 {
  let bounded: i32 = clamp(reading, 0, 100);
  let centered: i32 = bounded - 50;
  if centered < 0 { -centered } else { centered }
}

pub fn main() i32 {
  score(92) - 42
}

Calls evaluate the callee and then arguments from left to right. They require the declared arity and compatible argument types. A mutable storage reference may become immutable only at the top level, and a reference may gain top-level volatile; numeric promotion, immutable-to-mutable conversion, volatility removal, and deeper qualifier changes are not implicit. Parameters and results keep their declared types across the call; there are no named, default, or variadic arguments.

An expected result type also reaches an explicit return, the final expression of a sequence, each yielding conditional or switch arm, and a contextual while, for, or loop expression’s break and else results. Aggregate initializers in these positions can use that type. This is contextual checking, not inference of an aggregate shape. Returning newly allocated aggregate backing is type-correct but leaves a dangling reference once the allocating function returns.

Keep precedence unsurprising

Postfix calls, conversions, and selections bind tightly, followed by prefix operations, arithmetic, comparisons, Boolean operations, and assignment. A control head consumes an expression directly, so if ready and if (ready) differ only in the ordinary expression grouping around ready. Every control body is a block, so a completed if, while, loop, for, or switch is an ordinary operand and needs no parentheses:

if ready { current } else { fallback } + 1

The same rule replaces else if: write the nested conditional inside the else block.

if value < low { low } else { if value > high { high } else { value } }

The exact grammar and precedence are defined by Expression Grammar.

Next, Storage and Loops adds mutation. This is where l departs most visibly from ordinary C variable syntax.

Storage and Loops

As Memory Model explains, let only binds an immutable value. To keep changing state, obtain a reference to storage and load or store through it. The reference and its binding remain unchanged.

A storage name is an address

These expressions perform different jobs:

Form Meaning
let initial: i32 = 40 Binds a scalar, with no slot allocation
let cell: mut struct { value: i32 } = {value: initial} The initializer allocates backing; the binding holds its mutable reference
let current: mut *i32 = cell.value Binds the projected mutable field-slot reference
let view: *i32 = current Binds an immutable view of the same slot

Load from storage with prefix *:

let initial: i32 = 40;
let cell: mut struct { value: i32 } = {value: initial};
let current: mut *i32 = cell.value;
let limit: i32 = 42;
let loaded: i32 = *current;

Every local binding has an initializing expression. There is no uninitialized local or implicit zero initialization, and let limit: i32 = 42 does not provide storage to load or write.

Assign to the pointer, not the load

Assignment expects a writable pointer as its target:

current = 41;
current += 1;
*current

The target is current, not *current. current identifies the storage; *current reads it. Assignment returns the value it writes, so it can feed a larger expression. Compound assignment evaluates its target only once. Neither form rebinds current; a previously loaded scalar remains the value that was read.

This model replaces several familiar C forms. There is no address-of operator; aggregate field and element projections already provide slot references. There are no increment or decrement operators. Use current += 1 and *current explicitly.

Loop over state

A while condition is checked before each iteration. The body value is discarded:

let cell: mut struct { value: i32 } = {value: 0};
let current: mut *i32 = cell.value;
while *current < 10 {
  current += 1
};
*current

continue starts the next iteration. break exits the innermost loop. Both forms can occur where another path would produce a value because they do not yield to the immediate expression. Like return, they are complete jump expressions rather than primary expressions; use parentheses to place one directly inside an operator operand.

Loop until an explicit result

loop has no condition or normal termination path:

let answer: i32 = loop {
  if ready() { break 42 };
  continue
};

Every normally completed body iteration starts the next one, and the body value is discarded. Reachable break values determine the expression’s type. A loop that cannot break is Never, so fn wait() i32 { loop {} } is type-correct even though it never returns.

Let a loop produce a value

Adding else turns normal loop completion into a result. A valued break supplies the alternative result and skips the else:

fn converge(start: i32) i32 {
  let cell: mut struct { value: i32 } = {value: start};
  let current: mut *i32 = cell.value;
  let limit: i32 = 42;
  while *current < limit {
    current += 1;
    if *current == 42 { break *current }
  } else {
    -1
  }
}

pub fn main() i32 {
  converge(40) - 42
}

Here the successful break produces 42. If the condition is false before that happens, else produces -1. All yielding break values and the else value must have compatible types. Without else, while and for have type void, and a bare break supplies void.

for uses the same break, continue, and else model. It becomes useful after the manual introduces arrays and slices.

Storage is deliberately low-level

The state above is allocated before the loop and reused. Moving an aggregate initializer inside the loop instead creates fresh backing on every evaluation. All those regions last until the allocating function returns, so such loops grow the stack and can exhaust it; iteration boundaries do not reclaim storage.

The language does not infer exclusive ownership of these addresses. A function can receive two writable references to the same storage, and a reference can escape the function that allocated it without extending storage duration. Raw Pointers and Unsafe develops those responsibilities after the data model is in place.

See Memory, Bindings, and Storage and Control Flow for exact typing and evaluation rules.

Next, Aggregate Data explains why arrays and structs behave differently from the direct values used in this chapter.

Aggregate Data

All l values are immutable semantic entities. Their runtime representations fall into two categories, which determine what a binding, parameter, return value, selection, and equality operation carries:

Model Types What moves through an expression
Direct Primitives, raw pointers, function references, tuples, slices The complete value
Address-valued Fixed arrays, structs, enums The address of backing storage

An array, struct, or enum value is already a reference. It is not implicitly loaded as one large composite value. Binding, passing, or returning it carries the backing address and does not copy its contents or transfer ownership.

Tuples are direct values

A tuple groups at least two values without naming them:

let Range: Type = (i32, i32);

fn width(range: Range) i32 {
  range.1 - range.0
}

Projection returns the selected value directly. range.0 is not a pointer to an assignable tuple slot. Tuple types are structural and positional, and tuple equality compares their elements recursively.

To keep a tuple in writable storage, use a mutable aggregate such as let holder: mut struct { range: Range } = {range: pair}. Its range field projects a mut *Range; load the complete tuple before projecting its elements. A direct tuple cannot itself be qualified mut, but its reference-valued elements can carry their own qualifiers.

Fixed arrays provide backing storage

An array type includes a positive literal length:

let readings: [i32; 4] = [10, 20, 30, 40];

Here readings has the written type [i32; 4], not an extra raw-pointer wrapper. The initializer allocates and constructs immutable backing; let only binds its reference. Indexing a direct element projects an immutable *i32 slot, so reading the third value uses *readings[2].

There are no bounds checks. The index may use any unsigned integer type, and a literal index is a usize, but it is your responsibility to keep it within the fixed length.

An array initializer needs a known array type and returns a fresh backing reference. Assigning array syntax to an existing writable array instead performs a constructor update in the same backing and returns void:

let readings: mut [i32; 3] = [10, 20, 30];
readings = [12, 22, 32];
readings[1] = 42;

Each element takes the declared element type, so let bytes: [u8; 2] = [1, 255]; needs no conversion. The array type itself must still come from context: let values = [1, 2]; is rejected, because an unannotated binding gives the initializer no expected type and the compiler does not infer an aggregate shape.

Array equality compares backing addresses, not elements.

Structs are structural addresses

Struct types are anonymous and structural. A transparent alias gives one a useful name:

let Reading: Type = struct {
  channel: u8,
  value: i32,
};

Field names, order, types, and whether the struct is packed all participate in its identity. Fields must have unique names. There are no nominal struct declarations.

Initialize every field exactly once. Source order in the initializer controls evaluation order, but it does not need to match declaration order:

let reading: mut Reading = {value: 40, channel: 1};
reading.value += 2;
let value: i32 = *reading.value;

A direct field selection yields mut *T from a mutable base and *T from an immutable base, including for a raw pointer, tuple, function reference, or slice field. An aggregate field selection instead automatically loads the boxed backing reference with exactly the declared field type. It adds no pointer level or deeper capability.

Put the models together

let Reading: Type = struct {
  channel: u8,
  value: i32,
};

let Range: Type = (i32, i32);

fn set_value(reading: mut Reading, value: i32) {
  reading.value = value;
}

fn width(range: Range) i32 {
  range.1 - range.0
}

pub fn main() i32 {
  let reading: mut Reading = {channel: 1, value: 40};
  let history: [i32; 3] = [10, 20, 42];
  let expected: i32 = *history[2];
  let accepted: Range = (0, expected);

  set_value(reading, expected);
  if *reading.value == expected && width(accepted) == 42 { 0 } else { 1 }
}

The tuple accepted moves as a complete pair. reading and history move as addresses. Calling set_value therefore updates the original reading storage.

Access follows declared types

Reading is immutable and mut Reading permits updates through that reference. mut Reading can implicitly become Reading only at the top level, never the reverse. An API taking or returning Reading keeps access immutable; another mutable alias may still change the same region.

Nested aggregates are separately backed, not inline. The distinction is visible during an outer update:

let Inner: Type = struct { value: i32 };
let Outer: Type = struct { inner: mut Inner, tag: u8 };

let outer: mut Outer = {inner: {value: 1}, tag: 0};
let alias: Outer = outer;
let old: Inner = outer.inner;
outer = {inner: {value: 2}, tag: 1};

outer == alias remains true. outer.inner != old is now true, and *old.value is still 1: nested initializer syntax created a new child and replaced the stored child reference. In contrast, outer.inner = {value: 3} loads and updates the current child without replacing it. outer = alias is invalid, while let another: Outer = alias only creates an alias.

For view: Outer, the direct view.tag slot is immutable, but view.inner still loads exactly the declared mut Inner, which permits child writes. Declare inner: Inner to keep that child immutable. A field declared *Inner is different again: it stores an immutable raw pointer to an Inner reference slot, not the child’s ordinary Inner reference.

Recursive aliases are possible when an alias in the cycle directly names an address-valued aggregate or function reference, allowing outer access qualifiers on eligible types. This makes a recursive struct type finite because a nested aggregate member stores a backing address:

let Node: Type = struct {
  value: i32,
  next: Node,
};

Cycles through only aliases or raw pointers are rejected. A slice’s element type must not reach any recursive alias cycle, even one that contains no slice itself. For example, let Chain: Type = [Chain; 1]; is accepted, but [Chain] and slicing a Chain value are not. See Type Aliases for the exact recursion rules.

Next, Slices and Text creates direct views over array and string storage.

Slices and Text

A fixed array reference designates a known amount of backing storage. A slice is a direct, by-value descriptor that views contiguous storage without copying it. The descriptor contains an element-storage address and a usize element count.

Create a view with a half-open range

Apply a postfix range to an array or another slice:

let readings: [i32; 5] = [10, 20, 30, 40, 50];
let middle: [i32] = readings[1..3];
let prefix: [i32] = readings[..2];
let suffix: [i32] = readings[2..];
let whole: [i32] = readings[..];

The range is half-open, so it holds end - start elements: readings[1..3] views two of the five. An omitted start is 0 and an omitted end is the source count, which makes readings[..] the whole view.

Read the current count through .length, which always returns usize:

let count: usize = middle.length;

Slicing preserves the element type and the base’s outer mut and volatile qualifiers. It allocates no element buffer and extends no lifetime. Neither slicing nor indexing checks its bounds or range. An unqualified [T] descriptor denies writes to the viewed element slots, mut [T] permits them, and volatile [T] makes reads volatile without permitting writes; none of these outer properties changes separately stored child references after they are loaded.

A slice’s element type cannot reach a recursive alias cycle, even through an aggregate that is legal on its own. let Chain: Type = [Chain; 1]; is valid, but values[..] for values: Chain is rejected: the resulting slice element reaches the recursive Chain type. The same restriction applies to written slice types such as [Chain], not only to cycles that contain a slice. See Type Aliases.

Iterate over element values

for accepts an array or slice, evaluates it once, and visits elements in ascending index order:

fn sum(values: [i32]) i32 {
  let cell: mut struct { value: i32 } = {value: 0};
  let total: mut *i32 = cell.value;
  for value in values {
    total += value
  };
  *total
}

The loop binding is the current element value. It does not need the explicit dereference used by *values[index].

As with while, a for can return a value. Exhaustion runs else; break value skips it:

fn first_even(values: [i32]) i32 {
  for value in values {
    if value % 2 == 0 { break value }
  } else {
    -1
  }
}

An empty slice reaches else immediately. continue advances to the next element.

Strings are UTF-8 byte slices

A string literal has type [u8], and its read-only backing storage lasts for the module’s runtime lifetime:

let label: [u8] = "monitor";
let first: u8 = *label[0];
let bytes: usize = label.length;

Indexing observes encoded bytes, not Unicode characters. There is no hidden trailing zero. Escapes are decoded before the text is encoded as UTF-8. Its unqualified immutable type is retained when the slice is passed, returned, stored, or sliced. A parameter accepting string literals must accept [u8], not mut [u8].

Triple quotes allow raw newlines:

let report: [u8] = """sensor one
sensor two""";

A c prefix creates a different value for C APIs: c"monitor" has type *u8 and includes one trailing zero byte. This matches @CType("const char *") because that macro expands to *u8. Targets and C Interoperation uses that form.

Complete example

fn sum(values: [i32]) i32 {
  let cell: mut struct { value: i32 } = {value: 0};
  let total: mut *i32 = cell.value;
  for value in values { total += value };
  *total
}

fn first_even(values: [i32]) i32 {
  for value in values {
    if value % 2 == 0 { break value }
  } else {
    -1
  }
}

pub fn main() i32 {
  let readings: [i32; 4] = [1, 3, 6, 8];
  let window: [i32] = readings[1..4];
  let label: [u8] = "monitor";

  if
    window.length == 3 &&
    sum(window) == 17 &&
    first_even(window) == 6 &&
    label.length == 7
  {
    0
  } else {
    1
  }
}

The exact count formulas, accepted index types, and invalid-range behavior are in Slice Types and Raw Pointer and Memory Behavior.

Next, Enums and Switches uses address-valued data to model alternatives.

Enums and Switches

An l enum is a structural discriminated union. Each variant is either a unit case or carries one payload. Like arrays and structs, an enum value is the address of backing storage.

Describe the alternatives

let Event: Type = enum {
  Reading: i32,
  Fault: u8,
  Offline,
};

Variant order, names, payload types, and IDs all participate in structural identity. IDs normally use zero-based source positions. When an external format requires specific IDs, write a unique nonnegative ID for every variant:

let Result: Type = enum {
  Ok: i32 = 10,
  Error = 20,
};

Do not mix implicit and explicit IDs in one enum.

Constructors name their enum

A variant is written through its enum, so a constructor carries its own type:

let event: mut Event = Event::Reading(40);
event = Event::Fault(2);
event = Event::Offline;

Event::Reading(40) allocates fresh enum backing and yields its reference, which let binds. event = Event::Fault(2) and event = Event::Offline instead run constructors against the existing writable backing, retain its identity, and yield void. event = another_event is not valid aggregate assignment.

The prefix is an ordinary type path, so a nested enum is reached as model::Event::Offline and, from the root, ::model::Event::Offline. A structural enum without an alias cannot be constructed, because there is no path that names it. Since variants and associated functions share the enum’s one namespace, impl Event { fn Offline() ... } is an error.

A payload variant takes exactly one value or nested aggregate initializer. An aggregate payload is boxed: nested syntax creates separate child backing and stores its reference. Updating an enum with a fresh nested initializer replaces the payload reference, not the old child’s data.

Switch exhaustively

switch evaluates the enum address once and chooses an arm:

fn event_score(event: Event) i32 {
  switch event {
    case Reading(value):
      value
    case Fault(code):
      -i32(code)
    case Offline:
      -1
  }
}

Without default, every variant must appear exactly once. This makes adding a variant visible wherever the enum is handled. A default can replace omitted cases, but it is invalid when all variants are already named.

A payload binding belongs only to its arm and loads the exact declared payload type. A direct payload binds as a value; an aggregate payload binds as its stored backing reference. An immutable Event protects enum storage, but a payload declared mut Child still loads as mutable; leave the payload type unqualified when child access must stay immutable. Unit variants cannot have payload bindings.

Every arm with a static value type must have a compatible type. As with if, an arm statically classified as Never, such as a direct return, break, or continue, does not force the other arms’ type. A strict expression containing a Never operand is also Never; a short-circuit right operand is the important exception. See Static Non-Yielding Classification.

A scrutinee that is statically Never, such as switch (return 0) { ... }, is rejected: no enum value ever selects an arm. See Switch Expressions.

Complete example

let Event: Type = enum {
  Reading: i32,
  Fault: u8,
  Offline,
};

fn event_score(event: Event) i32 {
  switch event {
    case Reading(value):
      value
    case Fault(code):
      -i32(code)
    case Offline:
      -1
  }
}

pub fn main() i32 {
  let event: mut Event = Event::Reading(42);
  if event_score(event) != 42 { return 1 };

  event = Event::Offline;
  if event_score(event) != -1 { return 2 };

  event = Event::Fault(2);
  if event_score(event) != -2 { return 3 };

  0
}

Enum equality compares backing addresses rather than variants and payloads. Use switch when you need semantic comparison or payload access.

For exact constructor, coverage, layout, and invalid-discriminator rules, see Enum Types and Switch Expressions.

Next, Function References applies the same expression-oriented model to behavior.

Function References

A function name is a first-class callable value. Its type records parameter and result types:

fn (left: i32, right: i32) i32

Parameter names inside a function-reference type are descriptive. Only their order and types and the result type determine identity.

Pass and return behavior

let Strategy: Type = fn (left: i32, right: i32) i32;

fn maximum(left: i32, right: i32) i32 {
  if left > right { left } else { right }
}

fn minimum(left: i32, right: i32) i32 {
  if left < right { left } else { right }
}

fn choose(want_maximum: bool) Strategy {
  if want_maximum { maximum } else { minimum }
}

fn apply(strategy: Strategy, left: i32, right: i32) i32 {
  strategy(left, right)
}

The selected function reference moves as a direct, pointer-sized value. It can be bound, compared for identity, passed, returned, and called. A call is a postfix expression, so a returned function can be called directly as choose(true)(20, 22).

Anonymous functions do not capture

An anonymous function writes its complete signature:

let average = fn(left: i32, right: i32) i32 {
  (left + right) / 2
};
average(20, 22)

The binding takes the anonymous function’s written type; an annotation, such as a compatible function-type alias, is optional. Anonymous functions are not closures: their bodies can use their own parameters and module-level functions or statics, but not parameters or bindings from an enclosing function.

Choose an ordinary function with an explicit parameter when behavior needs runtime context. Do not expect an anonymous function to retain local state.

Storage adds one pointer level

A function reference already represents its first reference level. It is not a raw pointer. To keep one in a storage slot, initialize an aggregate field and project that slot:

let holder: struct { operation: Strategy } = {operation: maximum};
let slot: *Strategy = holder.operation;
let selected: Strategy = *slot;
selected(20, 22)

The type of slot is *Strategy, equivalent to *fn (left: i32, right: i32) i32. Dereference the slot before calling the stored function.

mut Strategy is invalid: calling a function is not writing through a storage reference. An unqualified *Strategy prevents replacement of the stored function reference while still allowing it to be loaded and called; use mut *Strategy for a replaceable slot. Parameter and return types inside Strategy retain their own reference qualifiers; changing those qualifiers is not an implicit conversion between function types.

Complete example

let Strategy: Type = fn (left: i32, right: i32) i32;

fn maximum(left: i32, right: i32) i32 {
  if left > right { left } else { right }
}

fn minimum(left: i32, right: i32) i32 {
  if left < right { left } else { right }
}

fn choose(want_maximum: bool) Strategy {
  if want_maximum { maximum } else { minimum }
}

pub fn main() i32 {
  let average: Strategy = fn(left: i32, right: i32) i32 { (left + right) / 2 };
  let selected: Strategy = choose(true);
  let holder: struct { operation: Strategy } = {operation: selected};
  let slot: *Strategy = holder.operation;

  if average(20, 22) != 21 { return 1 };
  if (*slot)(20, 42) != 42 { return 2 };
  if *slot != maximum { return 3 };
  0
}

Function references do not convert to raw pointers or integers. See Function Reference Types for identity and invalid-call behavior.

Next, Associated Functions keeps a type’s operations next to its definition.

Associated Functions

A function that belongs with a type can be placed in that type’s namespace. An impl block does exactly that and nothing more: the functions inside are ordinary functions, and the block only decides which path names them.

Put functions in a type’s namespace

let Point: Type = struct {
  x: i32,
  y: i32,
};

impl Point {
  pub fn set(point: mut Point, x: i32, y: i32) {
    point = {x: x, y: y};
  }

  pub fn sum(point: Point) i32 { *point.x + *point.y }
}

pub fn main() i32 {
  let point: mut Point = {x: 0, y: 0};
  Point::set(point, 20, 22);
  Point::sum(point) - 42
}

Point::set and Point::sum are called through the type. There is no receiver: an associated function has no self, point.sum() is not a call, and the value it works on is an ordinary parameter. Compared to a free fn sum(point), the only difference is the name, which cannot collide with a module function, static, alias, or namespace called sum.

The block takes no pub. Each function inside does, with the usual meaning: pub fn is exported and callable from any module, while a function without pub is callable only from the module that contains the block. A bodyless fn f(); declares an associated function supplied by another object.

Any type owns a namespace

The target can be a primitive as well as an alias:

impl u8 {
  pub fn double(value: u8) u8 { value * 2 }
}

fn apply(operation: fn (value: u8) u8, value: u8) u8 { operation(value) }

pub fn main() i32 {
  let double: fn (value: u8) u8 = u8::double;
  if apply(double, 21) != 42 || u8::double(1) != 2 { return 1 };
  0
}

u8::double is an ordinary path: u8 is a predefined name, not a keyword, so it is a path segment like Point in Point::new. Like every associated function, it is an ordinary function value: bind it, pass it, compare it, or store it in a static. The target of an impl block may be any type expression, but a call always spells the type as a name, so give [u8], (i32, i32), or *u8 an alias first: let Bytes: Type = [u8]; impl Bytes { ... } Bytes::first(b). void and mut- or volatile-qualified targets are rejected.

Primitive numeric, integral, and Boolean types also own a fixed set of predefined functions. They can be used directly, including as function values:

let add: fn (left: i32, right: i32) i32 = i32::add;
let total: i32 = add(20, 22);
let ready: bool = bool::not(false);

Their names are reserved only on targets that provide them, so impl i32 { fn add(...) ... } is rejected while the user-defined u8::double above remains valid. Operators always apply the predefined functions selected by their existing typing rules; an impl block cannot overload them. See Static Typing and Predefined Functions for the complete operator and function tables.

Self names the target

Inside the block, Self stands for the target, so a function can be written without repeating the type it belongs to:

let Point: Type = struct {
  x: i32,
  y: i32,
};

impl Point {
  pub fn set(point: mut Self, x: i32, y: i32) mut Self {
    point = {x: x, y: y};
    point
  }

  pub fn origin(point: mut Self) mut Self { Self::set(point, 0, 0) }

  pub fn size() usize { @sizeOf(Self) }
}

pub fn main() i32 {
  let point: mut Point = {x: 1, y: 2};
  Point::origin(point);
  *point.x + *point.y + i32(Point::size()) - 8
}

Self is an alias and nothing more: it is replaced by the target before type checking, so Self and Point are one type, mut Self is mut Point, and Self::set is Point::set. It is accepted wherever a type is written inside the block, including anonymous-function signatures and macro arguments. Self::f needs the target to be a predefined name or an alias path, exactly as a call does, so under impl [u8] the type Self is fine but Self::first is an error; give the slice an alias to use both. Outside an impl block there is nothing for Self to name, and the target of a block is outside it, so impl Self is an error too.

Aliases are transparent here too

The language has no nominal types, so the namespace belongs to the structural type, not to a name. Two aliases of one type share it, blocks for one type merge wherever they are written, and a second function of the same name on the same type is an error even when a different alias spells it:

let Point: Type = struct { x: i32, y: i32 };
let Coordinate: Type = Point;

impl Point { pub fn sum(point: Point) i32 { *point.x + *point.y } }
impl Coordinate { pub fn zero() i32 { 0 } }

namespace geometry {
  // The same structural type, so `Point::sum` here is the root's `Point::sum`.
  let Point: Type = struct { x: i32, y: i32 };
  pub fn total(point: Point) i32 { Point::sum(point) + Point::zero() }
}

Resolving Point in geometry::total follows the ordinary type-path rules, so a child module reaches the namespace through an alias it can name; once the type is known, sum and zero are found on it from any module. Coordinate::sum and ::Point::zero name the same functions. impl Coordinate { pub fn sum(...) } would be a duplicate.

Linking follows the same idea. An associated function’s symbol is derived from the resolved type alone, so impl u8 defines 1h__parse and impl Point defines 8O1xl1ylE__new in every package, and --name does not apply. Two packages that both define Point::new therefore clash at link time, which the compiler cannot detect.

l bundle --header keeps public associated functions as declarations inside their impl block, and l doc lists them under their type. See Associated Functions and Associated Function Symbols for the exact rules and the encoding.

Next, Raw Pointers and Unsafe deals with addresses that do permit explicit reinterpretation.

Raw Pointers and Unsafe

Storage projections introduced raw pointers as part of ordinary code. This chapter makes their type and conversion rules explicit and, more importantly, defines what the language does not protect.

Immutable and mutable access

*T permits reading but not writing a slot containing T. mut *T explicitly permits writing that slot. Both are immutable reference values; mut describes access through the reference, not rebinding:

fn read(value: *i32) i32 {
  *value
}

fn increment(value: mut *i32) i32 {
  value += 1
}

A mutable reference converts implicitly to the corresponding immutable reference only at the outer level. For example, *mut *i32 is an immutable outer slot containing a mutable *i32; loading it yields exactly mut *i32. mut **i32 instead permits replacing an outer slot whose stored pointer is immutable. Neither type implicitly converts to the other because that would change capability at a deeper level.

*void and mut *void are opaque addresses. They can be passed, compared, and converted, but not dereferenced or assigned through because void has no stored representation.

Swap through addresses

Names bound to slot references can be passed directly because they already have pointer type:

fn swap(left: mut *i32, right: mut *i32) {
  let saved: i32 = *left;
  left = *right;
  right = saved;
}

There is no address-of operator. An aggregate field or element, a static name, a parameter, a function result, a loaded reference, or an explicit integer conversion supplies an address. let never allocates a slot on its own.

Use unsafe for address construction and retyping

Pointer-to-integer conversion is accepted directly only for usize and isize:

let address: usize = usize(pointer);

Constructing a pointer from either pointer-width integer, retyping a raw pointer, or adding mut to an immutable raw pointer requires an unsafe block:

let IntPointer: Type = *i32;
let BytePointer: Type = *u8;
let WritableBytes: Type = mut *u8;

let restored: IntPointer = unsafe { IntPointer(address) };
let bytes: BytePointer = unsafe { BytePointer(pointer) };
let writable: WritableBytes = unsafe { WritableBytes(bytes) };

unsafe is lexical and adds no runtime check. It states that the program is taking responsibility for a conversion; it does not prove that an address is aligned, live, correctly typed, or valid to access. It also does not make unsupported conversions legal.

Adding mut by explicit unsafe raw-pointer retyping changes only the pointer type. A later store is defined only when the underlying storage is genuinely writable; string backing and constant static regions remain physically read-only after a conversion. There is no safe capability-adding conversion, and converting an immutable aggregate or slice directly to its mutable type is invalid even inside unsafe.

Dereference and assignment through a pointer do not themselves require an unsafe block. Their syntax is ordinary, but their behavior is defined only when the address is valid.

Complete example

let IntPointer: Type = *i32;

fn read(value: *i32) i32 {
  *value
}

fn swap(left: mut *i32, right: mut *i32) {
  let saved: i32 = *left;
  left = *right;
  right = saved;
}

pub fn main() i32 {
  let pair: mut struct { left: i32, right: i32 } = {left: 20, right: 22};
  let left: mut *i32 = pair.left;
  let right: mut *i32 = pair.right;
  swap(left, right);

  if read(left) != 22 || read(right) != 20 { return 1 };

  let address: usize = usize(left);
  let restored: IntPointer = unsafe { IntPointer(address) };
  if restored != left { return 2 };
  0
}

Remember aggregate pointer depth

An address-valued aggregate A already represents address -> aggregate storage. A raw *A is therefore another level: address -> A address -> aggregate storage. Passing a struct normally uses A, not *A.

For Point = struct { value: i32 }, the following levels differ:

Type Access
*Point Immutable slot containing an immutable Point reference
mut *Point Mutable slot containing an immutable Point reference
*mut Point Immutable slot containing a mutable Point reference
mut *mut Point Mutable slot containing a mutable Point reference

If slot: mut *Point, slot = {value: 1} materializes fresh immutable Point backing and stores its reference in the slot, yielding Point. With slot: *mut Point, *slot = {value: 1} instead updates the currently referenced mutable backing and yields void. Use mut *mut Point when both operations are required.

Function references also occupy an address-sized representation but remain a separate type family. They do not convert to raw pointers or integers.

Use volatile references for memory-mapped I/O

volatile T is a shallow modifier for memory references. It is valid on raw pointers, fixed arrays, structs, enums, and slices. Every load through that reference level is emitted as a volatile LLVM operation; a store additionally requires mut:

let Register: Type = mut volatile *u32;
let register: Register = unsafe { Register(0x40021018) };
let enabled: u32 = *register;
register = enabled | (1 << 4);

The dereference loads the register with a volatile LLVM load, and the assignment writes it with a volatile LLVM store. An outer volatile array or struct similarly makes direct element or field accesses volatile. Loading a separately stored aggregate reference is itself volatile, but the resulting child keeps its declared type; mark the child reference volatile separately when accesses through it must also be volatile.

volatile R is immutable volatile access: reads are volatile, and stores through that level are rejected. mut volatile R permits volatile writes. Mutability and volatility are independent; mutable access may be discarded and volatile access may be added at the top level, but neither property moves into nested types implicitly. Neither volatile loads nor volatile stores are atomic or a synchronization primitive.

What remains your responsibility

The language performs no ownership, aliasing, provenance, lifetime, range, or bounds analysis. It allows null and dangling pointers, overlapping writable access, and pointers or slices that outlive stack storage. Address-valued arrays, structs, and enums returned from a function can also refer to expired stack storage.

There is no pointer arithmetic; use array or slice indexing. Unqualified accesses are ordinary non-volatile operations; use volatile references for memory-mapped I/O. No access form is atomic or provides a synchronization primitive.

Read Raw Pointer and Memory Behavior before relying on edge-case memory behavior.

Next, Packages and Modules organizes these functions and types across source files.

Packages and modules

A source file contains module items: functions, type aliases (module-level let bindings annotated Type), statics, impl blocks, and namespaces. As a program grows, it moves into a directory, and the directory’s layout says which files share a module and which form a namespace.

Everything reached from one entry point is a package: the distribution unit, and the outermost thing the language organizes. It has no manifest file, so the entry point alone defines it. The entry point is a single file, which is already a package, or a directory. A package with a public root main is an executable package; without one it is a library package, and an executable package serves as both.

Split a program into files

Create this layout:

monitor/
  main.l
  stats.l
  model/
    reading.l

model/reading.l defines the shared shape:

let Reading: Type = struct {
  value: i32,
};

stats.l reaches into the sibling namespace through a qualified path:

fn adjust(reading: mut model::Reading, amount: i32) {
  reading.value += amount;
}

main.l drives the program:

pub fn main() i32 {
  let reading: mut model::Reading = {value: 40};
  adjust(reading, 2);
  *reading.value - 42
}

Run the entry directory:

l check monitor
l run monitor

Nothing in the sources names another file. The .l files directly under monitor are one module, the root, so main.l calls adjust from stats.l without a qualifier and without pub. The model subdirectory is a namespace named after it, so Reading is reached as model::Reading. A subdirectory nested deeper is a namespace nested deeper: model/units/length.l would place its items at model::units.

A subdirectory forms a namespace only when it holds .l files somewhere below it, and its name must be an identifier. Directories holding documentation, build output, or anything else without .l files are ignored. The merged module is exactly what you could have written by hand in one file:

<contents of main.l>
<contents of stats.l>

namespace model {
  let Reading: Type = struct { value: i32 };
}

Inspect it with l bundle monitor. Files are merged in name order, then namespaces in name order; because items are visible throughout their module regardless of order, the order only affects the printed bundle.

Qualified paths are relative unless they start at the root

A path is resolved relative to the module that contains it. model::Reading written in the root module names Reading inside the root’s model namespace, which is why stats.l and main.l above can use it. The same path written inside a stats namespace would instead look for stats::model::Reading.

A leading :: makes a path absolute, so it starts at the root module wherever it is written:

namespace stats {
  pub fn adjust(reading: mut ::model::Reading, amount: i32) {
    reading.value += amount;
  }
}

::model::Reading reaches the root’s model namespace from inside stats. The prefix also works with a single segment: in the root module, ::adjust names the root’s own adjust function even where a parameter or local binding called adjust would shadow the bare name.

An unqualified function or named type searches only the current module and does not fall back to a parent. A child namespace cannot access items from a parent module, not even through an absolute path, so ::adjust written inside stats is an error. This keeps lookup local but means nested code must be designed around root-level namespace boundaries rather than parent access.

Paths never import a name: write the relative or absolute path directly, and leave a space after a : that precedes an absolute path: let reading: ::model::Reading lexes as intended, while reading:::model::Reading does not.

Declare a namespace inline

A namespace can also be declared inline with the namespace keyword:

namespace monitor {
  fn read() i32 { 40 }
  pub fn adjusted() i32 { read() + 2 }
}

pub fn main() i32 {
  monitor::adjusted() - 42
}

Each namespace is declared once. A second namespace monitor { ... } is a duplicate item, and so is an inline namespace model { ... } next to a model directory. Splitting a module across several .l files of one directory is not a declaration and stays allowed: items are visible throughout their module regardless of source order or which file contains them.

Export a module API with pub

A private function can be referenced only in its own module. Mark each function called through another module pub, as with monitor::adjusted above; adjust needs no pub, because stats.l and main.l supply the same root module. pub also controls exported linker visibility, although public module-qualified symbol names are not a stable foreign interface. A bodyless declaration takes the same pub for the same reason, even though it is externally linked either way: without it, only its own module may call the function another object supplies.

Statics use pub for cross-module visibility on both declarations and definitions:

namespace state {
  pub static count: mut *u32 = 0;
  pub static external_limit: *u32;
  static private_count: mut *u32 = 0;
}

state::count is a mutable reference to defined scalar storage; state::external_limit is an immutable reference to externally supplied storage. That declaration does not promise that the external region is physically constant. private_count is inaccessible outside state. Visibility is independent of write capability and of whether the item provides a definition. A private static definition has internal linkage; even a source-private declaration without an initializer has external linkage, because another object must supply it.

Name the package

A package name is not written in source. It is supplied when compiling, and the same sources can be compiled under any name:

l compile --emit ll --name acme/monitor monitor

A name is one or more / separated segments, such as acme/monitor or acme/monitor/stats; a one-segment name is how a library is named, as Use a library shows. A segment starts with an ASCII letter and continues with ASCII letters, digits, and interior hyphens. Each segment becomes one namespace around the package’s root module, so acme/monitor places that root module under acme and then monitor.

The namespace surrounds the root module rather than entering it, so nothing about writing the code changes: ::model::Reading still starts at the package’s own root. What changes is the compiled artifact, where stats::adjust becomes acme__monitor__stats__adjust. A bodyless function or a static without an initializer is named the same way, since it declares what a definition in its place defines. An extern declaration is the exception: it names a C symbol, so it always links to its bare item name, whatever package and namespace hold it; see Targets and C Interoperation. Omitting --name compiles without a namespace, which is what the examples above do.

main is namespaced along with every other definition, so a named executable package no longer offers the main symbol the system runtime calls. Build programs without a name:

l build -o monitor/monitor monitor

The compiler reads each .l file under its canonical path and rejects one that resolves outside the entry directory, so a symlink cannot pull sources in from elsewhere. An entry that holds no source, such as a broken symlink, is ignored, and a symlinked directory that leads back into a directory being read is rejected. See Packages and Modules for the layout rules.

Use a library

A package can use another package, called a library, by its name. The repository’s libs directory holds one such library per platform, linux, macos, and windows, each a directory of sources whose println writes a line to standard output:

use macos;

pub fn main() i32 {
  macos::println("Hello, world!");
  0
}

use macos; says that the program needs the macos library, and declares the namespace macos holding what the library exports. Every use comes before the items of its file or inline namespace. The namespace belongs to the module the file joins, so a second use macos; in another file of that module is a duplicate item.

The compiler looks for the library in each directory given by -L (--library-path), in order, as the source file macos.l or else the source directory macos, and skips a directory that does not exist:

l run -L libs hello.l

What the namespace holds is the library’s interface, the projection l bundle --header prints: its aliases and uses, its public declarations, and its public definitions turned into declarations. Private items stay out of reach, and macos::write is an unknown function even though the library declares one. The library’s own uses are read the same way, and a library that ends up using itself is rejected. Inside the interface, a path starting with :: starts at the library’s root, as it does when the library is compiled on its own.

l check checks the interface the program sees, not the library’s function bodies; those are checked when the library itself is compiled, as build and run do.

A library name is a one-segment package name, so it holds letters and digits but no _. The library is compiled as the package of that name, so macos::println links as macos__println. The interface’s declarations request exactly those symbols, since a declaration is named like a definition in its place. Only an extern declaration keeps a bare C name, as the library’s own extern fn write does for the write it calls. Since a library’s symbols begin with its name, a program compiled as the package of the same name, or holding a root namespace of that name without --name, defines symbols that conflict with the library’s. Build at the right boundary shows how the library’s code joins the program. See Libraries for the exact rules.

Targets and C Interoperation

l emits native code through LLVM and uses the selected target’s data layout and C calling convention. Fixed-width data stays fixed, pointer-sized types follow address width, and @CType translates supported C declarations for that target.

Separate portable data from platform interfaces

Use u32 or i64 when a format requires an exact width. Use usize or isize for values tied to address width. At a C boundary, write a type as @CType("C declaration") so the declaration follows the selected target ABI. CType is a case-sensitive predefined macro name, and its type result works anywhere a type is accepted.

The macro expands before checking to an ordinary language type rather than a distinct ABI type. Convert explicitly when the internal value has a different type from that target-specific result:

extern fn putchar(character: @CType("int")) @CType("int");
let CInt: Type = @CType("int");

fn emit(character: char) {
  putchar(CInt(character));
}

A declaration ending in ; names a function supplied during linking. extern says that a C object supplies it, so the declaration links to the bare name putchar. No ABI adapter is generated; the source declaration must actually match the linked symbol.

Because an extern declaration links to its bare name (see Name the package), C declarations can be grouped in a namespace and still link under --name:

namespace posix {
  pub extern fn write(descriptor: @CType("int"), buffer: @CType("const char *"), count: @CType("size_t")) @CType("long");
}

Declarations of one symbol anywhere in a package name one external entity, so they must all be functions of the same type or statics of the same type. A declaration also may not share its symbol with a definition, such as a root definition of a package compiled without --name, whose symbol is its bare name too.

Use C strings only at C boundaries

An ordinary "status" is an immutable [u8] slice with a byte count and no terminator. c"status" is a module-lifetime immutable *u8 with one trailing zero byte. C char also maps to u8, so this matches a parameter written as @CType("const char *"). Use the C string form only when the external API expects it.

Embedded zero bytes are allowed and may cause a C function to see a shorter string than the source text.

Ask the target about layout

@sizeOf(T) expands at compile time to a usize containing the selected target’s size:

@sizeOf(usize)
@sizeOf(@CType("int"))
@sizeOf((u8, u32))
@sizeOf([u8; 16])

The result can differ with pointer width, C data model, alignment, and aggregate layout. Object emission fails if the size does not fit the target usize.

Ordinary structs use target padding. A packed struct removes struct padding and may create unaligned fields:

let Header: Type = packed struct {
  tag: u8,
  value: u32,
};

Packed access supports unaligned fields. Qualify the reference with volatile when the packed region requires volatile access; neither packed nor volatile access is atomic.

Complete host example

extern fn putchar(character: @CType("int")) @CType("int");
let CInt: Type = @CType("int");

let Header: Type = packed struct {
  tag: u8,
  value: u32,
};

fn emit(character: char) {
  putchar(CInt(character));
}

pub fn main() i32 {
  emit('O');
  emit('K');
  emit('\n');
  if @sizeOf(Header) == 5 { 0 } else { 1 }
}

On a supported Linux or macOS host, l run links putchar from the system C runtime and prints OK followed by a newline.

Static storage and symbols

Module-level static separates an external declaration from a definition that provides storage:

extern static external_count: *i32;
pub extern static external_limit: *i32;

static count: mut *i32 = 0;
pub static limit: *i32 = 100;

Every definition is also a declaration, just as a function body makes a function declaration a definition. A form without an initializer requires an external symbol; it never defines implicitly zeroed storage. As with functions, extern makes that symbol the bare item name a C object defines. Here count has exactly the written type mut *i32, and the linked region contains an i32, not another pointer. let count: i32 = 0 would only bind a scalar and allocate nothing.

A static must expose either a raw pointer to a valid non-void slot or an address-valued array, struct, or enum, optionally qualified mut or volatile. Direct scalars, tuples, slices, functions, and *void are not eligible static types. Pointer slots can store any valid non-void value, including tuples, slices, raw pointers, function references, and aggregate references:

let Point: Type = struct { x: i32, y: i32 };

static origin: Point = {x: 0, y: 0};
static selected: *Point = origin;
static message: *[u8] = "ready";
static callback: *fn (value: i32) i32 = identity;
static pair: *(i32, bool) = (-1, true);

fn identity(value: i32) i32 { value }

origin directly names Point backing. selected names a separate pointer slot containing a reference to that backing. To load the text descriptor use *message; to call the stored function use (*callback)(42). Forward references such as identity are allowed. A top-level aggregate definition must describe its own backing contents, so static other: Point = origin; is invalid; a reference slot such as selected may alias origin.

Static initializers admit compatible literals, strings and C strings, negated numeric literals, tuples, array and struct syntax, qualified enum constructors, and named static or function references as relocations. They do not admit calls, loads, field selection, indexing, slicing, control flow, conversions, or arbitrary computation. Macros expand before validation, and an anonymous function is rejected there before it could become a function reference; the exact whitelist and typing exceptions are in Static Initializers.

Nested aggregate syntax creates separate private globals and stores relocations to them. Mutability applies at each written level: an immutable parent region can hold a child declared mut, whose separate backing is emitted writable. Definitions use ordinary ABI padding and alignment, including enum pointer payloads. No runtime constructor, startup function, or stack allocation initializes a static.

Definitions without pub have internal linkage; public definitions are exported. Declaration-only forms have external linkage even without pub, but pub is still required for source access from another module. It is independent of mut. A definition without outer mut, including an unqualified or volatile definition, emits constant storage in the target’s supported read-only or relocation-bearing section; a mut definition emits writable storage. The compiler does not verify the type, layout, or writability of an externally supplied symbol.

Know the ABI boundary

@CType accepts C character, Boolean, integer, size, pointer-difference, floating-point, and void scalar spellings. It normalizes whitespace and accepts aliases such as "short int", "signed", and "unsigned long int". It also accepts restricted pointer declarators with const and volatile: @CType("char *") becomes mut *u8, while @CType("const char *") becomes *u8, @CType("volatile char *") becomes mut volatile *u8, and @CType("const volatile char *") becomes volatile *u8. Pointer chains are supported: C const char ** becomes mut **u8, while char * const * becomes *mut *u8 and char * volatile * becomes mut volatile *mut *u8. Every non-const pointee level gains mut; every const pointee level is unqualified and immutable. A const or volatile qualifier on the final C pointer has no type-level effect because l bindings are already immutable, and a qualifier without any pointer, such as "const int", is rejected. These mappings preserve the corresponding volatile access and do not change the ABI; the C qualifiers remain C syntax inside the quoted declarator.

C char and unsigned char expand to u8, while signed char expands to i8. C _Bool, size_t, ptrdiff_t, float, and void expand to bool, usize, isize, f32, and void. The specification leaves the other spellings implementation-defined per target; this compiler maps them as follows:

C spelling AVR, MSP430 Windows (not Cygwin), UEFI Other targets
short, signed short, and their int forms i16 i16 i16
unsigned short, unsigned short int u16 u16 u16
int, signed, signed int isize i32 i32
unsigned, unsigned int usize u32 u32
long, signed long, and their int forms i32 i32 pointer-width signed
unsigned long, unsigned long int u32 u32 pointer-width unsigned
long long and its signed and int forms i64 i64 i64
unsigned long long, unsigned long long int u64 u64 u64
double f32 on AVR only f64 f64

“Pointer-width” means i32 or i64 for long and u32 or u64 for unsigned long, never isize or usize.

Parameters and results of type i8, u8, i16, u16, and bool receive the extension attributes the target’s C ABI requires, whether written directly or produced by @CType. On 64-bit RISC-V, LoongArch, and MIPS targets i32, u32, and char are sign-extended as well, and on 64-bit PowerPC, SystemZ, and SPARC targets they are extended by their signedness. Because @CType("char") is always u8, only values 0 through 127 agree with a signed plain C char across a call. Variadic functions and by-value C aggregates do not have a general direct mapping, and neither does a function with another calling convention, such as the Win32 API’s stdcall on 32-bit x86 Windows. Tuples and slices use an l language ABI, not a promised C struct ABI. Use a C-compatible wrapper when an interface contains unsupported forms.

Select a target when checking or compiling target-dependent code:

l check --target aarch64-unknown-linux-gnu monitor.l
l compile --target aarch64-unknown-linux-gnu --emit obj monitor.l monitor.o

Object emission can reject pointer-width integer literals, enum IDs, string lengths, or aggregates larger than the address space that earlier syntax and type checking accepted, including in code a jump makes unreachable. The complete layout and mapping rules are in Machine and ABI Model and Compile-Time Macros.

Next, Compiler Workflow shows where checking, bundling, code generation, linking, and inspection fit.

Compiler Workflow

The shortest development loop is still the most useful:

l check monitor.l
l run monitor.l

Use the other commands when you need to inspect a phase, keep an artifact, cross-compile, or publish a program.

Check before producing artifacts

check reads the entry file or directory, reduces it to MIR, checks it into LIR, and then reports lints over the parsed source. Successful checking produces no file. The one lint, identifier-underscore-limit, warns about an identifier holding more than one underscore; each finding names its source location. Lints are compiler guidance rather than language rules, and their warnings do not change a successful exit status.

A source entry is a file or a directory. Naming - instead reads one source file from standard input, which is also what check and bundle read when no entry is named.

Specify --target when target-dependent forms such as @CType("int") or @sizeOf affect checking:

l check --target x86_64-unknown-linux-gnu monitor.l

check does not perform the emitter’s representability checks for pointer-width integer literals, enum IDs, string lengths, or aggregate sizes. LLVM IR, bitcode, assembly, or object emission can still reject a checked source when those do not fit the selected target, or when declarations conflict under Linkage.

Inspect the program at the useful level

When the module layout is the question, inspect the bundled source:

l bundle monitor

The output merges the entry directory’s files, prints each subdirectory as a namespace block, and leaves every macro as written, use lines included. It is canonical source, not a preservation of comments or original formatting. l bundle --header projects the public interface of a library-shaped module, which is what a use of it sees. It retains public statics as pub static declarations with their initializers removed and public function definitions as bodyless pub declarations, inside impl blocks as well. Public declarations, aliases, and use lines remain, while private functions, statics, and declarations are omitted.

This projection keeps namespace nesting and qualified paths; it does not record --name or rebase the interface when placed elsewhere. A declaration without extern requests the symbol a definition in its place would have, so the header reaches the definitions it was projected from only in the same place of a package of the same name. use arranges exactly that: see Use a library. Saved as api/api.l under a consumer’s entry directory instead, a root pub fn answer() i32; sits at api::answer and requests api__answer, which the original object does not provide. Check both name resolution and external symbol compatibility before reusing a generated header by hand; neither is automatically adapted. See Linkage for how symbols are formed.

To inspect the frontend before macro expansion, emit the lexer token stream or the parser’s high-level intermediate representation (HIR):

l compile --emit ts monitor.l monitor.ts
l compile --emit hir monitor monitor.hir

The token stream includes whitespace and comments and prints one token and its source position per line; it describes one file, so it refuses a directory. The HIR is the parser’s pretty-printed tree of the whole package after those tokens are discarded, with every macro and shorthand still in place. See Source Text and Tokens for the lossless token model.

When implicit compiler decisions are the question, inspect the mid-level or low-level intermediate representation:

l compile --emit mir monitor.l monitor.mir.l
l compile --emit lir monitor.l monitor.lir.l

MIR is the reducer’s output: every @ macro and Self is expanded, and every shorthand is written out, including result types, enum IDs, else blocks, jump operands, block results, short-circuit operators as conditionals, and anonymous functions lifted to private anonymousFunctionN functions. LIR is the checker’s output, the same constructs annotated with their resolved types, names, and operations; its projection shows the constructs only, except that every let is printed with its inferred type. Both print as parseable l source without source provenance, so they are for inspection rather than serialization back into the compiler. Language Representations defines each level.

Build at the right boundary

build compiles and links a host program in one step:

l build -o monitor monitor.l

--shared builds a shared library instead, and --static a static library; the two flags exclude each other, and without either build produces an executable. A shared library needs position-independent objects, so --shared compiles them that way on its own:

l build --shared -o libmonitor.so monitor.l
l build --static -o libmonitor.a monitor.l

compile stops before linking and can emit textual LLVM IR, LLVM bitcode, assembly, or an object:

l compile --emit ll monitor.l monitor.ll
l compile --emit bc monitor.l monitor.bc
l compile --emit asm monitor.l monitor.s
l compile --emit obj monitor.l monitor.o

compile and build optimize with LLVM’s standard pipeline at level 2 unless -O selects another level, spelled as Clang spells it: 0 disables optimization, 1 keeps compilation fast, 3 adds aggressive code-growing transformations, s favors size, and z wants the smallest code even at the cost of speed. run and install always use the default level. Emitted LLVM IR and bitcode show the optimized module, so inspect the compiler’s own lowering at -O 0:

l compile --emit ll -O 0 monitor.l monitor.ll
l build -O z -o monitor monitor.l

Optimization keeps every volatile access but may remove other work whose result the program never uses. A busy-wait loop that only counts therefore has to count through a volatile reference; see Use volatile references for memory-mapped I/O. No level turns code into calls to C library functions, such as a zeroing loop into memset, because a bare-metal image has no C library to provide them. Operations without a native instruction still need runtime routines: floating % calls fmod, and 64-bit division on a 32-bit target calls the compiler runtime.

Objects follow the target’s default relocation model, which is position-dependent on most targets; some, such as macOS and 64-bit Windows, always use position-independent code. Pass --pic to compile position-independent code when the object is destined for a shared library:

l compile --emit obj --pic monitor.l monitor.o

Use l link when objects and libraries already exist:

l link -o monitor monitor.o support.a

--entry SYMBOL starts an executable at another symbol instead of through the C runtime, and --script FILE hands the linker a GNU-style linker script; build accepts --script as well.

compile and build accept the package name the sources are compiled as, which namespaces every module-level symbol the artifact defines. --name accepts one or more slash-separated name segments, without a revision suffix:

l compile --emit obj --name acme/monitor monitor.l monitor.o

Without --name the artifact carries no package namespace, which is what a program needs, because a namespaced main is no longer the symbol the system runtime calls.

A program that uses libraries needs their code as well as its own. check, compile, build, run, and install all take -L DIR, repeatable, to find each library’s source. build, run, and install compile every library the program uses, directly or through another library, as the package the library is named after, and link its object with the program’s own:

l build -L libs -o hello hello.l

compile produces only the program’s own code. --deps FILE writes the libraries it has to be linked with, one name per line, every library before the libraries it uses itself. Only output generated from checked code can list them, so --deps refuses --emit ts, hir, and mir. Compiling each listed library under its own name and linking the objects gives what build does in one step:

l compile -L libs --deps hello.deps hello.l hello.o
l compile --name macos libs/macos macos.o
l link -o hello hello.o macos.o

LLVM IR, bitcode, assembly, and object emission support targets with an LLVM code generator and a C scalar model understood by l: ordinary 32-bit or 64-bit pointer targets, plus 16-bit AVR and MSP430. Lanai, SPIR, and SPIR-V architectures are not supported.

l link always links for the host target. Use l build --target TRIPLE to compile and link for another supported target. The linker supports x86, x86-64, AArch64, ARM, RISC-V 64, LoongArch 64, little-endian PowerPC 64, and s390x Linux targets with supported GNU or musl environments, x86-64 and AArch64 macOS targets, and bare-metal ELF targets whose triple names the none system, such as thumbv7m-none-eabi. Cross-linking a Linux or macOS program also requires that target’s runtime and development files.

Build bare-metal firmware

A bare-metal target has no C runtime to start the program, so build links its image with ld.lld alone. A linker script places the code and data in the device’s memory and names the entry point:

l build --target thumbv7m-none-eabi --script linker.ld -o blink.elf main.l

The program and its script own everything a runtime would otherwise do: the vector table, the initial stack, copying .data into RAM, and clearing .bss. The repository’s examples/stm32 program does all of it for a Cortex-M3 microcontroller. Linker scripts apply to every ELF executable or shared library; Mach-O linking rejects them.

Device programmers and boot loaders usually take a raw binary or an Intel HEX file rather than ELF. objcopy converts an existing ELF image, and build --format converts the executable it links:

l objcopy --format bin -o blink.bin blink.elf
l build --target thumbv7m-none-eabi --script linker.ld --format ihex -o blink.hex main.l

A raw binary holds the loadable memory from the lowest load address on, while Intel HEX records carry their addresses. Conversion requires llvm-objcopy on PATH and refuses input that is not ELF, such as a macOS executable.

inspect reports the size of every section of an ELF, Mach-O, or COFF binary:

l inspect blink.elf

Each section has a class: text for code and constants, data for writable memory whose initial contents the image stores, bss for writable memory that starts zeroed, or - for a section no loaded program occupies, such as a symbol table. The totals sum the classes as Berkeley size does, then add the Flash an image needs, text + data, and the RAM, data + bss.

WebAssembly object output

The emitter can produce a nonempty relocatable WebAssembly object for wasm32-unknown-unknown, including code that uses the language’s aggregate types:

l compile --target wasm32-unknown-unknown --emit obj app.l app.o

This is partial end-to-end WebAssembly support. The bundled linker supports only Linux, macOS, and bare-metal ELF, so l build --target wasm32-unknown-unknown cannot complete a WebAssembly module. Use an external wasm-ld workflow and supply any host imports or runtime support the program needs. The compiler does not currently provide wasm-ld integration, host imports, runtime facilities, or JavaScript loading glue. WebAssembly object output is distinct from the planned JavaScript source backend; it does not translate l programs into JavaScript.

Generate and install

Generate API documentation from module items with:

l doc --output api monitor.l

The command can produce HTML or structured JSON. It currently documents canonical signatures and source organization, including static visibility, exposed type, and declaration/definition status. Private functions and private statics are included only with --document-private-items; /// comment text is reserved but not retained yet.

Install a host program into the language environment with:

l install monitor.l
l where

where prints the selected environment root, and install places the executable in its bin directory.

Locate and fetch packages

Packages live under the environment root in a fixed layout. Installed lookup and shorthand fetching accept exactly author/package, optionally followed by @tag, rather than the two-or-more-segment names accepted by compiler --name. Each name segment starts with an ASCII letter and continues with ASCII letters, digits, and interior hyphens. A tag matches [A-Za-z0-9][A-Za-z0-9._+-]*. The specification selects these published files:

$ROOT/packages/<author>/<package>/<tag>/src/                  entry point
$ROOT/packages/<author>/<package>/<tag>/<target>/<package>.a  linkable file
$ROOT/packages/<author>/<package>/<tag>/<target>/<package>.l  header

where reports those paths and fails when the file is not installed:

l where acme/monitor@v1.0.0
l where --lib acme/monitor@v1.0.0
l where --header acme/monitor@v1.0.0

A package name without @ is looked up under the literal latest directory, not the highest installed version. Naming a package without --lib or --header locates its entry point, the src directory. A linkable file and a header describe one target each, so both are looked up under the host triple unless --target names another. A target directory is named by the triple without the version its operating system carries, so arm64-apple-darwin24.6.0 and arm64-apple-darwin name one directory. Add --print to print the located file instead of its path; for the entry point it prints the merged source, as l bundle does.

fetch retrieves a package from a git remote using a one-commit-deep checkout of the selected revision, not its history:

l fetch acme/monitor
l fetch acme/monitor@v1.0.0
l fetch https://example.com/acme/monitor.git --dest vendor/monitor

An author/package source names a GitHub repository and is installed under the layout above. A git URL names only a repository, so --dest has to say where it goes. --dest writes the package into that directory without any structure of its own. Appending @ and a tag, branch, or commit to either form selects a revision; without one the remote’s default branch is taken. Shorthand revisions must fit the tag grammar above. An untagged shorthand is installed under latest, while explicitly writing @latest requests that git revision. These are explicit lookups and fetches, not automatic version or dependency resolution. Fetching needs git on PATH.

Diagnose the toolchain

The commands above lean on tools and files the compiler does not carry itself: git for fetching, the host’s LLD driver and C runtime for linking, the archive tool for static libraries, and llvm-objcopy for image conversion. doctor checks each of them and says how to get whatever is missing:

l doctor

Every dependency prints one line with its status, and the command exits unsuccessfully when any of them is missing.

interpret, lsp, package, and upload name planned tools. They take no arguments yet, report that they are not implemented, and exit unsuccessfully.

Use l COMMAND --help for the current options rather than treating this chapter as an exhaustive command reference.

A C programmer’s final checklist

Expectation from C l behavior
Integer operands promote Operand types must match; convert with T(value)
Literal suffixes such as 1u Literals have no suffix and take their type from context
Variables name stored values let only binds values; aggregate projections expose storage slots
Assignment can rebind a name Assignment stores data through a writable reference, never rebinds
An immutable parent freezes children Boxed children load their exact declared type, including a written mut
&value takes an address There is no address-of operator
Arrays decay implicitly Fixed arrays are backing addresses; slices are explicit descriptors
Ranges are usually half-open Slice ranges are half-open too: values[1..3] views two elements
Structs copy by value Struct values carry backing addresses
Function pointers are raw pointers Function references are a distinct callable type
Closures capture locals Anonymous functions do not capture
Functions live only at file scope impl T { ... } places functions in a type’s namespace, called T::f()
Scoping walks outward to parents Paths are relative to the current module; a leading :: starts at root
Enum variants are bare names A variant is constructed through its enum, as Event::Offline
else if chains conditionals Bodies are blocks; write else { if expression { ... } }
Raw pointers support arithmetic Use array or slice indexing; pointer arithmetic is absent
volatile can model MMIO volatile T reads only; mut volatile T also permits volatile writes
Headers declare foreign symbols extern declarations name C symbols by their bare names
#include pulls in a library’s API use name; reads the library’s interface as the namespace name

You now have the conceptual model needed to read and write l. Keep the Language Specification nearby for exhaustive syntax, typing, evaluation, ABI, diagnostics, and unsupported forms.