FormaLang Documentation
Last Updated: 2026-10-01
FormaLang is an embeddable compiler frontend for a statically typed,
declarative language. An application puts the frontend inside itself.
The frontend parses .fv source, checks it, and gives a fully resolved
Intermediate Representation (IR). The application supplies the backend:
an interpreter, a JIT compiler, a query engine or a code generator. See
Design.
For Users
The User Guide covers FormaLang syntax and features: core constructs, type system, definitions, expressions, control flow, generics, modules.
For Developers
- Architecture Overview: system design, compiler pipeline, plugin system, built-in passes
- AST Reference: Abstract Syntax Tree for tooling
- IR Reference: Intermediate Representation for code generation
Core Constructs
Comments
Single-line comments:
// This is a single-line comment
pub struct User { name: String } // Inline comment
Multi-line comments:
/*
* This is a multi-line comment
* spanning several lines
*/
pub struct Post { title: String }
Doc comments use /// (item-level) or //! (parent / file-level). A
/// comment attaches to the declaration after it. They flow through to
the IR and are available to backends as the doc: field on most
definitions.
A //! comment documents the file or the mod that holds it. Put it
at the start of the file, or at the start of the body of a mod. The
AST keeps it in File.doc or in the doc field of the module. At any
other place, a //! comment is a ParseError:
//! Shapes and their areas.
/// A square.
pub struct Square { side: I32 }
pub mod units {
//! The units of a length.
pub struct Metre { value: I32 }
}
Bidirectional control characters: a comment or a string must not
hold the characters U+202A to U+202E and U+2066 to U+2069. These
characters change the direction of the text around them, so an editor
can show the source in an order that is not the order that the compiler
reads (CVE-2021-42574, "Trojan Source"). The compiler refuses them with
the error BidirectionalControl. In a string, write the escape
\u202E if you need such a character. The escape is visible in the
source, so the compiler accepts it:
pub let marker: String = "\u202E"
pub fn run_checks() {
assert(condition: marker.len() == 3) // three bytes of UTF-8
}
Statements
In a function body or a block, a statement ends at a line break, at
the } that closes the block, or at a { that starts a block
statement. There is no terminator, and two statements on one line are
an error:
pub fn f() -> I32 {
let a = 1 let b = 2
a + b
}
A line that ends with an operator, a , or an open bracket continues
on the next line. A line that starts with ., else or { continues
the line above:
pub fn pick(big: Boolean) -> I32 {
let base = 10 +
5
let total = [1, 2, 3]
.len()
if big
{
base
}
else
{
total
}
}
A line that starts with another operator, such as - or +, is a new
statement.
The rule also applies at module level, in the body of a mod and in
the body of an impl. Each definition starts on a new line:
pub let a: I32 = 1 pub let b: I32 = 2
Nesting Depth
The compiler has a limit on how deep a program nests. Each open (,
[ or { adds one level, and so does each operator in a chain such
as 1 + 1 + 1. Above 1024 levels, the parser stops with a
ParseError. An expression more than 500 levels deep gets the error
ExpressionDepthExceeded (E130). A real program is far below both
limits. If you reach one, put a part of the expression in a let.
Visibility Modifiers
Control export visibility with the pub keyword:
// Public - can be imported by other modules
pub struct User { name: String }
pub trait Named { name: String }
pub enum Status { active, inactive }
pub let MAX_USERS: I32 = 100
// Private - module-local only (default)
struct Internal { id: I32 }
trait Helper { key: String }
enum State { ready, processing }
let secret_key: String = "xyz"
Keywords
Reserved words that cannot be used as identifiers. self is one of
them: it names the receiver of a method, and nothing else. See
Traits & Impls.
trait struct enum use pub impl mod
let mut sink match for in if
else true false nil as extern
fn self inline no_inline cold
Type System
Primitive Types
pub struct Primitives {
text: String, // Text data
count: I32, // 32-bit signed integer
amount: F64, // 64-bit IEEE 754 float
active: Boolean // true or false
}
Numeric Types
FormaLang has four width-tagged numeric primitives instead of a single
generic Number type. Backends emit native instructions directly without
guessing precision.
| Type | Range / shape |
|---|---|
I32 | 32-bit signed integer |
I64 | 64-bit signed integer |
F32 | 32-bit IEEE 754 float |
F64 | 64-bit IEEE 754 float |
The type of a literal
A literal with no suffix takes its type from the position where it
stands. An annotation, a parameter, a field, a variant field, a return
type and a container element each give a type. Where no type is given,
an integer literal is an I32 and a literal with a fraction or an
exponent is an F64. A suffix in uppercase gives the type at the
literal itself:
pub let a = 42 // I32: no type is given
pub let b = 42I64 // I64: the suffix
pub let c = 3.14 // F64: no type is given
pub let d = 3.14F32 // F32: the suffix
pub let big: I64 = 9_223_372_036_854_775_807 // the annotation gives I64
pub let tiny: F32 = 0.5 // the annotation gives F32
pub let ids: [I64] = [1, 2, 3] // each element is an I64
pub fn twice(n: I64) -> I64 { n * 2I64 }
pub let four: I64 = twice(n: 2) // the parameter gives I64
An operand of a binary operator takes its type from the other operand.
An empty array literal [] and an empty dictionary literal [:] do
the same, on either side of == and !=: a == [] compares a with
an empty array of its own type. When both operands are literals, an
arithmetic operator (+, -, *, /, %) gives them the type of
its own position:
pub fn next(n: I64) -> I64 {
n + 1 // 1 is an I64: the other operand gives the type
}
pub let product: I64 = 2 * 3 // 2 and 3 are I64: the annotation
pub let wide: Boolean = 5 < 3_000_000_000I64 // 5 is an I64
pub fn run_checks() {
assert(condition: next(n: 1) == 2)
assert(condition: product == 6)
let none: [I64] = []
assert(condition: none == [] && [] == none)
}
Two operands that each have a type must have the same type. The operator converts neither of them:
pub fn mixed(a: I32, b: I64) -> I64 {
a + b // error: I32 and I64 do not mix
}
An integer literal never becomes a float: let f: F64 = 1 is a type
mismatch. Write 1.0.
The range of a literal
The compiler checks each literal against its type:
- An integer literal that does not fit is the error
NumericOverflow. The lowest value of a type fits:-2147483648is anI32, and-9223372036854775808is anI64where the position givesI64. - A float literal that does not fit is the error
InvalidNumber. AnF32literal must fit theF32range, so3.5e38is an error where the type isF32. - The digits of an
F32literal round one time, to the nearestF32. They do not round to anF64first. A literal fits when thatF32is finite:3.4028235e38rounds to the largestF32, so it fits. - A literal with a fraction or an exponent cannot take an integer
suffix, whatever its value:
3.5I32and1e3I32are each the errorInvalidNumber. Write1000I32.
pub fn largest() -> F32 { 3.4028235e38F32 } // the largest F32
pub fn up() -> F32 { 1.000000059604644775390625001F32 }
pub fn run_checks() {
assert(condition: largest() > 3.4e38F32)
assert(condition: up() == 1.0000001F32) // the next F32 after 1.0
}
pub let lowest: I32 = -2147483648 // ok
pub let too_big: I32 = 2147483648 // error: does not fit in I32
A literal of a struct or an enum
A literal with no suffix can also take a struct or an enum type from
its position. The type must have From<Literal<K>>, and the syntax of
the literal fixes K:
| Syntax | K | Example |
|---|---|---|
| an integer | I64 | 5 gives Literal(value: 5I64) |
| a fraction or an exponent | (significand: I64, exponent: I32) | 1.5 gives (significand: 15I64, exponent: -1I32) |
| a string | String | "abc" gives Literal(value: "abc") |
The value of a fraction is significand × 10^exponent. The compiler reads
it from the digits of the literal, and not from an F64, so it is
exact: 0.1 is (1, -1), 1.50 is (150, -2) and -2.5e3 is
(-25, 2). The literal then becomes T.from(value: Literal(value: ...)):
pub struct Price {
cents: I64
}
impl From<Literal<I64>> for Price {
fn from(value: Literal<I64>) -> Self { Price(cents: value.value * 100I64) }
}
impl From<Literal<(significand: I64, exponent: I32)>> for Price {
fn from(value: Literal<(significand: I64, exponent: I32)>) -> Self {
// Only two decimals: 12.5 is (125, -1), 12.50 is (1250, -2).
let digits = value.value
if digits.exponent == -1 {
Price(cents: digits.significand * 10I64)
} else {
Price(cents: digits.significand)
}
}
}
fn total(p: Price) -> I64 { p.cents }
pub fn run_checks() {
let a: Price = 12.5
let b: Price = 3
assert(condition: a.cents == 1250I64)
assert(condition: b.cents == 300I64)
assert(condition: total(p: 0.99) == 99I64)
}
Rules:
- A position that expects
T?also acceptsTryFrom<Literal<K>>. A literal that the type refuses givesnil. A position that expectsTdoes not acceptTryFrom - A position that expects
Literal<K>takes a literal of the matching syntax directly. A program can also build one:Literal(value: 5I64) - With no position that gives the type, call
from:Price.from(value: 12.5). The parameter offromis aLiteral<K>, so the literal is exact there too - An integer must fit in an
I64, a significand in anI64and an exponent in anI32. Otherwise the literal is anInvalidNumberor aNumericOverflow. A type checks its own limits in itsfrom, when the program runs - A literal with a suffix,
1.5F64, keeps the type of its suffix - A type parameter converts a literal through its own bounds. The bound
must name
From<Literal<K>>with theKof the literal, orTryFrom<Literal<K>>in aT?position. The impls of a struct with the same name do not count, because the parameter hides the struct. Each call then gives the parameter its type:
fn zero<T: From<Literal<I64>>>() -> T {
0
}
fn double<T: From<Literal<I64>> + Mul>(x: T) -> T {
x * 2
}
pub fn run_checks() {
let d = zero<Decimal>()
let r = double(x: zero<Rational>())
assert(condition: d.mantissa == 0I64)
assert(condition: r.numerator == 0I64)
}
Decimal
The prelude declares Decimal, an exact decimal number. It is a struct
with two fields: mantissa: I64 and scale: I32.
Its value is mantissa × 10^-scale: 1.5 is mantissa 15 and scale 1.
A decimal keeps the trailing zeros of its literal, so 1.50 is mantissa
150 and scale 2. The scale goes from 0 to 28, as in the Rust crate
rust_decimal. A decimal has no operations yet: a program writes it and
gives it to a host function.
- An integer literal always fits, so
From<Literal<I64>>gives it:let d: Decimal = 42 - A fraction can be out of range, so
TryFromgives it, and only a place that expectsDecimal?takes one. A scale above 28, or a mantissa that does not fit in anI64, givesnil
extern fn greater_than(than: Decimal?) -> Boolean
pub fn query() -> Boolean {
greater_than(than: 1.5) // mantissa 15, scale 1
}
pub fn run_checks() {
let whole: Decimal = 42
let tiny: Decimal? = 1e-30 // nil: the scale is above 28
assert(condition: whole.mantissa == 42I64 && whole.scale == 0)
assert(condition: tiny.is_none())
}
Rational
The prelude declares Rational, an exact fraction. It is a struct with
two fields: numerator: I64 and denominator: I64. A rational is
reduced, and its sign is on the numerator, so its denominator is above
0. It has +, -, * and /: see Operators. A
division by zero gives 0/0, a value that no other operation gives, so
check the denominator.
A literal gives a rational as it gives a decimal: an integer through
From, and a fraction through TryFrom, so a fraction needs a place
that expects Rational?. 0.1 is 1/10, 1.5 and 1.50 are both 3/2,
and a fraction whose 10^exponent does not fit in an I64 gives nil.
A zero is 0/1 with any exponent.
No literal writes 1/3, but let r: Rational = 1 / 3 divides two
rationals, so it is exact. Rational.of(numerator: 1I64, denominator: 3I64) also builds it. It reduces the fraction, and gives nil for a
denominator of 0:
pub fn run_checks() {
let half: Rational? = 0.5
let third = Rational.of(numerator: 2I64, denominator: 6I64)
assert(condition: match half { .some(r): r.numerator == 1I64 && r.denominator == 2I64, .none: false })
assert(condition: match third { .some(r): r.denominator == 3I64, .none: false })
}
Overflow at run time
An integer operation whose result does not fit its type overflows.
The language does not say what the result is: the backend decides.
Integer division and remainder truncate toward zero. The
ConstantFoldingPass computes each operation in the declared type. It
leaves an operation that overflows, or that divides by zero, as it is,
so the backend makes the same decision for it.
Never Type
Never is an uninhabited type: it has no values and cannot be instantiated.
It is used as a return type for functions that diverge (infinite loops, panics):
extern fn abort() -> Never
Because Never has no value, it fits every type, as ! does in Rust.
A branch of an if or an arm of a match of type Never gives way to
the type of the other branches. A function body, a let value and an
argument of type Never fit any declared type. The reverse is not
true: a value of another type does not fit Never.
fn fail() -> Never { fail() }
pub fn positive(x: I32) -> I32 {
if x > 0 { x } else { fail() } // the type is I32
}
pub fn unwrap(o: I32?) -> I32 {
match o { .some(n): n, .none: fail() }
}
pub fn run_checks() {
assert(condition: positive(x: 5) == 5)
assert(condition: unwrap(o: 3) == 3)
}
Array Types
Arrays hold multiple values of the same type:
pub struct User { name: String }
pub struct Collections {
names: [String], // Variable-length array of strings
scores: [I32], // Variable-length array of integers
flags: [Boolean], // Variable-length array of booleans
matrix: [[I32]], // Nested arrays
users: [User] // Array of custom types
}
// Array literals
pub let tags = ["urgent", "bug", "frontend"]
pub let numbers = [1, 2, 3, 4, 5]
pub let empty: [String] = []
// Array destructuring (see Expressions for full rules)
pub let [first, second] = ["a", "b", "c"]
pub let [user, ...] = ["John", "pass", "etc"]
pub fn run_checks() {
assert(condition: numbers.len() == 5)
assert(condition: second == "b")
assert(condition: user == "John")
}
Optional Types
Optional types can be a value or nil:
pub struct User {
name: String,
email: String,
nickname: String?, // Optional field
avatar: String? // Optional field
}
pub let user1 = User(
name: "Alice",
email: "alice@example.com",
nickname: "Ally", // Provide a value
avatar: nil // Explicitly nil
)
Optional<T> behaves like a built-in two-variant enum with .some(T)
and .none. To consume the inner value, use the Rust-style if let
form documented in Control Flow, or match against
.some(x) / .none arms directly. Both branches are required when
the result is used as a value, so the type-checker can unify them.
The language has one level of absence. An optional of an optional is
one optional: Optional<I32?> is I32?. The same rule applies when
a type parameter stands for an optional. If T is I32?, then T?
is I32?, and the .some arm of a match on it binds an I32.
fn first_of<T>(xs: [T]) -> T? { xs[0] }
pub fn run_checks() {
let os: [I32?] = [1, nil]
let got: I32? = first_of(xs: os) // I32?, not I32??
let v = match got { .some(n): n, .none: 0 }
assert(condition: v == 1)
}
Dictionary Types
Key-value mappings using bracket syntax with colon:
pub struct User { id: I32, name: String }
pub struct AppConfig {
settings: [String: I32], // String keys to I32 values
scores: [I32: String], // I32 keys to String values
cache: [String: User] // String keys to custom types
}
// Dictionary literals (string keys must be quoted)
pub let settings: [String: I32] = ["timeout": 30, "maxRetries": 3]
pub let scores: [I32: String] = [100: "perfect", 95: "excellent"]
pub let empty: [String: Boolean] = [:]
// A dictionary made from data: see `collect` in Control Flow
pub fn by_id(users: [User]) -> [I32: User] {
for u in users { u }.collect(key: (u) -> u.id, value: (u) -> u)
}
pub fn run_checks() {
let found = by_id(users: [User(id: 7, name: "Ada")])
assert(condition: found.len() == 1)
assert(condition: settings["timeout"] != nil)
let bands = [0..3: "low", 3..5: "high"] // a range is a key
assert(condition: bands[0..3] != nil)
assert(condition: bands[0..4] == nil)
}
Rules:
- Keys can be
String,I32,I64,Boolean, a range, a struct, or an enum. A struct or an enum is a key only when each of its fields is a key type too. For a generic struct or enum, a field of typeThas the type of the type argument, soBox<I32>is a key andBox<F64>is not - A range is a key when its bounds are keys:
0..3is a key. Two ranges are one key when their starts are equal and their ends are equal - Any other key type is the error E134 (
InvalidDictionaryKey): a float, an optional, an array, a tuple, a dictionary, a closure, or a struct or enum that holds one of them F32andF64cannot be keys. A float has no usable equality:NaNis not equal to itself, so a key can never be found again, and0.0equals-0.0, so two distinct-looking keys collide. The rule also applies to a key type that the compiler infers: the keys of a literal, thekeyclosure ofcollect, and a generic call- A lookup
d[k]needs a key of the key type. A key of another type, an optional key included, is aTypeMismatch - A repeated key makes one entry. The later value replaces the earlier
one:
["a": 1, "a": 2]holds one entry,"a": 2 - String keys must be quoted in literals:
["key": value] - Numeric keys are unquoted:
[42: value] - Empty dict:
[:] - No destructuring support for dictionaries
Tuples
Named tuples group related values with field names:
pub struct Config {
person: (name: String, age: I32),
point: (x: I32, y: I32),
nested: (user: (first: String, last: String), active: Boolean)
}
pub fn run_checks() {
// Tuple literals
let person = (name: "John", age: 30)
let point = (x: 10, y: 20)
let nested = (user: (first: "John", last: "Doe"), active: true)
// Accessing tuple fields by name
assert(condition: person.name == "John")
assert(condition: point.x + point.y == 30)
assert(condition: nested.user.last == "Doe")
}
Rules:
- Tuples use parentheses:
(name: value, ...) - All fields must be named (no positional tuples)
- Access fields with dot notation:
tuple.fieldName - Trailing comma allowed:
(x: 1, y: 2,) - Tuples can be nested
Closure Types
Closure types define function signatures for callbacks and transformations.
The parameter list is always parenthesised, even for a single parameter,
so every -> in the language is preceded by ). For closure
expressions, see Closures.
struct Controls<E> {
// No parameters - returns E
onPress: () -> E,
// Single parameter (default / let convention)
onChange: (String) -> E,
// Multiple parameters
onResize: (I32, I32) -> E,
// mut parameter: caller must pass a mutable binding
onScale: (mut I32) -> E,
// sink parameter: caller's binding is consumed (moved)
onSubmit: (sink String) -> E,
// Optional closure (can be nil)
onFocus: ((String) -> E)?,
// Closure returning optional
validate: (String) -> Boolean?
}
Type syntax:
| Parameters | Syntax | Example |
|---|---|---|
| None | () -> T | () -> Event |
| One (default) | (T) -> U | (String) -> Event |
| One (mut) | (mut T) -> U | (mut I32) -> Event |
| One (sink) | (sink T) -> U | (sink String) -> Event |
| Multiple | (T, U) -> V | (I32, I32) -> Point |
| Mixed conventions | (mut T, sink U) -> V | (mut I32, sink String) -> V |
Rules:
- The parameter list is always parenthesised — even with one parameter
— and every
->in the language is preceded by). - Multiple parameters are comma-separated inside the parens.
- Convention keywords (
mut,sink) precede the type in the type position.
Generic Types
Types parameterized with type variables (full details in Generics):
pub trait Layout { width: I32 }
pub trait Render { fn render(self) -> Boolean }
pub trait Click { fn click(self) -> Boolean }
pub struct Box<T> { value: T } // Single type parameter
pub struct Pair<A, B> { first: A, second: B } // Multiple type parameters
pub struct Container<T: Layout> { items: [T] } // With trait constraint
pub struct Widget<T: Render + Click> { component: T } // Multiple trait constraints
pub enum Result<T, E> { ok(value: T), error(err: E) }
pub let answer: Result<String, I32> = .ok(value: "yes") // Instantiated generic
Indexing
Three types take an index: an array by position, a dictionary by key, and a string by byte offset. Nothing else does.
pub fn run_checks() {
let xs = [10, 20]
let a: I32? = xs[1] // the position may be out of range
let d = ["k": 5]
let b: I32? = d["k"] // the key may be absent
let c: I32 = "abc"[0] // the byte at that offset
assert(condition: if let v = a { v == 20 } else { false })
assert(condition: xs[2] == nil)
assert(condition: if let v = b { v == 5 } else { false })
assert(condition: c == 97)
}
Any other type is an error:
pub struct Point { x: I32 }
pub fn f() -> I32 {
let p = Point(x: 1)
p[0] // error E139: a struct has no index operation
}
The type of the index is checked too. An array and a string take an
I32 position, and a dictionary takes a key of its key type. Any other
index type is a TypeMismatch.
An array index and a dictionary lookup both produce an optional, because the position may be out of range and the key may be absent. When the elements are optional, the index gives one optional, not an optional of an optional. See Expressions / Indexing.
Immutable Elements
The three types that take an index never change after you make them. The language has no write through an index. One rule covers all three:
pub fn f() {
let mut xs: [I32] = [1, 2, 3]
xs[0] = 9 // error E143: cannot assign to an element
let mut d: [String: I32] = ["a": 1]
d["a"] = 9 // error E143
let mut s: String = "ab"
s[0] = 65 // error E143
}
let mut does not change this. It lets you assign the binding, and it
says nothing about the contents. A field above the index makes no
difference either: h.xs[0] = 9 reports the same error, because the
write still passes through the index.
Three kinds of write are legal. You assign a whole binding, you assign
a struct field, and you pass a mut parameter:
pub struct Point { x: I32 }
pub fn run_checks() {
let mut xs: [I32] = [1, 2]
xs = [3, 4, 5] // ok: the binding takes a new array
let mut p: Point = Point(x: 1)
p.x = 9 // ok: a field, through a mut binding
assert(condition: xs.len() == 3)
assert(condition: p.x == 9)
}
To change the elements, make a new value. A for pipeline builds one
in a single pass:
pub fn run_checks() {
let mut xs: [I32] = [1, 2, 3]
xs = for x in xs { x * 2 }.collect()
assert(condition: for x in xs { x }.fold(initial: 0, f: (a, b) -> a + b) == 12)
}
A host can still offer mutation. The signature shows it, and the runtime does the work:
extern fn push(mut xs: [I32], value: I32)
Why the elements are immutable
Three properties depend on the rule.
- A string slice is zero-copy.
sliceshares the memory of the source. A write to a string would therefore reach every slice that the source made. - The language uses Mutable Value Semantics. An element write makes
let ys = xsneed a copy. The copy would happen where nobody asked for it. See Functions. - A
foryields a lazy sequence. Nothing runs until a terminal combinator consumes it. If the source could change in between, the result of a pipeline would depend on evaluation order. See Large Data.
Optional Elements
A value wraps into an optional wherever one is declared, and that
reaches inside a container: [T] fits [T?] the same way T fits
T?.
Whether the optional is worth declaring is a separate question. A
plain let has no later, so if every element of the literal is
present, the optional says more than the value means:
let a: [I32?] = [nil, 3] // ok: one element really is absent
let mut b: [I32?] = [3] // ok: a nil may be put there later
let c: [I32?] = [3] // error E142: declare it [I32]
let d: [I32?] = [] // ok: an empty literal claims nothing
The same holds for a dictionary's values.
Structs
Structs define data types: a named record of typed fields, optionally generic over type parameters.
Definitions
A comma separates two fields. A line break after the comma is optional. A line break alone does not separate two fields.
pub trait Layout { width: I32 }
// Basic struct
pub struct Point {
x: I32,
y: I32
}
// With optional fields
pub struct User {
name: String,
email: String,
age: I32,
verified: Boolean,
nickname: String?
}
// All struct fields share the binding's mutability. There is no
// per-field `mut` modifier; to update any field of a value, the
// binding must be `let mut`.
pub struct Counter {
count: I32,
label: String
}
// Generic struct
pub struct Box<T> {
value: T
}
pub struct Pair<A, B> {
first: A,
second: B
}
// Generic with constraints
pub struct Container<T: Layout> {
items: [T],
gap: I32
}
A line break alone between two fields is an error:
pub struct Point {
x: I32
y: I32
}
A comma after the last field is allowed.
Instantiation
A struct literal names each field: Point(x: 10, y: 20). A positional
argument is the error PositionalArgInStruct. Each field needs a value,
with two exceptions: a field with a default value, and an optional
field, which is nil when you leave it out. A missing field is the
error MissingField, and a name that is not a field is the error
UnknownField.
pub struct Point { x: I32, y: I32 }
pub struct User {
name: String,
email: String,
age: I32,
nickname: String?
}
pub struct Box<T> { value: T }
pub struct Pair<A, B> { first: A, second: B }
// Basic instantiation
pub let point = Point(x: 10, y: 20)
// Multi-line instantiation; `nickname` is optional, so it is nil
pub let user = User(
name: "Alice",
email: "alice@example.com",
age: 30
)
// Generic instantiation with type arguments
pub let box_str = Box<String>(value: "hello")
pub let pair = Pair<I32, Boolean>(first: 42, second: true)
// Type inference (type arguments optional when inferrable)
pub let box_inferred = Box(value: "inferred as String")
pub fn run_checks() {
assert(condition: point.x + point.y == 30)
assert(condition: user.age == 30)
assert(condition: pair.second)
}
Field Defaults
A field can declare a default value with = expr. A literal that
leaves out the field gets the default:
pub struct Config {
retries: I32 = 3,
name: String = "default"
}
pub fn run_checks() {
let c = Config()
assert(condition: c.retries == 3)
let d = Config(retries: 9)
assert(condition: d.retries == 9)
assert(condition: d.name == "default")
}
Adding Methods
To attach methods to a struct, write an impl block: see
Traits & Impls.
Each Name Appears Once
Two fields of one name leave no answer to what the name means, so a definition may not declare one twice. The same holds for the parameters of a function, a method, or a trait method, and for the payload fields of an enum variant.
pub struct P {
x: I32,
x: I32 // error: Duplicate definition
}
fn g(a: I32, a: I32) -> I32 { a } // error: Duplicate definition
Shadowing is a different thing and stays legal inside a body. A later
let of the same name introduces a second binding that hides the
first, and the name has a defined meaning at every point:
pub fn f() -> I32 {
let a = 1
let a = a + 1 // ok: a is 2 from here on
a
}
At module level a name is a definition, so two let bindings of one
name at module level are a duplicate definition.
The rule covers every place where a name is declared. Each of these is
the error DuplicateDefinition:
- two top-level definitions of one name: two structs, a struct and an
enum, a function and a
let, two traits, or two modules; - two variants of one enum with one name;
- two fields or two methods of one trait with one name;
- two functions or two methods with one signature. A different return type does not make a second signature (see Function Overloading);
- two names of one pattern: a destructuring pattern, a match arm, or the parameters of a closure;
- two arguments of one call with one label, and two fields of one literal with one name.
Two type parameters of one name are the error DuplicateGenericParam,
and two match arms for one variant are the error DuplicateMatchArm.
Enums
Enums define sum types (tagged unions): a value is exactly one of the declared variants.
Definitions
A comma separates two variants. A line break after the comma is optional. A line break alone does not separate two variants: an enum with one variant on each line needs a comma at the end of each line but the last.
// Simple enum
pub enum Status {
pending,
active,
completed
}
// With associated data (named parameters)
pub enum Message {
text(content: String),
image(url: String, size: I32),
video(url: String, duration: I32)
}
// Generic enum
pub enum Result<T, E> {
ok(value: T),
error(err: E)
}
pub enum Option<T> {
some(value: T),
none
}
Instantiation
Enum values use the leading-dot shorthand .variant. The type that
the context gives, here the type of the let, tells which enum the
variant belongs to:
pub enum Status {
pending,
active
}
pub enum Message {
text(content: String),
image(url: String, size: I32)
}
pub enum Result<T, E> {
ok(value: T),
error(err: E)
}
// Simple variant
let status1: Status = .pending
let status2: Status = .active
// With named parameters
let msg1: Message = .text(content: "Hello")
let msg2: Message = .image(url: "/pic.jpg", size: 1024)
// Generic enum
let result1: Result<String, I32> = .ok(value: "success")
let result2: Result<String, I32> = .error(err: 404)
The full form names the enum: Status.pending. A generic enum takes
its type arguments on the path, the same way a struct literal does:
Result<String, I32>.ok(value: "done"). The full form needs no
context:
pub enum Status {
pending,
active
}
pub enum Maybe<T> {
some(value: T),
none
}
pub let first = Status.pending
pub let empty = Maybe<I32>.none
pub let held = Maybe<I32>.some(value: 3)
pub fn run_checks() {
assert(condition: first == Status.pending)
assert(condition: empty != held)
}
The compiler checks the dot form against the enum that the context expects:
- A dot form with no expected enum, such as
let v = .pending, is the errorCannotInferEnumType. Write the annotation or the full form. - A variant that the expected enum does not have is the error
UnknownEnumVariant, also when another enum has the variant. - Each payload field is checked against its declared type. A missing
payload field is the error
MissingField, an unknown one is the errorUnknownField, and a value of the wrong type is aTypeMismatch. - A variant with data needs its data:
.imagealone is the errorEnumVariantRequiresData. A variant without data takes none:.pending(x: 1)is the errorEnumVariantWithoutData.
pub enum Status { pending, active }
pub enum Level { low, high }
pub let s: Status = .low // error: 'low' is a variant of Level
Pattern Matching
To consume enum values, use a match expression: see
Control Flow & Pattern Matching. A
destructuring pattern cannot take an enum value: see
Expressions / Destructuring.
Traits & Impls
Traits declare requirements (fields and method signatures); impl blocks
attach methods to a concrete type or declare conformance.
Trait Definitions
// Fields only
pub trait Named {
name: String
}
// Fields and methods
pub trait Shape {
color: String
fn area(self) -> I32
fn perimeter(self) -> I32
}
// Methods only
pub trait Drawable {
fn draw(self) -> Boolean
fn visible(self) -> Boolean
}
pub trait Identifiable {
id: I32
}
// Trait composition (inheritance)
pub trait Entity: Named + Identifiable {
createdAt: I32
}
// Generic trait
pub trait Collection<T> {
items: [T]
}
Trait Rules:
- Fields listed without
fnare structural requirements (the struct must have them) fnsignatures listed without a body are method requirements- A line break separates two members of a trait. A comma is also allowed
- Trait composition (
+) combines requirements from multiple traits - A type satisfies a trait by providing all required fields and all required methods
- A trait method may declare type parameters of its own. See Generic Trait Methods
Generic Trait Methods
A trait method may declare type parameters of its own, after its name. Each impl of the trait declares the same number, in the same order:
pub struct Meters { v: I64 }
impl From<Literal<I64>> for Meters {
fn from(value: Literal<I64>) -> Meters { Meters(v: value.value) }
}
pub trait Conv {
fn convert<T: From<Literal<I64>>>(self) -> T
}
pub struct Probe { n: I64 }
impl Conv for Probe {
fn convert<X: From<Literal<I64>>>(self) -> X { 2 }
}
fn through<U: Conv>(u: U) -> Meters {
u.convert() // T = Meters, from the return type
}
pub let m: Meters = Probe(n: 1).convert()
pub let d = Probe(n: 1).convert<Decimal>()
pub let t = through(u: Probe(n: 1))
Rules:
- An impl method declares as many type parameters as the trait method. The names may differ: the check reads them by position
- After that renaming, the signatures must be the same
- Each bound of the impl method must be a bound of the trait method too. The impl method may have fewer bounds, because a call through the trait meets each bound of the trait method. A bound that only the impl method has is a mismatch: a call through the trait does not check it
- A mismatch is the error
TraitMethodSignatureMismatch(E101). The message shows both signatures with their type parameters - A call gives the type parameters their types as for a method in an
impl block: by
<...>, by an argument, or by the position of the call. This holds on a value and on a type parameter with a bound
Impl Blocks
Impl blocks add methods to a struct (inherent impl) or declare trait conformance (impl Trait for Struct).
Inherent impl: methods belong to the struct:
pub struct Counter {
value: I32
}
impl Counter {
fn increment(self) -> I32 {
self.value + 1
}
fn reset(self) -> Counter {
Counter(value: 0)
}
}
pub fn run_checks() {
let c = Counter(value: 4)
assert(condition: c.increment() == 5)
assert(condition: c.reset().value == 0)
}
The self parameter
self is the receiver of a method. It exists only in a method that
declares it:
selfmust be the first parameter. In another position it is aParseError.- A method without a
selfparameter is a static method. A read ofselfin it is the errorUndefinedReference. - A free function has no receiver, so a
selfparameter there is aParseError. selfis a keyword, so it cannot name a binding, a parameter, a field or a function.
self takes a convention, as each parameter does: self, mut self
or sink self. See
Functions / Parameter Conventions.
pub struct Counter { value: I32 }
impl Counter {
fn get() -> I32 {
self.value // error: this method declares no `self`
}
}
Static methods
A call on the type, Type.method(...), reaches a static method. This
is the form of Swift. A call through a value of the type, c.zero(),
reaches it too:
pub struct Counter {
value: I32
}
impl Counter {
fn zero() -> Counter {
Counter(value: 0)
}
fn starting(at: I32) -> Counter {
Counter(value: at)
}
fn reset(self) -> Counter {
Counter.zero() // inside the impl, name the type too
}
}
pub fn run_checks() {
assert(condition: Counter.zero().value == 0)
assert(condition: Counter.starting(at: 5).reset().value == 0)
}
Rules:
Name.method(...)is a static call whenNameis a struct. On an enum,Name.member(...)is a variant, and it is a static call only when the enum has no variant of that name:Optional.from(value: 3)- A call on the type reaches only a method with no
self. A method that takesselfneeds a value:Counter.get()is the error E151 (NotAStaticMethod) - A bare name names a function, not a method, as in Rust: inside the
impl, a bare
zero()is the errorUndefinedReference. WriteCounter.zero() - On a generic struct, the path gives the type arguments:
Box<I32>.empty(). With no<...>, the arguments give them, as for a generic function:Box.of(v: 3)is a call onBox<I32>. A call that gets no type for a parameter of the struct is the errorMissingGenericArguments
pub struct Box<T> { value: T? }
impl Box<T> {
fn of(v: T) -> Box<T> { Box(value: v) }
fn empty() -> Box<T> { Box(value: nil) }
}
pub fn run_checks() {
assert(condition: Box.of(v: 3).value.is_some())
assert(condition: Box<I32>.empty().value.is_none())
}
pub struct Counter { value: I32 }
impl Counter {
fn get(self) -> I32 { self.value }
}
pub fn f() -> I32 {
Counter.get() // error E151: get takes self
}
Self
Self names a type. Its meaning depends on where it is:
- In an impl block,
Selfis the target type. Inimpl Box<T>,SelfisBox<T>.Self(value: v)andSelf.zero()name the target type too. - In a trait,
Selfis the type that implements the trait. An impl givesSelfits own type: inimpl Zero for Point, the trait'sfn zero() -> Selfisfn zero() -> Point. - In other places,
Selfis the errorUndefinedType.
A call through a bound reads Self as the type parameter: through
T: Zero, x.twice() returns a T. A call on the type parameter
itself, T.zero(), reaches a static method of the bound. A method that
takes self needs a value: T.twice() is the error NotAStaticMethod.
pub trait Zero {
fn zero() -> Self
fn twice(self) -> Self
}
pub struct Point {
x: I32
}
impl Zero for Point {
fn zero() -> Self { Self(x: 0) }
fn twice(self) -> Self { Point(x: self.x * 2) }
}
fn doubled<T: Zero>(item: T) -> T { item.twice() }
fn origin<T: Zero>() -> T { T.zero() }
pub fn run_checks() {
assert(condition: doubled(item: Point(x: 3)).x == 6)
assert(condition: origin<Point>().x == 0)
}
One type can implement two instances of one generic trait. When their methods differ in a parameter type, the type of the argument chooses the impl, for a static call too:
pub trait Make<T> {
fn make(value: T) -> Self
}
pub struct Tag {
n: I64
}
impl Make<I64> for Tag {
fn make(value: I64) -> Self { Tag(n: value) }
}
impl Make<String> for Tag {
fn make(value: String) -> Self { Tag(n: 0I64) }
}
fn build<T: Make<I64>>(n: I64) -> T { T.make(value: n) }
pub fn run_checks() {
assert(condition: Tag.make(value: "x").n == 0I64)
assert(condition: build<Tag>(n: 4I64).n == 4I64)
}
Impl Trait for Type
Declare that a type conforms to a trait using impl Trait for Type:
pub trait Named {
name: String
}
pub trait Drawable {
fn draw(self) -> Boolean
}
pub struct Circle {
name: String,
radius: I32
}
// Declare conformance (fields are checked against struct definition)
impl Named for Circle {}
// Provide required methods
impl Drawable for Circle {
fn draw(self) -> Boolean {
self.radius > 0
}
}
Trait composition requires a separate impl block for each trait in the hierarchy:
pub trait Base {
fn id(self) -> I32
}
pub trait Extended: Base {
fn name(self) -> String
}
pub struct Item {
value: I32
}
impl Base for Item {
fn id(self) -> I32 {
self.value
}
}
impl Extended for Item {
fn name(self) -> String {
"item"
}
}
Conformance rules:
impl Trait for Typeis the only way to declare trait conformance- A struct or an enum can conform to a trait. Struct fields required
by the trait must be present in the struct definition
(
MissingTraitField,TraitFieldTypeMismatch). An enum has no fields, so it can only meet a trait with no field requirements - All
fnsignatures in the trait must be implemented in the impl block (MissingTraitMethod) - Method signatures must match exactly: the parameter count, each
label, each parameter type, the convention of
self, and the return type (TraitMethodSignatureMismatch) - Each trait in a chain needs its own impl block.
impl Extended for Itemdoes not satisfyBase: withoutimpl Base for Item, the error isMissingTraitMethod - An impl block provides only the methods of its own trait. Any other
method, also a method of a parent trait, is the error
MethodNotInTrait(E185). A method of the type alone goes in an impl block with no trait - A trait impl is part of its type. A module that imports the type
gets the impl too, with no
useof the trait. See Module System
Impls on Primitive Types
A primitive type (String, I32, I64, F32, F64, Boolean)
takes a regular impl, a trait impl and an extern impl. In a method
of a regular impl, self is a value of the primitive. A static method
is called on the type name: I32.zero(). An extern impl declares
methods that the host provides: see
Extern Declarations.
pub trait Zero {
fn zero() -> Self
}
impl I32 {
fn double(self) -> I32 { self * 2 }
}
impl Zero for I32 {
fn zero() -> Self { 0 }
}
fn origin<T: Zero>() -> T { T.zero() }
pub fn run_checks() {
assert(condition: 21.double() == 42)
assert(condition: I32.zero() == 0)
assert(condition: origin<I32>() == 0)
}
A method that the prelude declares on a primitive cannot be declared again with the same signature: see Extern Declarations.
Blanket Impls
A blanket impl is for each type that meets its bounds. impl<...>
declares its type parameters, and the target is one of them:
pub trait Named {
fn name(self) -> String
}
pub trait Greet {
fn greet(self) -> String
}
impl<T: Named> Greet for T {
fn greet(self) -> String { "hi " + self.name() }
}
pub struct Robot {
id: String
}
impl Named for Robot {
fn name(self) -> String { self.id }
}
fn welcome<X: Greet>(x: X) -> String { x.greet() }
pub fn run_checks() {
assert(condition: Robot(id: "r2").greet() == "hi r2")
assert(condition: welcome(x: Robot(id: "c3")) == "hi c3")
}
A parameter that only the trait arguments name takes its type from a
bound: in impl<U, T: Make<U>> MakeInto<T> for U, each type T with
impl Make<U> for T gives U the trait MakeInto<T>.
Rules:
- The compiler checks the body of a blanket impl once, as generic code. Then it writes a concrete impl for each primitive, and for each struct and enum with no type parameters, that meets the bounds
- Each parameter of
impl<...>must appear in the target or in the trait arguments: otherwise it is the errorUnconstrainedImplParameter(E182) - Only a trait impl can be a blanket impl:
impl<T> T { ... }is the errorInherentBlanketImpl(E181) - A blanket impl can be in an inline
mod. The compiler writes its concrete impls in that module, so the bodies name what the module sees. A blanket impl of the top level gives its trait to the types of each inline module too - A bound that needs the trait that the impl gives, such as
impl<T: Show> Show for T, is a cycle. A cycle proves nothing, so such an impl is for no type. A bound that asks about a larger trait argument at each step, such asT: A<[U]>in an impl ofA<U>forT, has no end: it is the errorBlanketRequirementTooDeep(E183) - A blanket impl gives its trait to a primitive, and to a struct or an
enum with no type parameters.
Box<I32>or[I32]does not get it impl<T: Show> Show for Box<T>is the other form ofimpl<...>: an impl of a generic type whose parameters have bounds- A blanket impl from another module can name only the public items of that module, because the concrete impl is written in the module that uses it
Two impls of one trait must not match one type. This is the error
OverlappingImpls (E180):
- Two blanket impls of one trait overlap when their trait arguments can be the same, whatever their bounds say. A type could meet both bounds, now or when a module adds an impl.
- A blanket impl and a concrete impl overlap when the type of the concrete impl meets the bounds of the blanket impl.
pub trait A { fn a(self) -> I32 }
pub trait B { fn b(self) -> I32 }
pub trait C { fn c(self) -> I32 }
impl<T: A> C for T { fn c(self) -> I32 { self.a() } }
impl<T: B> C for T { fn c(self) -> I32 { self.b() } } // error E180
An impl of a trait must be in the module of the trait or of the type: see Modules.
Conversions
The prelude declares four conversion traits:
| Trait | Method | Meaning |
|---|---|---|
From<T> | fn from(value: T) -> Self | makes a Self from a T |
Into<T> | fn into(self) -> T | makes a T from self |
TryFrom<T> | fn try_from(value: T) -> Self? | makes a Self from a T, or gives nil |
TryInto<T> | fn try_into(self) -> T? | makes a T from self, or gives nil |
Write From or TryFrom. The prelude gives Into and TryInto with
two blanket impls: impl<U, T: From<U>> Into<T> for U, and the same
for TryInto. A type with two From impls for one source gives that
source two into methods, and the type that the position expects
chooses one.
pub struct Meters {
v: I64
}
impl From<I64> for Meters {
fn from(value: I64) -> Self { Meters(v: value) }
}
pub struct Small {
v: I32
}
impl TryFrom<I32> for Small {
fn try_from(value: I32) -> Self? { if value < 10 { Small(v: value) } else { nil } }
}
pub fn run_checks() {
let m: Meters = 5I64.into()
assert(condition: m.v == 5I64)
assert(condition: Meters.from(value: 2I64).v == 2I64)
let s: Small? = 30.try_into()
assert(condition: s.is_none())
}
A conversion is explicit: write .into() or T.from(value: x). The
compiler converts in one place only: a value of T in a place that
expects T? wraps, with the prelude's From<T> for Optional<T>. So
Optional.from(value: 3) and 3 are the same I32?.
Operators
The prelude declares a trait for each arithmetic operator:
| Operator | Trait | Method |
|---|---|---|
+ | Add | fn add(self, other: Self) -> Self |
- | Sub | fn sub(self, other: Self) -> Self |
* | Mul | fn mul(self, other: Self) -> Self |
/ | Div | fn div(self, other: Self) -> Self |
% | Rem | fn rem(self, other: Self) -> Self |
On two operands of one struct or enum type that implements the trait,
the operator calls the method: a + b is a.add(other: b). A literal
operand takes the type of the other operand, or the type of the
position when both are literals. So let r: Rational = 1 / 3 divides
two Rational values, and the result is exactly 1/3.
The operators on the numbers stay built in. The prelude gives the
numbers these traits too, and String the trait Add, so a bound such
as T: Add takes them:
pub struct Money {
cents: I64
}
impl Add for Money {
fn add(self, other: Self) -> Self { Money(cents: self.cents + other.cents) }
}
fn total<T: Add>(a: T, b: T) -> T { a + b }
pub fn run_checks() {
let price = Money(cents: 150I64)
assert(condition: (price + price).cents == 300I64)
assert(condition: total(a: price, b: price).cents == 300I64)
assert(condition: total(a: 2, b: 3) == 5)
}
An operator on a type with no impl of its trait is the error
InvalidBinaryOp. Both operands must have the same type.
Trait-Bounded Polymorphism
A trait is a constraint, never the type of a value. Write
<T: Printable>, not item: Printable. Every call in FormaLang
dispatches statically, so there is no vtable and no indirect call.
pub trait Printable {
fn label(self) -> String
}
pub struct Doc {
text: String
}
impl Printable for Doc {
fn label(self) -> String { self.text }
}
// Correct: a generic bound. Monomorphisation makes one specialised
// function per concrete type, and the call is direct.
fn print_it<T: Printable>(item: T) -> String {
item.label()
}
// Error E063: a trait cannot be the type of a value.
fn print_any(item: Printable) -> String {
item.label()
}
The rule holds at every value position: a parameter, a return type, a
let annotation, a struct field, an enum variant field, an array
element, a dictionary value, and a closure parameter.
When the type is chosen at run time
A generic bound fixes one concrete type per call. For a value that may
hold any of several types, declare an enum with one variant per type
and match on it:
pub struct Square { side: I32 }
pub struct Rect { w: I32, h: I32 }
impl Square { fn area(self) -> I32 { self.side * self.side } }
impl Rect { fn area(self) -> I32 { self.w * self.h } }
pub enum AnyShape {
square(value: Square),
rect(value: Rect)
}
fn area_of(shape: AnyShape) -> I32 {
match shape {
.square(value): value.area(),
.rect(value): value.area()
}
}
// Two branches, one type — no cast needed.
fn pick(k: I32) -> AnyShape {
if k == 0 {
.square(value: Square(side: 2))
} else {
.rect(value: Rect(w: 2, h: 3))
}
}
The enum lists its variants in one place, so adding a type means
editing the enum and each match. In exchange the compiler checks that
every case is handled, and the generated code needs no vtable.
Generic Traits
Traits can themselves be generic, and constraints / impls can carry the concrete arguments:
pub trait Container<T> {
fn get(self) -> T
}
pub struct Box {
value: I32
}
impl Container<I32> for Box {
fn get(self) -> I32 { self.value }
}
fn unwrap<T: Container<I32>>(b: T) -> I32 {
b.get()
}
pub fn run_checks() {
assert(condition: unwrap(b: Box(value: 7)) == 7)
}
A bound on a generic trait gives its types. Through T: Container<I32>,
the method get returns an I32, and a parameter of type T in a
trait method takes an I32. A call through the bound is checked
against these types, and against the argument count and the labels of
the trait method. A generic trait needs its type arguments at each
bound and each impl: <T: Container> is the error
MissingGenericArguments, and a wrong count is the error
GenericArityMismatch.
One type can implement two instances of one generic trait. Each
instance is its own impl, and a bound says which one a call means. A
second impl of the same instance is a DuplicateDefinition. When both
instances answer a call, such as p.get() on a Pair below, the type
that the position expects chooses the instance whose method returns
it: let n: I32 = p.get(). With no expected type, the call is the
error AmbiguousCall: give the type, or make the call through a
bound.
pub trait Container<T> {
fn get(self) -> T
}
pub struct Pair {
n: I32,
s: String
}
impl Container<I32> for Pair {
fn get(self) -> I32 { self.n }
}
impl Container<String> for Pair {
fn get(self) -> String { self.s }
}
fn number<T: Container<I32>>(b: T) -> I32 { b.get() }
fn text<T: Container<String>>(b: T) -> String { b.get() }
pub fn run_checks() {
let p = Pair(n: 7, s: "seven")
assert(condition: number(b: p) == 7)
assert(condition: text(b: p) == "seven")
}
The monomorphisation pass clones generic traits, structs, enums,
and functions per unique argument tuple, then rewrites every
reference (including DispatchKind::Virtual on now-concrete
receivers) to point at the specialised clone. After mono runs, no
generic definitions remain in the IR.
A field of a generic trait takes the type arguments of the impl too:
in impl Coll<I32> for Bag, the field items: [T] of
trait Coll<T> is items: [I32]. Through a bound C: Coll<I32>, the
field c.items is an [I32].
Allowed trait positions:
- Generic constraint:
<T: Trait>or<T: Trait<X>> - Impl target:
impl Trait for Fooorimpl Trait<X> for Foo - Trait composition:
trait A: B + C
An impl is for every instance of a generic type. Its header names the
type parameters of the type: impl Sz for Box<T>. A header that names
a type there, such as impl Sz for Box<I32>, is the error
ImplForOneInstance (E178).
pub trait Sz { fn sz(self) -> I32 }
pub struct Box<T> { value: T }
impl Sz for Box<I32> { // error E178: I32 is a type, not a type parameter
fn sz(self) -> I32 { self.value }
}
The header uses the names that the type gives its type parameters, in
the same order. A header that renames a parameter, or puts the
parameters in another order, is the error
ImplRenamesTypeParameter (E184). The message names the parameters
of the type in their order.
pub struct Two<A, B> { first: A, second: B }
impl Two<B, A> { // error E184: write Two<A, B>
fn one(self) -> I32 { 1 }
}
A trait impl holds only the methods of its trait. A method that the
trait does not declare is the error MethodNotInTrait (E185). Put a
method of the type alone in an impl with no trait.
pub trait A { fn a(self) -> I32 }
pub struct P { n: I32 }
impl A for P {
fn a(self) -> I32 { 1 }
fn b(self) -> I32 { 2 } // error E185: A declares no method b
}
Rejected trait positions (use a generic bound instead):
- Function parameter type:
fn foo(x: Trait)✗ - Function return type:
fn make() -> Trait✗ - Let annotation:
let x: Trait = ...✗ - Struct/enum field:
field: Trait✗ - Closure params/return:
(x: Trait) -> I32✗
Functions
Top-level functions, parameter conventions, codegen attributes, and overloading. Closure expressions live on a separate page: see Closures.
Definitions
fn add(a: I32, b: I32) -> I32 {
a + b
}
pub fn greet(name: String) -> String {
"Hello, " + name
}
// No return type (returns unit)
fn log_value(value: I32) {
value
}
// Generic function
pub fn identity<T>(value: T) -> T {
value
}
A function with no return type
A function with no return type returns unit. A call to it gives no value, so the call is a statement. It can stand alone in a block, in a branch or an arm whose value is not used, and as the last expression of a function with no return type:
fn log(value: I32) { }
pub fn run_checks() {
log(value: 1) // ok: a statement
if true { log(value: 2) } // ok: the value of the `if` is not used
assert(condition: true) // `assert` has no return type too
}
The call cannot stand where a value is used. That is the value of a
let, a branch of an if or an arm of a match that gives a value,
an argument, an element, a field, an operand, and the body of a
function that has a return type. Each of these is the error E190
(NoValueFromCall). A closure body and a loop body can be such a call,
for its effect: see run in Large Data.
fn log(value: I32) { }
pub fn f(b: Boolean) -> I32? {
let v: I32? = log(value: 1) // error E190: the value of a `let`
if b { 1 } else { log(value: 2) } // error E190: a branch that gives a value
}
Calls
A call gives each argument with the label of its parameter, or by position:
fn area(width: I32, height: I32) -> I32 {
width * height
}
pub fn run_checks() {
assert(condition: area(width: 2, height: 3) == 6) // labels
assert(condition: area(height: 3, width: 2) == 6) // labels in any order
assert(condition: area(2, 3) == 6) // positions
assert(condition: area(2, height: 3) == 6) // both
}
A label binds the argument to the parameter of that name, in any order. An argument with no label takes the parameter at its position.
A parameter can have an external label in front of its name. The call writes the label, and the body reads the name:
fn shift(by amount: I32, from start: I32) -> I32 {
start + amount
}
pub fn run_checks() {
assert(condition: shift(by: 2, from: 10) == 12)
}
The compiler checks each call:
- Too many or too few arguments is the error
ArgumentCountMismatch(E138). - A label that names no parameter is the error
NoMatchingOverload. - Two arguments with one label are the error
DuplicateDefinition. - Each argument must have the type of its parameter. An unsuffixed literal takes the parameter type (see Type System / The type of a literal).
A call can call the closure that another call returns: adder()(3)
calls adder, then calls its result with 3.
fn adder() -> (I32) -> I32 {
(y) -> y + 10
}
pub fn run_checks() {
assert(condition: adder()(3) == 13)
}
Default Parameter Values
Parameters may declare a default value with = expr:
fn greet(name: String, greeting: String = "Hello") -> String {
greeting + ", " + name
}
pub fn run_checks() {
assert(condition: greet("world") == "Hello, world") // the default
assert(condition: greet("world", "Hi") == "Hi, world") // an argument
assert(condition: greet(name: "you", greeting: "Hey") == "Hey, you")
}
Rules:
- Defaults must be positional from the right.
fn f(x: I32 = 0, y: I32)is rejected at definition time (RequiredParamAfterDefault): every parameter after a defaulted one must also have a default (selfis not counted). - Defaults may reference earlier parameters.
fn f(x: I32, y: I32 = x + 1)is valid; calls likef(5)lower to a Let-wrapped Block that bindsxto the call-site value, so the default sees the actual passed value. A default cannot read a later parameter. - Defaults are re-evaluated on every call.
fn f(x: I32 = current_count())runscurrent_count()once per call: Python's mutable-default footgun is avoided. - Overload resolution prefers the no-default match. With both
fn f(x: I32)andfn f(x: I32, y: I32 = 1)defined,f(5)resolves to the no-default overload.
Codegen Attributes
Three optional keyword prefixes hint to backends about call-site
behavior. They are pure metadata: the frontend passes them through
unchanged. Multiple prefixes can stack and combine freely with
pub and extern.
inline fn fast_add(a: I32, b: I32) -> I32 { a + b }
no_inline fn dont_inline_me() -> I32 { 42 }
cold fn rare_error_path() { 0 }
pub cold extern fn abort() -> Never
| Prefix | Meaning |
|---|---|
inline | Hint: inline this function at every call site if possible |
no_inline | Hint: do not inline |
cold | Hint: this function is rarely called (error / branch) |
Parameter Conventions
FormaLang uses Mutable Value Semantics. Every parameter has a convention that controls how the callee may use the value:
| Convention | Syntax | Meaning |
|---|---|---|
| (default) | x: T | Immutable. Callee reads only. |
mut | mut x: T | Exclusive mutable. The caller sees the change. |
sink | sink x: T | Ownership transfer. Caller gives up the value. |
mut is not a private copy. The callee gets exclusive access to the
caller's value, and every change it makes is visible to the caller
when the call returns — which is why the argument must be a let mut
binding. Exclusivity is guaranteed by the compiler, so no two live
references to the same value ever exist.
// Default: immutable parameter
fn read(x: I32) -> I32 {
x
}
// mut: callee may mutate; argument must be let mut at call site
fn bump(mut score: I32) -> I32 {
score
}
// sink: callee owns the value; caller cannot use it after
fn consume(sink label: String) -> String {
label
}
The same conventions apply to self in impl methods:
pub struct Counter { count: I32 }
impl Counter {
fn value(self) -> I32 { self.count } // immutable self
fn increment(mut self) { self.count = self.count + 1 } // mutable self
fn destroy(sink self) -> I32 { self.count } // consuming self
}
pub fn run_checks() {
let mut c = Counter(count: 1)
c.increment() // c is let mut, so it satisfies mut self
assert(condition: c.value() == 2)
assert(condition: c.destroy() == 2) // c is consumed here
}
Call sites are transparent: no extra syntax required. The caller sees
the change that a mut parameter makes:
fn bump(mut score: I32) {
score = score + 1
}
pub fn run_checks() {
let mut n: I32 = 0
bump(score: n) // n is let mut, so it satisfies mut convention
assert(condition: n == 1)
}
A mut argument that is not a let mut binding is the error
MutabilityMismatch. A literal is not a binding, so it cannot be a
mut argument either.
A mut argument must have exactly the type of its parameter. The
callee can write back any value of the parameter type, and the
caller's binding must hold it. So an I32 binding cannot fill a
mut parameter of type I32?, because the callee can write nil.
This is the error TypeMismatch. A plain argument can still widen:
an I32 fills a plain parameter of type I32?.
fn clear(mut o: I32?) { o = nil }
pub fn f() -> I32 {
let mut x: I32 = 1
clear(o: x) // error: x cannot hold nil
x
}
Sink
A sink argument gives the value away. The rules follow from that:
- A binding that a
sinkargument consumed cannot be read again: the error isUseAfterSink. This holds on every path: a sink in one branch of anifconsumes the binding after theif. - A
let mutbinding that was consumed can take a new value. After the assignment, you can read it again. - A sink in a loop body or in a closure body can run more than once, so
it cannot consume a binding from outside that body (
UseAfterSink). - A module
letbelongs to every function, so no call can consume it (UseAfterSink). - A plain or
mutparameter belongs to the caller. A function cannot give it away withsink(MutabilityMismatch). - A sink of a field consumes a part of the struct. After it, the
struct is not whole, and a read of the whole struct is a
UseAfterSink.
fn take(sink s: String) -> I32 { s.len() }
pub fn f(flag: Boolean) -> I32 {
let label: String = "hello"
let n = if flag { take(s: label) } else { 0 }
n + label.len() // error: label may be consumed
}
fn take(sink s: String) -> I32 { s.len() }
pub fn run_checks() {
let mut label: String = "hello"
let a = take(s: label)
label = "hi" // the binding takes a new value
assert(condition: a + label.len() == 7)
}
Closure parameters carry the same conventions; the convention constrains the caller of the closure: see Closures for details.
Exclusive Access
A mut argument and a sink argument each need sole access to the
value for the whole call. So two arguments of one call must not reach
the same value when one of them is mut or sink:
fn swap(mut a: I32, mut b: I32) {
let t = a
a = b
b = t
}
fn put(mut a: I32, b: I32) {
a = b
}
pub fn f() {
let mut x: I32 = 1
swap(a: x, b: x) // error E144: two arguments reach 'x'
put(a: x, b: x) // error E144: 'b' reads what 'a' changes
}
A default argument goes by pointer too, so it would see the change that the callee makes. That is why a read and a write may not share a value. Two reads may.
The check follows fields. Two different fields of one binding are two places, and a field is part of its struct:
pub struct Point { x: I32, y: I32 }
fn swap(mut a: I32, mut b: I32) {
let t = a
a = b
b = t
}
fn reset(mut p: Point, x: I32) {
p.x = x
}
pub fn f() {
let mut p: Point = Point(x: 1, y: 2)
swap(a: p.x, b: p.y) // ok: two places
reset(p: p, x: p.x) // error E144: 'p' holds 'p.x'
}
The receiver of a method counts as an argument, with the convention of
its self parameter. So c.set(to: c.n) is an error when set takes
mut self. A call to a closure binding follows the same rule.
To pass the same value twice, copy it into its own binding first. Under value semantics the copy is a separate place:
fn put(mut a: I32, b: I32) {
a = b
}
pub fn run_checks() {
let mut x: I32 = 1
let y: I32 = x // a copy: a separate place
x = 7
put(a: x, b: y) // ok
assert(condition: x == 1)
}
Function Overloading
Multiple functions with the same name are allowed when their signatures differ. The compiler selects the right overload at each call site.
By labels and count: the labels of the call and the number of arguments pick the overload:
fn format(value: I32) -> String { "number" }
fn format(value: I32, precision: I32) -> String { "precise" }
fn format(text: String) -> String { "text" }
pub fn run_checks() {
assert(condition: format(value: 1) == "number")
assert(condition: format(value: 1, precision: 2) == "precise")
assert(condition: format(text: "a") == "text")
}
By argument types: when the labels and the count fit more than one overload, the types of the arguments pick one. This works with labels and with positions:
fn describe(value: I32) -> String { "I32" }
fn describe(value: I64) -> String { "I64" }
fn describe(value: String) -> String { "String" }
fn process(I32) -> String { "number" }
fn process(String) -> String { "string" }
pub fn run_checks() {
assert(condition: describe(value: 1) == "I32")
assert(condition: describe(value: 1I64) == "I64")
assert(condition: describe(value: "a") == "String")
assert(condition: process(1) == "number")
assert(condition: process("a") == "string")
}
Rules:
- An overload fits a call when each label of the call names one of its parameters, the argument count is between its required and its declared parameters, and each argument has the type of its parameter
- The types are compared exactly first. An unsuffixed literal fits its
default type first:
describe(value: 1)means theI32overload. If no overload fits exactly, the literal fits any numeric type of its kind - Of the overloads that fit, the one that leaves the fewest parameters to their defaults wins
- Two definitions with the same labels and parameter types are a
DuplicateDefinition, also when their return types differ - A parameter with no name, as in
fn process(I32), takes only an argument by position - A call that no overload fits is the error
NoMatchingOverload. A call that two overloads fit equally well is the errorAmbiguousCall
Methods
A method overloads the same way a free function does, and the same rules pick the one a call means:
pub struct Formatter {
tag: I32
}
impl Formatter {
fn format(self, text: String) -> String { text }
fn format(self, value: I32, prefix: String) -> String { prefix }
}
let a: String = Formatter(tag: 1).format(text: "x")
let b: String = Formatter(tag: 1).format(value: 2, prefix: "p")
The receiver is not counted when the labels are matched: a call supplies it separately.
Closures
Closure types (function-shaped types in fields, params, returns) live in Type System / Closure Types. This page covers the expression form: the values you assign to those types.
Closures are single-expression functions. Every closure form
wraps its parameter list in parentheses, mirroring fn signatures, so
every -> in the language is preceded by ):
pub enum Event {
textChanged(value: String),
resized(width: I32, height: I32),
submit
}
struct Form<E> {
onChange: (String) -> E,
onResize: (I32, I32) -> E,
onSubmit: () -> E,
onScale: (mut I32) -> E,
onConsume: (sink String) -> E
}
fn make_form() -> Form<Event> {
Form<Event>(
// Single parameter — parens required
onChange: (x) -> .textChanged(value: x),
// Multiple parameters — comma separated, inside the parens
onResize: (w, h) -> .resized(width: w, height: h),
// No parameters — empty parens
onSubmit: () -> .submit,
// mut convention: caller must pass a mutable binding
onScale: (mut n) -> .resized(width: n, height: n),
// sink convention: caller's binding is consumed
onConsume: (sink s) -> .textChanged(value: s)
)
}
Each field type gives the closure its parameter types and its return type, so the closures need no annotations.
Expression syntax:
| Parameters | Syntax | Example |
|---|---|---|
| None | () -> expr | () -> .submit |
| One | (x) -> expr | (x) -> .changed(value: x) |
| One (mut) | (mut x) -> expr | (mut n) -> .resized(width: n, height: n) |
| One (sink) | (sink x) -> expr | (sink s) -> .text(value: s) |
| Multiple | (x, y) -> expr | (x, y) -> .point(x: x, y: y) |
| With types | (x: T) -> expr | (x: String) -> .text(x: x) |
Rules:
- The body is one expression. A block
{ ... }is one expression, so a body can holdletstatements inside braces. - The parameter list is always parenthesised — even for a single
parameter — so
->is unambiguous. - Empty parameters:
() -> expr. - Convention keywords (
mut,sink) precede the parameter name inside the parens. - A parameter type is optional only where a declared type gives it. See Parameter Types.
- Convention on a closure param means the caller of the closure must satisfy it.
- Closures are internal-only: a
pub structfield, or apub enumvariant field, cannot have a closure type. Drop thepub(so the type stays inside its module), or replace the field with a non-closure type.
Parameter Types
A closure parameter with no type takes its type from the position of the closure. A position gives a type only when a declared type gives it:
- the annotation of a
let:let f: (I32) -> I32 = (x) -> x + 1; - the declared type of a parameter, for an argument:
apply(f: (x) -> x * 2); - the declared type of a field, and of a parameter or field default;
- the declared return type, for the result of a function, or of a closure that declares its return type.
The type goes through ( ), the branches of if and match, the
result of a block, and the elements of an array, a tuple and a
dictionary. An optional closure slot, such as (() -> E)?, gives the
type of its closure.
In each other position, a parameter with no type is an error:
let f = (x) -> x + 1 // error E145: 'x' needs a type
let g = (x: I32) -> x + 1 // ok
let h: (I32) -> I32 = (x) -> x // ok
The compiler does not infer a parameter type from the body, or from a later call. A closure with the wrong number of parameters for its position is a type mismatch.
The Call Shape
A call to a closure gives one argument for each parameter the closure type declares, and each argument must have the parameter's type:
fn apply(f: (I32, I32) -> I32, a: I32, b: I32) -> I32 {
f(a, b) // ok
}
fn wrong(f: (I32) -> I32) -> I32 {
f(1, 2) // error E138: this closure takes 1 argument
}
fn also_wrong(f: (I32) -> I32) -> I32 {
f("text") // error: TypeMismatch: expected I32
}
A closure type has no parameter names, so a call to a closure takes
its arguments by position. A label is the error E150
(LabelledClosureArgument):
fn apply(f: (I32) -> I32) -> I32 {
f(x: 1) // error E150: a closure call takes no labels
}
Returning a Closure
A function can return a closure. A closure captures by value: when the
closure is made, it keeps a copy of each binding that its body names.
So a returned closure can capture a module let, a plain parameter, a
mut parameter or a local let. A later change to the binding does
not change the copy. A sink parameter moves into the closure, and the
function cannot use it after that:
pub let base: I32 = 100
fn from_base() -> (I32) -> I32 {
(y) -> y + base // ok: a module let
}
fn from_owned(sink step: I32) -> (I32) -> I32 {
(y) -> y + step // ok: step moves into the closure
}
fn adder(x: I32) -> (I32) -> I32 {
(y) -> x + y // ok: a copy of the parameter
}
fn from_local() -> (I32) -> I32 {
let k = 3
(y) -> y + k // ok: a copy of the local let
}
pub fn run_checks() {
assert(condition: from_base()(1) == 101)
assert(condition: from_owned(step: 2)(1) == 3)
assert(condition: adder(x: 2)(3) == 5)
assert(condition: from_local()(1) == 4)
}
Caller Constraints
When a closure type carries mut or sink, every caller is checked
against that requirement at compile time:
let scale: (mut I32) -> I32 = (mut n) -> n
let mut x: I32 = 10
let _r: I32 = scale(x) // ok: x is mutable
let y: I32 = 5
let _s: I32 = scale(y) // error: MutabilityMismatch: y is immutable
let consume: (sink String) -> String = (sink s) -> s
let label: String = "hello"
let _a: String = consume(label) // ok: label is moved
let _b: String = label // error: UseAfterSink: label was consumed
Expressions
This page covers value-producing expressions: literals, field access,
destructuring, operators, and the range operator. For function-call
shapes see Functions; for closure expressions see
Closures; for control flow (if / for / match) see
Control Flow & Pattern Matching.
Literals
All literal types as expressions:
// String literals
let text = "Hello, World"
let multiline = """
Multi-line
string literal
"""
// Numeric literals (see Numeric Types for suffixes and defaults)
let integer = 42 // I32
let negative = -17 // I32
let float = 3.14 // F64
let with_underscore = 1_000_000 // I32
let wide: I64 = 9_223_372_036_854_775_807
let tagged = 3.14F32 // F32 via suffix
// Boolean literals
let yes = true
let no = false
// Nil literal
let nothing: String? = nil
// Array literals
let tags = ["urgent", "bug", "frontend"]
let numbers = [1, 2, 3, 4, 5]
let empty: [String] = []
// Dictionary literals
let settings: [String: I32] = ["timeout": 30, "maxRetries": 3]
let emptyDict: [String: Boolean] = [:]
An unsuffixed number takes its type from the position where it stands. The comments above give the type where no position gives one. See Type System / The type of a literal.
A plain string stays on one line: a line break in it is the error
UnterminatedString. A """ string can hold line breaks.
Escape sequences (strings): \", \\, \n, \t, \r, \uXXXX,
and \( for a hole: see String interpolation.
The Unicode escape takes exactly four hex digits, and it must name a
Unicode scalar value. A backslash before any other character is the
error InvalidEscape. A wrong \u escape is the error
InvalidUnicodeEscape: fewer than four digits, a character that is not
a hex digit, a surrogate such as \uD800, or the brace form \u{41}.
pub fn run_checks() {
assert(condition: "\u0041" == "A")
assert(condition: "\u00E9".len() == 2) // two bytes of UTF-8
assert(condition: "a\tb".len() == 3) // one byte for each escape
}
String interpolation
A hole, \(expression), puts the value of an expression into a string:
"total: \(n)". The code in a hole can hold parentheses, strings, and
holes of its own. A hole that its ) does not close leaves the string
open: the error UnterminatedString.
Each hole converts its value with Into<String>, through the prelude's
String.of(value: x). The prelude gives the conversion for String,
I32, I64, F32, F64 and Boolean; the host formats each number.
A type of your module gets it with impl Into<String>. The orphan rule
refuses impl From<Point> for String, because the module owns neither
From nor String. A hole of a type with no Into<String> is the
error GenericConstraintViolation.
pub struct Point {
x: I32,
y: I32
}
impl Into<String> for Point {
fn into(self) -> String { "(\(self.x), \(self.y))" }
}
pub fn run_checks() {
let n = 3
assert(condition: "n = \(n)" == "n = 3")
assert(condition: "\(n) + 1 = \(n + 1)" == "3 + 1 = 4")
assert(condition: "at \(Point(x: 1, y: 2))" == "at (1, 2)")
assert(condition: "inner: \("a \(n)")" == "inner: a 3")
}
Field Access
A . reads a field of a struct or a tuple. The reads chain:
pub struct Profile { avatar: String }
pub struct User { name: String, profile: Profile }
pub fn run_checks() {
let user = User(name: "Ada", profile: Profile(avatar: "ada.png"))
let point = (x: 1, y: 2)
assert(condition: user.name == "Ada") // a field
assert(condition: point.x == 1) // a tuple field
assert(condition: user.profile.avatar == "ada.png") // nested access
}
A field that the type does not have is the error UnknownField. An
enum, a number, an array and a closure have no fields.
Destructuring
Extract values from arrays, tuples and structs:
// Array destructuring (positional)
pub let items = ["first", "second", "third", "fourth"]
pub let [a, b] = items // a="first", b="second"
pub let [x, ...rest] = items // x="first", rest=["second", "third", "fourth"]
pub let [_, second, ...] = items // Skip first, get second, ignore rest
// Tuple destructuring (by position)
pub let pair = (x: 3, y: 4)
pub let (px, py) = pair // px=3, py=4
// Struct destructuring (by field name)
pub struct User { name: String, age: I32 }
pub let user = User(name: "Alice", age: 30)
pub let {name, age} = user // name="Alice", age=30
pub let {name as username} = user // Rename: username="Alice"
// Enum data: use `match`, not a destructuring pattern
pub enum AccountType {
admin,
user(permissions: [String], articles: [String])
}
pub fn article_count(account: AccountType) -> I32 {
match account {
.user(permissions, articles): articles.len(),
.admin: 0
}
}
An enum value is one of several variants, and each variant has its own
data, or none. So a destructuring pattern cannot take an enum value. To
read the data of a variant, use match, or if let for an optional.
A tuple pattern on an enum value is a type mismatch:
pub enum AccountType {
admin,
user(permissions: [String], articles: [String])
}
pub let account: AccountType = .admin
pub let (permissions, articles) = account // error: not a tuple
Rules:
- Array destructuring is positional (order matters)
- Tuple destructuring is positional too, and the pattern needs one name for each field of the tuple
- Struct destructuring is by field name. A name that is not a field is
the error
UnknownField - An array pattern on another type is the error
ArrayDestructuringNotArray. A struct pattern on another type, a tuple included, is the errorStructDestructuringNotStruct - An enum value cannot be destructured: use
match - Use
asto rename fields during destructuring - Use
_to skip array elements - Use
...for rest pattern (can appear anywhere in array destructuring) - Dictionaries do not support destructuring
Binary Operators
// Arithmetic
let sum = 10 + 20
let difference = 50 - 30
let product = 4 * 5
let quotient = 100 / 4
let remainder = 17 % 5
// Comparison
let greater = 10 > 5
let less = 3 < 7
let greaterEq = 10 >= 10
let lessEq = 5 <= 5
// Equality
let equal = 5 == 5
let notEqual = 5 != 10
// Logical
let andResult = true && false
let orResult = true || false
// String concatenation
let greeting = "Hello, " + "World"
// Complex expressions with precedence
let complex = (10 + 20) * 3
let condition = (5 > 3) && (10 < 20)
Unary Operators
- negates a number, and ! negates a Boolean. The operand is
checked: -"text" and -true are a TypeMismatch, and so is !1.
pub fn run_checks() {
let n: I32 = 5
let s = "abc"
assert(condition: -n == 0 - 5)
assert(condition: !(n > 10))
assert(condition: -s.len() == -3) // `.` binds before `-`
}
Operator Precedence
From highest to lowest:
- Parentheses:
( ) - Field access, call and index:
.,f(...),xs[i] - Unary:
-,! - Multiplicative:
*,/,% - Additive:
+,- - Comparison:
<,>,<=,>= - Equality:
==,!= - Logical AND:
&& - Logical OR:
|| - Range:
..
A comparison does not chain: 1 < 2 < 3 compares a Boolean with a
number, which is an InvalidBinaryOp.
Examples:
pub struct User { age: I32, verified: Boolean }
pub fn run_checks() {
let x = 7
let y = 3
let user = User(age: 30, verified: true)
assert(condition: 10 + 20 * 3 == 70) // multiplication first
assert(condition: (10 + 20) * 3 == 90) // parentheses override
assert(condition: x > 5 && y < 10) // comparison before AND
assert(condition: true || false && false) // AND before OR
assert(condition: user.age > 18 && user.verified) // field access, comparison, AND
assert(condition: -2 * 3 == -6) // unary before multiplication
}
Operand Types
Each operator group accepts a limited set of operands. There is no
implicit conversion between types, so the two operands must have the
same type. An unsuffixed numeric literal takes its type from the other
operand: in n + 1 with n: I64, the 1 is an I64. See
the type of a literal.
| Group | Operands |
|---|---|
+ | two numbers of one type, or two strings (concatenation) |
-, *, /, % | two numbers of one type |
<, >, <=, >= | two numbers of one type |
==, != | two values of one type, if the type is equatable |
&&, || | two booleans |
.. | two integers (I32 or I64) |
An optional compares to nil, which is the plainest way to ask
whether it holds anything. Either side may be the nil:
pub fn run_checks() {
let held: I32? = 5
let empty: I32? = nil
assert(condition: !(held == nil))
assert(condition: empty == nil)
assert(condition: nil == empty) // the same question
assert(condition: held != nil)
}
This answers exactly what .is_some() and .is_none() answer; pick
whichever reads better where it sits.
Two optionals of one type compare too: they are equal when both are
nil, or when both hold equal values. An optional and a plain value do
not compare, because they are two types: held == 5 is an
InvalidBinaryOp. Unwrap the optional with if let first. The
language has no ?? operator and no ?. chaining.
Equality is structural: it compares each field of a struct and each element of a container. A closure has no structure to compare, so a closure is not equatable. Neither is a type that holds one — an array of closures, or a struct with a closure field.
Indexing
Both array indexing (xs[i]) and dictionary lookup (d[k]) return an
optional value (T?). The bound may be missing for an array index and
the key may be absent from a dictionary, so the result is wrapped in an
optional and the caller is responsible for handling the nil case.
pub fn run_checks() {
let xs: [I32] = [1, 2, 3]
let first: I32? = xs[0] // I32?, not I32
let cfg: [String: I32] = ["timeout": 30]
let t: I32? = cfg["timeout"] // same shape
// Unwrap with `if let`, and give the value for the nil case
let timeout: I32 = if let v = t { v } else { 60 }
assert(condition: timeout == 30)
assert(condition: first != nil)
assert(condition: xs[3] == nil)
}
To get a plain value, unwrap the optional with if let and give a
fallback for the nil case, or match on .some and .none. See
Control Flow.
An index into optional elements gives one optional. The language has
one level of absence (see Type System / Optional Types),
so os[0] on an [I32?] is an I32?, not an I32??. The .some arm
of a match on it binds an I32. The result is nil for a nil
element and also for an index past the end. The program cannot tell
these two cases apart. A dictionary of optionals obeys the same rule:
a nil value and a missing key both give nil.
pub fn read(os: [I32?], i: I32) -> I32 {
match os[i] { .some(v): v, .none: 50 }
}
pub fn run_checks() {
let os: [I32?] = [1, nil]
assert(condition: read(os: os, i: 0) == 1)
assert(condition: read(os: os, i: 1) == 50) // a nil element
assert(condition: read(os: os, i: 5) == 50) // past the end
let ds: [String: I32?] = ["a": 1, "b": nil]
let b: I32? = ds["b"] // I32?, not I32??
assert(condition: b == nil)
assert(condition: ds["z"] == nil) // a missing key
}
The type of the index is checked: an array takes an I32 position,
and a dictionary takes a key of its key type. See
Type System / Indexing.
An index reads; it never writes. xs[0] = 9 is an error, and let mut
does not make it legal. See
Type System / Immutable Elements.
Range Operator
The .. operator produces a range from a start value (inclusive) to an end
value (exclusive). It is the lowest-precedence binary operator, so its
operands are evaluated before the range itself.
A range counts in steps of one, so both bounds must be integers
(I32 or I64). To iterate floats, put them in an array.
pub fn run_checks() {
let n = 4
let start = 2
let length = 3
// A simple range
let digits = 0..10
// Iterating over a range. A `for` yields a lazy sequence, so a
// terminal combinator ends the pipeline.
let count: I32 = for i in 0..n { i }.count()
// Range with arithmetic on the bounds
let window = start..(start + length)
assert(condition: digits.len() == 10)
assert(condition: count == 4)
assert(condition: for i in window { i }.fold(initial: 0, f: (a, b) -> a + b) == 9)
}
Control Flow & Pattern Matching
All control flow is compile-time validated. Each form is an expression: it evaluates to a value.
For Expressions
A for produces a lazy sequence, Seq<T>. It is not a
collection: it holds no elements and nothing runs until a terminal
combinator consumes it. So a chain of stages collapses into a single
pass with no intermediate array, and the one step that allocates is
the one you can see.
pub struct User { emails: [String] }
extern fn validate(address: String) -> Boolean
pub fn check(user: User, matrix: [[I32]]) -> I32 {
// Ends in a single value: no allocation, one pass.
let total: I32 = for n in [1, 2, 3, 4, 5] { n }.fold(initial: 0, f: (a, b) -> a + b)
// Ends in an array: one allocation, and `collect` says so.
let doubled: [I32] = for n in [1, 2, 3] { n * 2 }.collect()
// Runs for its calls alone.
for email in user.emails { validate(address: email) }.run()
// Iterates a range.
let count: I32 = for i in 0..10 { i }.count()
// Nested: the inner sequence is made and consumed within one step of
// the outer one, so neither builds an array.
let sum: I32 = for row in matrix {
for cell in row { cell }.fold(initial: 0, f: (a, b) -> a + b)
}.fold(initial: 0, f: (a, b) -> a + b)
total + doubled.len() + count + sum
}
Combinators
for is in the language; the combinators are in the standard library.
A pipeline normally uses both.
| Kind | Members |
|---|---|
Adapter, Seq to Seq | map, filter, take, skip |
| Terminal, ends the pipeline | collect, count, fold, first, any, all, run |
pub fn run_checks() {
let xs = [3, 6, 9]
let big: I32 = for x in xs { x * 2 }
.filter(f: (v) -> v > 10)
.count()
assert(condition: big == 2)
}
map can change the element type: its closure gives the new one.
pub fn run_checks() {
let xs = [5, 15]
let flags: [Boolean] = for x in xs { x }.map(f: (v) -> v > 10).collect()
assert(condition: flags.len() == 2)
}
collect has two forms. With no arguments, it makes an array. With
key and value, it makes a dictionary: the two closures give the
entry of each element.
pub struct User { id: I32, name: String }
pub fn run_checks() {
let users = [User(id: 1, name: "Ada"), User(id: 2, name: "Bo")]
let ids: [I32] = for u in users { u.id }.collect()
let names: [I32: String] = for u in users { u }
.collect(key: (u) -> u.id, value: (u) -> u.name)
assert(condition: ids.len() == 2)
assert(condition: names[2] != nil)
}
When two elements give the same key, the value of the later element replaces the earlier one. The entry keeps its place, and the dictionary holds one entry for the key.
The accumulator of fold takes the type of initial. This type can
be different from the element type:
pub fn run_checks() {
let xs = [4, 12]
let any_big: Boolean = for x in xs { x }
.fold(initial: false, f: (seen, x) -> seen || x > 10)
assert(condition: any_big)
}
A sequence is consumed exactly once
At most once. Reading a sequence twice is an error (E136). A
sequence runs once and keeps nothing, so a second read cannot mean
what you want. To read the same values twice, collect() into an
array first and iterate that.
At least once. Dropping a sequence is an error (E135). A dropped
sequence is not like a dropped number: drop an I32 and a value was
computed and ignored, but drop a Seq and nothing ran at all.
extern fn log(message: String)
pub fn f(xs: [I32]) {
for x in xs { log(message: "x") } // E135: the logs never run
}
pub fn f(xs: [I32]) -> I32 {
let s = for x in xs { x }
let a: I32 = s.count()
let b: I32 = s.count() // E136: s holds nothing now
a + b
}
No operator is a terminal. So s == t on two sequences drops both,
and nothing runs: this is the error E135.
A sequence is read once also where the program branches: a read in
each branch of an if, or in each arm of a match, is one read. A
read in only one branch leaves the sequence unconsumed on the other
path, which is the error E135.
Rules:
- The source is an array, a range, or another sequence. Any other
source, such as a dictionary or an optional array, is the error
ForLoopNotArray - The result is
Seq<body_type>, consumed exactly once - The loop variable is scoped to the body and typed as the element
- A sequence lives only where it is made and consumed. It holds no
elements and runs once, so there is nothing to store or hand back.
These positions are the error
SeqInvalidPosition(E137): a struct field, an enum variant field, a module-levellet, a function return type, a closure return type, and a value inside another type, such as[Seq<I32>],Seq<I32>?or a tuple field. A closure with no written return type returns the type of its body, soxs.fold(s, (a, b) -> a)with a sequencesis E137. Aforbody that gives a sequence makesSeq<Seq<T>>, which is E137 too - A parameter can take a sequence only with the
sinkconvention, because the function must consume it:fn total(sink s: Seq<I32>). Asinkparameter of typeSeq<I32>?is still E137 - An
extern fnmay return a sequence: the host produces the elements. See Large Data
If Expressions
Conditional expressions:
pub fn size_label(count: I32, x: I32) -> String {
// Boolean condition
let items = if count > 0 {
"some"
} else {
"none"
}
// Chained conditions
if x > 100 {
"large"
} else if x > 50 {
"medium"
} else {
items
}
}
pub fn run_checks() {
assert(condition: size_label(count: 1, x: 200) == "large")
assert(condition: size_label(count: 0, x: 1) == "none")
}
The condition must be a Boolean. Any other type is the error
InvalidIfCondition. The two branches must have one type.
Without else, an if is nil when the condition is false. So the
type of an if with no else is optional. It fits an optional binding
and not a plain one:
pub fn f(is_admin: Boolean) -> I32 {
let a: I32? = if is_admin { 1 } // ok
let b: I32 = if is_admin { 1 } // error: an I32? does not fit I32
b
}
Optionals in conditionals (if let):
To consume an optional value, use if let to bind the inner value
inside the truthy branch. The form is Rust-style: pattern, equals,
optional expression, then both branches.
pub struct User { name: String, nickname: String? }
fn greet(name: String) -> String { "Hi, " + name }
pub fn welcome(user: User) -> String {
if let nickname = user.nickname {
// nickname is bound to the unwrapped String here
greet(name: nickname)
} else {
// taken when user.nickname is nil
greet(name: user.name)
}
}
pub fn run_checks() {
assert(condition: welcome(user: User(name: "Ada", nickname: "A")) == "Hi, A")
assert(condition: welcome(user: User(name: "Ada", nickname: nil)) == "Hi, Ada")
}
if let requires both a then and an else branch, since the result
type unifies the two. The else branch is taken when the optional is
nil. Internally if let pat = optional { … } else { … } desugars to a
match on .some(pat) and .none, so its semantics match a two-arm
match expression.
Match Expressions
Pattern matching on enums (exhaustive):
pub enum Status { pending, active, completed }
pub fn step(status: Status) -> I32 {
match status {
.pending: 0,
.active: 1,
.completed: 2
}
}
// With data binding
pub enum Message {
text(content: String),
image(url: String, size: I32)
}
pub fn weight(message: Message) -> I32 {
match message {
.text(content): content.len(),
.image(url, size): url.len() + size
}
}
// `_` ignores a value
pub fn size(message: Message) -> I32 {
match message {
.text(_): 0,
.image(_, s): s
}
}
pub fn run_checks() {
assert(condition: step(status: .active) == 1)
assert(condition: weight(message: .text(content: "hey")) == 3)
assert(condition: weight(message: .image(url: "/a", size: 10)) == 12)
assert(condition: size(message: .image(url: "/a", size: 10)) == 10)
}
Rules:
- The value must be an enum or an optional. Any other type is the
error
MatchNotEnum. An optional has the arms.some(x)and.none - Must be exhaustive (cover all variants): a missing variant is the
error
NonExhaustiveMatch. A_arm covers each variant that no arm above it names - Pattern uses
.variantsyntax (short form) - Each name in the pattern takes the associated value at the same
position in the variant. The names are free:
.image(u, s)bindsutourlandstosize. A name does not select a field by its name - The pattern gives one name for each associated value. Another count
is the error
VariantArityMismatch. A name that the arm does not read is legal - Write
_in place of a name to ignore that value..image(_, s)binds onlys. A_counts as one value, and it can occur more than once, as in.pair(_, _) - A name bound in an arm is visible only in that arm
- Two arms for one variant are the error
DuplicateMatchArm - All arms must have one type
An Arm Below _ Never Runs
A _ arm takes every value the arms above it did not, so nothing
below it can run:
pub enum E { a, b }
pub fn f(e: E) -> I32 {
match e {
_: 0,
.a: 1 // error E140: this arm can never run
}
}
Write the named arms first and _ last.
Large Data
FormaLang is built for programs where a loop over a million elements
is the normal case, not the exception. Two properties make that work,
and both come from the same decision: a for yields a lazy
sequence, not an array.
- A pipeline holds memory that does not grow with the input.
- A pipeline makes one pass over the data.
Why a loop does not allocate
A for produces Seq<T>. A sequence holds no elements. Nothing runs
until a terminal combinator consumes it, and consuming it drives the
whole chain in a single pass.
pub fn total(xs: [I32]) -> I32 {
// One pass, no allocation. The intermediate stages never exist.
for x in xs { x * 2 }
.filter(f: (v) -> v > 10)
.fold(initial: 0, f: (a, b) -> a + b)
}
pub fn run_checks() {
assert(condition: total(xs: [1, 6, 10]) == 32)
}
Had a loop produced an array, the same three stages over one million
I32 values would allocate three arrays and walk memory three times,
to produce one number.
collect() is the one call in a pipeline that allocates for the data,
and it is visible on the line that asks for it:
pub fn doubled(xs: [I32]) -> [I32] {
for x in xs { x * 2 }.collect()
}
Where the data lives
For real volumes the data belongs to the host, not to the program. It never has to be copied in.
Pull on the way in. An extern fn that returns a sequence is a
cursor. The host produces the elements and the generated code pulls
them one at a time, so the host's million rows stay in the host's
memory:
pub struct Row { id: I32, score: I32 }
extern fn rows() -> Seq<Row>
Push on the way out. A program does not hand a sequence back. It calls a hook once per element:
extern fn emit(id: I32)
Put together, a program that filters ten million rows holds a working set of one row:
pub struct Row { id: I32, score: I32 }
extern fn rows() -> Seq<Row>
extern fn emit(id: I32)
pub fn process() {
for row in rows() { row }
.filter(f: (r) -> r.score > 0)
.map(f: (r) -> emit(id: r.id))
.run()
}
The rules that keep it honest
A sequence is consumed exactly once. Both halves of that rule earn their place.
At most once is what makes the single pass a guarantee rather than a hope. A sequence has at most one reader, so there is never a second consumer to build an array for, and the compiler never has to decide whether joining two stages is safe. There is no case where a pipeline quietly allocates.
At least once is what stops a loop that never runs from looking like work:
extern fn emit(id: I32)
pub fn send(xs: [I32]) {
for x in xs { emit(id: x) } // E135: the calls never happen
}
Add .run() and the intent is on the page.
Choosing the terminal
| You want | Use | Allocates |
|---|---|---|
| one value | fold, count, first, any, all | no |
| the effects only | run | no |
| an array | collect | yes, once |
Reach for collect() when you genuinely need random access or a
second pass over the same values. Everywhere else, a terminal that
reduces keeps the program flat in memory however large the input
grows.
Generics
Full generic type system with constraints and type inference.
Generic Structs
// Single type parameter
pub struct Box<T> {
value: T
}
// Multiple type parameters
pub struct Pair<A, B> {
first: A,
second: B
}
// With constraints
pub trait Layout {
width: I32
}
pub struct Container<T: Layout> {
items: [T],
gap: I32
}
// Multiple constraints
pub trait Renderable { fn render(self) -> Boolean }
pub trait Clickable { fn click(self) -> Boolean }
pub struct Widget<T: Renderable + Clickable> {
component: T
}
Generic Traits
pub trait Collection<T> {
items: [T]
}
pub trait Comparable<T> {
fn compare(self, other: T) -> I32
}
Generic Enums
pub enum Result<T, E> {
ok(value: T),
error(err: E)
}
pub enum Option<T> {
some(value: T),
none
}
Generic Instantiation
pub struct Box<T> { value: T }
pub struct Pair<A, B> { first: A, second: B }
pub enum Result<T, E> { ok(value: T), error(err: E) }
pub enum Option<T> { some(value: T), none }
// With explicit type arguments
pub let string_box = Box<String>(value: "hello")
pub let number_box = Box<I32>(value: 42)
pub let pair = Pair<I32, Boolean>(first: 42, second: true)
// Type inference (when inferrable)
pub let inferred_box = Box(value: "inferred as String")
pub let inferred_pair = Pair(first: 10, second: true)
// Generic enums
pub let success: Result<String, I32> = .ok(value: "success")
pub let failure: Result<String, I32> = .error(err: 404)
pub let maybe: Option<I32> = .some(value: 42)
pub let nothing: Option<I32> = .none
// A variant path takes the type arguments too
pub let also_nothing = Option<I32>.none
An instantiation with no <...> takes its type arguments from the
type that its position expects: a let annotation, a parameter, a
return type, a field, an element, or the branches of an if or a
match. Only when the position expects nothing do the arguments come
from the fields or the payload.
pub struct Box<T> { value: T }
pub fn run_checks() {
let b: Box<I32?> = Box(value: 1) // a Box<I32?>, not a Box<I32>
let xs: [Box<I32?>] = [Box(value: 2), Box(value: nil)]
assert(condition: b.value != nil)
assert(condition: xs.len() == 2)
}
Two instances of one generic type are two types. A value of type
Box<I32> does not fit where a Box<I32?> goes, even though an I32
fits where an I32? goes:
pub struct Box<T> { value: T }
pub fn run_checks() {
let a = Box(value: 1) // a Box<I32>
let b: Box<I32?> = a // error: TypeMismatch
}
A type parameter that only the return type mentions takes its type
from the position of the call: a let annotation, a parameter, a
return type, a field, a payload, an assignment, an element, or the
branches of an if or a match. The call is then the same as a call
with <...>:
pub struct Meters { v: I64 }
impl From<Literal<I64>> for Meters {
fn from(value: Literal<I64>) -> Meters { Meters(v: value.value) }
}
fn zero<T: From<Literal<I64>>>() -> T { 0 }
pub let d: Decimal = zero() // zero<Decimal>()
pub let m: Meters = zero() // zero<Meters>()
pub let o: Meters? = zero() // zero<Meters>(): a value fits its optional
A return type of T in a position that expects X? gives T the
type X when X meets the bounds of T, and else X?. With no
expected type, let d = zero() is the error
UninferableMethodTypeParameter (E147).
Generic Methods
A method can declare its own type parameters, after its name. They sit beside the type parameters of the impl block:
pub struct Box<T> {
value: T
}
impl Box<T> {
fn map<U>(self, f: (T) -> U) -> Box<U> {
Box(value: f(self.value))
}
}
pub let flag = Box(value: 3).map(f: (v) -> v > 2) // Box<Boolean>
A type parameter that only the return type mentions takes its type from the position of the call, on a value and on a type:
pub struct Meters { v: I64 }
impl From<Literal<I64>> for Meters {
fn from(value: Literal<I64>) -> Meters { Meters(v: value.value) }
}
pub struct Scale { n: I64 }
impl Scale {
fn unit<T: From<Literal<I64>>>() -> T { 1 }
fn size<T: From<Literal<I64>>>(self) -> T { 2 }
}
pub let u: Meters = Scale.unit() // unit with T = Meters
pub let d: Decimal = Scale(n: 1).size() // size with T = Decimal
Rules:
- A call may write the method's type arguments after its name, as for
a function:
s.size<Decimal>(). A written type must meet the bounds (GenericConstraintViolation), the call must write one for each type parameter (GenericArityMismatch), and an argument must agree with it (TypeMismatch) - With no
<...>, the arguments give each type parameter of the method its type, as for a free function. A type parameter that no argument binds takes its type from the position of the call, through the return type. So each one must appear in the type of a parameter with no default, or in the return type (E147) - A call may leave out a parameter with a default, and then that
parameter gives no type. A call whose arguments and position give a
type parameter no type is the error E147 at the call:
let x = s.convert()forfn convert<T>(self) -> T - The type parameters of the impl block come from the receiver, never from the position of the call
- A closure argument can give a type parameter its type through its
return type:
Uabove takes the type thatfanswers. A closure parameter with no type cannot: infn apply<U>(self, f: (U) -> I32), the callb.apply(f: (x) -> 1)givesxno type (E145). Write it:(x: String) -> 1 - A method type parameter cannot repeat a name of its impl block, or of the type when the impl block names none (E084)
- A trait method may declare type parameters of its own too. See Traits
- A type parameter hides a type with the same name inside its definition
Type Constraints
pub trait Named { name: String }
pub trait Layout { width: I32 }
pub trait Renderable { fn render(self) -> Boolean }
pub trait Clickable { fn click(self) -> Boolean }
// Single constraint
pub struct Wrapper<T: Named> {
item: T
}
// Multiple constraints
pub struct Interactive<T: Renderable + Clickable> {
component: T
}
// Constraint on trait field
pub trait Container<T: Layout> {
items: [T]
}
Rules:
- Type parameters use
<T>,<A, B>, etc. - Constraints use
:syntax:<T: Constraint> - Multiple constraints use
+:<T: A + B> - Type arguments must match parameter count (arity). A wrong count,
or type arguments on a type that takes none, is the error
GenericArityMismatch - A type parameter is visible only in its own definition. A use
outside it is the error
OutOfScopeTypeParameter - Type inference works when types can be determined. Two arguments
that give one type parameter two types are a
TypeMismatch - A free function gives each type parameter its type through
<...>at the call, through an argument, or through the type that the position of the call expects. The arguments give their types first. The position gives a type only to a parameter that the arguments leave free, through the return type of the function fn make<T>() -> I32needsmake<I32>(): a baremake()is the errorUninferableMethodTypeParameter(E147). Neither an argument nor the return type givesTa type- The type arguments at the call give each type parameter its type
in the function, and a copy of the function for those types.
make<I32>()andmake<String>()are two calls of two copies - Constraints must reference existing traits: an unknown name is the
error
UndefinedTrait, and a struct is the errorNotATrait - A bound can name the type parameter that it bounds:
<T: Comparable<T>>. WithT = N, the bound isComparable<N> - A body that compares values of a type parameter with
==or!=needs an equatable type argument. A closure, and a type that holds one, is not equatable:same(a: f, b: g)on two closures is the errorNotEquatableTypeArgument(E177). This holds through a generic caller that gives its own type parameter tosame - A constraint holds wherever the type argument comes from — written at
the call site (
total<Square>(item: s)) or inferred from the argument (total(item: s))
pub trait Shape { fn area(self) -> I32 }
pub struct Square { side: I32 }
pub struct Plain { n: I32 }
impl Shape for Square {
fn area(self) -> I32 { self.side * self.side }
}
fn total<T: Shape>(item: T) -> I32 { item.area() }
let a: I32 = total(item: Square(side: 3)) // ok
let b: I32 = total(item: Plain(n: 1)) // error E081: Plain is not a Shape
fn same<T>(a: T, b: T) -> Boolean { a == b }
let a: Boolean = same(a: 1, b: 1) // ok
let b: Boolean = same(a: (x: I32) -> x, b: (x: I32) -> x) // error E177
Monomorphisation
The MonomorphisePass clones generic definitions per unique
argument tuple after parsing: see
Built-in Passes / MonomorphisePass.
The set of clones must be finite. A generic function that calls
itself with a larger type argument than its own, such as Box<T> for
T, would need a new clone at each level. The pass stops at 32 levels
with the error InstantiationDepthExceeded (E148). A written type that
nests its type arguments more than 32 levels deep gets the same error.
struct Box<T> { value: T }
fn grow<T>(x: T, n: I32) -> I32 {
if n <= 0 { 0 } else { 1 + grow(x: Box(value: x), n: n - 1) }
}
pub fn probe() -> I32 { grow(x: 1, n: 3) }
A generic function that calls itself must call itself with the same type arguments. Wrap the growing value in a non-generic type, or give it a fixed type.
Module System
Use Statements
A module is a .fv file. A use statement imports items from another
module. Take a project with these files:
project/
├── main.fv
├── types.fv
├── components.fv
├── widgets/
│ └── controls.fv
└── utils/
└── helpers.fv
pub struct User { name: String, age: I32 }
impl User {
fn is_adult(self) -> Boolean { self.age >= limit() }
}
// Private: other modules cannot import it, but the module's own code
// still calls it.
fn limit() -> I32 { 18 }
pub struct Button { label: String }
pub struct Text { value: String }
pub struct VStack { gap: I32 }
pub struct Slider { value: I32 }
pub fn formatDate(day: I32) -> String { "day " }
main.fv imports from each of them:
// Import a single item
use types::User
// Import multiple items
use components::{Button, Text, VStack}
// Import from a nested path: widgets/controls.fv
use widgets::controls::Slider
use utils::helpers::formatDate
pub fn run_checks() {
let user = User(name: "Ada", age: 36)
assert(condition: user.is_adult()) // the impl block comes too
assert(condition: Button(label: "ok").label == "ok")
assert(condition: Slider(value: 3).value == 3)
assert(condition: formatDate(day: 1) == "day ")
}
Module Resolution:
- Modules map to
.fvfiles - Path separators use
::. The segments before the last one name the file:use widgets::controls::Sliderreadswidgets/controls.fv. The last segment, or each name in{ }, names an item of that file - A
useimports items. It does not import a module as a whole:use utils::helperslooks for an itemhelpersinutils.fv - A path that no file matches is the error
ModuleNotFound. A name that the file does not declare is the errorImportItemNotFound - Can only import
pubitems. A private item is the errorPrivateImport - No circular imports allowed: two modules that import each other are
the error
CircularImport
What an Import Brings
An import brings the item and what belongs to it:
- A struct or an enum comes with its impl blocks, so its methods work in the importer.
- A trait impl is part of its type. The importer can call the methods
of the impl with no
useof the trait. - A trait comes with its blanket impls. Each type of the importer that meets the bounds gets the trait: see Blanket Impls.
- A blanket impl of the importer is for each type of an imported module that meets its bounds, also a type that the importer does not import by name but gets as a value, as for a type of an inline module.
- Two module files can each declare a trait of one name. They stay two traits in the importer: a bound on one does not accept an impl of the other, and each call runs the impl of its own trait.
- The imported code keeps its own private helpers.
is_adultabove calls the privatelimit, and the importer cannot namelimit.
pub trait Area { fn area(self) -> I32 }
pub struct Square { side: I32 }
impl Area for Square {
fn area(self) -> I32 { self.side * self.side }
}
use geom::Square
pub fn run_checks() {
assert(condition: Square(side: 3).area() == 9) // no `use geom::Area`
}
Which module may write an impl
An impl of a trait is in the module that declares the trait or the
target type. This is the orphan rule. The prelude declares the
primitives. An impl in another module is the error OrphanImpl (E179).
So two modules can never give one type two impls of one trait. An impl
with no trait, impl String { ... }, has no such rule.
Re-exports
A plain use makes the name available inside the importing module
only. Another module cannot import the name through it. A pub use
exports the name again, so a module can collect items from several
files:
pub use types::User
pub use geom::Square
use prelude::{User, Square}
pub fn run_checks() {
assert(condition: Square(side: 2).area() == 4)
assert(condition: User(name: "Bo", age: 9).age == 9)
}
The module plain.fv imports Button with a plain use:
use components::Button
So another module cannot import Button from it:
use plain::Button
Nested Modules
Use mod blocks to create nested namespaces within a file:
mod alignment {
pub enum Vertical {
top,
center,
bottom
}
pub enum Horizontal {
left,
center,
right
}
}
// Use with namespace path
pub let vertical: alignment::Vertical = .top
pub let horizontal: alignment::Horizontal = .center
// Can also import nested items
use alignment::Vertical
pub let v: Vertical = .bottom
Items of a module are private unless they are pub. Code outside the
module reaches only the pub items, by path (alignment::Vertical)
or by use. A private item is the error VisibilityViolation when a
path names it, and PrivateImport when a use names it. A nested
module that is not pub is private too:
mod tools {
pub fn shown() -> I32 { hidden() } // ok: inside the module
fn hidden() -> I32 { 1 }
}
pub let a: I32 = tools::shown() // ok
pub let b: I32 = tools::hidden() // error: 'hidden' is private
An inline module and a module file can have the same name. Then a
use of that name has two meanings, and the compiler refuses it with
the error AmbiguousModulePath. This page has the file types.fv:
mod types {
pub struct User { name: String }
}
use types::User
Rename the inline module, or the file.
Multiple Levels:
mod ui {
pub mod layout {
pub enum Direction {
horizontal,
vertical
}
}
pub struct Theme {
primary: String,
secondary: String
}
}
pub let direction: ui::layout::Direction = .horizontal
pub let theme: ui::Theme = ui::Theme(
primary: "#007AFF",
secondary: "#5856D6"
)
Paths in a module, and traits by path:
A path in a module starts from that module first, then from each
module that holds it, then from the top level. In ui, the path
layout::Direction names ui::layout::Direction.
A trait of a module is one trait by each name that reaches it:
Show inside shapes, shapes::Show outside, and Show after
use shapes::Show. A bound and an impl header can name it by path:
mod shapes {
pub trait Show {
fn show(self) -> I32
}
}
pub struct Square { side: I32 }
impl shapes::Show for Square {
fn show(self) -> I32 { self.side }
}
fn shown<T: shapes::Show>(x: T) -> I32 { x.show() }
pub let s: I32 = shown(x: Square(side: 2))
A Public Signature Names Public Types
A pub definition is what another module sees. Naming a private type
in one hands the reader a value whose type they cannot write down: they
can call the function, but they cannot declare a binding for the
result, pass it on, or name it in their own signature.
struct Hidden { x: I32 }
pub fn f() -> Hidden { Hidden(x: 1) } // error E141
pub fn g(h: Hidden) -> I32 { h.x } // error E141
pub struct Shown { h: Hidden } // error E141
fn build() -> Hidden { Hidden(x: 1) } // ok: not public
The rule reaches inside containers, because [Hidden] and Hidden?
name the same type a bare Hidden does. It covers each part of a
public definition that another module sees: the parameters and the
return type of a function (an extern fn too), the fields of a
struct, the payload fields of an enum, the fields and methods of a
trait, the type of a let (written or inferred), a type argument, a
tuple field, a closure type, and a generic bound. The expression of
a default value is not part of the signature: it may use a private
type, if the value has a public type. The rule
holds inside a nested module too.
This pairs with the closure rule: a struct that holds a closure field
cannot be pub, so no public signature may name it either. Such a
struct is built and read inside its own module, and the module exposes
the results instead. See
Closures / Rules.
Extern Declarations
Extern declarations describe functions and method stubs defined outside FormaLang (in the host runtime or a linked library). They have no FormaLang body.
Types are always declared as normal structs. Use extern impl to attach host-provided
methods to a struct, and extern fn for standalone host-provided functions.
Extern Functions
A bodyless function provided by the host:
pub struct Canvas { width: I32, height: I32 }
pub struct Connection { id: I32 }
extern fn create_canvas(width: I32, height: I32) -> Canvas
extern fn connect(url: String) -> Connection
extern fn log(message: String)
extern "C" fn read(fd: I32) -> I32
extern "system" fn GetTickCount() -> I32
A bare extern fn defaults to the C calling convention. Specify
"C" or "system" explicitly when the calling convention matters
(e.g. Win32 stdcall on x86). Unknown ABI strings are rejected at
parse time.
An extern function has no body: a body after an extern fn is a
ParseError. A regular function needs a body: a function without one is the error
RegularFnWithoutBody. The compiler checks a call to an extern
function as it checks each call: the argument count, the labels and
the types.
Extern Impl
Host-provided methods on a struct:
struct Canvas { width: I32, height: I32 }
extern impl Canvas {
fn get_width(self) -> I32
fn get_height(self) -> I32
fn clear(self)
}
Rules:
- Types are always normal structs: there is no
extern type - Extern functions and extern impl methods have no body. A method with
a body in an
extern implis the errorExternImplWithBody - A struct can have both a regular
implblock and anextern implblock
Extern Impl on Primitive Types
Host-provided methods on built-in types like String, I32, F64.
A primitive type also takes a regular impl and a trait impl, whose
methods have bodies: see
Impls on Primitive Types.
extern impl String {
fn to_upper(self) -> String
fn repeat(self, count: I32) -> String
}
extern impl I32 {
fn abs(self) -> I32
}
A method that the prelude declares already (see below) cannot be
declared again with the same signature. The second declaration is the
error DuplicateDefinition, as for two methods of one type (see
Each Name Appears Once):
extern impl String {
fn len(self) -> I32 // error: the prelude declares `len` already
}
The compiler ships a prelude (src/prelude.fv). Each program sees it
without a use. It declares:
| Type | Methods |
|---|---|
String | len, is_empty, slice, starts_with, contains, byte_at |
[T] (Array<T>) | len, is_empty |
[K: V] (Dictionary<K, V>) | len, is_empty |
a range (Range<T>) | len, is_empty |
T? (Optional<T>) | is_some, is_none, and from of From<T> |
Seq<T> | the combinators: see Control Flow |
It also declares extern fn assert(condition: Boolean), which the
examples in this guide use in run_checks(), the conversion traits
From, Into, TryFrom and TryInto: see
Conversions, the structs Decimal and Rational: see Decimal
and Rational, and the struct
Literal<T>: see
A literal of a struct or an enum.
Backends bind these as host-provided extern functions through their
existing extern-binding paths (wasm component imports, JS runtime
bindings, etc.). The Seq combinators are the exception: each one takes a
closure, which a host cannot call, so a backend lowers them itself as
loop structure.
pub fn run_checks() {
let s = "hello"
assert(condition: s.len() == 5)
assert(condition: s.slice(start: 1, end: 3) == "el")
assert(condition: s.starts_with(prefix: "he"))
assert(condition: s.contains(needle: "ll"))
assert(condition: s.byte_at(i: 1) == 101)
assert(condition: [1, 2].len() == 2)
assert(condition: ["a": 1].is_empty() == false)
assert(condition: (0..4).len() == 4)
}
s.slice(start: a, end: b) gives the bytes from offset a up to,
but not including, offset b. A slice whose start is after its end is
the empty string: "abc".slice(start: 2, end: 1) is "".
pub fn run_checks() {
assert(condition: "abc".slice(start: 0, end: 2) == "ab")
assert(condition: "abc".slice(start: 2, end: 1) == "")
}
s[i] for a String receiver desugars to s.byte_at(i) at IR
lowering, so backends only see standard MethodCall shapes.
Feature Checklist
Implemented Features
Core Language:
- Comments (single-line
//, multi-line/* */, doc///and//!) - One statement per line in a body; no terminator
- Refusal of bidirectional control characters in comments and strings
- A nesting depth limit
- Visibility modifiers (
pub) - Use statements (Rust-style imports with
::and{})
Type System:
- Primitive types (
String,I32,I64,F32,F64,Boolean,Never) foryields a lazySeq<T>, consumed exactly once by a terminal combinator- Array types (
[Type]) - Dictionary types (
[KeyType: ValueType]) - Optional types (
Type?) - Tuple types (named-only)
- Generic types (
Type<T>,Type<T: Constraint>) - Closure types (
(T) -> U,(T, U) -> V,() -> T) - Type inference
Definitions:
- Struct definitions (with field defaults)
- Inherent impl blocks (methods, and static methods without
self, called asType.method(...)) - Trait definitions (field requirements and method signatures)
impl Trait for Typeconformance blocks- Enum definitions (with associated data, generics)
extern fndeclarations (with"C"/"system"ABI selection)extern implblocks (includingextern impl String,extern impl I32, etc. on primitive receivers)- Function definitions with optional overloading
- Parameter conventions (
mut,sink) on regular and closure params - Default parameter values (
fn f(x: I32 = 0)); arity checks treat defaulted params as optional - Codegen attribute prefixes (
inline,no_inline,cold) - Let bindings (file-level, with
pub,mut) - Generic parameters on structs, traits, enums, functions and methods
Expressions:
- All literals (string, multi-line string, number with suffix, boolean, nil, array, dictionary)
- Literal types from the position (
let x: I64 = 5), with a range check - Unary operators (
-,!) and binary operators (arithmetic, comparison, equality, logical, concatenation) - Field access (including nested)
- Destructuring (arrays, tuples, structs); an enum value is read with
matchorif let - Struct and enum instantiation
- Closure expressions
- Range operator (
..) - Correct operator precedence
Control Flow:
- For expressions over arrays, ranges and sequences, which yield a
lazy
Seq<T> - If expressions (with boolean and optional unwrapping)
- Match expressions (exhaustive pattern matching, positional bindings)
Generics:
- Generic type parameters with constraints
- Generic structs, traits, enums
- Generic methods: a method's own type parameters, inferred from its arguments
- Generic instantiation with type arguments and inference
- Nested generics, generic arity validation
- Monomorphisation pass (
MonomorphisePass) clones definitions per unique argument tuple and devirtualises trait calls on concrete receivers - Cross-module monomorphisation: imported items (functions, impls,
pub
lets, generic types) are inlined into the entry module under qualified names so backends see one self-containedIrModule
Module System:
- Use statements,
pub usere-exports, and module path resolution - Visibility control
- Nested modules (
modblocks)
Validation (semantic analysis):
- Module resolution
- Symbol table building
- Type resolution
- Expression validation
- Trait conformance validation
- Cycle detection
- Function overload resolution by labels, argument count and argument types
- Exclusive access: a
mutorsinkargument shares its value with no other argument of the same call - Sequences: each one consumed exactly once, and never stored
- Private types in public signatures, and private items across modules
- Monomorphisation depth limit (E148)
Source Spans (for tooling / source maps / DWARF):
- Every
IrExpr, definition, andIrBlockStatementcarries anIrSpan { span: Span { start, end }, file: FileId } IrModule.file_tableresolvesFileIdto aPathBuf; cross-module clones have theirFileIds remapped onto the entry module's table
Serde:
- The
serdefeature (off by default): serialize and deserialize forIrModuleand every type in it. The JSON form is not a stable format. - The AST has no serialized form.
#[non_exhaustive]on public enums and structs
Not Yet Implemented
- Incremental compilation (salsa)
- Code formatter
- REPL mode
- Evaluation/expansion stage (runtime)
Design
FormaLang is an embeddable compiler frontend for a statically
typed, declarative language. An application puts the frontend inside
itself. The frontend parses .fv source, checks it, and gives a fully
resolved Intermediate Representation (IR). The application supplies the
backend, through the IrPass and Backend traits.
.fv source → FormaLang (in your application) → IrModule → your backend → result
The frontend stops at the IR. It has no runtime, no interpreter and no code generator. Thus one frontend serves many kinds of backend:
- An interpreter or a JIT compiler, that runs the program.
- A query engine, for example in a database, that runs a query that a user wrote in FormaLang.
- A code generator, that writes source in another language.
- Editor tooling, that reads the AST and the analysis.
Embeddable
The frontend fits inside another program:
- No I/O of its own. The library reads a file only through a
ModuleResolver. AMemoryResolvergives the modules from memory, for a host with no file system. - No global state. Two compiles share nothing, and a compile can run on any thread.
- No runtime. The output is data: the
IrModule. The host decides what runs, and when. - Many targets. The library builds for a native host and for
wasm32. The same frontend runs in a server, in a database and in a browser. - Errors as data. Each error is a
CompilerErrorwith its place in the source.report_errorsmakes a report that a person can read.
Single-Crate Design
The compiler is a single Rust crate (formalang). All phases share
types directly: no IPC, serialization, or process boundaries. The
compile does not run on the thread of the caller. Each public entry
point runs its work on a thread of its own, with a stack that fits the
nesting of the program. See Pipeline.
Logic Model
FormaLang logic is pure and declarative:
- Conditionals (
if/else, optional unwrapping) - Iteration (
forover an array, a range or a sequence) - Pattern matching (
matchon enums) - Struct/enum/trait definitions with generics and constraints
A program touches the world only through the host. An extern fn or an
extern impl declares what the host gives, and the frontend checks
each call against the declaration.
The IR is complete. Each type, name, call target and overload is resolved, so a backend does no analysis of its own.
Use Cases
- A query language: a database or a search engine takes queries
that its users write in FormaLang, and runs them on its own data.
extern fngives the data as a lazySeq<T>. - User-written logic in an application: rules, configuration that computes, UI definitions and state machines, checked before they run.
- An interpreter or a JIT compiler: a backend that runs the
IrModuledirectly. - Code generation: emit TypeScript, Swift, Kotlin or any other
language from one
.fvsource. - Editor tooling: hover, completion and go-to-definition through
compile_with_analyzer. - Static analysis and linting: walk the AST or the IR with a visitor.
Compiler Pipeline
Source → Lexer → Parser → Semantic Analyzer → IR Lowering → (Plugin System)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Tokens AST Validated AST IrModule IrPass / Backend
The compiler puts the statements of the prelude (src/prelude.fv)
before the statements of each module. The prelude declares Optional,
Array, Seq, Dictionary, Range, the methods of these types and
of String, and assert. A program uses these names with no use.
Lexer
The lexer (src/lexer) makes tokens with logos. A callback scans each
string literal by hand, so a long literal does not use the stack, and a
wrong escape does not hide the closing quote. The lexer reports these
errors:
| Variant | Code | Cause |
|---|---|---|
InvalidCharacter | E030 | A character that starts no token |
UnterminatedString | E031 | A line break or the end of the input cuts off a string |
InvalidNumber | E032 | A slice that starts with a digit and is not a numeric literal |
UnterminatedBlockComment | E033 | A /* has no */ |
InvalidUnicodeEscape | E034 | \u has no four hex digits, or they are not a Unicode scalar value |
InvalidEscape | E035 | A backslash starts no escape, for example \q |
BidirectionalControl | E036 | A comment or a string holds a bidirectional control character (CVE-2021-42574). The escape \u202E stays legal |
Parser
The parser (src/parser) makes the AST from the tokens with chumsky.
Binary operators use Pratt precedence. Before the parse, one pass over
the tokens computes a nesting score. A score above 1024 gives a
ParseError (E001), and the parser does not start. The parser runs on
a thread of its own, with a stack that grows with the score. So a deep
program gives an error and does not abort the process.
The compile thread
The phases after the parser recurse too. So each public entry point
that compiles runs all its phases on a thread of its own, not on the
thread of the caller. The code is in src/stack.rs. The stack of the
thread is 8 MiB plus 512 KiB for each unit of the nesting score. The
entry points are compile_to_ir, compile_to_ir_with_path,
compile_to_ir_with_resolver, compile_to_ir_with_path_and_resolver,
compile_with_analyzer, compile_with_analyzer_and_resolver and
compile_and_report.
- An imported module gets a thread of its own, with a stack sized from its own nesting. A deep module under a small entry module does not overflow.
- The entry points that get no source have no score. These are
SemanticAnalyzer::analyze,SemanticAnalyzer::analyze_and_classify,ir::lower_to_ir,ir::lower_to_ir_with_pathandPipeline::run. They get the stack of a program at the nesting limit, about 520 MiB. - The operating system commits a stack page only when the thread uses it, so the size costs address space, not memory. A thread start costs some microseconds.
- When the thread cannot start, the work runs on the thread of the caller. A panic on the thread continues on the thread of the caller.
- The work moves to the thread. So a
ModuleResolverisSendandSync, and anIrPassisSend.
Two things still run on the thread of the caller:
- a
Backend:Pipeline::emitruns the passes on the compile thread, and then the backend on the thread of the caller. The recursion of a backend is the code of its author; - a clone of a deep
FileorIrModule, and the drop of one: the derivedCloneandDroprecurse once for each level.
See Parsing for the score, the recovery after a wrong statement, the end of a statement and the closure guard.
Semantic analyzer
SemanticAnalyzer (src/semantic) checks the AST and builds the
SymbolTable. It does not evaluate or expand the program. It runs
these passes in order (run_passes in src/semantic/mod.rs):
| Pass | Work |
|---|---|
| 0 | Resolve each use. Analyze and lower each imported module (see Modules) |
| 1 | Build the symbol table. Bring in the names of each use of an inline module |
| 1.1 | Change each EnumInstantiation whose name is a value into a field access or a method call (value_paths.rs). Make each -<numeric literal> one negative literal |
| 1.5 | Validate the generic parameters |
| 1.6 | Infer the types of the module-level let bindings, and record the captures of their closures |
| 2 | Resolve the type references |
| 3 | Validate the expressions. Then write the literal types into the AST |
| 4 | Validate the trait implementations |
| 5 | Detect circular dependencies in let bindings and in field types |
Inference and validation use SemType, a structural type. A type that
the analyzer cannot compute is SemType::Unknown, and a check asks
is_indeterminate() before it reports. No string sentinel such as
"Unknown" stays in the analyzer. LetInfo::inferred_type holds a
SemType too.
The analyzer returns each error once, in source order. Several passes
can find one mistake, so analyze_and_classify removes the copies.
Types from the position
An unsuffixed numeric literal takes its type from its position, when
the position declares a type of the same kind. let big: I64 = 3000000000 holds an I64. An integer literal takes I32 or I64. A
float literal takes F32 or F64. With no such type, the literal is
an I32 or an F64.
The expected type goes down from a declared type (a let annotation, a
parameter, a field or a return type). It goes through a group, the
branches of if and match, the result of a block, and the elements of
an array, a tuple and a dictionary (validation/expected.rs). IR
lowering follows the same positions.
Pass 3 records the type of each such literal in a map. The key is the
address of the literal node (literal_types::node_key). After pass 3,
apply_literal_types walks the AST with ast_walk::visit_exprs_mut and
writes each type into the literal as a suffix. So IR lowering reads the
same type from the AST. The key is an address, so the AST must not move
between the check and the write. Validation thus reads the AST in place
and never clones it.
The dot form of an enum value, .ok(value: 1), takes its enum and the
type arguments of a generic enum from the expected type too
(validation/expr/enums.rs). The analyzer records the full type of the
value by node address, for the inference of the containers around it.
Calls and overloads
A call gives each argument by position or by label
(invocation/call_shape.rs). The call must give each parameter that has
no default. It must not give a parameter twice, and each label must
name a parameter.
When several functions have one name, the analyzer chooses the overload
whose parameter types fit the arguments (invocation/resolution.rs).
It compares the types exactly first. When no overload fits exactly, an
unsuffixed literal fits any numeric type of its kind. Among the
overloads that fit, the one with the fewest defaults wins. No fit gives
NoMatchingOverload, and two equal fits give AmbiguousCall.
The analyzer records each choice in the SymbolTable, keyed by the span
of the call and the name of the function
(record_overload_choice). IR lowering reads the record
(overload_choice), so the two phases always choose the same overload.
Match arms
A match arm, and an if let, push a frame of typed bindings on the
inference scope stack (inference/match_scope.rs). The bindings are
positional: the first binding takes the type of the first field of the
variant, and so on. For a generic enum, the frame puts in the type
arguments of the scrutinee: an arm of a Result<I32, String> binds the
ok payload as an I32. For an optional, .some(x) binds x to the
inner type. The arm body sees these types, and the branch types unify
with them.
Bound methods
In a generic function, a method call on a value of a type parameter
reads the method from the bound (bound_methods.rs,
validation/method_call/bound.rs). With T: Container<I32>, the call
b.get() means get of Container, and the trait's own type
parameters read as I32. The call gets the same checks as any other
method call: the labels, the count and the type of each argument. The
search goes through the parent traits of the bound.
Dictionary keys
A dictionary key needs an equality that a backend can hash
(type_resolution/key_types.rs). String, I32, I64 and Boolean
are key types. A struct or an enum is a key type when each field and
each payload is one. A float, a closure, an array, a dictionary, a
tuple and an optional are not key types.
Sequences
A Seq<T> is not a value that a program can store
(validation/sequence_placement.rs). A sequence may be a local let,
a for source, a sink parameter and the return type of an
extern fn. It may not be a field, a module-level let, the return
type of a function or a closure, an element of an array, a tuple or a
dictionary, or a part of another type. A wrong position gives
SeqInvalidPosition (E137).
A sequence runs once, so the program must read it exactly once on each
path (validation/sequence_linear.rs). A second read gives
SeqUsedTwice (E136). A read in a body that can run more than once, a
for body or a closure argument such as the closure of fold, is a
second read too. A sequence that a path does not read gives
SeqNotConsumed (E135): for example, one branch of an if reads it and
the other does not.
Ownership
The language uses Mutable Value Semantics. A sink argument consumes
its binding, and a later use gives UseAfterSink (E071). Two arguments
of one call must not reach the same place when one of them is mut or
sink: OverlappingArguments (E144, validation/exclusivity.rs).
Each for body and each closure body pushes an outer frame
(validation/outer_frames.rs). The frame holds the names in scope where
the body starts. These rules use it:
- A
sinkof an outer binding in such a body runs again on the next pass. That is a use after the sink. - A closure does not assign to a binding that it captures, and it does
not give one to a
mutparameter.
Private in public
A public definition must not name a private type
(validation/private_in_public.rs). The check covers the parameters,
the return type and the bounds of a public function, the fields of a
public struct, the payloads of a public enum, the fields and the
methods of a public trait, and the type of a public let, written or
inferred. A wrong definition gives PrivateTypeInPublic (E141).
Other checks
- An expression deeper than 500 levels gives
ExpressionDepthExceeded(E130). The parser limit comes first for most programs.
Modules
Pass 0 resolves each use through the ModuleResolver. Each imported
module gets a new analyzer of its own with the prelude, so no state of
the importing module goes into it. Its errors go to the caller.
module_links.rs gives each module file an id when a use first names
it, and keeps the module path that the source wrote. When the analysis
of an imported module succeeds, the module lowers to IR on top of the
modules that it imports, and ModuleLinks keeps that linked IR. The
entry module lowers last, on top of all of them.
A use path whose first name is both an inline mod of the file and a
module file gives AmbiguousModulePath (E160). A module cannot import
a name of the prelude, and an imported name leaves a module again only
through a pub use.
IR lowering
IR lowering turns the checked AST and the symbol table into a
type-resolved IrModule.
Module nesting is flattened in the per-type vectors: an inline
mod foo { struct Bar { ... } } lowers to a top-level
IrStruct { name: "foo::Bar", ... }. A backend that does not care about
the source structure sees a flat list of definitions with qualified
names. A parallel tree, IrModule.modules: Vec<IrModuleNode>, mirrors
the mod hierarchy with the ids of each module, for a backend that
needs namespaced output.
The result is one self-contained module. Each lowering starts from a
copy of the lowered prelude. The linker then copies each item of each
imported module into it. The items of an imported module have hidden
names such as @2::helper while the program lowers. At the end,
finish_entry gives them the module path that the source wrote, such
as utils::helper. See Obtaining the IR for the
details.
Plugin system
External IrPass transforms and Backend emitters compose through a
Pipeline. See Plugin System and
Built-in Passes.
Compiler outputs
| Output | Type | Use case |
|---|---|---|
| AST | File | Syntax analysis, source-level tooling, LSP |
| IR | IrModule | A backend, type-aware analysis |
See the AST Reference and IR Reference for the data shapes each phase produces.
Public API
The crate root (src/lib.rs) has these entry points. Each one reads the
source as a &str. The library reads no file, except through a
ModuleResolver when the source imports a module.
| Function | Returns | Use case |
|---|---|---|
compile_to_ir(src) | Result<IrModule, Vec<CompilerError>> | A backend (canonical) |
compile_to_ir_with_path(src, path) | Result<IrModule, Vec<CompilerError>> | A backend, with real source paths in the spans |
compile_to_ir_with_resolver(src, resolver) | Result<IrModule, Vec<CompilerError>> | A program of several modules, then MonomorphisePass |
compile_to_ir_with_path_and_resolver(src, path, resolver) | Result<IrModule, Vec<CompilerError>> | A program of several modules, with the source path, then MonomorphisePass |
compile_with_analyzer(src) | Result<(File, SemanticAnalyzer<FileSystemResolver>), Vec<CompilerError>> | LSP, AST-level tooling |
compile_with_analyzer_and_resolver(src, resolver) | Result<(File, SemanticAnalyzer<R>), Vec<CompilerError>> | The same, with a custom resolver |
parse_only(src) | Result<File, Vec<CompilerError>> | Parse with no semantic analysis |
compile_and_report(src, filename) | Result<IrModule, String> | CLI: compile_to_ir with the errors as a report |
compile_to_ir, compile_to_ir_with_path and compile_with_analyzer
resolve an import through a FileSystemResolver that starts at the
current directory. Give a resolver of your own for any other root:
a FileSystemResolver with another root, a MemoryResolver for
modules in memory, or a custom ModuleResolver. A ModuleResolver
must be Send and Sync,
because the compile runs on a thread of its own and takes the resolver
with it.
The stack of the caller does not matter. Each entry point runs its
work on a compile thread with a stack sized from the program, so a
caller on a small thread can compile the deepest program that the
parser accepts. An IrPass must be Send for the same reason:
Pipeline::run runs the passes on such a thread. See
The compile thread. On wasm32 this
is not true; see WebAssembly.
Each entry point that returns an IrModule gives one self-contained
module. The IR of each imported module links into it. See
Obtaining the IR.
compile_to_ir_with_resolver and compile_to_ir_with_path_and_resolver
run MonomorphisePass on the result. The other entry points do not:
their result can hold generic definitions. Add the pass through a
Pipeline when you need it.
WebAssembly
The library builds for wasm32-unknown-unknown and wasm32-wasip1.
The binary fvc does not run on WebAssembly.
A browser has no file system. Give the modules to a MemoryResolver,
each with a path relative to the root .. It finds a module the same
way as a FileSystemResolver with the root ., and it gives the same
errors.
wasm32 has no threads, so the compile runs on the stack of the
caller, not on a compile thread. Two stacks limit the depth of nesting
that a compile can take:
- The stack of the module, in its linear memory. It is 1 MiB by
default, and the linker flag
-C link-arg=-zstack-size=<bytes>sets another size. An overflow traps with "out of bounds memory access". - The native stack of the engine, which runs the frames of the
compiled code. The browser, or the runtime, sets it, and the linker
flag does not change it. wasmtime allows 512 KiB by default. An
overflow traps with "call stack exhausted" in wasmtime, and throws a
RangeErrorin a browser.
A stack overflow stops the whole module, so the compiler has lower
limits on wasm32:
| Limit | wasm32 | Other targets |
|---|---|---|
| The nesting score that the parser accepts | 96 | 1024 |
| The depth of an expression that the semantic pass accepts | 48 | 500 |
A deeper program is a compile error. These are the deepest programs
that compile on wasm32-wasip1, in a release build, and then run the
passes of a backend (DefunctionalisePass, ConstantFoldingPass and
DeadCodeEliminationPass):
| Shape | The deepest program | The depth that traps with no limit |
|---|---|---|
A call in a call, f(v: f(v: 1)) | 47 | 229 |
A call of a block, f(v: { f(v: { 1 }) }) | 47 | 105 |
| Parentheses | 46 | 236 |
| Blocks | 92 | 195 |
if in a branch | 46 | 173 |
match in an arm | 31 | 243 |
| Arrays | 46 | 246 |
The last column is for the default stacks of wasmtime. Each program at the limit compiles with a native stack of 256 KiB, half the default of wasmtime, and with the default stack of the module. Each engine has its own frame sizes, so measure the deepest program in the browser that you use. The numbers change with each release of the compiler.
scripts/wasm_stack.sh measures the depths with the probe in
tests/wasm-probe/. It needs wasmtime. Give it a size to measure another
stack of the module: scripts/wasm_stack.sh 2097152.
Cargo features
The crate has one feature, serde. It is off by default. It derives
serde::Serialize and serde::Deserialize on IrModule and on every
type in it. It also derives them on the AST types that the IR holds,
for example Literal, PrimitiveType and Span.
[dependencies]
formalang = { version = "0.0.16-beta", features = ["serde"] }
The JSON form of the IR is not a stable format. It can change in each
release. After you read a module, call IrModule::rebuild_indices
before a lookup by name.
The AST has no serialized form. File has no format_version, and
there is no File::to_json or File::from_json.
The symbol table
SemanticAnalyzer::symbols() returns the SymbolTable. IR lowering and
LSP tools read it. Two parts of its public surface changed:
SymbolTable::define_trait_impl(trait_name, struct_name, generics, trait_args, span)takestrait_args: Vec<Type>. These are the type arguments of a generic trait:[I32]forimpl Container<I32> for Box. Give an empty vector for a trait with no type parameters.TraitImplInfohas the fieldtrait_args: Vec<Type>with the same meaning.
A type has one impl of each trait instance. A second impl of the same
trait with the same trait_args for the same type is a duplicate. Two
impls with other trait_args, such as Container<I32> and
Container<String>, are two instances, and both are allowed.
Plugin System
Defined in src/pipeline.rs:
IrModule → [IrPass, IrPass, ...] → IrModule → Backend → Output
IrPass: Takes ownership ofIrModule, transforms it, returnsResult<IrModule, Vec<CompilerError>>. Use for optimization, specialization, or lowering. A failing pass aborts the pipeline and returns its errors.Backend: Borrows anIrModule, produces anyOutputtype. Use it to run the program, to answer a query, or to emit code.Pipeline: Chains passes with.pass(...)and drives a backend with.emit(module, &backend).
For a tour of the passes shipped in the box, see Built-in Passes.
Built-in Passes
Exported from formalang::ir. Compose them through a Pipeline.
Pipeline::new() and Pipeline::default() hold no pass.
Pipeline::for_codegen() holds four passes, in this order:
MonomorphisePassResolveReferencesPassClosureConversionPassDeadCodeEliminationPass
ConstantFoldingPass and DefunctionalisePass are in no preset. Add
them with .pass(...).
compile_to_ir_with_resolver and compile_to_ir_with_path_and_resolver
run MonomorphisePass on their result. The other compile_* entry
points run no pass.
MonomorphisePass
Replaces each generic definition with one concrete copy for each set of type arguments that the program uses. The pass:
- copies each generic struct, enum and trait once for each
(base, args)pair that a type in the module names, and rewrites eachGeneric { base, args }to the copy. A copy has a name that holds the types, for exampleBox__I32; - copies each impl block of a generic type once for each copy of the type, and points each static dispatch at the right copy;
- copies each generic function once for each tuple of type arguments that a call gives it, and points the call at the copy;
- copies each method that declares its own type parameters and has a
body, in the same way. The copy goes at the end of its impl block
under a name that holds the types (
pair__I32), and each call gets that name and index. An extern method has no body to copy, so it keeps its type parameters; each call carries the concrete types in its arguments and itsty; - turns each
Virtualdispatch on a concrete receiver into aStaticdispatch. The frontend has no dynamic dispatch, so noVirtualdispatch remains. A dispatch through a generic trait carries itstrait_args, so the pass picks the impl of the right instance when one type implementsContainer<I32>andContainer<String>; - removes the generic originals, renumbers the ids that are left, and
makes the
function_idof each call and thestruct_idorenum_idof each instance agree with its path or type.
A call gives no <...> for a method, and often none for a function.
The pass finds the type arguments by unification of each declared
parameter type with the type of its argument. An argument can already
have a specialised type, such as Box__I32. The pass keeps the
instantiation behind each copy, so a parameter of type Box<T> still
binds T to I32. When the arguments bind one type parameter to two
different types, the pass reports an InternalError: the semantic
pass refuses such a call, so the conflict is a compiler defect.
The five prelude carriers stay generic: Array, Seq, Dictionary,
Range and Optional. After the pass, Generic { base, args } over
one of them is the final shape of [T], a sequence, [K: V], a..b
and T?. No other generic definition, TypeParam or Generic
remains, in any type position: the type of each expression, field,
parameter and answer, the annotation of a local let, the variable
of a for, the parameters and captures of a closure, and the bindings
of a match arm. The pass reports a leftover as an InternalError.
The pass never makes an optional of an optional. When a type argument
is an optional, T? becomes that optional, as the type check says:
T? with T = I32? is I32?.
Depth limit. A generic that calls itself with a larger type, such
as grow(x: Box(value: x)) inside grow<T>, needs a new copy at each
level. The pass stops at a nesting depth of 32 type arguments and
reports InstantiationDepthExceeded (E148), one time for each generic.
The field written is true when the deep type is in the program text,
such as Box<Box<...>>, and false when a generic makes it by a call of
itself.
The result does not depend on the order of HashMap walks: two runs
on one source give the same module.
MonomorphisePass::with_imports takes the IR of each imported module,
keyed by module path. No entry point uses it: the entry points link
each imported module during the lowering (see
Obtaining the IR),
so their modules hold no ResolvedType::External. It is for a module
that holds External types, such as the result of
formalang::ir::lower_to_ir for a file with a use. The pass then
copies each imported definition into the module under a qualified name
and rewrites each External to the copy.
ResolveReferencesPass
Rewrites name-keyed references into typed ids:
- each parameter, local
let, loop variable and match binding gets aBindingId, and eachLetRefandReferenceto it gets the same id; - each
Referencegets aReferenceTarget: a function, struct, enum, trait, module-levellet, local binding or parameter; - each field read and each field of a struct or variant literal gets
its
FieldIdx, each match arm itsVariantIdx, and each method call itsMethodIdx.
A generic struct or enum has its fields and variants on its base
definition, and the pass looks there. So a match arm on I32? or on
Maybe<I32> gets the index of its variant, and a field read on
Box<I32> gets the index of its field. This holds before
MonomorphisePass too.
The pass walks each function body, each impl method body, each
module-level let, and the default of each field of each struct, enum
variant and trait. After the walk, it adds the missing trailing default
arguments to each call whose callee was not known at lowering time.
The pass is idempotent. It is in Pipeline::for_codegen. Use it when
the backend emits integer-indexed code (wasm, JVM, native).
ClosureConversionPass
Lifts every closure body to a top-level function and collects the
values it captures into a synthetic env struct, so a backend only ever
sees named functions. IrExpr::Closure becomes
IrExpr::ClosureRef { funcref, env_struct }. The lifted functions are
named __closure<N> and the env structs __ClosureEnv<N>. Included in
Pipeline::for_codegen.
A call of a closure value stays an IrExpr::CallClosure. After the
pass its closure gives a ClosureRef, and a backend reads the lifted
function and the env out of that value.
The pass is idempotent and preserves source spans: a captured
variable's synthesised __env.x access carries the span of the
reference it replaces, so a debugger stepping over it lands on the
name the user wrote.
DefunctionalisePass
Turns every closure value into an enum tag. Run it after
ClosureConversionPass, whose output it consumes, and before
DeadCodeEliminationPass.
Closure conversion answers "where does the body live?": it lifts each body to a top-level function and collects the captures into an env struct. It leaves open what the closure value is. Two answers are possible:
address form: data bytes → address → indirect jump
defunctionalised: data bytes → tag → match → direct call
This pass takes the second. It builds one enum per distinct closure type, with one variant per lifted function, each carrying the env struct closure conversion already made:
enum __Fn0 {
__closure0(env: __ClosureEnv0),
__closure1(env: __ClosureEnv1),
}
fn __call_Fn0(f: __Fn0, x: I32) -> I32 {
match f {
.__closure0(env): __closure0(__env: env, x: x),
.__closure1(env): __closure1(__env: env, x: x),
}
}
Every ClosureRef becomes an EnumInst, every CallClosure becomes a
FunctionCall to the dispatch function, and every occurrence of the
closure type becomes the enum type. After the pass, the module
contains no indirect call. The pass checks this, and reports a
leftover ClosureRef or CallClosure as an InternalError.
The pass collects the closure types from the closure values and from the call sites. A closure type that only a parameter declares, with no closure value in the module, gets an enum with no variants.
Names. The number K in __Fn<K> and __call_Fn<K> counts from
0. The pass skips each K for which the module already has an enum
__Fn<K> or a function __call_Fn<K>. A user function called
__call_Fn0 thus keeps its name, and its calls do not reach a
dispatcher.
Why a tag and not an address. With the address form a code address lives inside a data value, so any defect that corrupts those bytes becomes a jump to anywhere. With a tag, the same corruption at worst selects the wrong arm: a wrong answer, not a takeover. A backend also gains, because every call target is known and each arm can be inlined.
Closed world. The pass must see every closure of a shape before it
can build that shape's enum. A just-in-time backend compiles the whole
program at once, so this holds there. It would not hold under separate
compilation, which is why the pass is opt-in rather than part of
Pipeline::for_codegen.
DeadCodeEliminationPass
Removes code that nothing uses:
- the branch of an
ifwhose condition is a literaltrueorfalse; - each local
letbinding that nothing reads, when its value can have no effect and no fault. A value that holds a call, a division or an index stays: a call can reach an extern function, and a division or an index can fault; - each struct, trait and enum that nothing references, and each impl block whose target goes. A public definition stays: it is the contract of the module with its users. The pass repeats this until nothing more goes, because the removal of one definition can make another one unused;
- each private module-level
letthat nothing reads, with the same rule for its value. A publicletstays.
The pass removes no function. After a removal, it rewrites each id in the module and rebuilds the name indexes.
DeadCodeEliminationPass::new() sets remove_unused_structs to
true. With false, the pass only removes branches and local
bindings. Included in Pipeline::for_codegen.
ConstantFoldingPass
Evaluates constant expressions at compile time. It folds an operation
when each operand is a literal. It does not propagate the value of a
binding: let x = 1 followed by x + 1 stays a BinaryOp.
A numeric operation computes in the width of its operand type:
| Operand type | Arithmetic | Stays unfolded when |
|---|---|---|
I32 | checked i32 | the result overflows i32, or the divisor is 0 |
I64 | checked i64 | the result overflows i64, or the divisor is 0 |
F32 | IEEE 754 binary32: each operand rounds to f32 first | the result is not finite, or the divisor is 0 |
F64 | IEEE 754 binary64 | the result is not finite, or the divisor is 0 |
So 2147483647 + 1 > 2147483647 does not fold for I32: the addition
overflows, and the backend decides what it gives. A result that is not
finite stays unfolded because the IR JSON cannot hold it. The negation
of the lowest value of an integer type overflows too, so it stays
unfolded.
The pass also folds &&, ||, == and != on two boolean literals,
! on a boolean literal, and + on two string literals. It walks
function bodies, impl method bodies, module-level let values and
struct field defaults.
AST Overview
The FormaLang compiler produces a validated AST as a Rust data structure.
The AST represents the complete structure of a .fv source file after
parsing and semantic validation.
Note: For a backend, use the IR (Intermediate Representation) instead. The IR gives resolved types and linked references, and it is made for a backend.
Obtaining the AST
Use compile_with_analyzer for a fully validated AST plus the semantic
analyzer (useful for LSP tooling). For pure syntax inspection without
semantic checks, use parse_only.
#![allow(unused)] fn main() { use formalang::compile_with_analyzer; let source = r#" pub struct User { name: String, age: I32 } "#; match compile_with_analyzer(source) { Ok((file, _analyzer)) => { // file is the root AST node for statement in &file.statements { // process statements } } Err(errors) => { for error in errors { eprintln!("Error: {}", error); } } } }
Use parse_only for syntax-only parsing without semantic validation:
#![allow(unused)] fn main() { use formalang::parse_only; let file = parse_only(source)?; }
Parsing
parse_only runs the lexer, then the parser. The two phases report
their errors together: a lexer error does not stop the parse.
Strings and comments
The lexer scans a string literal by hand, not with a regular
expression. The code is in src/lexer/token/strings.rs. A line break
or the end of the input stops a "..." literal, and the lexer then
reports UnterminatedString. A """...""" literal ends at the first
""" that no backslash escapes.
The escapes are \", \\, \n, \t, \r and \uXXXX with four
hex digits. A wrong escape does not stop the scan. The quote still
closes the literal, and the lexer reports one error for each wrong
escape:
| Escape | Error |
|---|---|
\u without four hex digits, \u{41}, or a surrogate such as \uD800 | InvalidUnicodeEscape |
A backslash before any other character, such as \q | InvalidEscape (E035) |
The decoded text holds U+FFFD in place of each wrong escape.
A bidirectional control character (U+202A to U+202E, U+2066 to
U+2069) in a comment or in a string literal gives
BidirectionalControl (E036). This rule stops the "Trojan Source"
attack (CVE-2021-42574). The check covers //, ///, //! and
/* */ comments. An escape such as \u202E is not the character
itself, so the lexer accepts it.
Line breaks and statements
A statement has no terminator. The lexer keeps a line break only when
it ends a statement, and it drops all other line breaks. The code is in
drop_continuation_newlines in src/lexer/mod.rs. The lexer keeps a
line break when all of these conditions are true:
- No
(or[is open. A{starts a new count, because its contents are statements again. - The token before the line break can end a statement: a name, a
literal,
self,),],}or?. - The next token is not
.,elseor{. These tokens can only continue the line above.
Inside a function body or a block, a statement must end at a line
break, at the } of the block, or before a {. So
let a = 1 let b = 2 on one line is a parse error. The code is in
statement_end in src/parser/recovery.rs. At the top level of a
file, the parser does not apply this rule: two top-level statements on
one line parse.
In a { ... } list that is not a block, a line break does not end
anything. A , must separate two fields of a struct and two variants
of an enum; a line break alone is not a separator. In a trait, the ,
between two items is optional. The items of an impl block and of a
mod have no separator.
The nesting limit
The parser and the phases after it are recursive. A deep program can
overflow the stack, and a stack overflow stops the whole process. So
before the parse starts, nesting_score in src/parser/nesting.rs
reads the tokens once and computes a nesting score:
- Each open
(,[or{adds one. - Inside a level, each operator,
.,?,->,=and eachif,else,match,fororletadds one more. A chain such as1 + 1 + 1builds a tree as deep as the chain is long. - A
,or a line break ends one item of a level. The count of that item goes back to zero.
The score is never lower than the depth of the tree. It can be higher.
When the score goes above MAX_NESTING (1024), the parser does not
start. It returns one ParseError at the first token above the limit:
"the program nests too deeply here; the limit is 1024 levels". The
time of the check grows linearly with the length of the input.
The semantic pass has its own limit. It refuses an expression deeper
than 500 with ExpressionDepthExceeded.
The parser thread
The parser runs on its own thread, named formalang-parser. The code
is in on_parser_stack in src/parser/nesting.rs. The stack of the
thread is 8 MiB plus 512 KiB for each unit of the nesting score. So a
program at the limit gets a stack of about 520 MiB. The operating
system commits a stack page only when the thread uses it, so the size
costs address space, not memory. When the thread cannot start, the
parse runs on the thread of the caller. A panic on the parser thread
continues on the thread of the caller.
The phases after the parser run on a compile thread of their own, with a stack of the same size. See The compile thread.
Recovery
The parser reports more than one syntax error in one run:
- At the top level, a statement that fails to parse is skipped up to
the next token that can start a statement:
use,let,pub,struct,enum,trait,impl,fn,externormod. - In a function body or a block,
skip_failed_statementinsrc/parser/recovery.rsskips the failed statement up to the nextletor}of the same block. It skips a( ... ),[ ... ]or{ ... }group as a whole, and it never consumes the}that closes the block. The failed statement becomes anilexpression in the AST. An unclosed group makes the skip go to the end of the input, and the block then reports its missing}.
Each token is skipped once, so recovery does not multiply the parse time for each level of nesting.
The closure guard
A closure and a tuple both start with (, and both can start with
(name: .... The parser tries the closure form only when the matching
) is followed by ->. closure_ahead in
src/parser/exprs/closure_guard.rs answers this question with one scan
over the tokens of the group. The scan records the answer for each
( inside the group too, and a cache keeps the answers for the
parse. So all the scans of one parse take linear time.
After the parse
compile_with_analyzer and compile_to_ir change the AST after the
parse. parse_only does not. The semantic pass makes these changes:
- It makes
-directly before a numeric literal part of the literal, so-2147483648is oneLiteral. - It writes the type of each unsuffixed numeric literal that takes
its type from its position as a suffix. In
let big: I64 = 3000000000, the literal gets the suffixI64. - It changes an
EnumInstantiationwhose name is a value, not a type, into aReferencepath or aMethodCall. See Expressions.
Files & Statements
Spans, locations, identifiers, and the root nodes of every parsed .fv file.
Locations
Span
Every AST node includes a Span that tracks its source location for
error reporting.
#![allow(unused)] fn main() { pub struct Span { pub start: Location, pub end: Location, } }
Location
#![allow(unused)] fn main() { pub struct Location { pub offset: usize, // Byte offset from start of file pub line: usize, // Line number (1-indexed) pub column: usize, // Column number (1-indexed, byte-based) } }
Ident
Identifiers carry both their name and source location.
#![allow(unused)] fn main() { pub struct Ident { pub name: String, pub span: Span, } }
Root Nodes
File
The root node representing a complete .fv source file.
#![allow(unused)] fn main() { pub struct File { pub doc: Option<String>, // The `//!` lines at the start of the file pub statements: Vec<Statement>, pub span: Span, } }
doc holds the //! doc comments at the start of the file, joined
with newlines.
The AST has no serialized form. To compare two trees, use
PartialEq or Debug.
Statement
Top-level statements in a file.
#![allow(unused)] fn main() { pub enum Statement { Use(UseStmt), Let(Box<LetBinding>), Definition(Box<Definition>), } }
Definition
Type definitions.
#![allow(unused)] fn main() { pub enum Definition { Trait(TraitDef), Struct(StructDef), Impl(ImplDef), Enum(EnumDef), Module(ModuleDef), Function(Box<FunctionDef>), } }
Each definition has a doc: Option<String> field. It holds the ///
lines before the definition, joined with \n. A LetBinding, a
StructField, a FieldDef and a method FnDef have one too. A use
statement drops its doc comment.
Visibility
#![allow(unused)] fn main() { pub enum Visibility { Public, // pub keyword Private, // default (no modifier); also the serde default } }
Imports & Let Bindings
The two top-level statement shapes that aren't type definitions:
use imports and module-level let bindings.
UseStmt
#![allow(unused)] fn main() { pub struct UseStmt { pub visibility: Visibility, // pub use for re-exports pub path: Vec<Ident>, // Module path segments pub items: UseItems, // What to import pub span: Span, } }
UseItems
#![allow(unused)] fn main() { pub enum UseItems { Single(Ident), // use module::Item Multiple(Vec<Ident>), // use module::{A, B, C} Glob, // use module::* (imports all public symbols) } }
LetBinding
File-level constants.
#![allow(unused)] fn main() { pub struct LetBinding { pub visibility: Visibility, pub mutable: bool, pub pattern: BindingPattern, pub type_annotation: Option<Type>, // Optional: let x: String = "hello" pub value: Expr, pub doc: Option<String>, // Joined `///` lines before the binding pub span: Span, } }
The pattern field uses BindingPattern,
allowing array / struct / tuple destructuring in module-level lets.
Type Definitions
The shape of trait, struct, impl, enum, and module declarations. Method bodies inside impls are described separately on Functions & Parameters.
TraitDef
#![allow(unused)] fn main() { pub struct TraitDef { pub visibility: Visibility, pub name: Ident, pub generics: Vec<GenericParam>, pub traits: Vec<Ident>, // Trait composition (A + B + C) pub fields: Vec<FieldDef>, // Required fields pub methods: Vec<FnSig>, // Required method signatures pub doc: Option<String>, pub span: Span, } }
StructDef
#![allow(unused)] fn main() { pub struct StructDef { pub visibility: Visibility, pub name: Ident, pub generics: Vec<GenericParam>, pub fields: Vec<StructField>, // Regular fields pub doc: Option<String>, pub span: Span, } }
Trait conformance is declared separately via impl Trait for Type blocks;
not inline on the struct definition.
StructField
#![allow(unused)] fn main() { pub struct StructField { pub mutable: bool, pub name: Ident, pub ty: Type, // Type::Optional(..) for `T?` pub optional: bool, // true if Type? pub default: Option<Expr>, // Default value pub doc: Option<String>, pub span: Span, } }
FieldDef
Used in traits and enum variants.
#![allow(unused)] fn main() { pub struct FieldDef { pub mutable: bool, pub name: Ident, pub ty: Type, pub doc: Option<String>, pub span: Span, } }
ImplDef
Implementation body for structs. Supports inherent implementations, trait implementations, and extern impl blocks.
#![allow(unused)] fn main() { pub struct ImplDef { pub trait_name: Option<Ident>, // None for inherent impl, Some for trait impl pub trait_args: Vec<Type>, // generic-trait args: `impl Foo<X> for Y` → [X] pub name: Ident, // Struct/enum being implemented pub generics: Vec<GenericParam>, pub functions: Vec<FnDef>, // Method definitions pub is_extern: bool, // true for `extern impl` blocks pub doc: Option<String>, pub span: Span, } }
trait_args carries the concrete type arguments when the impl
instantiates a generic trait (impl Container<I32> for Box).
Empty for non-generic traits and inherent impls.
The functions vector contains FnDef values;
their parameter shape and conventions are documented on the
Functions & Parameters page.
EnumDef
Sum types.
#![allow(unused)] fn main() { pub struct EnumDef { pub visibility: Visibility, pub name: Ident, pub generics: Vec<GenericParam>, pub variants: Vec<EnumVariant>, pub doc: Option<String>, pub span: Span, } }
EnumVariant
#![allow(unused)] fn main() { pub struct EnumVariant { pub name: Ident, pub fields: Vec<FieldDef>, // Named fields (empty for simple variants) pub span: Span, } }
ModuleDef
Namespace for grouping types.
#![allow(unused)] fn main() { pub struct ModuleDef { pub visibility: Visibility, pub name: Ident, pub definitions: Vec<Definition>, pub doc: Option<String>, pub span: Span, } }
Functions & Parameters
FnDef is the body-bearing function shape used inside impl blocks;
FnSig is the body-less shape used to declare trait-required methods;
FunctionDef is the standalone (top-level) form. They share the same
parameter and attribute machinery.
FnDef
Function definition inside an impl block.
#![allow(unused)] fn main() { pub struct FnDef { pub name: Ident, pub generics: Vec<GenericParam>, // The method's own type parameters pub params: Vec<FnParam>, pub return_type: Option<Type>, pub body: Option<Expr>, // None for extern fn / extern impl methods pub attributes: Vec<AttributeAnnotation>, // inline / no_inline / cold prefixes pub doc: Option<String>, pub span: Span, } }
generics holds the type parameters of the method itself: U in
fn map<U>(self, f: (T) -> U) -> [U]. The type parameters of the impl
block are on ImplDef.generics. Serialisation omits an empty
generics.
attributes carries codegen-hint keyword prefixes parsed before
fn: inline fn foo() { ... }, cold fn rare() { ... }. The
frontend passes them through unchanged; backends decide whether to
honour them.
FnSig
A signature-only function declaration (no body). Used in trait method declarations.
#![allow(unused)] fn main() { pub struct FnSig { pub name: Ident, pub generics: Vec<GenericParam>, // The method's own; each impl declares as many pub params: Vec<FnParam>, pub return_type: Option<Type>, pub attributes: Vec<AttributeAnnotation>, // inline / no_inline / cold pub span: Span, } }
ParamConvention
Controls how a parameter receives its argument (Mutable Value Semantics).
#![allow(unused)] fn main() { #[non_exhaustive] #[derive(Default)] pub enum ParamConvention { #[default] Let, // Immutable reference: the callee cannot mutate the value Mut, // Exclusive mutable access: callee may mutate the value Sink, // Ownership transfer: the binding is consumed at the call site } }
Syntax summary (Let is the Rust enum variant name: there is no let
keyword in FormaLang parameter position):
| Variant | FormaLang parameter syntax | Meaning |
|---|---|---|
Let | fn f(x: T) (no keyword) | Default; callee reads the value |
Mut | fn f(mut x: T) | Callee may mutate; arg must be mut |
Sink | fn f(sink x: T) | Callee owns the value; arg is moved |
All three use the same call syntax: f(x). There is no annotation at the
call site.
Semantic rules enforced during validation:
- A
Mutparameter requires that the argument binding is declaredlet mut(or is anothermut/sinkparameter). Passing an immutable binding producesMutabilityMismatch. - A
Sinkparameter consumes the argument binding. Any subsequent use of that binding producesUseAfterSink. - Two arguments of one call must not reach the same place when one of
them fills a
MutorSinkparameter. The method receiver counts as an argument. Such a call producesOverlappingArguments. The check follows fields, sop.xandp.ydo not overlap, andpandp.xdo.
self parameters follow the same conventions: fn f(self),
fn f(mut self), fn f(sink self).
FnParam
#![allow(unused)] fn main() { pub struct FnParam { pub convention: ParamConvention, // Let (default), Mut, or Sink pub external_label: Option<Ident>, // External call-site label (e.g., `to` in `fn send(to name: String)`) pub name: Ident, pub ty: Option<Type>, // None for bare `self` parameter pub default: Option<Expr>, // Default value expression pub span: Span, } }
external_label mirrors Swift's argument-label convention:
fn send(to recipient: String) creates a parameter whose external label
is to and whose internal name is recipient. Callers write
send(to: "Alice"). When external_label is None, the internal name
is used at the call site.
FunctionDef (standalone)
#![allow(unused)] fn main() { pub struct FunctionDef { pub visibility: Visibility, pub name: Ident, pub generics: Vec<GenericParam>, pub params: Vec<FnParam>, pub return_type: Option<Type>, pub body: Option<Expr>, // None for `extern fn` declarations pub extern_abi: Option<ExternAbi>, // Some(_) for `extern fn`; None otherwise pub attributes: Vec<AttributeAnnotation>, // inline / no_inline / cold pub doc: Option<String>, pub span: Span, } }
extern_abi carries the FFI calling convention. Source forms:
| Source | extern_abi |
|---|---|
fn foo() { ... } | None |
extern fn foo() | Some(ExternAbi::C) |
extern "C" fn foo() | Some(ExternAbi::C) |
extern "system" fn foo() | Some(ExternAbi::System) |
Unknown ABI strings are rejected at parse time. The convenience method
is_extern() returns extern_abi.is_some() for the common boolean check.
FunctionAttribute
Codegen-hint keyword prefixes parsed before fn. The frontend passes
them through unchanged; backends with inlining heuristics or
section-placement controls consume them as hints.
#![allow(unused)] fn main() { pub enum FunctionAttribute { Inline, // `inline fn` NoInline, // `no_inline fn` Cold, // `cold fn` } }
Multiple prefixes can stack: pub cold no_inline fn rare_path() { ... }.
AttributeAnnotation
The AST stores attributes as AttributeAnnotation, a thin wrapper that
pairs a FunctionAttribute with the source span of the keyword that
introduced it. Diagnostics can cite the exact inline / cold keyword
token (e.g. for duplicate-annotation errors). IR lowering drops the span
and stores plain FunctionAttributes, so IrModule JSON is unchanged.
#![allow(unused)] fn main() { pub struct AttributeAnnotation { pub kind: FunctionAttribute, pub span: Span, } }
Generics
Type parameters and their constraints. Used wherever a definition
introduces generics: TraitDef.generics, StructDef.generics,
EnumDef.generics, ImplDef.generics, FunctionDef.generics.
GenericParam
#![allow(unused)] fn main() { pub struct GenericParam { pub name: Ident, pub constraints: Vec<GenericConstraint>, pub span: Span, } }
GenericConstraint
#![allow(unused)] fn main() { pub enum GenericConstraint { Trait { name: Ident, args: Vec<Type> }, // T: TraitName or T: TraitName<X, Y> } }
The args slot carries concrete type arguments when the constraint
references a generic trait: <T: Container<I32>> parses with
args = [I32]. Empty args means a non-generic trait bound.
Type Expressions
The shape of every type written in source: used in field annotations, function signatures, generic arguments, and let-binding annotations.
Type
#![allow(unused)] fn main() { pub enum Type { Primitive(PrimitiveType), Ident(Ident), // Type reference Generic { name: Ident, args: Vec<Type>, span: Span, }, Array(Box<Type>), // [T] Optional(Box<Type>), // T? Tuple(Vec<TupleField>), // (name1: T1, name2: T2) Dictionary { // [K: V] key: Box<Type>, value: Box<Type>, }, Closure { // (T1, T2) -> R, with optional mut/sink per param params: Vec<(ParamConvention, Type)>, ret: Box<Type>, }, } }
The parser gives Type::Ident for every name that is not a primitive:
a struct, an enum, a trait, and a type parameter such as T. A path
such as shapes::Point is one Ident with the segments joined by
::. The
semantic pass decides what the name means. The type Never is
Type::Primitive(PrimitiveType::Never).
PrimitiveType
#![allow(unused)] fn main() { pub enum PrimitiveType { String, I32, I64, F32, F64, Boolean, Never, // Uninhabited type: has no values } }
TupleField
#![allow(unused)] fn main() { pub struct TupleField { pub name: Ident, pub ty: Type, pub span: Span, } }
Expressions
The full expression tree as parsed. After semantic analysis the same
expressions are lowered to IrExpr for code
generation.
Expr
#![allow(unused)] fn main() { pub enum Expr { Literal { value: Literal, span: Span, }, /// Unified invocation: struct instantiation or function call. /// Semantic analysis determines which based on the name. Invocation { path: Vec<Ident>, // Name/path being invoked type_args: Vec<Type>, // Generic type arguments args: Vec<(Option<Ident>, Expr)>, // Named or positional args span: Span, }, /// A call of the value of an expression: `make()(4)`. Call { callee: Box<Expr>, args: Vec<(Option<Ident>, Expr)>, span: Span, }, EnumInstantiation { enum_name: Ident, // `Status`, or `shapes::Status` as one name type_args: Vec<Type>, // `Maybe<I32>.none` gives [I32] variant: Ident, data: Vec<(Ident, Expr)>, span: Span, }, InferredEnumInstantiation { variant: Ident, data: Vec<(Ident, Expr)>, span: Span, }, Array { elements: Vec<Expr>, span: Span, }, Tuple { fields: Vec<(Ident, Expr)>, span: Span, }, Reference { path: Vec<Ident>, span: Span, }, BinaryOp { left: Box<Expr>, op: BinaryOperator, right: Box<Expr>, span: Span, }, UnaryOp { op: UnaryOperator, operand: Box<Expr>, span: Span, }, ForExpr { var: Ident, collection: Box<Expr>, body: Box<Expr>, span: Span, }, IfExpr { condition: Box<Expr>, then_branch: Box<Expr>, else_branch: Option<Box<Expr>>, span: Span, }, MatchExpr { scrutinee: Box<Expr>, arms: Vec<MatchArm>, span: Span, }, Group { expr: Box<Expr>, span: Span, }, DictLiteral { entries: Vec<(Expr, Expr)>, // Key-value pairs span: Span, }, DictAccess { dict: Box<Expr>, key: Box<Expr>, span: Span, }, FieldAccess { object: Box<Expr>, field: Ident, span: Span, }, ClosureExpr { params: Vec<ClosureParam>, return_type: Option<Type>, // The parser always sets None body: Box<Expr>, span: Span, }, LetExpr { mutable: bool, pattern: BindingPattern, ty: Option<Type>, value: Box<Expr>, body: Box<Expr>, span: Span, }, MethodCall { receiver: Box<Expr>, method: Ident, type_args: Vec<Type>, // `I32` in `Box<I32>.of(v: 3)`; empty on a value args: Vec<(Option<Ident>, Expr)>, // Arguments with optional labels span: Span, }, Block { statements: Vec<BlockStatement>, result: Box<Expr>, span: Span, }, } }
Expr::span() returns the span of any variant.
Calls
The parser reads name(args) and a::b::name(args) as an
Invocation. The semantic pass decides if the name is a struct or a
function. The parser reads (args) after any other expression as a
Call: make()(4), f(x)(3) and (g)(1) are calls of a value. A
Call lowers to IrExpr::CallClosure.
Enum paths
EnumInstantiation.enum_name holds the whole path as one name, joined
with ::: shapes::Status.active gives enum_name shapes::Status.
type_args holds the type arguments written on the path, so
Maybe<I32>.none gives [I32]. It is empty when the path writes
none. Serialisation omits an empty type_args, and deserialisation
reads a missing one as empty.
Field paths
The parser appends a field to a Reference path: user.name is a
Reference with the path [user, name]. After any other expression,
.field gives a FieldAccess, for example make().name.
if let
The parser changes if let x = value { a } else { b } into a
MatchExpr on value with two arms: .some(x): a and .none: b. So
the AST has no if let node.
Closures
A closure is (params) -> body. The parentheses are required, also for
one parameter. The syntax has no place for a return type, so the parser
always sets return_type to None. The semantic pass reads a
return_type that a tool sets on a hand-built AST.
Value paths
The parser reads Name.variant and Name.variant(label: value) as an
EnumInstantiation when Name starts with an uppercase letter. The
same text is a field access or a method call when Name is a value,
for example G.slice(start: 1, end: 3) on let G: String. The parser
cannot tell the two apart. After the semantic analyzer builds the
symbol table, it changes each EnumInstantiation whose name is not a
type or a trait into a Reference path or a MethodCall. The code is
in src/semantic/value_paths.rs.
BlockStatement
#![allow(unused)] fn main() { pub enum BlockStatement { Let { mutable: bool, pattern: BindingPattern, ty: Option<Type>, value: Expr, span: Span, }, Assign { target: Expr, value: Expr, span: Span, }, Expr(Expr), } }
ClosureParam
#![allow(unused)] fn main() { pub struct ClosureParam { pub convention: ParamConvention, // Let (default), Mut, or Sink pub name: Ident, pub ty: Option<Type>, pub span: Span, } }
convention on a ClosureParam constrains the caller of the
closure, not the closure itself. Sink means the caller gives up the
argument on each invocation; Mut means the caller must pass a mutable
binding.
Literal
#![allow(unused)] fn main() { pub enum Literal { String(String), /// Numeric literal: see `NumberLiteral` for the carried payload. Number(NumberLiteral), Boolean(bool), Nil, } }
NumberLiteral
Discriminated payload for a numeric literal: preserves the exact
integer digits as i128 (so i64-and-narrower targets round-trip
without precision loss) or the float bits as f64. Carries the
optional source-level type suffix and the integer-vs-float source-
syntax kind so later passes can pick the resolved primitive without
re-running inference.
#![allow(unused)] fn main() { pub struct NumberLiteral { pub value: NumberValue, pub suffix: Option<NumericSuffix>, pub kind: NumberSourceKind, } pub enum NumberValue { Integer(i128), // integer-syntax literals: 42, 1_000, 0xFF Float(f64), // float-syntax literals: 3.14, 1e5 } pub enum NumericSuffix { I32, I64, F32, F64, // uppercase suffix: 42I64, 3.14F32 } pub enum NumberSourceKind { Integer, // unsuffixed default → I32 Float, // unsuffixed default → F64 } }
BinaryOperator
#![allow(unused)] fn main() { pub enum BinaryOperator { // Arithmetic Add, Sub, Mul, Div, Mod, // Comparison Lt, Gt, Le, Ge, Eq, Ne, // Logical And, Or, // Range Range, // .. } }
Operator precedence (higher binds tighter). All binary operators are left-associative:
| Precedence | Operators |
|---|---|
| 11 | .method(...), call (...) (postfix) |
| 10 | .field, [index] (postfix) |
| 9 | -, ! (prefix) |
| 6 | *, /, % |
| 5 | +, - |
| 4 | <, >, <=, >= |
| 3 | ==, != |
| 2 | && |
| 1 | || |
| 0 | .. |
UnaryOperator
#![allow(unused)] fn main() { pub enum UnaryOperator { Neg, // -x Not, // !x } }
Pattern Matching
Two pattern shapes coexist in the AST:
Pattern for match arms (enum-variant matching) and
BindingPattern for destructuring let bindings.
MatchArm
#![allow(unused)] fn main() { pub struct MatchArm { pub pattern: Pattern, pub body: Expr, pub span: Span, } }
Pattern
#![allow(unused)] fn main() { pub enum Pattern { Variant { name: Ident, bindings: Vec<Ident>, }, Wildcard, // _ } }
bindings are positional. The first name binds the first field of the
variant, the second name binds the second field, and so on. The names
do not have to match the field names. The parser also makes a
Pattern::Variant for each arm of an if let; see
Expressions.
BindingPattern
For destructuring in let bindings (file-level and inside blocks).
#![allow(unused)] fn main() { pub enum BindingPattern { Simple(Ident), Array { elements: Vec<ArrayPatternElement>, span: Span, }, Struct { fields: Vec<StructPatternField>, span: Span, }, Tuple { elements: Vec<BindingPattern>, span: Span, }, } }
ArrayPatternElement
#![allow(unused)] fn main() { pub enum ArrayPatternElement { Binding(BindingPattern), Rest(Option<Ident>), // ...rest or just ... Wildcard, // _ } }
StructPatternField
#![allow(unused)] fn main() { pub struct StructPatternField { pub name: Ident, pub alias: Option<Ident>, // field as alias } }
AST Examples
Worked examples showing how source maps to the AST. For the IR shape of the same constructs, see Worked Examples in the IR reference.
Simple Struct
FormaLang source:
pub struct User {
name: String,
age: I32
}
AST structure:
File
└── statements[0]: Statement::Definition
└── Definition::Struct
├── visibility: Public
├── name: "User"
├── generics: []
└── fields:
├── [0] StructField
│ ├── mutable: false
│ ├── name: "name"
│ ├── ty: Type::Primitive(String)
│ ├── optional: false
│ └── default: None
└── [1] StructField
├── mutable: false
├── name: "age"
├── ty: Type::Primitive(I32)
├── optional: false
└── default: None
Enum with Variants
FormaLang source:
pub enum Status {
Active,
Inactive,
Pending(reason: String)
}
AST structure:
File
└── statements[0]: Statement::Definition
└── Definition::Enum
├── visibility: Public
├── name: "Status"
├── generics: []
└── variants:
├── [0] EnumVariant
│ ├── name: "Active"
│ └── fields: []
├── [1] EnumVariant
│ ├── name: "Inactive"
│ └── fields: []
└── [2] EnumVariant
├── name: "Pending"
└── fields:
└── [0] FieldDef
├── name: "reason"
└── ty: Type::Primitive(String)
Generic Struct with Trait
FormaLang source:
pub trait Container {
items: [String]
}
pub struct Box<T: Container> {
content: T,
label: String?
}
AST structure:
File
├── statements[0]: Statement::Definition
│ └── Definition::Trait
│ ├── visibility: Public
│ ├── name: "Container"
│ ├── generics: []
│ ├── traits: []
│ ├── fields:
│ │ └── [0] FieldDef
│ │ ├── name: "items"
│ │ └── ty: Type::Array(Type::Primitive(String))
│ └── methods: []
│
└── statements[1]: Statement::Definition
└── Definition::Struct
├── visibility: Public
├── name: "Box"
├── generics:
│ └── [0] GenericParam
│ ├── name: "T"
│ └── constraints:
│ └── [0] GenericConstraint::Trait { name: "Container", args: [] }
└── fields:
├── [0] StructField
│ ├── name: "content"
│ ├── ty: Type::Ident("T")
│ └── optional: false
└── [1] StructField
├── name: "label"
├── ty: Type::Optional(Type::Primitive(String))
└── optional: true
Impl Block with Functions
FormaLang source:
pub struct Counter {
count: I32
}
impl Counter {
fn increment(self) -> I32 {
self.count + 1
}
fn display(self) -> String {
if self.count > 10 {
"High"
} else {
"Low"
}
}
}
AST structure:
File
├── statements[0]: Statement::Definition
│ └── Definition::Struct (Counter)
│
└── statements[1]: Statement::Definition
└── Definition::Impl
├── trait_name: None
├── trait_args: []
├── name: "Counter"
├── generics: []
└── functions:
├── [0] FnDef
│ ├── name: "increment"
│ ├── params: [FnParam { convention: Let, external_label: None, name: "self", ty: None }]
│ ├── return_type: Some(Type::Primitive(I32))
│ └── body: Expr::BinaryOp { ... }
└── [1] FnDef
├── name: "display"
├── params: [FnParam { convention: Let, external_label: None, name: "self", ty: None }]
├── return_type: Some(Type::Primitive(String))
└── body: Expr::IfExpr { ... }
Trait Implementation
FormaLang source:
pub struct Counter {
count: I32
}
pub trait Drawable {
fn draw(self) -> String
}
impl Drawable for Counter {
fn draw(self) -> String {
"Counter"
}
}
AST structure:
File
└── statements[2]: Statement::Definition
└── Definition::Impl
├── trait_name: Some("Drawable")
├── trait_args: []
├── name: "Counter"
├── generics: []
└── functions:
└── [0] FnDef
├── name: "draw"
├── params: [FnParam { name: "self", ty: None }]
├── return_type: Some(Type::Primitive(String))
└── body: Expr::Literal { value: Literal::String("Counter") }
Match Expression with Wildcard
FormaLang source:
pub enum Status {
active,
inactive,
pending
}
pub fn label(status: Status) -> String {
match status {
.active: "Online",
.inactive: "Offline",
_: "Unknown"
}
}
AST structure (the body of label; a body with one expression
and no statement is that expression, not a Block):
Expr::MatchExpr
├── scrutinee: Expr::Reference { path: ["status"] }
└── arms:
├── [0] MatchArm
│ ├── pattern: Pattern::Variant { name: "active", bindings: [] }
│ └── body: Expr::Literal { value: Literal::String("Online") }
├── [1] MatchArm
│ ├── pattern: Pattern::Variant { name: "inactive", bindings: [] }
│ └── body: Expr::Literal { value: Literal::String("Offline") }
└── [2] MatchArm
├── pattern: Pattern::Wildcard
└── body: Expr::Literal { value: Literal::String("Unknown") }
Block Expression
FormaLang source:
pub struct Output {
value: I32
}
fn compute_value() -> I32 {
21
}
pub fn make() -> Output {
let x = compute_value()
let y = x * 2
Output(value: y)
}
AST structure (the body of make):
Expr::Block
├── statements:
│ ├── [0] BlockStatement::Let
│ │ ├── mutable: false
│ │ ├── pattern: BindingPattern::Simple("x")
│ │ └── value: Expr::Invocation { path: ["compute_value"], ... }
│ └── [1] BlockStatement::Let
│ ├── mutable: false
│ ├── pattern: BindingPattern::Simple("y")
│ └── value: Expr::BinaryOp { left: "x", op: Mul, right: 2 }
└── result: Expr::Invocation { path: ["Output"], args: [(Some("value"), "y")] }
For Expression
FormaLang source:
pub struct ListItem {
text: String
}
pub fn list(items: [String]) -> [ListItem] {
for item in items {
ListItem(text: item)
}.collect()
}
AST structure (the body of list). A for gives a Seq, so
.collect() makes the array:
Expr::MethodCall
├── receiver: Expr::ForExpr
│ ├── var: "item"
│ ├── collection: Expr::Reference { path: ["items"] }
│ └── body: Expr::Invocation
│ ├── path: ["ListItem"]
│ └── args: [(Some("text"), Expr::Reference { path: ["item"] })]
├── method: "collect"
└── args: []
Closure Expression
FormaLang source:
let add = (x: I32, y: I32) -> x + y
let scale: (mut I32) -> I32 = (mut n) -> n
AST structure:
Statement::Let // let add = ...
├── pattern: BindingPattern::Simple("add")
└── value: Expr::ClosureExpr
├── params:
│ ├── [0] ClosureParam { convention: Let, name: "x", ty: Some(I32) }
│ └── [1] ClosureParam { convention: Let, name: "y", ty: Some(I32) }
└── body: Expr::BinaryOp { op: Add, ... }
Statement::Let // let scale: (mut I32) -> I32 = ...
├── pattern: BindingPattern::Simple("scale")
├── type_annotation: Some(Type::Closure {
│ params: [(Mut, I32)],
│ ret: I32
│ })
└── value: Expr::ClosureExpr
├── params:
│ └── [0] ClosureParam { convention: Mut, name: "n", ty: None }
└── body: Expr::Reference { path: ["n"] }
IR Overview
The IR is the output for a backend: an interpreter, a JIT compiler, a
query engine or a code generator. The library has no backend of its
own. A backend plugs in through the IrPass and Backend traits in
src/pipeline.rs.
Note: For syntax analysis or source-level tooling, use the AST instead. The IR is optimized for code generation, not source fidelity.
What the IR provides
The IR is a type-resolved representation of FormaLang programs, produced after semantic analysis. Unlike the AST which preserves source syntax, the IR provides:
- Resolved types on every expression
- Linked references (IDs pointing to definitions, not string names)
- One module for the whole program: the items of each imported module are in it (see Obtaining the IR)
- Flattened structure, made for a backend
- Visitor pattern for traversal
Compiler Pipeline
Source
|
v
Lexer -> Tokens
|
v
Parser -> AST (File)
|
v
Semantic Analyzer -> Validated AST + SymbolTable
|
v
IR Lowering -> IrModule <-- This reference
|
v
Plugin System -> [IrPass, ...] -> Backend -> Output
AST vs IR
| Feature | AST | IR |
|---|---|---|
| Source locations (spans) | Yes | Yes (IrSpan) |
| Multi-file source identity | No | Yes (FileId + file_table) |
| Type resolution | No | Yes |
| ID-based references | No | Yes |
| String type names | Yes | No |
| Use statements | Yes | Names only (IrModule.imports) |
| Comments | Yes | No |
| Parentheses/grouping | Yes | No |
The IR intentionally omits:
- Use statements: the lowering resolves them.
IrModule.importskeeps the imported names for backends that emit import statements - Comments: purely syntactic, not needed for codegen
- Parentheses/grouping: expression structure is normalized
- String type references: all resolved to typed IDs
Source Spans (DWARF / source-map / line-table)
Every IR shape carries an IrSpan field:
#![allow(unused)] fn main() { pub struct IrSpan { pub span: crate::location::Span, // byte / line / column range pub file: FileId, // index into IrModule.file_table } }
FileId(0) is reserved for synthetic / hand-built nodes (closure-
converted lift wrappers, monomorphisation specialisations, test
fixtures). Real source files start at FileId(1) and live in
IrModule.file_table: Vec<PathBuf>. The lowerer registers files via
IrModule.register_file(path) which returns the assigned id.
compile_to_ir knows no path, so every span in its result has
FileId(0). Use compile_to_ir_with_path or
compile_to_ir_with_path_and_resolver when a backend needs the file.
In a program over several files, the linker registers the file of each
imported module, and the spans of each copied item name that file.
Spans cover every data struct (IrFunction, IrStruct, IrEnum,
IrEnumVariant, IrField, IrLet, IrTrait, IrFunctionSig,
IrFunctionParam, IrImpl) and every IrExpr variant
(Literal, Reference, FunctionCall, MethodCall, BinaryOp,
UnaryOp, If, For, Match, Block, etc.).
Backends emit:
- DWARF
DW_TAG_subprogram/DW_AT_decl_file/.debug_lineby readingIrFunction.span+IrModule.file_table. - Source maps (v3) by walking IR expressions, mapping each
emitted instruction back to
IrSpan.span.start. - JVM
LineNumberTableby mapping bytecode offsets toIrFunctionSig.span.start.line.
With the serde feature, serde leaves out each span field that is
at its default (IrSpan::is_default), and reads a missing span as
the default. So synthetic IR does not make the serialised form larger.
Every built-in pass carries these spans through the nodes it rewrites,
so the positions survive Pipeline::for_codegen.
tests/suite/metamorphic.rs checks that with
no_pass_throws_away_source_positions, which compares the count of
nodes holding a real span before and after each pass.
A node a pass synthesises carries the span of whatever it replaces
where that is meaningful — a captured variable's __env.x access
keeps the span of the reference it stands in for, and a folded
constant keeps the span of the expression it collapsed. A node with no
source counterpart, such as a lifted closure's wrapper, carries
FileId(0).
Module nesting is flattened in the per-type vectors: a struct
inside mod foo { ... } is stored on IrModule.structs with a
qualified name "foo::Bar". A parallel
IrModule.modules: Vec<IrModuleNode> tree mirrors the source mod
hierarchy with per-module ID lists for backends that need namespaced
output (see IrModuleNode).
Relationship to the Symbol Table
The SymbolTable (built by the semantic analyzer) and the IrModule
(produced by IR lowering) carry overlapping definitions by design:
SymbolTablekeys everything by name and stores types as strings (e.g."User","[I32]?"). It is the authoritative view for the validation passes and for LSP-style tooling that operates at the source level.IrModulekeys everything by typed IDs (StructId,TraitId,EnumId,FunctionId,ImplId) and stores types asResolvedTypeenums with embedded IDs. It is the authoritative view for code generators.
The two are built in sequence: the symbol table drives lowering, then falls out of scope. Backends that need human-readable names can read them from the IR directly; they never need to inspect the symbol table.
Obtaining the IR
Compile a .fv source string to a fully type-resolved IrModule:
#![allow(unused)] fn main() { use formalang::compile_to_ir; let source = r#" pub struct User { name: String, age: I32 } "#; match compile_to_ir(source) { Ok(module) => { // module is the root IR node for (id, struct_def) in module.structs.iter().enumerate() { println!("Struct {}: {}", id, struct_def.name); } } Err(errors) => { for error in errors { eprintln!("Error: {}", error); } } } }
The loop above also prints the prelude structs (Array, Seq,
Dictionary and Range). The prelude comes first in each module. Use
module.user_structs() and module.user_enums() to skip it.
For multi-file projects, pair compile_to_ir_with_resolver with a
FileSystemResolver, a MemoryResolver when there is no file system,
or a custom ModuleResolver impl. See the
Public API for the complete entry-point list.
Programs over several files
Each entry point that takes a resolver gives one self-contained
IrModule. A backend does not need the IR of the imported modules.
The linker (src/ir/link/ and src/ir/lower/linked.rs) builds this
module. It works in these steps:
- The prelude is analysed and lowered one time for each process
(
prelude_irinsrc/lib.rs). - Each imported module lowers before the modules that import it. Its lowering starts from a copy of the lowered prelude.
- The linker copies each item of each imported module into the new
module: the structs, enums, traits, impls, functions and
letbindings. The copy gets new ids in the new module. An item that two imports share arrives one time. The linker finds a shared item by its origin: the module that defines it and its place in that module. - The module's own statements lower on top of these items. The
lowering thus finds each imported item as a local item with a real
id, and gives a
Struct,Enum,TraitorFunctionCallwith that id. It gives noResolvedType::External. - The entry module lowers last, in the same way.
Hidden names
Two modules can each have a private item of one name, but a name of a
module-level item must be unique in one IrModule. So the own items
of an imported module get a hidden name: @, the id of the module,
::, then the name. An example is @2::helper. A program cannot
write @, so a hidden name never collides with a name in the source.
While a module lowers, each item that it imports has its short name
(use lib::open gives @3::open the name open), so that the
lowering finds it. After the lowering, the item gets its hidden name
back. A call in the code of a module names the item of that module. A
private helper of an imported module is thus the function that the
module calls, even when the importer defines a function with the same
name.
Final names
When the entry module is complete, finish_entry replaces each hidden
prefix with the module path that the source wrote in its use. For
example, @2::double_x becomes geom::double_x, and a module in
lib/util.fv gives lib::util::one. Sometimes this path is not
free: two modules would get one path, or the first segment of the path
is also the name of an inline mod of the entry module. Then the
module gets its id as a suffix, for example geom#2.
finish_entry also puts each imported struct, enum, trait and
function in the module tree
under the path of its module.
What the result holds
For this program in main.fv:
// geom.fv
pub struct Point { x: I32, y: I32 }
fn helper(n: I32) -> I32 { n * 2 }
pub fn double_x(p: Point) -> I32 { helper(n: p.x) }
let secret: I32 = 7
// main.fv
use geom::{Point, double_x}
fn helper(n: I32) -> I32 { n + 100 }
pub fn run() -> I32 { double_x(p: Point(x: 1, y: 2)) + helper(n: 1) }
compile_to_ir_with_path_and_resolver gives:
structs: Array, Seq, Dictionary, Range, geom::Point
functions: assert, geom::helper, geom::double_x, helper, run
lets: geom::secret
modules: [IrModuleNode { name: "geom", structs: [StructId(4)],
functions: [FunctionId(1), FunctionId(2)] }]
imports: [IrImport { module_path: ["geom"],
items: [Point (Struct), double_x (Function)] }]
file_table: [main.fv, geom.fv]
- Each item of an imported module is in the result one time, under
the path of its module. This includes the private items
(
geom::helper,geom::secret) and the items that the entry module does not name. - An imported struct keeps its impl blocks, and an imported trait keeps the impls that satisfy it. So an imported trait satisfies a local generic bound.
- The call in
geom::double_xnamesgeom::helper, not thehelperofmain.fv. importslists what the entry module wrote in itsusestatements. A backend can use it to emit import statements.file_tableholds each source file. The spans of each copied item name the file of its module.
compile_to_ir_with_resolver and compile_to_ir_with_path_and_resolver
then run MonomorphisePass.
lower_to_ir and lower_to_ir_with_path in formalang::ir lower one
AST alone. They do not link the imported modules, so a type that a
use imports is a ResolvedType::External in their result.
IrModule Structure
The root container for all IR definitions, plus the per-source-module
index that mirrors mod foo { ... } hierarchy.
Architecture Overview
IrModule (root)
|
+-- structs: Vec<IrStruct>
| |
| +-- name: String
| +-- visibility: Visibility
| +-- traits: Vec<IrTraitRef> ----> trait_id + optional generic-trait args
| +-- fields: Vec<IrField>
| | |
| | +-- name: String
| | +-- ty: ResolvedType (may contain StructId/TraitId/EnumId refs)
| | +-- mutable: bool
| | +-- optional: bool
| | +-- default: Option<IrExpr>
| |
| +-- generic_params: Vec<IrGenericParam>
| |
| +-- name: String
| +-- constraints: Vec<IrTraitRef> (trait_id + Vec<ResolvedType> args)
|
+-- traits: Vec<IrTrait>
| |
| +-- name: String
| +-- visibility: Visibility
| +-- composed_traits: Vec<TraitId> -----> trait inheritance
| +-- fields: Vec<IrField>
| +-- methods: Vec<IrFunctionSig> -----> required method signatures
| +-- generic_params: Vec<IrGenericParam>
|
+-- enums: Vec<IrEnum>
| |
| +-- name: String
| +-- visibility: Visibility
| +-- variants: Vec<IrEnumVariant>
| | |
| | +-- name: String
| | +-- fields: Vec<IrField>
| |
| +-- generic_params: Vec<IrGenericParam>
|
+-- impls: Vec<IrImpl>
| |
| +-- target: ImplTarget ----------> Struct(StructId), Enum(EnumId) or Primitive(PrimitiveType)
| +-- trait_ref: Option<IrTraitRef>
| +-- functions: Vec<IrFunction>
|
+-- lets: Vec<IrLet> // Module-level let bindings
|
+-- functions: Vec<IrFunction> // Standalone function definitions
|
+-- imports: Vec<IrImport> // What the `use` statements import
|
+-- modules: Vec<IrModuleNode> // Source `mod` tree and imported modules
|
+-- file_table: Vec<PathBuf> // Source files, indexed by FileId
IrModule
#![allow(unused)] fn main() { pub struct IrModule { pub structs: Vec<IrStruct>, pub traits: Vec<IrTrait>, pub enums: Vec<IrEnum>, pub impls: Vec<IrImpl>, pub lets: Vec<IrLet>, // Module-level let bindings pub functions: Vec<IrFunction>, // Standalone function definitions pub imports: Vec<IrImport>, // What the `use` statements import pub modules: Vec<IrModuleNode>, // Source `mod foo { ... }` hierarchy pub file_table: Vec<PathBuf>, // Source files, indexed by FileId // private name-to-id indexes, skipped by serde } }
The flat per-type vectors remain authoritative: every definition
lives in the appropriate slot regardless of source nesting. The
modules tree is an index on top of those flat vectors, opt-in
for backends that need to emit code into namespaces.
The vectors start with the prelude. Array, Seq, Dictionary and
Range are the first four structs, Optional is the first enum, the
prelude's extern impl blocks are the first impls, and assert is the
first function. See Resolved Types
for the accessors that find them.
Lookup Methods
#![allow(unused)] fn main() { impl IrModule { /// Look up a definition by ID. Returns None if out of bounds. pub fn get_struct(&self, id: StructId) -> Option<&IrStruct>; pub fn get_trait(&self, id: TraitId) -> Option<&IrTrait>; pub fn get_enum(&self, id: EnumId) -> Option<&IrEnum>; pub fn get_function(&self, id: FunctionId) -> Option<&IrFunction>; /// Look up an ID by name. pub fn struct_id(&self, name: &str) -> Option<StructId>; pub fn trait_id(&self, name: &str) -> Option<TraitId>; pub fn enum_id(&self, name: &str) -> Option<EnumId>; /// For an overloaded name, the id of the first overload. pub fn function_id(&self, name: &str) -> Option<FunctionId>; /// Look up a module-level let binding by name. pub fn get_let(&self, name: &str) -> Option<&IrLet>; pub fn has_let(&self, name: &str) -> bool; /// The source path of a FileId. None for FileId(0). pub fn file_path(&self, file: FileId) -> Option<&PathBuf>; /// Add a path to `file_table`, or find it there, and return its id. pub fn register_file(&mut self, path: PathBuf) -> FileId; /// Rebuild the internal name-to-ID indices after mutating the module. /// /// Call this after adding, removing or reordering definitions in /// `structs`, `traits`, `enums`, `functions` or `lets` so that the /// `*_id()` lookup methods stay consistent. Serde skips the /// indices: call it after you deserialise a module, too. pub fn rebuild_indices(&mut self); } }
Imports
imports records what the use statements of the entry module
import. A backend can use it to emit import statements.
The public entry points link each imported module into the result (see
Programs over several files).
The imported definitions are then local definitions under a qualified
name, such as geom::Point, and each reference to them uses a local
id. So imports only records names. A backend does not need the IR of
another module.
IrImport
#![allow(unused)] fn main() { pub struct IrImport { /// Logical module path (e.g., ["utils", "helpers"]) pub module_path: Vec<String>, /// Items imported from this module pub items: Vec<IrImportItem>, /// Filesystem path to the source module file pub source_file: PathBuf, } }
IrImportItem
#![allow(unused)] fn main() { pub struct IrImportItem { /// Name of the imported item, as the `use` wrote it pub name: String, /// Kind of the item pub kind: ImportedKind, } }
ImportedKind
#![allow(unused)] fn main() { pub enum ImportedKind { Struct, Trait, Enum, Function, // use other::compute ModuleLet, // use other::CONST } }
Using Imports in Code Generators
A generator that emits one output file for each source module can
read imports for the import statements, and the
module tree for the items of
each imported module:
#![allow(unused)] fn main() { fn generate_typescript_imports(module: &IrModule) -> String { let mut output = String::new(); for import in &module.imports { let path = import.module_path.join("/"); let items: Vec<&str> = import.items.iter().map(|i| i.name.as_str()).collect(); output.push_str(&format!( "import {{ {} }} from './{}';\n", items.join(", "), path )); } output } }
IrModuleNode: source mod hierarchy
IrModule.modules mirrors the source mod foo { ... } tree. Each
node lists the IDs of struct/trait/enum/function definitions
declared directly in that module plus nested sub-modules. The
flat per-type vectors on IrModule remain authoritative: this
tree is an index on top of them for backends that need to
preserve source structure in their output (JS export * from,
Swift nested types, Kotlin packages).
#![allow(unused)] fn main() { pub struct IrModuleNode { /// Module name as written in source (the unqualified segment, /// e.g. `"shapes"` for `mod shapes { ... }`). pub name: String, /// IDs of structs declared directly in this module. pub structs: Vec<StructId>, /// IDs of traits declared directly in this module. pub traits: Vec<TraitId>, /// IDs of enums declared directly in this module. pub enums: Vec<EnumId>, /// IDs of functions declared directly in this module. pub functions: Vec<FunctionId>, /// Nested sub-modules. pub modules: Vec<IrModuleNode>, } }
Top-level (non-mod) definitions of the entry module are not
mirrored in the tree; backends iterate the flat vectors for those.
The tree also holds one node for each imported module, at the path
that the use wrote. For use lib::util::one, the node lib holds
the node util, and that node lists the id of lib::util::one.
ID Types
The IR uses typed IDs for referencing definitions. IDs are simple
newtypes wrapping u32, making them copyable and cheap to pass around.
#![allow(unused)] fn main() { #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] pub struct StructId(pub u32); #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] pub struct TraitId(pub u32); #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] pub struct EnumId(pub u32); }
IDs index into the corresponding Vec in IrModule. The prelude
definitions come first, so the first user struct is not StructId(0).
Look a definition up by name:
#![allow(unused)] fn main() { // Use helper method (returns Option) if let Some(struct_def) = module.get_struct(id) { // use struct_def } // Lookup by name if let Some(id) = module.struct_id("User") { if let Some(struct_def) = module.get_struct(id) { // use struct_def } } // Direct indexing (when ID is known valid) let struct_def = &module.structs[id.0 as usize]; }
ID Type Safety
IDs are type-safe: you cannot accidentally use a StructId where a
TraitId is expected. This prevents a common class of bugs:
#![allow(unused)] fn main() { let struct_id = StructId(0); let trait_id = TraitId(0); // Compile error: types don't match // module.get_struct(trait_id); }
Other typed IDs
Beyond the three definition-level IDs above, these typed IDs flow
through the IR. The lowering sets FunctionId on a call and ImplId
in a DispatchKind::Static. ResolveReferencesPass sets the others:
BindingId: function-localletbindings, parameters, loop variablesFieldIdx: index into the matching struct/enum variant'sfieldsVariantIdx: index into the matching enum'svariantsMethodIdx: index into the matching impl'sfunctionsor trait'smethodsLetId: module-levelletbindingsImplId: impl blocksFunctionId: index intoIrModule.functions
A pass that removes or reorders definitions changes the ids of the definitions after them. Each built-in pass rewrites every id that it changes, so the ids in its result are consistent.
These appear on IrExpr, IrMatchArm,
and IrBlockStatement.
Resolved Types
Every type in the IR is fully resolved. Unlike AST types which use string names, resolved types use IDs that directly reference definitions.
ResolvedType
#![allow(unused)] fn main() { pub enum ResolvedType { /// Primitive type (String, I32, I64, F32, F64, Boolean, Never) Primitive(PrimitiveType), /// Reference to a struct definition Struct(StructId), /// Reference to a trait definition Trait(TraitId), /// Reference to an enum definition Enum(EnumId), /// Named tuple type: (name1: T1, name2: T2) Tuple(Vec<(String, ResolvedType)>), /// Generic type instantiation: Box<String>, Optional<I32>, [I32] Generic { /// The generic struct, enum or trait. base: GenericBase, args: Vec<ResolvedType>, }, /// Type parameter (T) inside a generic definition TypeParam(String), /// Reference to a type in another module External { module_path: Vec<String>, // e.g., ["utils", "helpers"] name: String, // Type name kind: ImportedKind, // Struct, Trait, Enum, Function or ModuleLet type_args: Vec<ResolvedType>, // For generics }, /// Closure / function type: (T1, T2) -> R /// /// Each element is `(convention, type)`. The convention constrains /// the caller of the closure. Closure { param_tys: Vec<(ParamConvention, ResolvedType)>, return_ty: Box<ResolvedType>, }, /// Error placeholder. See below. Error, } }
The built-in compound types
The IR has no special variant for an array, an optional, a dictionary,
a range or a sequence. The prelude declares each one as an ordinary
generic definition, and a type of that kind is a Generic over it:
| Source type | Prelude definition | ResolvedType |
|---|---|---|
[T] | struct Array<T> | Generic { base: Struct(array_id), args: [T] } |
a for result | struct Seq<T> | Generic { base: Struct(seq_id), args: [T] } |
[K: V] | struct Dictionary<K, V> | Generic { base: Struct(dictionary_id), args: [K, V] } |
a..b | struct Range<T> | Generic { base: Struct(range_id), args: [T] } |
T? | enum Optional<T> | Generic { base: Enum(optional_id), args: [T] } |
The IR has no optional of an optional. The language has one level of
absence, so the argument of an Optional is never an Optional. An
index into [I32?] has the type I32?, as the type check says.
The prelude comes first in each module, so these definitions have the
lowest ids: Array, Seq, Dictionary and Range are StructId(0)
to StructId(3), and Optional is EnumId(0). Do not write these
numbers in a backend. Use the accessors on IrModule:
#![allow(unused)] fn main() { impl IrModule { pub fn prelude_array_id(&self) -> Option<StructId>; pub fn prelude_seq_id(&self) -> Option<StructId>; pub fn prelude_dictionary_id(&self) -> Option<StructId>; pub fn prelude_range_id(&self) -> Option<StructId>; pub fn prelude_optional_id(&self) -> Option<EnumId>; /// `T` when `ty` is `[T]`, and so on for each carrier. pub fn array_element_ty<'a>(&self, ty: &'a ResolvedType) -> Option<&'a ResolvedType>; pub fn seq_element_ty<'a>(&self, ty: &'a ResolvedType) -> Option<&'a ResolvedType>; pub fn dictionary_kv_ty<'a>(&self, ty: &'a ResolvedType) -> Option<(&'a ResolvedType, &'a ResolvedType)>; pub fn range_element_ty<'a>(&self, ty: &'a ResolvedType) -> Option<&'a ResolvedType>; pub fn optional_inner_ty<'a>(&self, ty: &'a ResolvedType) -> Option<&'a ResolvedType>; /// True for `Array`, `Seq`, `Dictionary` and `Range`. pub fn is_prelude_struct(&self, id: StructId) -> bool; /// True for `Optional`. pub fn is_prelude_enum(&self, id: EnumId) -> bool; /// The structs and enums without the prelude definitions. pub fn user_structs(&self) -> impl Iterator<Item = &IrStruct>; pub fn user_enums(&self) -> impl Iterator<Item = &IrEnum>; } }
MonomorphisePass does not specialise these five definitions. After
the pass, a Generic over one of them is the final shape, and a
backend reads the element type from args.
GenericBase
Target of a Generic instantiation: a generic struct, enum, or
trait. Traits appear here only inside generic constraints
(<T: Foo<X>>) and impl headers (impl Foo<X> for Y). FormaLang
has no dynamic dispatch, so a trait base never sits in a value
type position. Match exhaustively when you extract the ID.
#![allow(unused)] fn main() { pub enum GenericBase { Struct(StructId), Enum(EnumId), Trait(TraitId), } }
External
The lowering of each module links in the modules that it imports (see
Obtaining the IR). An
imported struct, enum or trait is then a local definition with a local
id, so a type that names it is Struct, Enum or Trait. The public
entry points do not give External for a type that a use imports.
External stays in the IR for backends that build IR by hand or read
it from another tool. MonomorphisePass::with_imports
can replace each External with a local copy of the imported
definition.
Error
The lowering gives Error when it has already recorded a
CompilerError for the node, and it must still give the node a type.
The compile then returns that error, so a module that reaches a backend
holds no Error. A backend can treat Error as unreachable.
Type Resolution Examples
The ids below are the ids in a module with no other definitions: the prelude takes the first ids.
| FormaLang Type | ResolvedType |
|---|---|
String | Primitive(PrimitiveType::String) |
I32 / I64 | Primitive(PrimitiveType::I32) / Primitive(PrimitiveType::I64) |
F32 / F64 | Primitive(PrimitiveType::F32) / Primitive(PrimitiveType::F64) |
Boolean | Primitive(PrimitiveType::Boolean) |
Never | Primitive(PrimitiveType::Never) |
User (local struct) | Struct(StructId(4)) |
Named (local trait) | Trait(TraitId(0)) |
Status (local enum) | Enum(EnumId(1)) |
[String] | Generic { base: Struct(StructId(0)), args: [Primitive(String)] } |
0..10 | Generic { base: Struct(StructId(3)), args: [Primitive(I32)] } |
String? | Generic { base: Enum(EnumId(0)), args: [Primitive(String)] } |
[[I32]] | Generic { base: Struct(StructId(0)), args: [Generic { base: Struct(StructId(0)), args: [Primitive(I32)] }] } |
[String: I32] | Generic { base: Struct(StructId(2)), args: [Primitive(String), Primitive(I32)] } |
Box<String> (local generic struct) | Generic { base: Struct(StructId(4)), args: [Primitive(String)] } |
Maybe<I32> (local generic enum) | Generic { base: Enum(EnumId(1)), args: [Primitive(I32)] } |
(x: I32, y: I32) | Tuple([("x", Primitive(I32)), ("y", Primitive(I32))]) |
T (in generic) | TypeParam("T") |
(String, I32) -> Boolean | Closure { param_tys: [(Let, Primitive(String)), (Let, Primitive(I32))], return_ty: Primitive(Boolean) } |
(mut I32) -> Boolean | Closure { param_tys: [(Mut, Primitive(I32))], return_ty: Primitive(Boolean) } |
(sink String) -> Boolean | Closure { param_tys: [(Sink, Primitive(String))], return_ty: Primitive(Boolean) } |
Display Names
#![allow(unused)] fn main() { impl ResolvedType { /// A display name for this type, for debug output and messages. pub fn display_name(&self, module: &IrModule) -> String; } // Example let ty = &field.ty; println!("Field type: {}", ty.display_name(&module)); // Output: "[String]" or "User" or "Box<I32>" }
display_name writes the source form of a built-in carrier: [T],
T?, [K: V] and T..T. A sequence has the form Seq<T>.
Definition Types
Top-level definitions stored in the per-type vectors on
IrModule. Functions live on a separate page;
see Functions. Module-level let bindings appear
at the bottom of this page as IrLet.
IrStruct
#![allow(unused)] fn main() { pub struct IrStruct { /// The struct name pub name: String, /// Visibility (public or private) pub visibility: Visibility, /// Traits implemented by this struct, with optional generic-trait /// args (`impl Container<I32> for Foo` → entry with non-empty /// args). Empty args means a non-generic trait. pub traits: Vec<IrTraitRef>, /// Regular fields pub fields: Vec<IrField>, /// Generic type parameters pub generic_params: Vec<IrGenericParam>, /// Joined `///` doc comments preceding this struct. pub doc: Option<String>, /// Source span. See [Source Spans](overview.md#source-spans-dwarf--source-map--line-table). pub span: IrSpan, } }
IrTrait
#![allow(unused)] fn main() { pub struct IrTrait { /// The trait name pub name: String, /// Visibility (public or private) pub visibility: Visibility, /// Traits composed into this trait (trait inheritance) pub composed_traits: Vec<TraitId>, /// Required fields pub fields: Vec<IrField>, /// Required method signatures. A signature can name `Self`, the /// type that implements the trait: it is `TypeParam("Self")`, and /// it is not in `generic_params`. pub methods: Vec<IrFunctionSig>, /// Generic type parameters pub generic_params: Vec<IrGenericParam>, /// Joined `///` doc comments preceding this trait. pub doc: Option<String>, /// Source span. See [Source Spans](overview.md#source-spans-dwarf--source-map--line-table). pub span: IrSpan, } }
IrEnum
#![allow(unused)] fn main() { pub struct IrEnum { /// The enum name pub name: String, /// Visibility (public or private) pub visibility: Visibility, /// Enum variants pub variants: Vec<IrEnumVariant>, /// Generic type parameters pub generic_params: Vec<IrGenericParam>, /// Joined `///` doc comments preceding this enum. pub doc: Option<String>, /// Source span. See [Source Spans](overview.md#source-spans-dwarf--source-map--line-table). pub span: IrSpan, } pub struct IrEnumVariant { /// The variant name pub name: String, /// Associated data fields (empty for unit variants) pub fields: Vec<IrField>, /// Source span. pub span: IrSpan, } }
ImplTarget
Identifies what an impl block implements: a struct, an enum or a primitive.
#![allow(unused)] fn main() { pub enum ImplTarget { Struct(StructId), Enum(EnumId), /// An impl on a primitive: an `extern impl`, such as the prelude's /// `extern impl String`, or a regular impl or trait impl whose /// methods have bodies. In such a body, `self` is a value of the /// primitive. Primitive(PrimitiveType), } }
IrImpl
Impl blocks provide methods for a struct, an enum or a primitive.
#![allow(unused)] fn main() { pub struct IrImpl { /// The struct or enum this impl is for pub target: ImplTarget, /// `Some(IrTraitRef { trait_id, args })` for `impl Trait for Type` /// or `impl Trait<X> for Type`; `None` for inherent impls. Args /// are empty for non-generic traits and carry the concrete /// instantiation for generic-trait impls. pub trait_ref: Option<IrTraitRef>, /// Whether this is an `extern impl` block (all methods have /// `extern_abi = Some(_)` and `body = None`). pub is_extern: bool, /// Generic parameters declared on the impl block itself /// (`impl<T: Bound> Box<T>`). pub generic_params: Vec<IrGenericParam>, /// Methods defined in this impl block pub functions: Vec<IrFunction>, /// Source span. pub span: IrSpan, } impl IrImpl { /// Convenience: trait id of the impl, ignoring args. Equivalent /// to `self.trait_ref.as_ref().map(|t| t.trait_id)`. pub fn trait_id(&self) -> Option<TraitId>; /// Returns the struct ID if `target` is a struct, otherwise `None`. pub fn struct_id(&self) -> Option<StructId>; /// Returns the enum ID if `target` is an enum, otherwise `None`. pub fn enum_id(&self) -> Option<EnumId>; /// Returns the primitive if `target` is a primitive, otherwise `None`. pub fn primitive(&self) -> Option<PrimitiveType>; } }
IrField
Used in structs, traits, and enum variants:
#![allow(unused)] fn main() { pub struct IrField { /// Field name pub name: String, /// Resolved type pub ty: ResolvedType, /// Whether this field is mutable (mut keyword) pub mutable: bool, /// Whether this field is optional (T?) pub optional: bool, /// Default value expression, if any pub default: Option<IrExpr>, /// Joined `///` doc comments preceding this field, if any. pub doc: Option<String>, /// Passing convention. Always `Let` for a field in the source. /// `ClosureConversionPass` sets it on the fields of the env structs /// that it makes: a capture of a `mut` or `sink` binding keeps that /// convention. pub convention: ParamConvention, /// Source span. pub span: IrSpan, } }
IrGenericParam
#![allow(unused)] fn main() { pub struct IrGenericParam { /// Parameter name (e.g., "T") pub name: String, /// Trait constraints. Each entry carries the constrained trait /// id plus zero or more concrete arg types: empty when the /// trait isn't generic (`T: Container`), populated for /// generic-trait constraints (`T: Container<I32>`). pub constraints: Vec<IrTraitRef>, } }
IrTraitRef
A reference to a trait, optionally with concrete type arguments.
Used in two places: as the constraint shape on
IrGenericParam and as the
implements-relationship shape on IrImpl /
IrStruct.traits. An empty args slot means the
trait isn't generic; a non-empty slot carries the instantiation so
monomorphisation can specialise generic traits. With the serde
feature, serde leaves out an empty args, so a JSON trait reference
can be { "trait_id": 0 }.
#![allow(unused)] fn main() { pub struct IrTraitRef { pub trait_id: TraitId, pub args: Vec<ResolvedType>, } impl IrTraitRef { /// Construct a non-generic trait reference (no args). pub const fn simple(trait_id: TraitId) -> Self; } }
IrLet
Module-level let bindings (constants and computed values stored on
IrModule.lets):
#![allow(unused)] fn main() { pub struct IrLet { /// Binding name pub name: String, /// Visibility (public or private) pub visibility: Visibility, /// Whether this binding is mutable pub mutable: bool, /// The resolved type of the binding pub ty: ResolvedType, /// The bound expression pub value: IrExpr, /// Joined `///` doc comments preceding this binding. pub doc: Option<String>, /// Source span. See [Source Spans](overview.md#source-spans-dwarf--source-map--line-table). pub span: IrSpan, } }
Functions
IrFunction is the body-bearing function shape used both for standalone
functions on IrModule.functions and for impl methods on IrImpl.functions.
IrFunctionSig is the body-less shape used for trait-required methods on
IrTrait.methods.
IrFunction
#![allow(unused)] fn main() { pub struct IrFunction { /// Function name pub name: String, /// Visibility. A backend keys its export list on this. Serde /// defaults it to private. pub visibility: Visibility, /// Generic type parameters declared on the function or method /// itself (e.g. `fn identity<T>(x: T) -> T`, /// `fn map<U>(self, f: (T) -> U) -> Box<U>`). Enclosing-type /// generics live on the containing `IrImpl` / `IrStruct`. After /// `MonomorphisePass`, only an extern method keeps its own: it has /// no body to specialise, and each call carries the concrete types. pub generic_params: Vec<IrGenericParam>, /// Parameters (first is `self` for methods; no `self` for standalone functions) pub params: Vec<IrFunctionParam>, /// Return type (None = unit/void) pub return_type: Option<ResolvedType>, /// Function body expression (None for extern functions) pub body: Option<IrExpr>, /// Calling convention when the function is `extern`. `None` for /// regular functions; `Some(ExternAbi::C)` for `extern fn` / /// `extern "C" fn`; `Some(ExternAbi::System)` for /// `extern "system" fn`. pub extern_abi: Option<ExternAbi>, /// Codegen-hint attributes (`inline`, `no_inline`, `cold`) /// declared as keyword prefixes before `fn`. pub attributes: Vec<FunctionAttribute>, /// Joined `///` doc comments preceding this function. pub doc: Option<String>, /// Source span: the declaration of the function. pub span: IrSpan, } impl IrFunction { /// Whether this function is declared `extern`. Convenience /// wrapper over `extern_abi.is_some()`. pub const fn is_extern(&self) -> bool; } }
IrFunctionSig
A signature-only function declaration (no body). Used for required methods declared in traits.
#![allow(unused)] fn main() { pub struct IrFunctionSig { /// Function name pub name: String, /// Parameters (first is typically `self`) pub params: Vec<IrFunctionParam>, /// Return type (None = unit/void) pub return_type: Option<ResolvedType>, /// Codegen-hint attributes (`inline`, `no_inline`, `cold`). pub attributes: Vec<FunctionAttribute>, /// Source span. pub span: IrSpan, } }
IrFunctionParam
#![allow(unused)] fn main() { pub struct IrFunctionParam { /// Per-function-unique id. Uses of the parameter carry the same id. /// Lowering emits `BindingId(0)` and `ResolveReferencesPass` /// overwrites it. pub binding_id: BindingId, /// Parameter name pub name: String, /// External call-site label, for `fn send(to name: String)`. /// `None` when the parameter has no separate label. pub external_label: Option<String>, /// Parameter type (None for bare `self`) pub ty: Option<ResolvedType>, /// Default value expression, if any pub default: Option<IrExpr>, /// Parameter passing convention pub convention: ParamConvention, /// Source span. pub span: IrSpan, } }
ParamConvention in the IR
ParamConvention is re-exported from formalang::ast. Backends should
interpret it as follows:
| Variant | Meaning for the backend |
|---|---|
Let | Immutable read access. The backend may pass by reference or copy. |
Mut | Exclusive mutable access. The frontend guarantees no aliasing: see below. |
Sink | Ownership transfer. The value is logically moved; the caller cannot use it after this call. |
No two arguments of one call alias, when one of them is Mut or
Sink. The semantic analyser rejects such a call with E144 OverlappingArguments, and the check follows fields: p.x and p.y
are two places, while p and p.x are one. So a backend may pass a
Mut argument as a raw pointer and add no alias check. A Let
argument may still share a value with another Let argument, because
nothing changes it.
All three conventions use identical call syntax in FormaLang source; the distinction is purely semantic. Backends that target languages with explicit ownership (Rust, C++ move semantics, Swift inout) should map directly. Backends targeting garbage-collected languages (TypeScript, Python) may treat all three as pass-by-value and ignore the distinction.
#![allow(unused)] fn main() { use formalang::ast::ParamConvention; fn emit_param(param: &IrFunctionParam) { match param.convention { ParamConvention::Let => { /* pass by value / reference */ } ParamConvention::Mut => { /* pass as mutable / inout */ } ParamConvention::Sink => { /* consume / move */ } } } }
ExternAbi
The FFI calling convention of an extern function.
#![allow(unused)] fn main() { pub enum ExternAbi { C, // `extern fn` (default) or `extern "C" fn` System, // `extern "system" fn` (stdcall on Win32 x86, C elsewhere) } }
Unknown ABI strings (extern "rustcall" fn ...) are rejected at
parse time. A backend-side mapping by function name still owns
symbol-name overrides and type marshalling rules across the FFI
boundary.
FunctionAttribute
#![allow(unused)] fn main() { pub enum FunctionAttribute { Inline, // `inline fn` NoInline, // `no_inline fn` Cold, // `cold fn` } }
Source syntax stacks freely with pub and extern:
pub cold extern fn abort() -> Never. The frontend passes
attributes through unchanged; backends decide whether to honour them.
Expressions
Every expression carries its resolved type in the ty field. This
eliminates the need for code generators to re-infer types.
Every variant also carries a span: IrSpan field. The listing below
leaves it out. See Source Spans.
Several expression variants also carry typed-id payloads
(ReferenceTarget, BindingId, FieldIdx, VariantIdx, MethodIdx,
FunctionId, DispatchKind). Lowering emits placeholder 0-valued ids
for BindingId, FieldIdx, VariantIdx and MethodIdx, and
Unresolved for ReferenceTarget. ResolveReferencesPass rewrites
them. The lowering sets FunctionId and DispatchKind itself.
Backends that consume integer-indexed code (wasm, JVM, native) should
run that pass; Pipeline::for_codegen holds it. Backends that re-walk
the module by name can skip it.
ReferenceTarget
Identifies what an IrExpr::Reference resolves to. Pre-resolve, every
reference carries Unresolved; ResolveReferencesPass rewrites it to
the matching variant. Backends dispatch on the variant directly without
re-walking module symbol tables. The original path is preserved
alongside for diagnostics.
#![allow(unused)] fn main() { pub enum ReferenceTarget { /// A standalone function (resolved against `IrModule::functions`). Function(FunctionId), /// A struct definition used as a value or type. Struct(StructId), /// An enum definition. Also the receiver of a static call on an /// enum, `Optional.from(value: 3)`. Enum(EnumId), /// A trait definition. Trait(TraitId), /// A module-scope `let` binding. ModuleLet(LetId), /// A function-local `let` binding (introduced by `IrBlockStatement::Let`). Local(BindingId), /// A function parameter (introduced by `IrFunctionParam`). Param(BindingId), /// A reference into another module that is not linked. The public /// entry points link each imported module, so their result has /// none of these. External { module_path: Vec<String>, name: String, kind: ImportedKind, }, /// The receiver of a static call on a type that is not a struct: a /// type parameter (`T` in `T.zero()`) or a primitive. The `ty` of /// the reference names the type, and the call reads no value of it. /// After `MonomorphisePass`, a receiver whose type became a struct /// is `Struct`. Type, /// Pre-`ResolveReferencesPass` placeholder; backends should never see it. Unresolved, } }
DispatchKind
How a method call should be dispatched.
#![allow(unused)] fn main() { pub enum DispatchKind { /// Direct call on a known concrete type: no runtime lookup needed. Static { impl_id: ImplId, }, /// Trait method call through a generic type parameter. There is no /// trait object: a trait cannot be the type of a value, so semantic /// analysis rejects one before lowering. `MonomorphisePass` /// rewrites every one of these to `Static` once the receiver is /// concrete, and reports any that survive. Virtual { trait_id: TraitId, method_name: String, /// The type arguments of the trait in the bound: `[I32]` for /// `<T: Container<I32>>`. Empty for a trait with no type /// parameters, and then left out of the JSON. trait_args: Vec<ResolvedType>, }, } }
One type can implement two instances of one generic trait, for
example Container<I32> and Container<String>. trait_args says
which instance a call means. MonomorphisePass points the dispatch at
the specialised trait of that instance, and then at its impl.
IrExpr
#![allow(unused)] fn main() { pub enum IrExpr { /// Literal value: string, number, boolean, nil Literal { value: Literal, ty: ResolvedType, }, /// Struct instantiation: `User(name: "Alice", age: 30)`. An optional /// field with no default that the call leaves out is in `fields` as /// a `nil` literal, as if the call wrote `field: nil`. StructInst { /// `None` for external structs: read `ty` instead. struct_id: Option<StructId>, /// Generic type args (e.g., `[String]` for `Box<String>`). type_args: Vec<ResolvedType>, /// Fields: `(name, field_idx, value)`. `field_idx` is the position /// in the target `IrStruct.fields`; lowering emits `FieldIdx(0)` /// and `ResolveReferencesPass` overwrites it. fields: Vec<(String, FieldIdx, IrExpr)>, ty: ResolvedType, }, /// Enum variant instantiation: `Status.active` or `.active` EnumInst { enum_id: Option<EnumId>, variant: String, /// Variant index in the target `IrEnum.variants`. variant_idx: VariantIdx, /// Associated data: `(name, field_idx, value)`. fields: Vec<(String, FieldIdx, IrExpr)>, ty: ResolvedType, }, /// Array literal: `[1, 2, 3]` Array { elements: Vec<IrExpr>, ty: ResolvedType, }, /// Tuple literal: `(x: 1, y: 2)` Tuple { fields: Vec<(String, IrExpr)>, ty: ResolvedType, }, /// Reference to a parameter, a loop variable, a module-level `let`, /// or a function-local binding. A path such as `user.name` keeps /// all its segments. Reference { /// Original source path (preserved for diagnostics). path: Vec<String>, /// Resolved target. `Unresolved` pre-`ResolveReferencesPass`. target: ReferenceTarget, ty: ResolvedType, }, /// `self.field` reference within an impl block. SelfFieldRef { field: String, /// Position in the impl's struct's `fields`. field_idx: FieldIdx, ty: ResolvedType, }, /// Field access on an arbitrary expression: `(a + b).len`. FieldAccess { object: Box<IrExpr>, field: String, field_idx: FieldIdx, ty: ResolvedType, }, /// Reference to a function-local `let` binding by name. The /// lowering also uses it for the closure of a `CallClosure` on a /// named binding. Module-scope `let`s use `Reference` with /// `ReferenceTarget::ModuleLet`. LetRef { name: String, /// Per-function-unique id, paired with the introducing /// `IrBlockStatement::Let::binding_id`. binding_id: BindingId, ty: ResolvedType, }, /// Binary operation: `a + b`, `x == y`, `p && q`. BinaryOp { left: Box<IrExpr>, op: BinaryOperator, right: Box<IrExpr>, ty: ResolvedType, }, /// Unary operation: `-x`, `!flag`. UnaryOp { op: UnaryOperator, operand: Box<IrExpr>, ty: ResolvedType, }, /// Conditional expression: `if cond { a } else { b }`. If { condition: Box<IrExpr>, then_branch: Box<IrExpr>, else_branch: Option<Box<IrExpr>>, ty: ResolvedType, }, /// For loop: `for item in items { body }`. For { var: String, var_ty: ResolvedType, /// Per-function-unique id for the loop variable, paired with /// `LetRef::binding_id` on references to `var` inside `body`. var_binding_id: BindingId, collection: Box<IrExpr>, body: Box<IrExpr>, /// `Seq<body_type>`: a lazy sequence. `.collect()` makes an array. ty: ResolvedType, }, /// Match expression: `match x { .a: ..., .b(v): ... }`. Match { scrutinee: Box<IrExpr>, arms: Vec<IrMatchArm>, ty: ResolvedType, }, /// Direct call to a top-level function: `sin(angle: x)` or /// `math::sin(angle: x)`. For closure values, see `CallClosure`. FunctionCall { /// Function path. For an imported function, the path holds /// the module path, for example `["geom", "double_x"]`. path: Vec<String>, /// Resolved target. The lowering sets it, and each pass keeps /// it in step with `path`. `None` when no function in the /// module has the path. Backends key on this id to dispatch /// directly without re-walking `IrModule.functions`. function_id: Option<FunctionId>, /// The type arguments that the call writes, in the order of /// the callee's type parameters: `I32` in `make<I32>()`. Empty /// when the call writes none. `MonomorphisePass` reads a type /// argument here when no argument gives it, and empties the /// list when it points the call at the specialised copy. The /// JSON form leaves an empty list out. type_args: Vec<ResolvedType>, /// `(optional_parameter_name, value)`. args: Vec<(Option<String>, IrExpr)>, ty: ResolvedType, }, /// Indirect call of a closure value. The lowering gives it for: /// - `f(x)`, where `f` is a closure-typed parameter, local binding /// or module-level `let` (the closure is a `LetRef`); /// - the AST node `Expr::Call`: a call of the value of any other /// expression, such as `make()(4)` (the closure is that /// expression, here a `FunctionCall`). CallClosure { /// Expression producing the closure value. closure: Box<IrExpr>, /// `(optional_label, value)`, as in `FunctionCall::args`. args: Vec<(Option<String>, IrExpr)>, /// `return_ty` from the closure type. ty: ResolvedType, }, /// Method call: `self.fill.sample(coords)`. /// A static method call, `Counter.zero()`, has a `Reference` to /// the struct as its receiver: its `target` is /// `ReferenceTarget::Struct` and its `ty` is the struct. A static /// method has no `self`, so a backend does not evaluate that /// receiver. MethodCall { receiver: Box<IrExpr>, method: String, /// Method position: index into the impl's `functions` for `Static`, /// or into the trait's `methods` for `Virtual`. method_idx: MethodIdx, args: Vec<(Option<String>, IrExpr)>, dispatch: DispatchKind, ty: ResolvedType, }, /// Closure expression: `(x: F32, y: F32) -> x + y`. /// /// Convention on each parameter constrains the **caller** of the /// closure (`Mut` requires a mutable argument; `Sink` moves it). /// /// `captures` lists every free variable referenced by the body that's /// bound in an enclosing scope. Each capture entry is /// `(outer_binding_id, name, capture_mode, resolved_type)`. The mode /// mirrors the outer binding's `ParamConvention` (or `Let` for plain /// immutable captures). A closure captures by value: it holds a copy /// of each value when it is made, and a later assignment to the /// binding does not change the copy. A returned closure can capture /// a local, so a capture never refers to the frame of its function. /// A `Sink` capture moves the value. Capture entries are deduplicated by name and ordered by /// the first reference encountered during the body walk. Both `params` /// and `captures` carry `BindingId`s assigned by `ResolveReferencesPass`. Closure { params: Vec<(ParamConvention, BindingId, String, ResolvedType)>, captures: Vec<(BindingId, String, ParamConvention, ResolvedType)>, body: Box<IrExpr>, /// `ResolvedType::Closure { param_tys, return_ty }`. ty: ResolvedType, }, /// Reference to a lifted closure: a top-level function paired with a /// runtime environment value carrying its captures. /// /// Produced by `ClosureConversionPass`. After that pass runs, every /// `IrExpr::Closure` has been replaced by a `ClosureRef` whose /// `funcref` names the lifted top-level function (its first parameter /// is the env struct, followed by the original closure parameters) /// and whose `env_struct` is an expression constructing the /// corresponding capture-environment `IrStruct`. Backends can render /// this as a function-pointer / environment pair (e.g. `funcref` + /// `call_indirect` in WebAssembly). ClosureRef { funcref: Vec<String>, env_struct: Box<IrExpr>, ty: ResolvedType, }, /// Dictionary literal: `["key": value, ...]`. DictLiteral { entries: Vec<(IrExpr, IrExpr)>, ty: ResolvedType, }, /// Dictionary access: `dict["key"]`. An array index `a[i]` is a /// `DictAccess` too. A string index `s[i]` lowers to the method /// call `s.byte_at(i)`. DictAccess { dict: Box<IrExpr>, key: Box<IrExpr>, ty: ResolvedType, }, /// Block expression: `{ ... }` with statements and a result. Block { statements: Vec<IrBlockStatement>, result: Box<IrExpr>, ty: ResolvedType, }, } }
IrMatchArm and IrBlockStatement: referenced from Match and Block
above: are defined on the Match Arms & Block Statements page.
Type Contract
The ty field is guaranteed correct after lowering:
| Expression | Type |
|---|---|
Literal { value: Number(_), .. } | Primitive(I32 / I64 / F32 / F64): the suffix, else the type that the context expects, else I32 for an integer and F64 for a fraction |
Literal { value: String(_), .. } | Primitive(String) |
Literal { value: Boolean(_), .. } | Primitive(Boolean) |
BinaryOp { op: Add/Sub/Mul/Div/Mod, .. } | Same as operands |
BinaryOp { op: Eq/Ne/Lt/Gt/Le/Ge, .. } | Primitive(Boolean) |
BinaryOp { op: And/Or, .. } | Primitive(Boolean) |
UnaryOp { op: Neg, .. } | Same as operand |
UnaryOp { op: Not, .. } | Primitive(Boolean) |
For { body, .. } | Generic { base: Struct(seq_id), args: [body.ty()] } |
If { then_branch, .. } | Same as branches |
Match { arms, .. } | Same as arm bodies |
Getting Expression Type
#![allow(unused)] fn main() { impl IrExpr { /// Get the resolved type of this expression pub fn ty(&self) -> &ResolvedType; } // Example let expr: &IrExpr = /* ... */; let ty = expr.ty(); match ty { ResolvedType::Primitive(PrimitiveType::String) => { // Generate string handling code } ResolvedType::Generic { .. } => { if let Some(inner) = module.array_element_ty(ty) { // Generate array handling code } } // ... } }
Match Arms & Block Statements
The two non-expression building blocks referenced from
IrExpr::Match and
IrExpr::Block.
IrMatchArm
#![allow(unused)] fn main() { pub struct IrMatchArm { /// Variant name being matched (empty for wildcard); preserved /// alongside `variant_idx` for diagnostics. pub variant: String, /// Position of the matched variant in the scrutinee enum's `variants` /// vector. Lowering emits `VariantIdx(0)` and `ResolveReferencesPass` /// overwrites it. pub variant_idx: VariantIdx, /// Whether this is a wildcard (`_`). pub is_wildcard: bool, /// Bindings for associated data: `(name, binding_id, type)`. A /// binding takes the payload field at its position, not the field /// with its name: `.some(v)` binds `v` to the first field. Each /// `binding_id` is a fresh per-function id introduced by the arm; /// backends key on it to reach the slot the arm writes the payload /// into. Lowering emits `BindingId(0)` and `ResolveReferencesPass` /// overwrites it. pub bindings: Vec<(String, BindingId, ResolvedType)>, /// Body expression pub body: IrExpr, } }
IrBlockStatement
Statements inside an IrExpr::Block.
#![allow(unused)] fn main() { pub enum IrBlockStatement { /// Let binding: `let x = expr` or `let mut x = expr`. Let { /// Per-function-unique id paired with `LetRef::binding_id` on /// references inside the block. Lowering emits `BindingId(0)` /// and `ResolveReferencesPass` overwrites it. binding_id: BindingId, name: String, mutable: bool, ty: Option<ResolvedType>, value: IrExpr, span: IrSpan, }, /// Assignment: `x = expr`. Assign { /// Variable or field path being written. target: IrExpr, value: IrExpr, span: IrSpan, }, /// Expression evaluated for its side effects. The expression /// carries its own span. Expr(IrExpr), } }
Visitor Pattern
The IR provides a visitor trait for traversal, allowing code generators to process nodes without implementing manual traversal logic.
IrVisitor Trait
#![allow(unused)] fn main() { pub trait IrVisitor { /// Visit entire module (default walks all children) fn visit_module(&mut self, module: &IrModule) { walk_module_children(self, module); } /// Visit a struct definition fn visit_struct(&mut self, _id: StructId, _s: &IrStruct) {} /// Visit a trait definition fn visit_trait(&mut self, _id: TraitId, _t: &IrTrait) {} /// Visit an enum definition fn visit_enum(&mut self, _id: EnumId, _e: &IrEnum) {} /// Visit an enum variant fn visit_enum_variant(&mut self, _v: &IrEnumVariant) {} /// Visit an impl block fn visit_impl(&mut self, _i: &IrImpl) {} /// Visit a function: a standalone function or an impl method fn visit_function(&mut self, _f: &IrFunction) {} /// Visit a module-level let binding fn visit_let(&mut self, _l: &IrLet) {} /// Visit an import record fn visit_import(&mut self, _i: &IrImport) {} /// Visit a field definition fn visit_field(&mut self, _f: &IrField) {} /// Visit an expression (default walks children) fn visit_expr(&mut self, e: &IrExpr) { walk_expr_children(self, e); } } }
walk_module_children visits, in this order:
- each struct, then each of its fields and the default of each field;
- each trait, then each of its fields;
- each enum, then each variant and each field of the variant;
- each impl block, then each of its methods and the body of each;
- each standalone function and its body;
- each module-level
letand its value; - each import record.
The walk does not enter the defaults of trait fields, variant fields or parameters. Read them in the visitor when a backend needs them.
The module starts with the prelude, so the walk also visits the
prelude definitions (Array, Seq, Dictionary, Range,
Optional, their impl blocks and assert). Use
IrModule::is_prelude_struct and IrModule::is_prelude_enum to skip
them.
Walking Functions
#![allow(unused)] fn main() { /// Walk an entire IR module pub fn walk_module<V: IrVisitor + ?Sized>(visitor: &mut V, module: &IrModule); /// Walk children of a module (called by default visit_module) pub fn walk_module_children<V: IrVisitor + ?Sized>(visitor: &mut V, module: &IrModule); /// Walk an expression tree pub fn walk_expr<V: IrVisitor + ?Sized>(visitor: &mut V, expr: &IrExpr); /// Walk children of an expression (called by default visit_expr) pub fn walk_expr_children<V: IrVisitor + ?Sized>(visitor: &mut V, expr: &IrExpr); /// Walk the expressions of one block statement pub fn walk_block_statement<V: IrVisitor + ?Sized>(visitor: &mut V, stmt: &IrBlockStatement); }
Example: Type Counter
#![allow(unused)] fn main() { use formalang::compile_to_ir; use formalang::ir::{walk_module, EnumId, IrEnum, IrModule, IrStruct, IrVisitor, StructId}; struct TypeCounter<'m> { module: &'m IrModule, struct_count: usize, enum_count: usize, } impl IrVisitor for TypeCounter<'_> { fn visit_struct(&mut self, id: StructId, _s: &IrStruct) { if !self.module.is_prelude_struct(id) { self.struct_count += 1; } } fn visit_enum(&mut self, id: EnumId, _e: &IrEnum) { if !self.module.is_prelude_enum(id) { self.enum_count += 1; } } } let source = r#" pub struct User { name: String } pub enum Status { active, inactive } "#; let module = compile_to_ir(source).unwrap(); let mut counter = TypeCounter { module: &module, struct_count: 0, enum_count: 0 }; walk_module(&mut counter, &module); assert_eq!(counter.struct_count, 1); assert_eq!(counter.enum_count, 1); }
Worked Examples
These examples show how source code maps to the IR. Each tree below
comes from compile_to_ir on the source above it. The trees leave out
doc, span, convention and the fields that hold their default
value.
The prelude comes first in every module. So the first user struct is
StructId(4) (after Array, Seq, Dictionary and Range), the
first user enum is EnumId(1) (after Optional), and the first user
function is FunctionId(1) (after assert). The prelude has no
traits, so the first user trait is TraitId(0).
Typed-id values like BindingId, VariantIdx, FieldIdx, MethodIdx,
and the target field on Reference are populated by
ResolveReferencesPass. Pre-pass (raw lowering output), they carry
0 / Unresolved placeholders. The trees that show a function body
show the module after that pass.
Simple Struct
FormaLang source:
pub struct User {
name: String,
age: I32
}
IR structure:
IrModule
+-- structs[4]: IrStruct // StructId(4)
+-- name: "User"
+-- visibility: Public
+-- traits: []
+-- fields:
| +-- [0] IrField
| | +-- name: "name"
| | +-- ty: Primitive(String)
| | +-- mutable: false
| | +-- optional: false
| | +-- default: None
| +-- [1] IrField
| +-- name: "age"
| +-- ty: Primitive(I32)
| +-- mutable: false
| +-- optional: false
| +-- default: None
+-- generic_params: []
JSON (serde_json::to_string_pretty(&module.structs[4]), with
the serde feature):
{
"name": "User",
"visibility": "Public",
"traits": [],
"fields": [
{
"name": "name",
"ty": {
"Primitive": "String"
},
"mutable": false,
"optional": false,
"default": null,
"doc": null,
"convention": "Let"
},
{
"name": "age",
"ty": {
"Primitive": "I32"
},
"mutable": false,
"optional": false,
"default": null,
"doc": null,
"convention": "Let"
}
],
"generic_params": []
}
Serde leaves out a span that is the default span, and a doc of
None on a struct. An IrField always writes its doc.
Enum with Variants
FormaLang source:
pub enum Status {
active,
inactive,
pending(reason: String)
}
IR structure:
IrModule
+-- enums[1]: IrEnum // EnumId(1)
+-- name: "Status"
+-- visibility: Public
+-- variants:
| +-- [0] IrEnumVariant
| | +-- name: "active"
| | +-- fields: []
| +-- [1] IrEnumVariant
| | +-- name: "inactive"
| | +-- fields: []
| +-- [2] IrEnumVariant
| +-- name: "pending"
| +-- fields:
| +-- [0] IrField
| +-- name: "reason"
| +-- ty: Primitive(String)
+-- generic_params: []
Struct Implementing Trait
FormaLang source:
pub trait Named {
name: String
}
pub struct User {
name: String,
age: I32
}
impl Named for User {}
The impl block declares the conformance. The lowering puts the trait
in the traits list of the struct, and keeps the empty impl block.
IR structure:
IrModule
+-- traits[0]: IrTrait // TraitId(0)
| +-- name: "Named"
| +-- visibility: Public
| +-- composed_traits: []
| +-- fields:
| | +-- [0] IrField
| | +-- name: "name"
| | +-- ty: Primitive(String)
| +-- methods: []
| +-- generic_params: []
|
+-- structs[4]: IrStruct // StructId(4)
| +-- name: "User"
| +-- visibility: Public
| +-- traits: [IrTraitRef { trait_id: TraitId(0), args: [] }]
| +-- fields:
| | +-- [0] IrField { name: "name", ty: Primitive(String), ... }
| | +-- [1] IrField { name: "age", ty: Primitive(I32), ... }
| +-- generic_params: []
|
+-- impls[6]: IrImpl // after the six prelude impls
+-- target: Struct(StructId(4))
+-- trait_ref: Some(IrTraitRef { trait_id: TraitId(0), args: [] })
+-- is_extern: false
+-- functions: []
Generic Struct with Constraint
FormaLang source:
pub trait Container {
items: [String]
}
pub struct Box<T: Container> {
content: T,
label: String?
}
IR structure:
IrModule
+-- traits[0]: IrTrait // TraitId(0)
| +-- name: "Container"
| +-- fields:
| +-- [0] IrField
| +-- name: "items"
| +-- ty: Generic { base: Struct(StructId(0)), args: [Primitive(String)] }
| // StructId(0) is the prelude `Array`
|
+-- structs[4]: IrStruct // StructId(4)
+-- name: "Box"
+-- visibility: Public
+-- traits: []
+-- fields:
| +-- [0] IrField
| | +-- name: "content"
| | +-- ty: TypeParam("T") // a parameter of the definition
| | +-- optional: false
| +-- [1] IrField
| +-- name: "label"
| +-- ty: Generic { base: Enum(EnumId(0)), args: [Primitive(String)] }
| | // EnumId(0) is the prelude `Optional`
| +-- optional: true
+-- generic_params:
+-- [0] IrGenericParam
+-- name: "T"
+-- constraints: [IrTraitRef { trait_id: TraitId(0), args: [] }]
MonomorphisePass removes Box and adds one copy for each type that
the program gives T. This program uses no Box, so no copy is made.
Struct with Cross-References
FormaLang source:
enum Status { active, inactive }
struct Author {
name: String
}
struct Book {
title: String,
author: Author,
status: Status
}
IR structure:
IrModule
+-- enums[1]: IrEnum // EnumId(1)
| +-- name: "Status"
| +-- visibility: Private
| +-- variants: [active, inactive]
|
+-- structs[4]: IrStruct // StructId(4)
| +-- name: "Author"
| +-- visibility: Private
| +-- fields:
| +-- [0] IrField { name: "name", ty: Primitive(String) }
|
+-- structs[5]: IrStruct // StructId(5)
+-- name: "Book"
+-- visibility: Private
+-- fields:
+-- [0] IrField { name: "title", ty: Primitive(String) }
+-- [1] IrField { name: "author", ty: Struct(StructId(4)) } // linked
+-- [2] IrField { name: "status", ty: Enum(EnumId(1)) } // linked
Impl Block with Methods
FormaLang source:
pub struct Counter {
count: I32
}
impl Counter {
fn increment(self) -> I32 {
self.count + 1
}
fn reset(mut self) -> I32 {
0
}
}
IR structure (after ResolveReferencesPass):
IrModule
+-- structs[4]: IrStruct // StructId(4)
| +-- name: "Counter"
| +-- fields:
| +-- [0] IrField { name: "count", ty: Primitive(I32) }
|
+-- impls[6]: IrImpl
+-- target: Struct(StructId(4))
+-- trait_ref: None
+-- is_extern: false
+-- functions:
+-- [0] IrFunction
| +-- name: "increment"
| +-- visibility: Private
| +-- params: [IrFunctionParam { binding_id: BindingId(0), name: "self",
| | ty: None, convention: Let }]
| +-- return_type: Some(Primitive(I32))
| +-- body: Some(IrExpr::BinaryOp {
| left: IrExpr::SelfFieldRef {
| field: "count",
| field_idx: FieldIdx(0),
| ty: Primitive(I32)
| },
| op: Add,
| right: IrExpr::Literal {
| value: Number(NumberLiteral { value: Integer(1), suffix: None, kind: Integer }),
| ty: Primitive(I32)
| },
| ty: Primitive(I32)
| })
+-- [1] IrFunction
+-- name: "reset"
+-- visibility: Private
+-- params: [IrFunctionParam { binding_id: BindingId(0), name: "self",
| ty: None, convention: Mut }]
+-- return_type: Some(Primitive(I32))
+-- body: Some(IrExpr::Literal {
value: Number(NumberLiteral { value: Integer(0), suffix: None, kind: Integer }),
ty: Primitive(I32)
})
A read of self.count in a method is a SelfFieldRef, not a
Reference.
Match Expression
FormaLang source:
pub enum Option {
none,
some(value: I32)
}
pub fn describe(opt: Option) -> String {
match opt {
.none: "Nothing",
.some(value): "Got value"
}
}
IR structure (after ResolveReferencesPass):
IrModule
+-- enums[1]: IrEnum // EnumId(1)
| +-- name: "Option"
| +-- variants:
| +-- [0] IrEnumVariant { name: "none", fields: [] }
| +-- [1] IrEnumVariant { name: "some", fields: [IrField { name: "value", ... }] }
|
+-- functions[1]: IrFunction // FunctionId(1)
+-- name: "describe"
+-- visibility: Public
+-- params:
| +-- [0] IrFunctionParam { binding_id: BindingId(0), name: "opt",
| ty: Some(Enum(EnumId(1))), convention: Let }
+-- return_type: Some(Primitive(String))
+-- body: Some(IrExpr::Match {
scrutinee: IrExpr::Reference {
path: ["opt"],
target: ReferenceTarget::Param(BindingId(0)),
ty: Enum(EnumId(1))
},
arms: [
IrMatchArm {
variant: "none",
variant_idx: VariantIdx(0),
is_wildcard: false,
bindings: [],
body: IrExpr::Literal { value: String("Nothing"), ty: Primitive(String) }
},
IrMatchArm {
variant: "some",
variant_idx: VariantIdx(1),
is_wildcard: false,
bindings: [("value", BindingId(1), Primitive(I32))],
body: IrExpr::Literal { value: String("Got value"), ty: Primitive(String) }
}
],
ty: Primitive(String)
})
For Expression
FormaLang source:
pub fn tag_labels(tags: [String]) -> [String] {
for tag in tags { tag }.collect()
}
A for gives a Seq, not an array, and a function cannot return a
Seq. The call collect() makes the array. See
Large Data.
IR structure (after ResolveReferencesPass):
IrModule
+-- functions[1]: IrFunction // FunctionId(1)
+-- name: "tag_labels"
+-- params:
| +-- [0] IrFunctionParam { binding_id: BindingId(0), name: "tags",
| ty: Some(Generic { base: Struct(StructId(0)), args: [Primitive(String)] }),
| convention: Let }
+-- return_type: Some(Generic { base: Struct(StructId(0)), args: [Primitive(String)] })
+-- body: Some(IrExpr::MethodCall {
receiver: IrExpr::For {
var: "tag",
var_ty: Primitive(String),
var_binding_id: BindingId(1),
collection: IrExpr::Reference {
path: ["tags"],
target: ReferenceTarget::Param(BindingId(0)),
ty: Generic { base: Struct(StructId(0)), args: [Primitive(String)] }
},
body: IrExpr::Reference {
path: ["tag"],
target: ReferenceTarget::Local(BindingId(1)),
ty: Primitive(String)
},
ty: Generic { base: Struct(StructId(1)), args: [Primitive(String)] }
},
method: "collect",
method_idx: MethodIdx(4),
args: [],
dispatch: Static { impl_id: ImplId(2) },
ty: Generic { base: Struct(StructId(0)), args: [Primitive(String)] }
})
StructId(0) is the prelude Array and StructId(1) is the prelude
Seq. ImplId(2) is the prelude extern impl Seq<T>, and
collect is the method at index 4 in it. A backend lowers the methods
of that block as loop structure: see IrModule::is_seq_intrinsic_impl.
Call of a Returned Closure
FormaLang source:
fn doubler() -> (I32) -> I32 {
(x) -> x * 2
}
pub fn eight() -> I32 {
doubler()(4)
}
The callee of doubler()(4) is not a name. The parser gives the AST
node Expr::Call, and the lowering gives an IrExpr::CallClosure
whose closure is the call doubler().
IR structure (after ResolveReferencesPass):
IrModule
+-- functions[1]: IrFunction // FunctionId(1)
| +-- name: "doubler"
| +-- return_type: Some(Closure { param_tys: [(Let, Primitive(I32))],
| | return_ty: Primitive(I32) })
| +-- body: Some(IrExpr::Closure {
| params: [(Let, BindingId(0), "x", Primitive(I32))],
| captures: [],
| body: IrExpr::BinaryOp {
| left: IrExpr::Reference { path: ["x"], target: Local(BindingId(0)), ... },
| op: Mul,
| right: IrExpr::Literal { value: Number(... Integer(2) ...), ty: Primitive(I32) },
| ty: Primitive(I32)
| },
| ty: Closure { param_tys: [(Let, Primitive(I32))], return_ty: Primitive(I32) }
| })
|
+-- functions[2]: IrFunction // FunctionId(2)
+-- name: "eight"
+-- return_type: Some(Primitive(I32))
+-- body: Some(IrExpr::CallClosure {
closure: IrExpr::FunctionCall {
path: ["doubler"],
function_id: Some(FunctionId(1)),
args: [],
ty: Closure { param_tys: [(Let, Primitive(I32))], return_ty: Primitive(I32) }
},
args: [(None, IrExpr::Literal { value: Number(... Integer(4) ...), ty: Primitive(I32) })],
ty: Primitive(I32)
})
ClosureConversionPass then lifts the closure body to a function, and
DefunctionalisePass can turn the CallClosure into a direct call.
See Built-in Passes.
Building a Code Generator
A complete TypeScript interface generator demonstrating how to walk
IrModule with a IrVisitor, resolve types via
ResolvedType, and emit target-language source.
The program below compiles and runs against the current crate.
use formalang::ast::PrimitiveType; use formalang::compile_to_ir; use formalang::ir::{ walk_module, EnumId, GenericBase, IrEnum, IrField, IrModule, IrStruct, IrTrait, IrVisitor, ResolvedType, StructId, TraitId, }; struct TypeScriptGenerator<'a> { module: &'a IrModule, output: String, } impl<'a> TypeScriptGenerator<'a> { fn new(module: &'a IrModule) -> Self { Self { module, output: String::new(), } } fn struct_name(&self, id: StructId) -> String { self.module .get_struct(id) .map_or_else(|| "unknown".to_string(), |s| s.name.clone()) } fn enum_name(&self, id: EnumId) -> String { self.module .get_enum(id) .map_or_else(|| "unknown".to_string(), |e| e.name.clone()) } fn trait_name(&self, id: TraitId) -> String { self.module .get_trait(id) .map_or_else(|| "unknown".to_string(), |t| t.name.clone()) } fn resolve_type(&self, ty: &ResolvedType) -> String { // The built-in carriers first: they are `Generic` over a // prelude definition. if let Some(inner) = self.module.array_element_ty(ty) { return format!("{}[]", self.resolve_type(inner)); } if let Some(inner) = self.module.optional_inner_ty(ty) { return format!("{} | null", self.resolve_type(inner)); } if let Some((key, value)) = self.module.dictionary_kv_ty(ty) { return format!( "Record<{}, {}>", self.resolve_type(key), self.resolve_type(value) ); } match ty { ResolvedType::Primitive(p) => match p { PrimitiveType::String => "string".to_string(), PrimitiveType::I32 | PrimitiveType::I64 | PrimitiveType::F32 | PrimitiveType::F64 => "number".to_string(), PrimitiveType::Boolean => "boolean".to_string(), PrimitiveType::Never => "never".to_string(), _ => "unknown".to_string(), }, ResolvedType::Struct(id) => self.struct_name(*id), ResolvedType::Trait(id) => self.trait_name(*id), ResolvedType::Enum(id) => self.enum_name(*id), ResolvedType::Tuple(fields) => { let fields: Vec<String> = fields .iter() .map(|(name, ty)| format!("{}: {}", name, self.resolve_type(ty))) .collect(); format!("{{ {} }}", fields.join("; ")) } ResolvedType::Generic { base, args } => { let base_name = match base { GenericBase::Struct(id) => self.struct_name(*id), GenericBase::Enum(id) => self.enum_name(*id), GenericBase::Trait(id) => self.trait_name(*id), }; let args: Vec<String> = args.iter().map(|a| self.resolve_type(a)).collect(); format!("{}<{}>", base_name, args.join(", ")) } ResolvedType::Closure { param_tys, return_ty, } => { let params: Vec<String> = param_tys .iter() .enumerate() .map(|(i, (_, t))| format!("a{}: {}", i, self.resolve_type(t))) .collect(); format!("({}) => {}", params.join(", "), self.resolve_type(return_ty)) } ResolvedType::External { name, type_args, .. } => { if type_args.is_empty() { name.clone() } else { let args: Vec<String> = type_args.iter().map(|a| self.resolve_type(a)).collect(); format!("{}<{}>", name, args.join(", ")) } } ResolvedType::TypeParam(name) => name.clone(), ResolvedType::Error => "never".to_string(), } } fn emit_field(&mut self, field: &IrField) { let ts_type = self.resolve_type(&field.ty); let optional = if field.optional { "?" } else { "" }; self.output .push_str(&format!(" {}{}: {};\n", field.name, optional, ts_type)); } } impl IrVisitor for TypeScriptGenerator<'_> { fn visit_trait(&mut self, _id: TraitId, t: &IrTrait) { if !t.visibility.is_public() { return; } self.output .push_str(&format!("export interface {} {{\n", t.name)); for field in &t.fields { self.emit_field(field); } self.output.push_str("}\n\n"); } fn visit_struct(&mut self, id: StructId, s: &IrStruct) { // Skip the prelude carriers and private structs if self.module.is_prelude_struct(id) || !s.visibility.is_public() { return; } let generics = if s.generic_params.is_empty() { String::new() } else { let params: Vec<String> = s.generic_params.iter().map(|p| p.name.clone()).collect(); format!("<{}>", params.join(", ")) }; // `impl Named for User` puts `Named` in `traits` let extends = if s.traits.is_empty() { String::new() } else { let traits: Vec<String> = s .traits .iter() .map(|t| self.trait_name(t.trait_id)) .collect(); format!(" extends {}", traits.join(", ")) }; self.output.push_str(&format!( "export interface {}{}{} {{\n", s.name, generics, extends )); for field in &s.fields { self.emit_field(field); } self.output.push_str("}\n\n"); } fn visit_enum(&mut self, id: EnumId, e: &IrEnum) { if self.module.is_prelude_enum(id) || !e.visibility.is_public() { return; } self.output.push_str(&format!("export type {} =\n", e.name)); for (i, variant) in e.variants.iter().enumerate() { let end = if i + 1 == e.variants.len() { ";" } else { "" }; if variant.fields.is_empty() { self.output .push_str(&format!(" | {{ type: \"{}\" }}{}\n", variant.name, end)); } else { let fields: Vec<String> = variant .fields .iter() .map(|f| format!("{}: {}", f.name, self.resolve_type(&f.ty))) .collect(); self.output.push_str(&format!( " | {{ type: \"{}\"; {} }}{}\n", variant.name, fields.join("; "), end )); } } self.output.push('\n'); } } fn generate_typescript(source: &str) -> Result<String, Vec<formalang::CompilerError>> { let module = compile_to_ir(source)?; let mut generator = TypeScriptGenerator::new(&module); walk_module(&mut generator, &module); Ok(generator.output) } fn main() { // Usage let source = r#" pub trait Named { name: String } pub struct User { name: String, age: I32, email: String? } impl Named for User {} pub enum Status { active, pending(reason: String), inactive } "#; let typescript = generate_typescript(source).unwrap(); print!("{}", typescript); }
Output. The visitor visits the structs before the traits:
export interface User extends Named {
name: string;
age: number;
email?: string | null;
}
export interface Named {
name: string;
}
export type Status =
| { type: "active" }
| { type: "pending"; reason: string }
| { type: "inactive" };
Design Rationale
The IR design follows patterns from the Rust compiler (HIR / THIR / MIR): the AST is preserved for source-level tooling, and a separate, type- resolved, ID-keyed representation is used for code generation.
Why Separate from AST?
- Clean separation: AST preserves source fidelity, IR optimises for codegen.
- No syntax noise: IR omits comments, parentheses and grouping.
It keeps the spans for debug information, and it keeps the names
of the
useimports only asIrModule.imports. - Different consumers: Linters and LSPs use AST, code generators use IR.
Why ID-Based References?
- Copyable: IDs are
Copy, no lifetime complexity. - Cheap: O(1)
Veclookup by index. - Type-safe:
StructIdcannot be used where aTraitIdis expected. - Stable: an ID does not change when a definition is appended. A pass that removes definitions renumbers the IDs after them, and rewrites each reference to them.
Why Type on Every Expression?
- No re-inference: Code generators don't need to re-derive types.
- Single source of truth: Type is computed once during lowering.
- Simpler codegen: Just read
expr.ty()and emit appropriate code.
Why Visitor Pattern?
- Selective processing: Implement only the methods you need.
- Controlled traversal: Producer decides traversal order.
- Extensible: New node types don't break existing visitors.
See Also
- AST Reference: for syntax analysis and source-level tooling
- Architecture Overview: overall compiler design
- Built-in Passes:
MonomorphisePass,ResolveReferencesPass,ClosureConversionPass,DefunctionalisePass,DeadCodeEliminationPass,ConstantFoldingPass