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

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.

TypeRange / shape
I3232-bit signed integer (default for unsuffixed integer literals)
I6464-bit signed integer
F3232-bit IEEE 754 float
F6464-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:

ParametersSyntaxExample
None() -> T() -> Event
One (default)T -> UString -> Event
One (mut)mut T -> Umut I32 -> Event
One (sink)sink T -> Usink String -> Event
MultipleT, U -> VI32, I32 -> Point
Mixed conventionsmut T, sink U -> Vmut 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 fn are structural requirements (the struct must have them)
  • fn signatures 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 Type is the only way to declare trait conformance
  • Struct fields required by the trait must be present in the struct definition
  • All fn signatures 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 Foo or impl 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 (self is not counted).
  • Defaults may reference earlier parameters. fn f(x: I32, y: I32 = x + 1) is valid; calls like f(5) lower to a Let-wrapped Block that binds x to 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()) runs current_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) and fn 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
PrefixMeaning
inlineHint: inline this function at every call site if possible
no_inlineHint: do not inline
coldHint: 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:

ConventionSyntaxMeaning
(default)x: TImmutable. Callee reads only.
mutmut x: TExclusive mutable. Callee may mutate x.
sinksink x: TOwnership 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 AmbiguousCall or NoMatchingOverload

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:

ParametersSyntaxExample
None() -> expr() -> .submit
One (default)x -> exprx -> .changed(value: x)
One (mut)mut x -> exprmut n -> .resized(width: n, height: n)
One (sink)sink x -> exprsink s -> .text(value: s)
Multiplex, y -> exprx, y -> .point(x: x, y: y)
With typesx: T -> exprx: 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 as to 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:

  1. Parentheses: ( )
  2. Field access: .
  3. Multiplicative: *, /, %
  4. Additive: +, -
  5. Comparison: <, >, <=, >=
  6. Equality: ==, !=
  7. Logical AND: &&
  8. Logical OR: ||
  9. 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 .variant syntax (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 .fv files
  • Path separators use ::
  • Can only import pub items
  • 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 impl block and an extern impl block

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 Type conformance blocks
  • Enum definitions (with associated data, generics)
  • extern fn declarations (with "C" / "system" ABI selection)
  • extern impl blocks (including extern 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-contained IrModule

Module System:

  • Use statements and module path resolution
  • Visibility control
  • Nested modules (mod blocks)

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, and IrBlockStatement carries an IrSpan { start, end, file: FileId }
  • IrModule.file_table resolves FileId to a PathBuf; cross-module clones have their FileIds remapped onto the entry module's table

Serde:

  • format_version on File
  • 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 (for over arrays)
  • Pattern matching (match on 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 .fv schema
  • 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. The SymbolTable boundary 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: inline mod foo { struct Bar { ... } } lowers to a top-level IrStruct { name: "foo::Bar", ... }, so backends that don't care about source structure see a flat list of definitions keyed by qualified name. A parallel IrModule.modules: Vec<IrModuleNode> tree mirrors the source mod hierarchy with per-module ID lists for backends that need namespaced output.
  • Plugin System: External IrPass transforms and Backend emitters composed through Pipeline: see Plugin System.

Compiler Outputs

OutputTypeUse case
ASTFileSyntax analysis, source-level tooling, LSP
IRIrModuleCode 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:

FunctionReturnsUse 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 of IrModule, transforms it, returns Result<IrModule, Vec<CompilerError>>. Use for optimization, specialization, or lowering. A failing pass aborts the pipeline and returns its errors.
  • Backend: Borrows an IrModule, produces any Output type. 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):

VariantFormaLang parameter syntaxMeaning
Letfn f(x: T) (no keyword)Default; callee reads the value
Mutfn f(mut x: T)Callee may mutate; arg must be mut
Sinkfn 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 Mut parameter requires that the argument binding is declared let mut (or is another mut / sink parameter). Passing an immutable binding produces MutabilityMismatch.
  • A Sink parameter consumes the argument binding. Any subsequent use of that binding produces UseAfterSink.

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:

Sourceextern_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):

PrecedenceOperators
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

FeatureASTIR
Source locations (spans)YesYes (IrSpan)
Multi-file source identityNoYes (FileId + file_table)
Type resolutionNoYes
ID-based referencesNoYes
String type namesYesNo
Use statementsYesNo
CommentsYesNo
Parentheses/groupingYesNo

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_line by reading IrFunction.span + IrModule.file_table.
  • Source maps (v3) by walking IR expressions, mapping each emitted instruction back to IrSpan.span.start.
  • JVM LineNumberTable by mapping bytecode offsets to IrFunctionSig.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:

  • SymbolTable keys 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.
  • IrModule keys everything by typed IDs (StructId, TraitId, EnumId, FunctionId, ImplId) and stores types as ResolvedType enums 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_resolver runs MonomorphisePass with the analyzer's imported_ir_modules() populated, which inlines every imported struct / enum / trait / function / impl / pub-let into the entry IrModule under qualified module::path::name form, then rewrites External references to point at the cloned local definitions. After the pass, ResolvedType::External is a transient artifact that doesn't reach the backend: backends consume one flat IrModule regardless of how many source files contributed to it. See plans/cross-module-codegen.md for 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-local let bindings, parameters, loop variables
  • FieldIdx: index into the matching struct/enum variant's fields
  • VariantIdx: index into the matching enum's variants
  • MethodIdx: index into the matching impl's or trait's methods
  • LetId: module-level let bindings
  • ImplId: impl blocks
  • FunctionId: 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 TypeResolvedType
StringPrimitive(PrimitiveType::String)
I32 / I64Primitive(PrimitiveType::I32) / Primitive(PrimitiveType::I64)
F32 / F64Primitive(PrimitiveType::F32) / Primitive(PrimitiveType::F64)
BooleanPrimitive(PrimitiveType::Boolean)
PathPrimitive(PrimitiveType::Path)
RegexPrimitive(PrimitiveType::Regex)
NeverPrimitive(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..10Range(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 -> BooleanClosure { param_tys: [(Let, Primitive(String)), (Let, Primitive(I32))], return_ty: Primitive(Boolean) }
mut I32 -> BooleanClosure { param_tys: [(Mut, Primitive(I32))], return_ty: Primitive(Boolean) }
sink String -> BooleanClosure { 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:

VariantMeaning for the backend
LetImmutable read access. The backend may pass by reference or copy.
MutExclusive mutable access. The backend must ensure no aliasing.
SinkOwnership 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:

ExpressionType
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) Vec lookup by index.
  • Type-safe: StructId cannot be used where a TraitId is 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