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
letonly binds its evaluated expression, without allocating, and its type annotation is optional; annotatedTypeat 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 Tgrants write capability at the written level. volatileindependently makes permitted access through the written reference level use volatile LLVM operations.staticdeclarations 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
elseandbreak. - Enum variants are constructed through their enum, as
Event::Offline, and switches are exhaustive. - Function values do not capture their surroundings.
- An
implblock places functions in a type’s namespace, called asT::f(...), with no receiver;Selfnames the target inside the block. - Module-qualified paths are relative unless they start with
::. use name;depends on the librarynameand reaches its interface as the namespacename.externmarks 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.
pubcontrols exported linkage and cross-module function visibility; there is no header declaration in this program.- An exported root
mainreturns fixed-widthi32, not the type selected by@CType("int")for a Cint.
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.