The l Programming Language Specification
This document is the normative specification of the l programming language. A program is valid only when it satisfies
every applicable rule in this document.
Except where stated otherwise, every statement in this document is normative. must and must not state requirements;
may states permission. Invalid and error require rejection before executable code is produced.
Implementation-defined behavior is valid behavior whose choice must be documented. Undefined behavior imposes no
requirements after the operation occurs. No defined result means that an operation produces no result value while
all other execution requirements continue to apply. A term is defined where it appears in bold.
This specification describes the language, not compiler commands, options, artifact formats, or implementation architecture beyond the required compilation boundaries.
Intermediate Representations
A language L is called a sub-language of M and denoted L ⊆ M if every valid program in L is also a valid program
in M and they produce exactly the same observable behavior.
There are 3 main steps of compilation in the l programming language and each step has its own representation.
Therefore, in principle, there are 3 different languages corresponding to each step. However, each language satisfies
S N+1 ⊆ S N for each step N, where S N is the set of valid programs at step N and ⊆ denotes the sub-language
relation. For this reason, they are called intermediate representations.
These three intermediate representations are:
| IR | Description |
|---|---|
| HIR (High-level Intermediate Representation) | It is the parsed representation of the source code, serving as the initial stage of compilation. |
| MIR (Mid-level Intermediate Representation) | It is desugared and optimized form of HIR. |
| LIR (Low-level Intermediate Representation) | Type checked and inferred version of MIR. |
HIR, the high-level intermediate representation, is the parsed form of a complete package and contains every source-language construct. It preserves source forms, item origins, and source ranges for diagnostic purposes, but not whitespace or comments.
MIR, the mid-level intermediate representation, is desugared HIR. Every function result, jump operand, block result,
branch, loop alternative, and enum ID that HIR permits to be implicit is explicit in MIR. No macro invocation, Self,
operator, or anonymous function reaches MIR. Type-dependent expressions—including applications that may be
calls or conversions, contextual aggregate construction, loops, switches, and unsafe—are core constructs rather than
checker rewrites.
LIR, the low-level intermediate representation, is checked and inferred MIR. It uses only MIR constructs and records
their resolved symbols, types, operation meanings, control-flow targets, and ABI-relevant facts. In LIR, every let and
every numeric literal has a type. The checker may reject or annotate a node but must not replace it with different
syntax.
Target output may be object code, LLVM IR (LLVM’s external intermediate representation), or another representation. LIR is always an internal language representation and must never be identified with LLVM IR.
A compiler must have the following stages:
| Stage | Takes | Produces | Sole responsibilities |
|---|---|---|---|
| Lexer | Source Text | Token Stream | Validate UTF-8 lexical structure and produce tokens with exact lexemes and origins. |
| Parser | Token Stream | HIR | Validate HIR grammar and build one package tree. |
| Reducer | HIR | MIR | Eliminate every syntactic sugar, and validate rules needed specifically for those reductions. |
| Checker | MIR | LIR | Resolve names and semantic identities, check and infer types. |
| Emitter | LIR | Target Code | Apply the selected ABI, layout, and target representability rules. It receives only checked input. |
Identifiers
An identifier is a name written in source text. It starts with an ASCII letter or _ and continues with ASCII
letters, ASCII digits, and _. Identifiers are case-sensitive. An identifier must not contain two adjacent underscores
and must not be one of the literal words true, false, and null. Every identifier may appear in HIR, MIR, and LIR.
The primitive names u8, u16, u32, u64, usize, i8, i16, i32, i64, isize, f32, f64, char,
bool, and void are predefined identifiers. No declaration may use a primitive name as its name, so primitive names
cannot be shadowed.
Primitives and Literals
A value is the result of evaluating an expression that completes normally. Values are immutable: no operation changes a value; an operation can only produce another value. A value is independent of its machine representation.
A primitive is a set of values predefined by the language whose values are not composed of other values. The primitives are:
| Type | Description | Signed | Size | Minimum | Maximum | Literal |
|---|---|---|---|---|---|---|
u8 |
Unsigned 8-bit integer. | ✗ | 1 byte | 0 | 28 - 1 | Integer |
u16 |
Unsigned 16-bit integer. | ✗ | 2 bytes | 0 | 216 - 1 | Integer |
u32 |
Unsigned 32-bit integer. | ✗ | 4 bytes | 0 | 232 - 1 | Integer |
u64 |
Unsigned 64-bit integer. | ✗ | 8 bytes | 0 | 264 - 1 | Integer |
usize |
Unsigned N-bit integer, where N is the target pointer width. | ✗ | N / 8 bytes | 0 | 2N - 1 | Integer |
i8 |
Signed 8-bit integer. | ✓ | 1 byte | -27 | 27 - 1 | Integer |
i16 |
Signed 16-bit integer. | ✓ | 2 bytes | -215 | 215 - 1 | Integer |
i32 |
Signed 32-bit integer. | ✓ | 4 bytes | -231 | 231 - 1 | Integer |
i64 |
Signed 64-bit integer. | ✓ | 8 bytes | -263 | 263 - 1 | Integer |
isize |
Signed N-bit integer, where N is the target pointer width. | ✓ | N / 8 bytes | -2N-1 | 2N-1 - 1 | Integer |
f32 |
IEEE 754 binary32, with ±0, ±∞, and NaN. | ✓ | 4 bytes | -∞ | +∞ | Floating |
f64 |
IEEE 754 binary64, with ±0, ±∞, and NaN. | ✓ | 8 bytes | -∞ | +∞ | Floating |
char |
Unsigned 32-bit integer. | ✗ | 4 bytes | 0 | 232 - 1 | Character |
bool |
Either true or false. |
✗ | 1 byte | — | — | Boolean |
void |
Exactly one value, null. |
✗ | 0 bytes | — | — | Null |
Every primitive may appear in HIR, MIR, and LIR.
A literal is an expression that denotes one value directly in source text. Every literal may appear in HIR, MIR, and
LIR. A literal has no sign: -1 is negation applied to the literal 1.
An integer literal is decimal digits, 0x followed by hexadecimal digits, or 0b followed by binary digits. It
denotes the integer those digits denote. Hexadecimal digits are case-insensitive; the prefixes 0x and 0b are
lowercase.
A floating literal is decimal digits followed by a fraction, an exponent, or both. It denotes the decimal number
those digits denote. A fraction is . followed by decimal digits. An exponent is e or E, an optional + or -,
and decimal digits.
Integer and floating literals are numeric literals. In a numeric literal, _ may separate digits, but every digit
sequence starts and ends with a digit. A numeric literal immediately followed by a letter, digit, or _ that is not
part of its form is invalid. A numeric literal has no type in HIR and MIR; the checker gives it one in LIR. An integer
literal takes the expected integer type of its context, and i32 without one; it never takes a floating type or char.
A floating literal takes the expected type of its context when that is f32 or f64, and f32 otherwise. An integer
literal must denote a value of its type, and a floating literal must round to a finite value of its type, rounding to
nearest with ties to even. For usize and isize, the checker checks the value against 64 bits and the emitter against
the target pointer width.
A character literal is one character or one escape between ' and '. It denotes one Unicode scalar as a char
value. A character is any Unicode scalar except ', \, and newline. The escapes are:
| Escape | Value |
|---|---|
\n |
U+000A |
\r |
U+000D |
\t |
U+0009 |
\0 |
U+0000 |
\\ |
U+005C |
\' |
U+0027 |
\u{H} |
H is one through six hexadecimal digits that must denote a Unicode scalar; that scalar. |
A boolean literal is true or false. It denotes the bool value of the same name.
The null literal is null. It denotes the only void value.
Aggregates
An aggregate is a value composed of other values, called its members. No member may have type void. Aggregate
types are structural: two aggregate types are the same type exactly when they have the same form and the same members,
as stated for each aggregate below. Every aggregate may appear in HIR, MIR, and LIR. The aggregates are:
| Aggregate | Type | Members | Construction | Access |
|---|---|---|---|---|
| Tuple | (T, U, ...) |
Two or more ordered elements. | (a, b, ...) |
t.0 |
| Array | [T; N] |
Exactly N elements of type T. |
[a, b, ...] |
— |
| Struct | struct { f: T, ... } |
One or more named fields. | { f: a, ... } |
s.f |
| Enum | enum { V, W: T, ... } |
One variant, and its payload if it has one. | E::V, E::W(a) |
switch |
A tuple is an ordered sequence of two or more elements. Two tuple types are the same when their element types are
the same at every position. A tuple expression evaluates its elements from left to right. A projection t.N produces
the element at zero-based position N; N is an integer literal that must be less than the number of elements.
An array [T; N] holds exactly N elements of type T. N is an integer literal that must denote a positive
integer representable by u64. Two array types are the same when their element types and lengths are the same. An array
initializer has exactly N comma-separated elements without a trailing comma and evaluates them from left to right.
A struct holds one or more fields, each with a name unique within the struct. A packed struct is a struct whose
fields are laid out without padding. Two struct types are the same when they have the same field names, in the same
order, with the same types, and are both packed or both not packed. A struct initializer names every field exactly once
in any order and evaluates the fields in its source order. A selection s.f produces field f of s.
An enum holds exactly one of its variants. Each variant has a unique name, at most one payload type, and an ID. A
variant without a payload, or with a void payload, is a unit variant. IDs are either written for every variant as
V = n or omitted for every variant; writing some and omitting others is invalid. A written ID is an integer literal
that must be unique within the enum and representable by u64 and the target pointer width. In HIR, omitted IDs are the
zero-based source positions of the variants; the reducer writes them, so every enum in MIR and LIR has written IDs. Two
enum types are the same when they have the same variant names, in the same order, with the same payload types and IDs.
E::V constructs unit variant V of enum type E, and E::W(a) constructs variant W with payload a. A switch
selects an arm by the variant of an enum value.
Functions
A function is a value that, when called, evaluates its body with its parameters bound to the call’s arguments and produces a result value. A function has no name of its own; a name refers to a function only as the value of a binding, parameter, or item.
A function type is written fn (p: T, q: U, ...) R. Each p: T is a parameter with name p and type T, and
R is the result type. No parameter type may be void. Parameter names in a function type are labels: they do not
affect identity, so two function types are the same when their parameter types, in order, and their result types are the
same.
A function expression is a function type followed by a block, its body:
fn (p: T, q: U, ...) R { body }
It produces a function of that function type. Parameter names in a function expression must be unique. The body may use its own parameters and bindings and any visible item, but not the parameters or bindings of enclosing expressions. Every function expression may appear in HIR, MIR, and LIR.
A call binds each parameter to the corresponding argument value, then evaluates the body; parameters are visible only in
the body. The call produces the value of the body, or the operand of the return that exits the body; both must have
the result type. Two functions are equal when the same function expression produced them.
Expressions
An expression is a construct that describes a computation. Evaluating an expression either completes normally and produces exactly one value, or does not complete normally and produces no value. An expression may contain other expressions as its operands. The expressions are:
| Expression | Form | Description |
|---|---|---|
| Literal | 42, 1.5, 'a', true, null |
Produces the value the literal denotes. |
| Function | fn (p: T) R { body } |
Produces a function. |
| Name | x, a::b, ::a::b |
Produces the value of the parameter, binding, or item that the path names. |
| Grouping | (e) |
Evaluates e; it changes only how operators group. |
| Tuple | (a, b, ...) |
Evaluates two or more elements from left to right and produces a tuple of their values. |
| Array initializer | [a, b, ...] |
Evaluates elements from left to right and produces a new array; requires an expected array type. |
| Struct initializer | { f: a, g: b, ... } |
Names every field exactly once, evaluates fields in source order, and produces a new struct; requires an expected struct type. |
| Block | { a; b; ...; e } |
Evaluates its entries in order and discards their values, then produces the value of e; without e, it produces null. |
| Binding | let x: T = e; |
A block entry. Evaluates e once; x then names its value in the rest of the block. Without : T, x has the type of e. |
| Call | f(a, b, ...) |
Evaluates f, then its arguments from left to right, and calls f with exactly as many arguments as it has parameters. |
| Conversion | T(v) |
When T names a type rather than a value, converts the value of v to T. |
| Field selection | s.f |
Selects field f of struct s. |
| Tuple projection | t.0 |
Produces the element of tuple t at a zero-based integer-literal index. |
| Conditional | if c { a } else { b } |
Evaluates the bool condition c, then only the selected block. Without else, the else block is { null }. |
| Loop | loop { a } |
Evaluates a repeatedly; it produces the value of the break that exits it. |
| Switch | switch e { case V(x): a ... default: b } |
Evaluates the enum e once, then only the arm of its variant, or default when no arm names it. |
| Return | return e |
Evaluates e, then exits the current function with its value. Without e, it returns null. |
| Break | break e |
Evaluates e, then exits the innermost enclosing loop with its value. Without e, it breaks with null. |
| Continue | continue |
Starts the next iteration of the innermost enclosing loop. |
return, break, and continue never complete normally. {} is an empty block, never a struct initializer.
Syntactic Sugars
Syntactic sugar is an HIR construct that the reducer replaces with other HIR constructs. A replacement may contain further syntactic sugar, which the reducer replaces in turn, so no syntactic sugar reaches MIR or LIR. A name a reduction introduces is fresh: it differs from every source name. Each reduced construct keeps the source range of the construct it replaces.
Control Flow
A while loop evaluates its bool condition before each iteration and its body while the condition is true; when
the condition is false, it produces the value of its else block. Without else, the else block is { null }.
while c { body } else { alt }
reduces to:
loop { if c { body; } else { break alt } }
A for loop visits the elements of an array in ascending position order. It evaluates the array once, then evaluates
its body once per element with x naming that element’s value; after the last element, it produces the value of its
else block. Without else, the else block is { null }. x is visible only in the body.
for x in xs { body } else { alt }
reduces to the following, where s and c are fresh names:
{
let s = xs;
let c: mut struct { i: usize } = { i: 0 };
while *c.i < length(s) {
let x = index(s, *c.i);
c.i += 1;
body;
} else {
alt
}
}
The counter advances before the body runs, so continue in the body moves to the next element. In both reductions,
break and continue in body target the generated loop, and so do break and continue in alt.
Namespace Blocks
A namespace block gives its items a common path prefix. It may appear wherever an item may, including inside another namespace block.
namespace N { items }
reduces to items, with N:: prepended to the name of every item whose name is not an absolute path. N is an
identifier or a relative path. In a nested block, the inner prefix is prepended first and the outer prefix after it, so
namespace a { namespace b { f : T = e } } reduces to a::b::f : T = e.
Operators
A polymorphic function is a function name that denotes a distinct function for each combination of argument types;
the checker selects the function from the argument types. An operator is syntactic sugar for an expression built
from calls to polymorphic functions and, for && and ||, a conditional. Operators appear only in HIR: the reducer
replaces each operator with its reduction. Operands of a call are evaluated left to right as call arguments. && and
|| evaluate their right operand only when their left operand does not decide the result.
| Precedence | Associativity | Operator | Reduces to | Defined for primitive operands |
|---|---|---|---|---|
| 1 | Right | -a |
neg(a) |
Integers, char, f32, f64 |
| 1 | Right | !a |
not(a) |
bool |
| 1 | Right | ~a |
bit_not(a) |
Integers, char |
| 1 | Right | *a |
load(a) |
None |
| 2 | Left | a * b |
mul(a, b) |
Integers, char, f32, f64 |
| 2 | Left | a / b |
div(a, b) |
Integers, char, f32, f64 |
| 2 | Left | a % b |
rem(a, b) |
Integers, char, f32, f64 |
| 3 | Left | a + b |
add(a, b) |
Integers, char, f32, f64 |
| 3 | Left | a - b |
sub(a, b) |
Integers, char, f32, f64 |
| 4 | Left | a << b |
shl(a, b) |
Integers, char |
| 4 | Left | a >> b |
shr(a, b) |
Integers, char |
| 5 | Left | a < b |
lt(a, b) |
Integers, char, f32, f64 |
| 5 | Left | a > b |
gt(a, b) |
Integers, char, f32, f64 |
| 5 | Left | a <= b |
le(a, b) |
Integers, char, f32, f64 |
| 5 | Left | a >= b |
ge(a, b) |
Integers, char, f32, f64 |
| 6 | Left | a == b |
eq(a, b) |
Every primitive |
| 6 | Left | a != b |
!(a == b) |
Every primitive |
| 7 | Left | a & b |
bit_and(a, b) |
Integers, char |
| 8 | Left | a ^ b |
bit_xor(a, b) |
Integers, char |
| 9 | Left | a | b |
bit_or(a, b) |
Integers, char |
| 10 | Left | a && b |
if a { b } else { false } |
bool |
| 11 | Left | a || b |
if a { true } else { b } |
bool |
| 12 | Right | a = b |
store(a, b) |
None |
| 12 | Right | a += b |
a = *a + b |
None |
| 12 | Right | a -= b |
a = *a - b |
None |
| 12 | Right | a *= b |
a = *a * b |
None |
| 12 | Right | a /= b |
a = *a / b |
None |
| 12 | Right | a %= b |
a = *a % b |
None |
| 12 | Right | a &= b |
a = *a & b |
None |
| 12 | Right | a |= b |
a = *a | b |
None |
| 12 | Right | a ^= b |
a = *a ^ b |
None |
| 12 | Right | a <<= b |
a = *a << b |
None |
| 12 | Right | a >>= b |
a = *a >> b |
None |
| 12 | Right | a &&= b |
a = *a && b |
None |
| 12 | Right | a ||= b |
a = *a || b |
None |
Precedence 1 binds tightest; calls, field selections, and tuple projections bind tighter than every operator. Operators with equal precedence group according to their associativity.
In a compound assignment, a is evaluated once, and both the assignment and *a use that one value.
Integers are u8, u16, u32, u64, usize, i8, i16, i32, i64, and isize. A binary operator defined for
primitive operands requires both operands to have the same primitive type. Comparison operators produce bool; every
other operator defined for primitive operands produces the type of its operands. An operator whose operands are not
listed has no definition among primitives; its first operand is a reference, which a later section defines.
Modules and Packages
A module is a set of named expressions, called items. An item is written as:
name : T = e
name is the item’s name, T is its type, and e is an expression of type T that gives the item’s value. When e
is a function expression, : T may be omitted and the item has the function type of e:
pub f = fn () bool { return true; }
Item names are unique within a module, and an item is visible throughout its module regardless of source order. pub
before the name makes the item selectable from other modules: pub name : T = e.
A package is a directory of source files. Each directory in a package is a module, and the package’s top directory
is its root module. The .l files directly in one directory share that directory’s module: a file introduces no
name, and an item written in one file is visible in every other file of the directory. Each subdirectory that contains
an .l file at any depth is a module nested in its parent’s module and named after the subdirectory, which must be an
identifier; other subdirectories are ignored. The order of files has no effect.
A path is one or more identifiers separated by ::. It names a binding, a parameter, an item, or a module:
| Path | Kind | Lookup |
|---|---|---|
x |
Simple | The innermost parameter or binding named x, then the item x of the current module, then the primitive name x. |
a::b |
Relative | a is a module nested in the current module; each later segment names a module or item within the one before it. |
::a::b |
Absolute | As a relative path, but starting at the root module. |
A path whose prefix does not name a module is invalid. A path never selects an item of a module that encloses the current one, even when absolute; an absolute path may pass through the root module into a module that does not enclose the current one. Primitive names are found only by simple paths.
An item’s name may itself be a relative or absolute path: x::y::z : T = e writes the item z into the module that
x::y names, and ::x::y : T = e writes the item y into the module x nested in the root module. A module named by
such a prefix exists even when no directory declares it. The expression of an item is resolved with the module that
contains the item as its current module.
An item selected from another module must be pub. Modules need no pub. A binding may shadow another binding, a
parameter, or an item named by a simple path. A binding is not visible in its own initializer. Bindings and parameters
never shadow relative or absolute paths.