codegen-lang 0.2.0

Backend abstraction targeting LLVM, Cranelift, or bytecode.
Documentation

Overview

A compiler's middle end produces a function in SSA form; the backend turns that control-flow graph into a linear stream of instructions a machine can run. codegen-lang draws the line between the two.

Backend is the trait a target implements. Bytecode is the target shipped here: a small, register-based bytecode that is enough to inspect, serialize, and run generated code without pulling in a native code generator. A backend built on LLVM or Cranelift slots in behind the same trait, producing its own output type.

The input is an ir_lang::Function. Each IR value is given its own virtual register, each basic block becomes a label in a flat op stream, and a block's parameters are filled by move ops emitted on the edges that target it — the bytecode's stand-in for an SSA phi. The function is validated before lowering, so a backend only ever sees well-formed SSA.

Installation

[dependencies]
codegen-lang = "0.2"
ir-lang = "1"

Or from the terminal:

cargo add codegen-lang ir-lang

Quick Start

use codegen_lang::{compile, Op};
use ir_lang::{Builder, BinOp, Type};

// Build `fn double(x: int) -> int { x + x }` with the IR builder.
let mut b = Builder::new("double", &[Type::Int], Type::Int);
let x = b.block_params(b.entry())[0];
let sum = b.bin(BinOp::Add, x, x);
b.ret(Some(sum));

// Lower it to bytecode.
let program = compile(&b.finish()).expect("double is well-formed");

assert_eq!(program.name(), "double");
assert!(matches!(program.ops()[0], Op::Bin { op: BinOp::Add, .. }));
assert!(matches!(program.ops()[1], Op::Return { value: Some(_) }));

// The Display impl is a readable disassembly:
//   double(r0) regs=2
//   L0:
//       r1 = add r0, r0
//       ret r1
println!("{program}");

Lowering model

The bytecode is register-based rather than stack-based. The pieces map onto the IR directly:

IR construct Lowers to
A value (instruction result or block parameter) A virtual Register, numbered by the value's index
An instruction (iconst, bin, un, …) One Op writing its result register
A basic block A Label at the op where the block begins
A block argument on an edge A move into the target block's parameter register
A two-way branch A jump_unless plus two exclusive arms

Because the source and destination registers of an edge are disjoint in SSA, the argument copies never interfere — no parallel-move scheduling is required. The result is a flat &[Op] that an interpreter or a further pass can walk with no indirection.

A branch and a loop

use codegen_lang::{compile, Op};
use ir_lang::{Builder, BinOp, Type, UnOp};

// fn abs(x: int) -> int { if x < 0 { -x } else { x } }
let mut b = Builder::new("abs", &[Type::Int], Type::Int);
let x = b.block_params(b.entry())[0];
let join = b.create_block(&[Type::Int]);
let neg_blk = b.create_block(&[]);
let pos_blk = b.create_block(&[]);

let zero = b.iconst(0);
let is_neg = b.bin(BinOp::Lt, x, zero);
b.branch(is_neg, neg_blk, &[], pos_blk, &[]);

b.switch_to(neg_blk);
let negated = b.un(UnOp::Neg, x);
b.jump(join, &[negated]);

b.switch_to(pos_blk);
b.jump(join, &[x]);

b.switch_to(join);
let result = b.block_params(join)[0];
b.ret(Some(result));

let program = compile(&b.finish()).expect("abs is well-formed");

// The branch became a conditional skip; the winner reaches the join via a move.
assert!(program.ops().iter().any(|op| matches!(op, Op::JumpUnless { .. })));
assert!(program.ops().iter().any(|op| matches!(op, Op::Move { .. })));

API Overview

For a complete reference with examples, see docs/API.md.

  • Backend — the trait a code generator implements; lowers a Function to a target Output.
  • Bytecode — the reference backend, emitting a flat Program.
  • compile — the shortcut for lowering with the bytecode backend.
  • Program — the lowered function: name, parameter registers, register count, op stream, and a disassembly Display.
  • Op — one bytecode instruction; the closed set the op stream is made of.
  • Reg / Label — a register and a jump target.
  • Const — a constant operand (Int / Float / Bool).
  • CodegenError — the reason a function could not be lowered.
  • BinOp / UnOp — re-exported from ir-lang so the operations carried by Op::Bin and Op::Un can be matched without naming ir-lang directly.

Error handling

compile validates its input with Function::validate before lowering. If the function is not well-formed, it returns CodegenError::InvalidIr carrying the precise reason; a backend never emits a program that is wrong in a way the IR already forbids.

use codegen_lang::{compile, CodegenError};
use ir_lang::{Builder, Type};

// A function whose entry block never receives a terminator is not well-formed.
let func = Builder::new("f", &[], Type::Unit).finish();
match compile(&func) {
    Err(CodegenError::InvalidIr(reason)) => assert!(reason.to_string().contains("terminator")),
    other => panic!("expected an InvalidIr error, got {other:?}"),
}

Performance

Performance is a hard constraint, not an afterthought (see REPS.md). Lowering is a single linear pass over the function: each value maps to one op, each edge adds a small fixed number of moves and a jump, and the op vector is preallocated. The cost is linear in function size.

Latest local Criterion means (cargo bench --bench bench --all-features, Windows x86_64 / WSL2, Rust stable, release build). compile includes the up-front validation pass:

Function shape Size compile time
Straight-line arithmetic 16 instructions ~0.23 µs
Straight-line arithmetic 256 instructions ~2.2 µs
Straight-line arithmetic 4096 instructions ~31 µs
Diamond branches 512 branches ~121 µs
Countdown loop header + back-edge ~0.35 µs

Numbers vary by CPU and environment; run the benches on your hardware for trends.

Configuration

Feature Flags

Feature Default Description
std on Links the standard library. Without it the crate is #![no_std] and needs only alloc.
serde off Derives Serialize / Deserialize for Program and its parts, for caching or moving a compiled program between tools.
# no_std build:
codegen-lang = { version = "0.2", default-features = false }

# with serialization:
codegen-lang = { version = "0.2", features = ["serde"] }

Testing

# Unit, integration (workflow), and doc tests
cargo test --all-features

# Property tests only
cargo test --all-features --test properties

# Lints and formatting
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings

# Benchmarks (Criterion)
cargo bench --bench bench --all-features

The integration suite in tests/workflow.rs compiles and runs representative functions — double, abs, max, a countdown loop — through a reference interpreter and checks the results. The property tests generate random straight-line functions and check that compiling and running matches an independent evaluation, and that every emitted program is structurally sound.

Cross-Platform Support

The crate is pure, dependency-light Rust with no platform-specific code, and is tested on Linux, macOS, and Windows (x86_64) through the CI matrix on stable and the 1.85 MSRV.

Contributing

Engineering standards for this crate are the Rust Efficiency & Performance Standards; the current scope and plan are in dev/ROADMAP.md. Before a PR: cargo fmt --all, cargo clippy --all-targets --all-features -- -D warnings, and cargo test --all-features must be clean.