FormaLang Documentation
Last Updated: 2026-05-03
FormaLang is a declarative DSL compiler frontend written in Rust. It parses
.fv source files, performs semantic analysis, and produces a type-resolved
Intermediate Representation (IR). Code generation is handled by external
backends via the plugin system.
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
Design Notes
Open / forthcoming features carry their own status pages:
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) and
attach to the following declaration. They flow through to the IR and are
available to backends as the doc: field on most definitions.
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:
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
logo: Path, // File/resource paths
pattern: Regex // Regular expressions
}
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 (default for unsuffixed integer literals) |
I64 | 64-bit signed integer |
F32 | 32-bit IEEE 754 float |
F64 | 64-bit IEEE 754 float (default for unsuffixed float literals) |
Numeric literals can carry an uppercase suffix to pin the type at the literal site:
let a = 42 // I32 (integer-syntax default)
let b = 42I64 // I64
let c = 3.14 // F64 (float-syntax default)
let d = 3.14F32 // F32
let big: I64 = 9_223_372_036_854_775_807
let tiny: F32 = 0.5F32
Suffix range checks happen at compile time; literals that don't fit their declared / suffixed type are a compile error.
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
Array Types
Arrays hold multiple values of the same type:
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 = []
// Array destructuring (see Expressions for full rules)
pub let [first, second] = ["a", "b", "c"]
pub let [user, ...] = ["John", "pass", "etc"]
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
)
Dictionary Types
Key-value mappings using bracket syntax with colon:
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
assets: [Path: String] // Path keys to String values
}
// 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 assets: [Path: String] = [/logo.svg: "icon", /bg.png: "background"]
pub let empty: [String: Boolean] = [:]
Rules:
- Keys can be any compiler-supported type (String, I32, Path, enum, etc.)
- String keys must be quoted in literals:
["key": value] - Numeric keys are unquoted:
[42: value] - Path keys use path literal syntax:
[/path: 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)
}
// Tuple literals
for item in items {
let person = (name: "John", age: 30)
let point = (x: 10, y: 20)
let nested = (user: (first: "John", last: "Doe"), active: true)
}
// Accessing tuple fields
for item in items {
let person = (name: "John", age: 30)
let name = person.name // Access by field name
}
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. For closure expressions, see Closures.
pub struct Controls<E> {
// No parameters - returns E
onPress: () -> E,
// Single parameter (default / let convention)
onChange: String -> E,
// Multiple parameters (comma-separated, no parens needed)
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:
- Arrow
->separates parameters from return type - Multiple parameters are comma-separated (no parentheses required)
- Empty parameters require parentheses:
() -> T - Convention keywords (
mut,sink) precede the type in the type position - Parser uses
->to determine grouping in ambiguous contexts
Generic Types
Types parameterized with type variables (full details in Generics):
Box<T> // Single type parameter
Pair<A, B> // Multiple type parameters
Container<T: Layout> // With trait constraint
Widget<T: Render + Click> // Multiple trait constraints
Result<String, I32> // Instantiated generic
Structs
Structs define data types: a named record of typed fields, optionally generic over type parameters.
Definitions
// 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?
}
// With mutable fields
pub struct Counter {
mut count: I32, // Mutable field (can be updated)
label: String // Immutable field
}
// 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
}
Instantiation
pub struct Point { x: I32, y: I32 }
// Basic instantiation
pub let point = Point(x: 10, y: 20)
// Multi-line instantiation
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")
Adding Methods
To attach methods to a struct, write an impl block: see
Traits & Impls.
Enums
Enums define sum types (tagged unions): a value is exactly one of the declared variants.
Definitions
// 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:
// 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)
Pattern Matching
To consume enum values, use a match expression: see
Control Flow & Pattern Matching. To
extract associated data without matching, use enum destructuring: 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
}
// 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- Trait composition (
+) combines requirements from multiple traits - A type satisfies a trait by providing all required fields and all required methods
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)
}
}
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- Struct fields required by the trait must be present in the struct definition
- All
fnsignatures in the trait must be implemented in the impl block - Method signatures (parameter count and return type) must match exactly
- Separate impl blocks for inherited traits; only provide methods declared in that trait
Trait-Bounded Polymorphism
FormaLang has no dynamic dispatch: a trait name in a value-
producing type position (parameter, return, let annotation, struct
field, closure params/return) is a compile-time error
(TraitUsedAsValueType). Take a trait-constrained value through a
generic-bounded parameter so the concrete type is known after
monomorphisation:
pub trait Printable {
fn label(self) -> String
}
pub struct Doc {
text: String
}
impl Printable for Doc {
fn label(self) -> String { self.text }
}
fn print_it<T: Printable>(item: T) -> String {
item.label()
}
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()
}
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.
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
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
}
Default Parameter Values
Parameters may declare a default value with = expr:
fn greet(name: String, greeting: String = "Hello") -> String {
greeting + ", " + name
}
greet("world") // greeting = "Hello"
greet("world", "Hi there") // greeting = "Hi there"
Rules:
- Defaults must be positional from the right.
fn f(x = 0, y)is rejected at definition time: 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. - Defaults are re-evaluated on every call.
fn f(x: I32 = current_count())runscurrent_count()once per call site: 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. Callee may mutate x. |
sink | sink x: T | Ownership transfer. Caller gives up the value. |
// 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:
impl Counter {
fn value(self) -> I32 { self.count } // immutable self
fn increment(mut self) -> I32 { self.count } // mutable self
fn destroy(sink self) -> I32 { self.count } // consuming self
}
Call sites are transparent: no extra syntax required:
let mut n: I32 = 0
let result = bump(n) // n is let mut, so it satisfies mut convention
Closure parameters carry the same conventions; the convention constrains the caller of the closure: see Closures for details.
Function Overloading
Multiple functions with the same name are allowed when their signatures differ. The compiler selects the right overload at each call site.
Mode A: named-argument label set match (exact label set determines the overload):
fn format(value: I32) -> String { "number" }
fn format(value: String) -> String { "string" }
fn format(value: I32, precision: I32) -> String { "precise" }
Mode B: first-positional-arg type match (when call has no labels):
fn process(I32) -> String { "number" }
fn process(String) -> String { "string" }
Rules:
- Overloads are distinguished by their named-argument label sets
- Calling with an ambiguous or unknown label set is a compile error
- An unresolvable call site produces
AmbiguousCallorNoMatchingOverload
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 pure, single-expression functions:
pub enum Event {
textChanged(value: String),
resized(width: I32, height: I32),
submit
}
pub struct Form<E> {
onChange: String -> E,
onResize: I32, I32 -> E,
onSubmit: () -> E,
onScale: mut I32 -> E,
onConsume: sink String -> E
}
impl Form {
// Single parameter - no parens needed
onChange: x -> .textChanged(value: x),
// Multiple parameters - comma separated
onResize: w, h -> .resized(width: w, height: h),
// No parameters - empty parens required
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)
}
Expression syntax:
| Parameters | Syntax | Example |
|---|---|---|
| None | () -> expr | () -> .submit |
| One (default) | 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) |
| Pipe syntax | |x, y| expr | |x, y| x + y |
Rules:
- Closures are pure: no side effects, single expression body
- Single parameter does not need parentheses
- Multiple parameters are comma-separated
- Empty parameters require parentheses:
() -> expr - Convention keywords (
mut,sink) precede the parameter name - Type annotations optional when inferable
- Convention on a closure param means the caller of the closure must satisfy it
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] = [:]
// Path literals
let logo = /assets/logo.svg
// Regex literals
let pattern = r/[a-z]+/i
Escape sequences (strings): \", \\, \n, \t, \r, \uXXXX
Regex flags: g, i, m, s, u, v, y
Field Access
user.name // Access field
point.x // Access coordinate
config.timeout // Access config field
user.profile.avatar // Nested access
theme.colors.primary // Multiple levels
Destructuring
Extract values from arrays, structs, and enums:
// 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
// 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 destructuring (extract associated data)
pub enum AccountType {
admin
user(permissions: [String], articles: [String])
}
pub let account: AccountType = .user(
permissions: ["read", "write"],
articles: ["article1", "article2"]
)
// Destructure enum to extract associated data
pub let (permissions, articles) = account
Rules:
- Array destructuring is positional (order matters)
- Struct destructuring is by field name
- Enum destructuring extracts associated data in parameter order
- 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)
Operator Precedence
From highest to lowest:
- Parentheses:
( ) - Field access:
. - Multiplicative:
*,/,% - Additive:
+,- - Comparison:
<,>,<=,>= - Equality:
==,!= - Logical AND:
&& - Logical OR:
|| - Range:
..
Examples:
10 + 20 * 3 // 70 (multiplication first)
(10 + 20) * 3 // 90 (parentheses override)
x > 5 && y < 10 // Comparison before AND
true || false && false // true (AND before OR)
user.age > 18 && user.verified // Field access → comparison → AND
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 simple range
let digits = 0..10
// Iterating over a range in a for-expression
for i in 0..n {
process(i)
}
// Range with arithmetic on the bounds
let window = start..(start + length)
Control Flow & Pattern Matching
All control flow is compile-time validated. Each form is an expression: it evaluates to a value.
For Expressions
Iterate over arrays:
// Basic for loop
for item in items {
process(item: item)
}
// With field access
for email in user.emails {
validate(address: email)
}
// With literal array
for n in [1, 2, 3, 4, 5] {
record(value: n)
}
// Nested loops
for row in matrix {
for cell in row {
process(value: cell)
}
}
Rules:
- Expression must be an array type
- Returns array of body results
- Loop variable scoped to body
If Expressions
Conditional expressions:
// Boolean condition
if count > 0 {
showItems()
} else {
showEmpty()
}
// Without else (returns nil if false)
if isAdmin {
showAdminPanel()
}
// Optional unwrapping (auto-unwrap)
if user.nickname {
// nickname is unwrapped and available here
greet(name: nickname)
}
// Chained conditions
if x > 100 {
showLarge()
} else if x > 50 {
showMedium()
} else {
showSmall()
}
Optional Unwrapping:
When the condition is an optional value:
- If not nil: unwraps and binds value in the true branch
- If nil: takes the else branch (or returns nil)
Match Expressions
Pattern matching on enums (exhaustive):
pub enum Status { pending, active, completed }
match status {
.pending: waitFor(),
.active: runNow(),
.completed: finalize()
}
// With data binding (named parameters)
pub enum Message {
text(content: String)
image(url: String, size: I32)
}
match message {
.text(content): displayText(value: content),
.image(url, size): displayImage(src: url, bytes: size)
}
Rules:
- Must be exhaustive (cover all variants)
- Pattern uses
.variantsyntax (short form) - Associated data bound to identifiers using parameter names
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
// 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
Type Constraints
// 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)
- Type inference works when types can be determined
- Constraints must reference existing traits
The MonomorphisePass clones generic definitions per unique
argument tuple after parsing: see
Built-in Passes / MonomorphisePass.
Module System
Use Statements
Import definitions from other modules:
// Import single item
use components::Button
// Import multiple items
use components::{Button, Text, VStack}
// Import from nested paths
use ui::controls::Slider
use data::models::User
// Import from file
use types::User // From types.fv
use utils::helpers // From utils/helpers.fv
Module Resolution:
- Modules map to
.fvfiles - Path separators use
:: - Can only import
pubitems - No circular imports allowed
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
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"
)
File Structure Example
project/
├── main.fv
├── types.fv
├── components/
│ ├── button.fv
│ └── text.fv
└── utils/
└── helpers.fv
// In main.fv
use types::User
use components::{Button, Text}
use utils::helpers::formatDate
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:
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.
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 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:
extern impl String {
fn len(self) -> I32
fn slice(self, start: I32, end: I32) -> String
}
extern impl I32 {
fn abs(self) -> I32
}
The compiler ships a prelude (src/prelude.fv) declaring the v1
String surface: len, is_empty, slice, starts_with,
contains, byte_at: so s.len() works on any String value
without an explicit use. Backends bind these as host-provided
extern functions through their existing extern-binding paths
(wasm component imports, JS runtime bindings, etc.).
s[i] for a String receiver desugars to s.byte_at(i) at IR
lowering, so backends only see standard MethodCall shapes.
Serde Stability
The File AST type carries a format_version field. Serialized ASTs produced
by this version of the compiler will always have format_version == 1. Tools
that consume serialized ASTs should check this field to detect incompatible
wire-format changes.
// All parsed files automatically have format_version: 1 set
All public AST types implement Serialize / Deserialize and are marked
#[non_exhaustive] so that adding new variants or fields in future releases
does not break existing consumers at the API boundary.
Feature Checklist
Implemented Features
Core Language:
- Comments (single-line
//, multi-line/* */, doc///and//!) - Visibility modifiers (
pub) - Use statements (Rust-style imports with
::and{})
Type System:
- Primitive types (
String,I32,I64,F32,F64,Boolean,Path,Regex,Never) - 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
- Inherent impl blocks (methods)
- 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
Expressions:
- All literals (string, multi-line string, number with suffix, boolean, nil, path, regex, array, dictionary)
- Binary operators (arithmetic, comparison, equality, logical, concatenation)
- Field access (including nested)
- Destructuring (arrays, structs, enums)
- Struct and enum instantiation
- Closure expressions
- Range operator (
..) - Correct operator precedence
Control Flow:
- For expressions (array iteration)
- If expressions (with boolean and optional unwrapping)
- Match expressions (exhaustive pattern matching)
Generics:
- Generic type parameters with constraints
- Generic structs, traits, enums
- 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 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
Source Spans (for tooling / source maps / DWARF):
- Every
IrExpr, definition, andIrBlockStatementcarries anIrSpan { start, end, file: FileId } IrModule.file_tableresolvesFileIdto aPathBuf; cross-module clones have theirFileIds remapped onto the entry module's table
Serde:
format_versiononFile- Full serialize/deserialize round-trip for all public AST types
#[non_exhaustive]on public enums and structs
Not Yet Implemented
- Incremental compilation (salsa)
- Code formatter
- REPL mode
- VSCode extension (full integration)
- Evaluation/expansion stage (runtime)
Design
FormaLang is a pure compiler frontend library written in Rust. It
parses .fv source files, validates them, and produces an Intermediate
Representation (IR). Code generation is not built in: backends are
external and plug in via the IrPass/Backend trait system.
.fv source → FormaLang library → IrModule → [your Backend] → output
Single-Crate Design
The compiler is a single Rust crate (formalang). All phases share
types directly: no IPC, serialization, or process boundaries.
Logic Model
FormaLang logic is pure and declarative:
- Conditionals (
if/else, optional unwrapping) - Iteration (
forover arrays) - Pattern matching (
matchon enums) - Struct/enum/trait definitions with generics and constraints
The IR carries all possible state resolved at compile time. Given runtime data, a backend computes the current state in the target language.
Use Cases
- Design systems and design tokens: define shared types/values once, generate platform-specific code per target
- Cross-platform type generation: emit TypeScript, Swift, Kotlin, or
any other language from a single
.fvschema - LSP tooling: hover, completion, go-to-definition via
compile_with_analyzer - Static analysis and linting: traverse the AST or IR with the visitor pattern
Compiler Pipeline
Source → Lexer → Parser → Semantic Analyzer → IR Lowering → (Plugin System)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Tokens AST Validated AST IrModule IrPass / Backend
- Lexer: Tokenizes source with
logos. - Parser: Builds AST from tokens with
chumsky(Pratt precedence). - Semantic Analyzer: 6-pass validation (Pass 0 resolves modules, Passes
1–5 build symbol tables, resolve types, validate expressions, validate
traits, detect cycles). Inference and validation operate on
SemType, a structural representation of type expressions that replaces the older stringly-typed format and removes the"Unknown"/"InferredEnum"/"Nil"sentinel-collision class of bugs. TheSymbolTableboundary with IR lowering and external consumers stays string-typed. - IR Lowering: Converts the validated AST + symbol table into a
fully type-resolved
IrModule. Module nesting is flattened in the per-type vectors: inlinemod foo { struct Bar { ... } }lowers to a top-levelIrStruct { name: "foo::Bar", ... }, so backends that don't care about source structure see a flat list of definitions keyed by qualified name. A parallelIrModule.modules: Vec<IrModuleNode>tree mirrors the sourcemodhierarchy with per-module ID lists for backends that need namespaced output. - Plugin System: External
IrPasstransforms andBackendemitters composed throughPipeline: see Plugin System.
Compiler Outputs
| Output | Type | Use case |
|---|---|---|
| AST | File | Syntax analysis, source-level tooling, LSP |
| IR | IrModule | Code generation, 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) exposes four entry points covering the
canonical compile path, LSP/AST tooling, and CLI-friendly error reporting:
| Function | Returns | Use case |
|---|---|---|
compile_to_ir(src) | Result<IrModule, Vec<CompilerError>> | Code generation (canonical) |
compile_with_analyzer(src) | Result<(File, SemanticAnalyzer<FileSystemResolver>), Vec<CompilerError>> | LSP, AST-level tooling |
parse_only(src) | Result<File, ...> | Parsing without validation |
compile_and_report(src, f) | Result<IrModule, String> | CLI: compile + formatted errors |
All four read source as a &str; there is no I/O inside the library.
For multi-file projects, pair compile_to_ir_with_resolver with a
FileSystemResolver (or a custom ModuleResolver impl) to load
.fv files from anywhere.
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 for code generation.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;
none are wired in by default unless noted.
MonomorphisePass
Specialises every Generic { base, args } instantiation (struct, enum,
trait), specialises generic functions per call-site arg-tuple, and
devirtualises Virtual dispatch on concrete receivers. The frontend has
no dynamic dispatch, so this pass is the bridge from generic source to
fully-resolved IR.
DeadCodeEliminationPass
Removes unreachable definitions.
ConstantFoldingPass
Evaluates constant expressions at compile time. Numeric folding takes
the high-precision path when both operands are
NumberValue::Integer(i128) (checked i128 arithmetic; overflow leaves
the BinaryOp un-folded so codegen decides the emit). Any operand
carrying NumberValue::Float(f64) falls back to f64 IEEE 754;
mixed-precision results are stored as Float, so
Integer(2^60) + Float(0.0) round-trips as Float, losing exactness
beyond 2^53. Backends that need exact integer results should ensure
their inputs are integer-only or skip folding for that expression.
ResolveReferencesPass
Rewrites name-keyed references (IrExpr::Reference.path, LetRef.name,
IrMatchArm.variant) into typed IDs (ReferenceTarget, BindingId,
VariantIdx). Opt-in; not included in Pipeline::default(). Use it
when the backend emits integer-indexed code (wasm, JVM, native).
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 code generation, use the IR (Intermediate Representation) instead. The IR provides resolved types, linked references, and is optimized for backend code generation.
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)?; }
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 format_version: u32, // Always FORMAT_VERSION (currently 1) pub statements: Vec<Statement>, pub span: Span, } }
format_version is set automatically by the parser. Tools that
deserialize serialized ASTs should check this field to detect
wire-format incompatibilities.
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>), } }
Visibility
#![allow(unused)] fn main() { pub enum Visibility { Public, // pub keyword Private, // default (no modifier) } }
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 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 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 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, pub optional: bool, // true if Type? pub default: Option<Expr>, // Default value 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 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 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 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 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 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 span: Span, } }
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 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.
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 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>, }, Never, // Never type (!) TypeParameter(Ident), // Reference to type parameter } }
PrimitiveType
#![allow(unused)] fn main() { pub enum PrimitiveType { String, I32, I64, F32, F64, Boolean, Path, Regex, /// Uninhabited type: has no values. Never, } }
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(Literal), /// 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, }, EnumInstantiation { enum_name: Ident, 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, }, ClosureExpr { params: Vec<ClosureParam>, 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, args: Vec<Expr>, span: Span, }, Block { statements: Vec<BlockStatement>, result: Box<Expr>, span: Span, }, } }
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), Regex { pattern: String, flags: String }, Path(String), 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):
| Precedence | Operators |
|---|---|
| 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, // _ } }
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: alias pub span: Span, } }
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::TypeParameter("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 trait Drawable {
fn draw(self) -> String
}
impl Drawable for Counter {
fn draw(self) -> String {
"Counter: " + self.count
}
}
AST structure:
File
└── statements[1]: 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::BinaryOp { ... }
Match Expression with Wildcard
FormaLang source:
match status {
.active: Label(text: "Online"),
.inactive: Label(text: "Offline"),
_: Label(text: "Unknown")
}
AST structure:
Expr::MatchExpr
├── scrutinee: Expr::Reference { path: ["status"] }
└── arms:
├── [0] MatchArm
│ ├── pattern: Pattern::Variant { name: "active", bindings: [] }
│ └── body: Expr::Invocation { path: ["Label"], ... }
├── [1] MatchArm
│ ├── pattern: Pattern::Variant { name: "inactive", bindings: [] }
│ └── body: Expr::Invocation { path: ["Label"], ... }
└── [2] MatchArm
├── pattern: Pattern::Wildcard
└── body: Expr::Invocation { path: ["Label"], ... }
Block Expression
FormaLang source:
{
let x = compute_value()
let y = x * 2
Result(value: y)
}
AST structure:
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: ["Result"], args: [("value", "y")] }
For Expression
FormaLang source:
for item in items {
ListItem(text: item)
}
AST structure:
Expr::ForExpr
├── var: "item"
├── collection: Expr::Reference { path: ["items"] }
└── body: Expr::Invocation
├── path: ["ListItem"]
└── args: [(Some("text"), Expr::Reference { path: ["item"] })]
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 recommended output for building code generators. Code
generation is not built into the library: backends are external and
plug in via the IrPass/Backend trait system defined 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)
- Flattened structure optimized for code generation
- 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 | No |
| Comments | Yes | No |
| Parentheses/grouping | Yes | No |
The IR intentionally omits:
- Use statements: already resolved during lowering
- 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.
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.
All span fields are #[serde(default, skip_serializing_if = "IrSpan::is_default")], so synthetic / round-tripped IR doesn't
bloat the serialised form.
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); } } } }
For multi-file projects, pair compile_to_ir_with_resolver with a
FileSystemResolver (or a custom ModuleResolver impl). See the
Public API for the complete entry-point list.
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 ----------> ImplTarget::Struct(StructId) or ImplTarget::Enum(EnumId)
| +-- functions: Vec<IrFunction>
|
+-- lets: Vec<IrLet> // Module-level let bindings
|
+-- functions: Vec<IrFunction> // Standalone function definitions
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>, // External module imports pub modules: Vec<IrModuleNode>, // Source `mod foo { ... }` hierarchy } }
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.
Lookup Methods
#![allow(unused)] fn main() { impl IrModule { /// Look up a struct by ID. Returns None if out of bounds. pub fn get_struct(&self, id: StructId) -> Option<&IrStruct>; /// Look up a trait by ID. Returns None if out of bounds. pub fn get_trait(&self, id: TraitId) -> Option<&IrTrait>; /// Look up an enum by ID. Returns None if out of bounds. pub fn get_enum(&self, id: EnumId) -> Option<&IrEnum>; /// Look up a function by ID. Returns None if out of bounds. pub fn get_function(&self, id: FunctionId) -> Option<&IrFunction>; /// Look up a struct ID by name. pub fn struct_id(&self, name: &str) -> Option<StructId>; /// Look up a trait ID by name. pub fn trait_id(&self, name: &str) -> Option<TraitId>; /// Look up an enum ID by name. pub fn enum_id(&self, name: &str) -> Option<EnumId>; /// Look up a function ID by name. pub fn function_id(&self, name: &str) -> Option<FunctionId>; /// Rebuild the internal name-to-ID indices after mutating the module. /// /// Call this after adding or removing definitions from `structs`, `traits`, /// `enums`, or `functions` so that the `*_id()` lookup methods stay /// consistent. pub fn rebuild_indices(&mut self); } }
External Imports
When a module uses types from other modules via use statements, those
types are represented as External variants in ResolvedType. The
imports field tracks which external types are used.
Direction A inline pass:
compile_to_ir_with_resolverrunsMonomorphisePasswith the analyzer'simported_ir_modules()populated, which inlines every imported struct / enum / trait / function / impl / pub-let into the entryIrModuleunder qualifiedmodule::path::nameform, then rewritesExternalreferences to point at the cloned local definitions. After the pass,ResolvedType::Externalis a transient artifact that doesn't reach the backend: backends consume one flatIrModuleregardless of how many source files contributed to it. Seeplans/cross-module-codegen.mdfor the design and the per-phase commit history.
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>, } }
IrImportItem
#![allow(unused)] fn main() { pub struct IrImportItem { /// Name of the imported type pub name: String, /// Kind of type (struct, trait, or enum) pub kind: ImportedKind, } }
ImportedKind
#![allow(unused)] fn main() { pub enum ImportedKind { Struct, Trait, Enum, } }
Using Imports in Code Generators
Code generators can use the imports to emit proper import statements:
#![allow(unused)] fn main() { fn generate_typescript(module: &IrModule) -> String { let mut output = String::new(); // Generate import statements from the imports list for import in &module.imports { let path = import.module_path.join("/"); let items: Vec<_> = import.items.iter().map(|i| &i.name).collect(); output.push_str(&format!( "import {{ {} }} from '{}';\n", items.join(", "), path )); } // Generate local definitions for struct_def in &module.structs { // ... generate struct } output } }
When generating type references, handle External separately:
#![allow(unused)] fn main() { fn type_to_typescript(ty: &ResolvedType, module: &IrModule) -> String { match ty { ResolvedType::Struct(id) => module.get_struct(*id).name.clone(), ResolvedType::External { name, type_args, .. } => { if type_args.is_empty() { name.clone() } else { let args: Vec<_> = type_args .iter() .map(|t| type_to_typescript(t, module)) .collect(); format!("{}<{}>", name, args.join(", ")) } } // ... other cases } } }
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 are not mirrored in the tree;
backends iterate the flat vectors for those.
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:
#![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 four definition-level IDs above, several expression-level
typed IDs flow through the IR after ResolveReferencesPass rewrites
name-keyed references:
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's or trait'smethodsLetId: module-levelletbindingsImplId: impl blocksFunctionId: standalone or impl-method functions
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, Path, Regex, Never) Primitive(PrimitiveType), /// Reference to a struct definition Struct(StructId), /// Reference to a trait definition Trait(TraitId), /// Reference to an enum definition Enum(EnumId), /// Array type: [T] Array(Box<ResolvedType>), /// Range type: T..T: produced by `start..end` expressions and consumed /// by `for x in start..end { ... }` loops. Range(Box<ResolvedType>), /// Optional type: T? Optional(Box<ResolvedType>), /// Named tuple type: (name1: T1, name2: T2) Tuple(Vec<(String, ResolvedType)>), /// Generic type instantiation: Box<String> or Option<I32> Generic { /// The generic struct or enum being instantiated. base: GenericBase, args: Vec<ResolvedType>, }, /// Unresolved type parameter (T) in generic definitions TypeParam(String), /// Reference to a type in another module (imported via `use`) External { module_path: Vec<String>, // e.g., ["utils", "helpers"] name: String, // Type name kind: ImportedKind, // Struct, Trait, or Enum type_args: Vec<ResolvedType>, // For generics }, /// Dictionary type: [K: V] Dictionary { key_ty: Box<ResolvedType>, value_ty: Box<ResolvedType>, }, /// General closure / function type: (T1, T2) -> R /// /// Each element is `(convention, type)`: convention constrains the /// **caller** of the closure. Event-handler shapes like /// `String -> Event` use this variant with the enum return type. Closure { param_tys: Vec<(ParamConvention, ResolvedType)>, return_ty: Box<ResolvedType>, }, /// Typed-out-of-band error placeholder. Produced by IR lowering when an /// upstream `CompilerError` has already been pushed but the surrounding /// code still needs to materialise *some* `ResolvedType` to keep walking /// the AST. Backends should treat `Error` as unreachable: if it survives /// to code generation, the compile would already have returned the /// associated `CompilerError` to the caller. Replaced the previous /// stringly-typed `TypeParam("Unknown")` sentinel. Error, } }
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 extracting the underlying ID.
#![allow(unused)] fn main() { pub enum GenericBase { Struct(StructId), Enum(EnumId), Trait(TraitId), } }
Type Resolution Examples
| 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) |
Path | Primitive(PrimitiveType::Path) |
Regex | Primitive(PrimitiveType::Regex) |
Never | Primitive(PrimitiveType::Never) |
User (local struct) | Struct(StructId(n)) |
Named (local trait) | Trait(TraitId(n)) |
Status (local enum) | Enum(EnumId(n)) |
[String] | Array(Box::new(Primitive(String))) |
0..10 | Range(Box::new(Primitive(I32))) |
String? | Optional(Box::new(Primitive(String))) |
[[I32]] | Array(Box::new(Array(Box::new(Primitive(I32))))) |
Box<String> | Generic { base: GenericBase::Struct(StructId(n)), args: [Primitive(String)] } |
Option<I32> | Generic { base: GenericBase::Enum(EnumId(n)), args: [Primitive(I32)] } |
(x: I32, y: I32) | Tuple(vec![("x", Primitive(I32)), ("y", Primitive(I32))]) |
T (in generic) | TypeParam("T") |
Helper (from use utils::Helper) | External { module_path: ["utils"], name: "Helper", ... } |
Box<String> (from use containers::Box) | External { module_path: ["containers"], name: "Box", type_args: [...] } |
[String: I32] | Dictionary { key_ty: Primitive(String), value_ty: Primitive(I32) } |
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 { /// Get a display name for this type (useful for debugging/error messages) pub fn display_name(&self, module: &IrModule) -> String; } // Example usage let ty = &field.ty; println!("Field type: {}", ty.display_name(&module)); // Output: "[String]" or "User" or "Box<I32>" }
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>, } }
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 pub methods: Vec<IrFunctionSig>, /// Generic type parameters pub generic_params: Vec<IrGenericParam>, } }
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>, } pub struct IrEnumVariant { /// The variant name pub name: String, /// Associated data fields (empty for unit variants) pub fields: Vec<IrField>, } }
ImplTarget
Identifies what an impl block implements: a struct or an enum.
#![allow(unused)] fn main() { pub enum ImplTarget { Struct(StructId), Enum(EnumId), } }
IrImpl
Impl blocks provide methods for a struct or enum.
#![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>, } 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>; } }
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>, } }
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.
#![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, } }
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, /// Generic type parameters declared on the function itself /// (e.g. `fn identity<T>(x: T) -> T`). Empty for impl methods; /// method-level generics aren't yet supported; enclosing-type /// generics live on the containing `IrImpl` / `IrStruct`. 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>, } 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>, } }
IrFunctionParam
#![allow(unused)] fn main() { pub struct IrFunctionParam { /// Parameter name pub name: 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, } }
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 backend must ensure no aliasing. |
Sink | Ownership transfer. The value is logically moved; the caller cannot use it after this call. |
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.
Several expression variants also carry typed-id payloads
(ReferenceTarget, BindingId, FieldIdx, VariantIdx, MethodIdx,
FunctionId, DispatchKind). Lowering emits placeholder 0-valued ids
for these slots and the optional ResolveReferencesPass rewrites them.
Backends that consume integer-indexed code (wasm, JVM, native) should
run that pass; 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. 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 has not yet been linked /// (cross-module linking is per-backend). External { module_path: Vec<String>, name: String, kind: ImportedKind, }, /// 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 or trait object. /// Monomorphisation devirtualises these on concrete receivers; surviving /// `Virtual` calls require a vtable in the target. Virtual { trait_id: TraitId, method_name: String, }, } }
IrExpr
#![allow(unused)] fn main() { pub enum IrExpr { /// Literal value: string, number, boolean, regex, path, nil Literal { value: Literal, ty: ResolvedType, }, /// Struct instantiation: `User(name: "Alice", age: 30)` 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, }, /// Variable or field reference. 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. /// 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>, /// `Array(body_type)`. ty: ResolvedType, }, /// Match expression: `match x { A => ..., B => ... }`. Match { scrutinee: Box<IrExpr>, arms: Vec<IrMatchArm>, ty: ResolvedType, }, /// Direct call to a top-level function: `sin(angle: x)` or /// `builtin::math::sin(angle: x)`. For closure-typed locals, see /// `CallClosure`. FunctionCall { /// Function path (preserved for diagnostics and as a fallback /// when resolution fails: e.g. cross-module calls). path: Vec<String>, /// Resolved target. `None` for genuinely external paths or when /// resolution couldn't bind. Backends key on this id to dispatch /// directly without re-walking `IrModule.functions`. function_id: Option<FunctionId>, /// `(optional_parameter_name, value)`. args: Vec<(Option<String>, IrExpr)>, ty: ResolvedType, }, /// Indirect call of a closure-typed value: `f(x)` where `f` is a /// closure-typed local (parameter, `let`, struct field, ...). /// Lowering emits this when a path resolves to a closure-typed /// binding rather than a top-level function. CallClosure { /// Expression producing the closure value (typically a `LetRef`, /// `Reference`, `FieldAccess`, or post-conversion `ClosureRef`). closure: Box<IrExpr>, /// Closures don't currently carry parameter names, so the optional /// name is always `None`; the structure mirrors `FunctionCall::args`. args: Vec<(Option<String>, IrExpr)>, /// `return_ty` from the closure type. ty: ResolvedType, }, /// Method call: `self.fill.sample(coords)`. 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) so backends can choose copy/move/reference/sink /// semantics. 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"]` or `dict[index]`. DictAccess { dict: Box<IrExpr>, key: Box<IrExpr>, ty: ResolvedType, }, /// Block expression: `{ let x = 1; let y = 2; x + y }`. 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): picked from the literal's suffix or source-syntax default (integer → I32, float → F64) |
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) |
For { body, .. } | Array(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::Array(inner) => { // 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)`. 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, }, /// Assignment: `x = expr`. Assign { /// Variable or field path being written. target: IrExpr, value: IrExpr, }, /// Expression evaluated for its side effects. 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 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); } } }
Walking Functions
#![allow(unused)] fn main() { /// Walk an entire IR module pub fn walk_module<V: IrVisitor>(visitor: &mut V, module: &IrModule); /// Walk children of a module (called by default visit_module) pub fn walk_module_children<V: IrVisitor>(visitor: &mut V, module: &IrModule); /// Walk an expression tree pub fn walk_expr<V: IrVisitor>(visitor: &mut V, expr: &IrExpr); /// Walk children of an expression (called by default visit_expr) pub fn walk_expr_children<V: IrVisitor>(visitor: &mut V, expr: &IrExpr); }
Example: Type Counter
#![allow(unused)] fn main() { use formalang::compile_to_ir; use formalang::ir::{ IrVisitor, IrStruct, IrEnum, StructId, EnumId, walk_module }; struct TypeCounter { struct_count: usize, enum_count: usize, } impl IrVisitor for TypeCounter { fn visit_struct(&mut self, _id: StructId, _s: &IrStruct) { self.struct_count += 1; } fn visit_enum(&mut self, _id: EnumId, _e: &IrEnum) { 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 { 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. 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.
Simple Struct
FormaLang source:
pub struct User {
name: String,
age: I32
}
IR structure:
IrModule
+-- structs[0]: IrStruct
+-- 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: []
Enum with Variants
FormaLang source:
pub enum Status {
active,
inactive,
pending(reason: String)
}
IR structure:
IrModule
+-- enums[0]: IrEnum
+-- 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: Named {
name: String,
age: I32
}
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[0]: IrStruct // StructId(0)
+-- name: "User"
+-- visibility: Public
+-- traits: [TraitId(0)] // <-- linked to Named trait
+-- fields:
| +-- [0] IrField { name: "name", ty: Primitive(String), ... }
| +-- [1] IrField { name: "age", ty: Primitive(I32), ... }
+-- generic_params: []
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: Array(Box::new(Primitive(String)))
|
+-- structs[0]: IrStruct // StructId(0)
+-- name: "Box"
+-- visibility: Public
+-- traits: []
+-- fields:
| +-- [0] IrField
| | +-- name: "content"
| | +-- ty: TypeParam("T") // Unresolved in definition
| | +-- optional: false
| +-- [1] IrField
| +-- name: "label"
| +-- ty: Optional(Box::new(Primitive(String)))
| +-- optional: true
+-- generic_params:
+-- [0] IrGenericParam
+-- name: "T"
+-- constraints: [TraitId(0)] // <-- must implement Container
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[0]: IrEnum // EnumId(0)
| +-- name: "Status"
| +-- variants: [active, inactive]
|
+-- structs[0]: IrStruct // StructId(0)
| +-- name: "Author"
| +-- fields:
| +-- [0] IrField { name: "name", ty: Primitive(String) }
|
+-- structs[1]: IrStruct // StructId(1)
+-- name: "Book"
+-- fields:
+-- [0] IrField { name: "title", ty: Primitive(String) }
+-- [1] IrField { name: "author", ty: Struct(StructId(0)) } // linked!
+-- [2] IrField { name: "status", ty: Enum(EnumId(0)) } // 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:
IrModule
+-- structs[0]: IrStruct // StructId(0)
| +-- name: "Counter"
| +-- fields:
| +-- [0] IrField { name: "count", ty: Primitive(I32) }
|
+-- impls[0]: IrImpl
+-- target: ImplTarget::Struct(StructId(0))
+-- functions:
+-- [0] IrFunction
| +-- name: "increment"
| +-- params: [IrFunctionParam { name: "self", ty: None, convention: Let }]
| +-- return_type: Some(Primitive(I32))
| +-- body: Some(IrExpr::BinaryOp {
| left: IrExpr::Reference { path: ["self", "count"], ty: Primitive(I32) },
| op: Add,
| right: IrExpr::Literal { value: Number(NumberLiteral { value: NumberValue::Integer(1), .. }), ty: Primitive(I32) },
| ty: Primitive(I32)
| })
+-- [1] IrFunction
+-- name: "reset"
+-- params: [IrFunctionParam { name: "self", ty: None, convention: Mut }]
+-- return_type: Some(Primitive(I32))
+-- body: Some(IrExpr::Literal { value: Number(NumberLiteral { value: NumberValue::Integer(0), .. }), ty: Primitive(I32) })
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:
IrModule
+-- enums[0]: IrEnum // EnumId(0)
| +-- name: "Option"
| +-- variants:
| +-- [0] IrEnumVariant { name: "none", fields: [] }
| +-- [1] IrEnumVariant { name: "some", fields: [IrField { name: "value", ... }] }
|
+-- functions[0]: IrFunction
+-- name: "describe"
+-- params:
| +-- [0] IrFunctionParam { name: "opt", ty: Some(Enum(EnumId(0))), convention: Let }
+-- return_type: Some(Primitive(String))
+-- body: Some(IrExpr::Match {
scrutinee: IrExpr::Reference {
path: ["opt"],
target: ReferenceTarget::Param(BindingId(0)),
ty: Enum(EnumId(0))
},
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 }
}
IR structure:
IrModule
+-- functions[0]: IrFunction
+-- name: "tag_labels"
+-- params:
| +-- [0] IrFunctionParam { name: "tags", ty: Some(Array(Primitive(String))), convention: Let }
+-- return_type: Some(Array(Primitive(String)))
+-- body: Some(IrExpr::For {
var: "tag",
var_ty: Primitive(String),
var_binding_id: BindingId(1),
collection: IrExpr::Reference {
path: ["tags"],
target: ReferenceTarget::Param(BindingId(0)),
ty: Array(Primitive(String))
},
body: IrExpr::LetRef {
name: "tag",
binding_id: BindingId(1),
ty: Primitive(String)
},
ty: Array(Primitive(String))
})
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.
#![allow(unused)] fn main() { use formalang::compile_to_ir; use formalang::ir::{ IrModule, IrStruct, IrEnum, IrEnumVariant, IrField, IrVisitor, StructId, EnumId, ResolvedType, walk_module }; use formalang::ast::PrimitiveType; struct TypeScriptGenerator<'a> { module: &'a IrModule, output: String, } impl<'a> TypeScriptGenerator<'a> { fn new(module: &'a IrModule) -> Self { Self { module, output: String::new(), } } fn resolve_type(&self, ty: &ResolvedType) -> String { 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::Path => "string".to_string(), PrimitiveType::Regex => "RegExp".to_string(), PrimitiveType::Never => "never".to_string(), }, ResolvedType::Struct(id) => { self.module.get_struct(*id).unwrap().name.clone() } ResolvedType::Trait(id) => { self.module.get_trait(*id).unwrap().name.clone() } ResolvedType::Enum(id) => { self.module.get_enum(*id).unwrap().name.clone() } ResolvedType::Array(inner) => { format!("{}[]", self.resolve_type(inner)) } ResolvedType::Optional(inner) => { format!("{} | null", self.resolve_type(inner)) } ResolvedType::Tuple(fields) => { let fields_str: Vec<_> = fields .iter() .map(|(name, ty)| format!("{}: {}", name, self.resolve_type(ty))) .collect(); format!("{{ {} }}", fields_str.join("; ")) } ResolvedType::Generic { base, args } => { let base_name = match base { GenericBase::Struct(id) => self.module.get_struct(*id).unwrap().name.clone(), GenericBase::Enum(id) => self.module.get_enum(*id).unwrap().name.clone(), GenericBase::Trait(id) => self.module.get_trait(*id).unwrap().name.clone(), }; let args_str: Vec<_> = args.iter().map(|a| self.resolve_type(a)).collect(); format!("{}<{}>", base_name, args_str.join(", ")) } ResolvedType::Dictionary { key_ty, value_ty } => { format!( "Record<{}, {}>", self.resolve_type(key_ty), self.resolve_type(value_ty) ) } ResolvedType::Closure { param_tys, return_ty } => { let params: Vec<_> = 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<_> = type_args.iter().map(|a| self.resolve_type(a)).collect(); format!("{}<{}>", name, args.join(", ")) } } ResolvedType::TypeParam(name) => name.clone(), } } 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<'a> IrVisitor for TypeScriptGenerator<'a> { fn visit_struct(&mut self, _id: StructId, s: &IrStruct) { // Skip private structs if !s.visibility.is_public() { return; } // Generic parameters let generics = if s.generic_params.is_empty() { String::new() } else { let params: Vec<_> = s.generic_params .iter() .map(|p| p.name.clone()) .collect(); format!("<{}>", params.join(", ")) }; // Extends clause for traits let extends = if s.traits.is_empty() { String::new() } else { let traits: Vec<_> = s.traits .iter() .map(|id| self.module.get_trait(*id).name.clone()) .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 !e.visibility.is_public() { return; } // Generate discriminated union self.output.push_str(&format!( "export type {} =\n", e.name )); for (i, variant) in e.variants.iter().enumerate() { let sep = if i == e.variants.len() - 1 { ";" } else { " |" }; if variant.fields.is_empty() { self.output.push_str(&format!( " | {{ type: \"{}\" }}{}\n", variant.name, sep )); } else { let fields: Vec<_> = 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("; "), sep )); } } self.output.push('\n'); } } fn generate_typescript(source: &str) -> Result<String, Vec<formalang::CompilerError>> { let module = compile_to_ir(source)?; let mut gen = TypeScriptGenerator::new(&module); walk_module(&mut gen, &module); Ok(gen.output) } // Usage let source = r#" pub trait Named { name: String } pub struct User: Named { name: String, age: I32, email: String? } pub enum Status { active, pending(reason: String), inactive } "#; let typescript = generate_typescript(source).unwrap(); println!("{}", typescript); // Output: // export interface Named { // name: string; // } // // export interface User extends Named { // name: string; // age: number; // email?: string | null; // } // // 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 spans, comments, use statements, parentheses, grouping.
- 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: IDs don't change when other definitions are added.
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,DeadCodeEliminationPass,ConstantFoldingPass