l
GitHub ↗

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.