A from-scratch Rust reimplementation of LLVM IR APIs.
Today llvmkit can lex, parse, build, verify, analyze, and print LLVM IR
without linking against libLLVM; bitcode support is still ahead.
Tracking LLVM 22.1.4 (llvmorg-22.1.4, released 2026-04-21).
The crates.io badge above shows 0.0.3, which is the last published release. The list below describes 0.0.4, which is unreleased — so
cargo add llvmkittoday gets the older closure-scoped API (Module::with_new), not the owned modules and storable ids described here. Trackmasterfor the 0.0.4 surface. The project is pre-1.0 and, under Cargo's pre-1.0 rules, every0.0.xis mutually incompatible; see ROADMAP.md for the release sequence.
Shipped today:
- Owned modules and storable ids — the 0.0.4 handle model.
Module<B, S>has no lifetime parameter, owns its storage, and isSend, so it can be returned, stored in a struct, collected into aVec, and moved between threads. Declarations and value-producing builder calls return aCopy + Sendid (IntValueId<W, B>,FunctionId<R, B>,GlobalId<B>, …) that carries the module's identity without borrowing it. Blocks are minted as linear,!Copyhandles instead (append_basic_blockreturns aBasicBlock<..>,append_block_with_paramsaBlockWithParams<..>); calling.id()on one gives the storableBlockId<R, B, Params>. Terminator builders (br,cond_br,ret, …) consume the builder and hand back the terminated block alongside the new instruction, not an id. The borrowing handles themselves are minted per operation fromm.view(id)/m.try_view(id). A module's identity is theB: ModuleBrandtype, in three rungs —module_new!(unnameable, generated at the expansion site),Module::branded::<B>(a named brand, at most one live module per brand), andModule::dynamic(DynBrand, unlimited live modules, separated by the runtime tag). See Same-module safety. .lllexer — done.llvmkit-asmparserportsllvm/lib/AsmParser/LLLexer.cppand borrows directly from the source slice, allocating only when escape decoding actually changes bytes..llparser — parses ordinary compiler output. Parses module-level directives (target datalayout/triple, module asm, type definitions, globals, function declarations and definitions), all instruction opcodes, metadata (standalone numbered nodes, named metadata, instruction trailing attachments), and value forms (integer/float literals, undef, poison, null, zeroinitializer, global/function references, and representedConstantExprforms for parser-needed opcodes, including upstream vector GEP, bitcast, cast, and select folding fixtures). Round-trip tested viaformat!("{module}"). Attribute coverage spans the function, parameter, and return attributes real compiler output uses — the typedbyval(T)/sret(T)family,uwtable's kind grammar, bothdereferenceableforms — plusdso_localon every global object andc"..."string constants.clang -O0and-O2output parses, verifies, and round-trips, asserted on whole programs intests/parser_attribute_matrix.rs. Companion guards parse the vendored upstream sources and fail CI on drift:tests/attribute_td_drift.rsagainstAttributes.td(itsNOT_YET_MODELEDlist is empty — every attribute LLVM 22.1.4 declares is accepted in every position it declares), andtests/lexer_token_drift.rsagainstLLLexer.cppandLLToken.h, which diffs the whole keyword and token vocabulary in both directions. Not yet modeled: bitcode.- Typed IR data model — done.
llvmkit-irships interned types, typed values, typed constants, functions, basic blocks, globals, comdats, data layout, target triple, module asm directives, and LLVM-style function-local value-name uniquing across arguments, blocks, and instructions. - IR construction — done for the currently modeled instruction families.
The builder covers integer and floating-point arithmetic, comparisons,
casts, memory ops, GEP, calls, select, phi, the Parser-1 terminator / EH /
atomic families, and the associated typed-return / typestate surfaces. The
default
ConstantFoldermirrorsllvm/include/llvm/IR/ConstantFolder.hfor the modeled IR surface and routes target-independent pure-constant folds through the LLVM 22.1.4ConstantFold.cpp-derived helper layer. - AsmWriter — done for the shipped surface.
format!("{module}")produces real textual LLVM IR, including upstream foldedConstantExprforms for vector GEP, bitcast, cast, and select fixtures. - Verifier — done for the shipped surface, including CFG-backed PHI checks and cross-block SSA dominance checks through a recomputed dominator tree.
- CFG and dominance queries — done.
FunctionCfg,BasicBlockEdge,BasicBlock::successors(), andDominatorTreeare available as reusable IR queries. - Capability-graded pass API — done, including explicit
analysis invalidation. A pass declares a capability rung
(
Inspect/PatchBody/ReshapeCfg/RewriteModule) and its required analyses; the driver derives which analyses survive and whether the output module is still verified, so over-claiming what a pass preserves is a compile error rather than a stale-analysis miscompile. Ships theFunctionPass/ModulePasstraits, single-pass drivers (run_function_pass/run_module_pass), compile-time tuple pipelines (function_pipeline/module_pipeline/for_each_function), runtime-assembledDyncontainers, the bundledAnalysesmanager,PreservedAnalyses,PassInstrumentationCallbacks, and the#[function_pass]/#[module_pass]authoring macros. See Built-in Analyses and Custom Passes. - KnownBits — complete.
KnownBits.h's public surface is fully modeled, compiler-verified: the parity ledger (crates/llvmkit-ir/tests/value_tracking_parity.rs) asserts an empty gap list, so a regression or a newly-synced upstream method has to be acknowledged rather than absorbed. - ValueTracking — every
ValueTracking.hentry point but a short, named gap list, tracked symbol-by-symbol in the same ledger, which asserts that modeled plus gaps equals the audited surface so a symbol cannot be silently neither. No figure is written here on purpose: readVALUE_TRACKING_GAPSincrates/llvmkit-ir/tests/value_tracking_parity.rsfor the gaps as they stand and the reason each is open — a stored count went stale once already, in every file that had copied it. Beyondcompute_known_bitsitself (withKnownBitsAnalysis,ValueTrackingQuery, recursion budgeting, dominator-tree hooks and a reusable per-analysis cache) this covers the select-pattern vocabulary and matching (select_pattern.rs), pointer and object analysis (pointer_analysis.rs), speculation safety and UB reachability (speculation.rs),@llvm.assumewith its dominating-condition cache (assumptions.rs,implied_conditions.rs), and floating-point classification — theFPClassTest/KnownFPClasslattice (fp_class.rs),computeKnownFPClassand its predicates (known_fp_class.rs), andfcmpImpliesClass(fp_predicate.rs).computeKnownFPClass's opcode dispatch is deliberately partial; its module header names every arm that is not yet consulted, and an unconsulted arm only ever weakens an answer. Each gap carries its reason in its own ledger row, and the wider not-yet-modeled picture is indocs/future-work.md. - Intrinsic signatures and facts — every intrinsic ID and name in the
vendored
Intrinsics*.tdfiles is generated at build time (llvmkit-tablegen), and a recognizedllvm.*name is checked against the signature its TableGen IIT entry decodes to (Module::intrinsic_descriptor_from_signature; the verifier runs the same check). A name the vendored data does not define is refused (IrError::UnknownIntrinsic). KnownBits/DemandedBits facts cover a smaller, shipped subset (for example constant-amount funnel shifts, bit permutations, counts, saturation arithmetic, min/max, vector-reduce add,ptrmaskandvscale;ROADMAP.mdlists it). Range metadata, range attributes on function/call returns, andreturnedcall/invoke arguments feed known-bits queries. Calls with no modeled fact stay unknown. - Demanded-bits and initial scalar cleanup transforms — shipped for the
modeled scalar-integer slice.
DemandedBitsAnalysiscovers the represented operator and intrinsic operand-mask subset;SimplifyDemandedBitsPassincludes scalar-integer constant replacement, no-use dead instruction-chain erasure, and the upstreamassoc-cast-assoc.ll::AndZextAnddemanded-mask transform.InstSimplifyPassandDcePassprovide the first conservative runnable O1-style scalar cleanup passes. - Strict upstream fixture/provenance policy — in force. Behavior is derived
from LLVM 22.1.4 sources and in-tree fixtures with
UPSTREAM.mdanchors; no shipped analysis fact is a stub, and tests/runtime do not depend onorig_cppor hidden C++ fixtures.
Not shipped yet:
-
Full analysis / optimization constant-folding parity beyond the represented target-independent
ConstantFoldersurface — DataLayout / TLI-dependent folds remain in analysis-only APIs where represented; the default folder does not ship LLVM's full optimization pipeline or broad transform library. -
Full metadata / attribute surface beyond the represented range,
absolute_symbol, debug/use-list, andreturnedfacts -
Bitcode reader / writer
-
Full ValueTracking / DemandedBits / SimplifyDemandedBits parity — the ledger is closed for
KnownBits.hand open for theValueTracking.hentry pointsVALUE_TRACKING_GAPSnames, someValueTracking.cppoperator arms (notablycomputeKnownFPClass's dispatch), demanded-bit rules, andInstCombineSimplifyDemandedtransforms. -
Intrinsic facts beyond the shipped subset, and intrinsic AutoUpgrade — an intrinsic outside the fact subset answers unknown, and an old intrinsic name or signature upstream's
AutoUpgradewould rewrite is refused instead (gap G1 indocs/fixture-coverage.md). -
Full built-in optimization transform library and pipeline builders (
PassBuilder, loop PM, CGSCC PM, legacy PM, runnable textual pipelines —pass_pipeline.rsparses a pipeline string into typed, data-only recipe values, but there is no NAME → constructor registry, so nothing can run one)
Out of scope:
- code generation
- target backends
- linking / object emission
- any dependency on
llvm-sys,inkwell, orlibLLVM
Lex a .ll string:
use llvmkit_asmparser::ll_lexer::Lexer;
let mut lex = Lexer::from("@x = i32 42");
while let Some(tok) = lex.next() {
let spanned = tok.expect("lex error");
println!("{:?}", spanned);
}Build IR programmatically:
use llvmkit_ir::{IrBuilder, IrError, Linkage, module_new};
fn build() -> Result<(), IrError> {
let m = module_new!("demo")?;
let f = m.add_typed_function::<i32, (i32, i32), _>("add", Linkage::External)?;
let entry = m.view(f).append_basic_block(&m, "entry");
let b = IrBuilder::at_end(entry);
let (lhs, rhs) = m.view(f).params();
let sum = b.int_add::<i32, _, _, _>(lhs, rhs, "sum")?;
b.ret(sum)?;
print!("{m}");
Ok(())
}Typed function facades are for signatures known in Rust. Parser or dynamic IR
code keeps using FunctionValue::param / params; a typed facade uses the tuple
parameter schema instead, so wrong typed access fails at compile time and
TypedFunctionValue::params() is infallible after construction.
TypedFunctionValue::try_from_function is the fallible boundary for wrapping an
existing raw function with a mismatched signature. For ordinary Rust function
pointer aliases, m.add_typed_function_of::<fn(i32) -> i32, _>(...) builds the
LLVM signature directly from the alias.
IrBuilder::call takes a TypedFunctionValue callee and an argument
tuple typed against its parameter schema. Wrong arity, a wrong-typed argument,
or misusing a void call's result are all compile errors instead of runtime
IrErrors or verifier failures, and the result narrows to the callee's real
return type with no try_into:
use llvmkit_ir::{IrBuilder, IrError, Linkage, module_new};
fn build_typed_call() -> Result<(), IrError> {
let m = module_new!("demo")?;
let callee = m.add_typed_function::<i32, (i32, i32), _>("add_inner", Linkage::External)?;
let entry = m.view(callee).append_basic_block(&m, "entry");
let b = IrBuilder::at_end(entry);
let (lhs, rhs) = m.view(callee).params();
let sum = b.int_add::<i32, _, _, _>(lhs, rhs, "sum")?;
b.ret(sum)?;
let caller = m.add_typed_function::<i32, (i32, i32), _>("caller", Linkage::External)?;
let entry = m.view(caller).append_basic_block(&m, "entry");
let b = IrBuilder::at_end(entry);
let (x, y) = m.view(caller).params();
// `call` hands back a storable `TypedCallInstId`; `b.view(..)` reaches
// the handle, whose `result()` is already `IntValue<i32>` -- no `try_into`.
let call = b.call(m.view(callee), (x, y), "r")?;
b.ret(b.view(call).result())?;
print!("{m}");
Ok(())
}A callee whose signature is only known at runtime (parsed IR, an extern
declaration built from user input) keeps using the _dyn counterparts —
call_dyn, indirect_call_dyn, invoke_dyn — which take a
plain FunctionValue (indirect_call_dyn takes a FunctionType and a pointer
instead) and an iterable of pre-widened Values, and reject a
wrong argument count or type with IrError::CallArgumentCountMismatch /
CallArgumentTypeMismatch at build time rather than deferring to the verifier.
indirect_call::<Sig> derives the callee's function type from a Rust
function-pointer schema Sig instead of taking it by hand; varargs_call
lowers a fixed, schema-typed prefix the same way call does and appends
an erased ... tail, matching LLVM's own no-static-check contract on variadic
arguments. call_erased is the third tier under all of them: the callee is an
already-erased Value and the call site carries its own FunctionType, so a
named function, an inline-asm value and a computed function pointer reach one
construction — LLVM's IRBuilder::CreateCall(FunctionType*, Value*, ...).
indirect_call_dyn and inline_asm_call forward to it.
Derived struct schemas let you derive the schema on a plain Rust struct, use the
generated <Struct>Value<'ctx, B> wrapper in IR, and call field
accessors/builders instead of indexing aggregates manually:
use llvmkit_ir::{IrBuilder, IrStruct, Linkage, module_new};
#[derive(IrStruct)]
struct Point {
x: i32,
y: i32,
}
#[derive(IrStruct)]
struct Rect {
min: Point,
max: Point,
}
#[derive(IrStruct)]
struct WindowPlacement {
show_cmd: i32,
normal_position: Rect,
}
type Normalize = fn(WindowPlacement) -> WindowPlacement;
let m = module_new!("window")?;
let f = m.add_typed_function_of::<Normalize, _>("normalize", Linkage::External)?;
let entry = m.view(f).append_basic_block(&m, "entry");
let b = IrBuilder::new_for_return::<Normalize>(&m).position_at_end(entry);
let (placement,) = m.view(f).params();
// `normal_position` returns `RectValue<'ctx, B>`, and `min` returns
// `PointValue<'ctx, B>`; nested structs keep their generated wrapper type.
let rect = placement.normal_position(&b)?;
let _min = rect.min(&b)?;Existing IR can be checked back into a generated wrapper with
WindowPlacementValue::try_from(raw)?. When a function boundary should receive
top-level fields separately, StructFields<WindowPlacement> emits i32, %Rect
parameters while nested structs keep their generated wrapper values.
Helper attributes are intentionally small: #[llvmkit(name = "...")] overrides
the LLVM identified-struct name, #[llvmkit(packed)] emits a packed body, and
#[llvmkit(crate = path::to::ir)] overrides the generated crate path. Field
rename/skip/default helpers do not ship because LLVM struct layout is positional
and hiding field changes would obscure ABI/layout changes.
Detailed macro docs: IrStruct derive macro.
A pointer's LLVM address space is part of its Rust type. m.ptr_type_in::<3>()
is a PointerType<'_, Space<3>, _>, and the pointers built from it — through a
GEP, select, a phi or an SSA variable — stay Space<3>, so a select or a
pointer comparison mixing address spaces 0 and 1 does not compile. Where only
run-time data knows the space — m.ptr_type(n), the parser, the default
alloca — the marker is SpaceDyn; TryFrom narrows it to Space<N>,
refusing another space with IrError::AddressSpaceMismatch, and as_dyn()
erases a static one. In a typed signature Ptr is address space 0 and Ptr<N>
is address space N.
TypedPointerValue<'ctx, T: IrField, A, B> overlays a compile-time pointee
schema on top of a plain opaque ptr value, keeping its address-space marker
A -- it is Rust-side bookkeeping
only, so printed IR is byte-identical to the erased path. PointerValue::with_pointee::<T>()
attaches the schema as an explicit, documented assertion (exactly as
powerful as passing a type to load today; a mis-assertion produces
wrong IR -- legal under opaque pointers, so no verifier flags it -- never
memory-unsafe behavior).
typed_alloca::<T>, typed_load, and typed_store skip
the runtime type-narrowing that the erased path needs, and
field_gep::<S, _, I, _> projects the field type at compile time straight
from a #[derive(IrStruct)] schema -- an out-of-range field index is a
missing trait impl, not a runtime bounds check.
VectorValue<'ctx, E, L, B> and ArrayValue<'ctx, E, L, B> carry the element
type (E -- a scalar marker like i64/f64) and the length (L -- Len<N>
for vectors, ArrLen<N> for arrays) in the type system, so a <N x T> /
[N x T] length mismatch or a wrong-element insertelement / insertvalue is
a compile error rather than a verify() diagnostic -- the vector/array analog
of IntValue<'ctx, W>. The bare VectorValue<'ctx> / ArrayValue<'ctx> is the
fully-erased (Dyn) form that parsed IR, scalable vectors, and runtime lengths
land in; it narrows to the typed form with TryFrom, which checks both element
and length.
use llvmkit_ir::{IrBuilder, IrError, Len, Linkage, VectorValue, module_new};
fn typed_vec() -> Result<(), IrError> {
let m = module_new!("demo")?;
let v4i32 = m.vector_type_n::<i32, 4>(); // VectorType<'_, i32, Len<4>>
let fn_ty = m.function_type(m.i32_type().as_type(), [v4i32.as_type(), v4i32.as_type()]);
let f = m.add_function_dyn("vadd", fn_ty, Linkage::External)?;
let entry = m.view(f).append_basic_block(&m, "entry");
let b = IrBuilder::at_end(entry);
// `try_into` checks element (i32) AND lane count (4) before stamping the markers.
let a: VectorValue<'_, i32, Len<4>, _> =
m.view(f).param(0).unwrap().as_erased().try_into().unwrap();
let c: VectorValue<'_, i32, Len<4>, _> =
m.view(f).param(1).unwrap().as_erased().try_into().unwrap();
// Both operands are pinned to `<4 x i32>`; a length/element mismatch would not compile.
let sum = b.vector_int_add(a, c, "sum")?;
// Extract returns the element as its typed scalar handle -- `IntValue<i32>`, inferred.
let lane0 = b.vector_extract(sum, m.i32_type().const_int(0_i32), "lane0")?;
b.ret(lane0)?;
Ok(())
}The full runnable version (vectors and arrays) is
crates/llvmkit-ir/examples/typed_vector_array.rs.
SsaBuilder (crates/llvmkit-ir/src/ssa_builder.rs) sits on top of the
typed IrBuilder and implements Braun et al.'s 2013 on-the-fly SSA
construction algorithm (the same family of technique Cranelift's
FunctionBuilder uses). Instead of pre-declaring phi nodes and patching
their incoming edges by hand, you declare a typed variable once and then
def_*_var/use_*_var it like a mutable local; the engine inserts,
completes, and trivial-phi-eliminates the phis for you as blocks are sealed.
The point isn't fewer lines -- the auto-SSA version of a loop is not
shorter than its manually-phi-wired twin. It is less error-prone and more
declarative: there is no phi pre-declaration, no incoming-edge patching,
and no label plumbing to get wrong. Compare the loop body of
examples/factorial.rs (manual phis) and examples/factorial_auto_ssa.rs
(auto-SSA) -- both are byte-parity locked to print the identical .ll:
// Explicit phis, via block parameters (examples/factorial.rs). A loop header's
// parameters ARE its head-phis: you declare them on the block, and each branch
// into it carries its incomings as block arguments. There is no phi to
// pre-declare and no incoming-edge list to patch after the fact -- an edge that
// forgets an argument, or carries the wrong type or arity, does not compile.
let (loop_bb, params) = bwp.append_block_with_named_params(
m.view(f).as_function(),
[(i32_ty.as_type(), "acc"), (i32_ty.as_type(), "i")],
"loop",
)?;
let loop_label = loop_bb.id(); // storable `BlockId` — the branch currency
// entry: enter the loop carrying the initial values `[ acc = 1, i = %n ]`.
b.cond_br_with_args(is_zero, base_label, &[], loop_label,
&[i32_ty.const_int(1_i32).as_erased(), n.as_erased()])?;
// loop: read the header params, compute, re-enter carrying the back-edge values.
let acc: IntValue<'_, i32, _> = params[0].try_into()?;
let i: IntValue<'_, i32, _> = params[1].try_into()?;
let next_acc = b.int_mul(acc, i, "next_acc")?;
let next_i = b.int_sub(i, 1_i32, "next_i")?;
b.cond_br_with_args(done, exit_label, &[], loop_label,
&[m.view(next_acc).as_erased(), m.view(next_i).as_erased()])?;(The raw int_phi / add_incoming pair that predates block parameters is
pub(crate) and cannot be called from outside the crate — that is deliberate,
and a compile-fail fixture pins it. Block parameters and SsaBuilder are the
two public ways to author a phi; FnReshape::insert_phi is the third, for
passes editing existing IR.)
// Auto-SSA (examples/factorial_auto_ssa.rs): declare typed variables once;
// def/use them like mutable locals. No phi, no incoming-edge bookkeeping,
// no label plumbing -- SsaBuilder inserts and completes the phis itself
// when `loop_bb` is sealed.
let acc_var = b.declare_int_var::<i32, _>("acc");
let i_var = b.declare_int_var::<i32, _>("i");
// entry block:
b.def_int_var(acc_var, 1_i32)?;
b.def_int_var(i_var, n)?;
// loop block:
let i = b.use_int_var(i_var)?;
let acc = b.use_int_var(acc_var)?;
let next_acc = b.ins()?.int_mul(acc, i, "next_acc")?;
let next_i = b.ins()?.int_sub(i, 1_i32, "next_i")?;
b.def_int_var(acc_var, next_acc)?;
b.def_int_var(i_var, next_i)?;
b.seal_block(loop_bb)?; // completes both phis from the now-known predecessor setSsaBuilder also turns "branch to a block whose predecessors are already
fully known" (Braun sealing) and "read/write before the builder is
positioned" into typed errors rather than caller discipline: create_block
auto-seals the entry block, seal_block completes a block's incomplete
phis, and finish() is the always-correct seal-everything fallback that
also rejects any created-but-never-filled block. It currently covers int / float /
pointer variables and the br / cond_br / switch / ret / ret_void /
unreachable terminators; mixing in manual phis via b.ins() for anything
outside that scope is legal and verifier-checked. See
docs/future-work.md for the planned scope (aggregate
variables, invoke/EH terminators).
Both llvmkit and inkwell give
Rust a typed LLVM IR-construction API, but they take different positions on
where invalid usage is caught. inkwell wraps libLLVM through
llvm-sys, and several of its typed accessors resolve their type check at
runtime: calling into_float_value() on a value that isn't actually a
float panics rather than failing to compile (see
wasmer#962 for a
production crate hitting exactly this).
llvmkit makes the corresponding class of bugs a compile error instead:
conversions between typed handles go through TryFrom/lift traits that
either resolve at the call site or fail to compile, module identifiers are
plain owned Strings (there is no C-string boundary for a name to cross),
and every workspace crate ships #![forbid(unsafe_code)] -- there is
no FFI boundary into libLLVM to begin with, because llvmkit is a
from-scratch reimplementation, not a binding. The tradeoff is real: llvmkit
does not generate code, link, or do anything past IR construction and
verification (see "Out of scope" above) -- pick inkwell when you need to
reach codegen through upstream LLVM, and llvmkit when the task is IR
construction / analysis and compile-time misuse safety matters more than
having libLLVM's full backend behind it.
Migrating an existing inkwell codebase? docs/inkwell-migration.md
is a side-by-side guide: the API mapping table, the three structural differences
to read first (no Context lifetime, owned modules, ids rather than handles),
and the ledger of what each migration buys you at compile time.
llvmkit models a subset of LLVM and stops at IR construction, analysis, and
verification (see "Out of scope"). Within that subset there are three places
where its API makes a guarantee upstream's cannot, and they are worth stating
concretely rather than as a slogan.
1. A module is an owned value, and every handle has a storable id.
Module<B, S> owns its storage, has no lifetime parameter, and is Send, so it
can be returned from a function, held in a struct field, collected into a Vec,
and moved to another thread. Handles like IntValue<'ctx, W, B> borrow the
module, but each one also has an id() — IntValueId<W, B>, BlockId<..>,
FunctionId<R, B>, GlobalId<B> — that is Copy + Send and carries the brand
without the borrow. That is what lets a binary lifter keep its own
HashMap<u64, BlockId<..>> from guest address to block, suspend in the middle
of a function, move to a worker thread, and resume there
(crates/llvmkit-ir/examples/lifter_session.rs does exactly this, with
SsaBlock<..> — a BlockId plus its builder's identity — as the map's value).
Upstream's equivalent of a stored id is a raw Value * / BasicBlock *, and
keeping it valid is the client's job. LLVM ships WeakVH, AssertingVH, and
CallbackVH (llvm/include/llvm/IR/ValueHandle.h) to react to a Value *
being deleted, and its own comment on AssertingVH calls that useful for
"catching dangling pointer bugs"; using one is the client's choice. An llvmkit
id is not a pointer:
resolving it re-checks the module tag and the arena range, so a foreign id
becomes IrError::ForeignValueId, None, or a panic — never a read of freed
memory. An id whose value was erased still names its tombstoned arena slot;
telling that slot from a live one is not built (see the Module::view
rustdoc). With #![forbid(unsafe_code)] on every workspace crate there is no
unsafe path available for it to take.
2. Several error classes are unrepresentable rather than diagnosed. An
integer width is a type parameter (IntValue<'ctx, i32, B>), a vector's element
and lane count are type parameters (VectorValue<'ctx, i32, Len<4>, B>), a
function's signature is a type parameter, and the owning module is a brand type.
So a mismatched add, a <4 x i32> mixed with a <8 x i32>, a call with the
wrong arity, a ret in a void function, and an operand borrowed from another
module are all compile errors — there is no program text that expresses them
and no runtime check to reach. Upstream accepts each of these as Value * and
reports them from Verifier.cpp, later, if verification runs at all. The
mapping from each upstream verifier message to the llvmkit type that forecloses
it is tabulated in Type Safety: llvmkit vs. LLVM C++,
and trybuild compile-fail fixtures lock them: each is a small program that
must not compile, and its blessed .stderr pins the diagnostic a user would
actually see. The type-safety ones live in crates/llvmkit-ir/tests/compile_fail/,
registered in tests/typestate_compile_fail.rs; the parser crate keeps its own
fixture, registered in crates/llvmkit-asmparser/tests/parse_error_compile_fail.rs.
In both, the registration list rather than the directory is what runs — an
unregistered fixture silently does not.
3. Verification is a typestate, not a function you must remember to call.
Module::verify(self) consumes Module<B, Unverified> and returns
Module<B, Verified>; APIs that are only sound on verified IR demand the
Verified token, and any mutating pass derives Module<B, Unverified> from
its capability rung, so the re-verify is enforced by the type checker rather
than by a convention. Upstream's verifyModule is a free function returning a
bool sentinel that a caller can simply not call — and its pass-manager form
reports preservation through a hand-written PreservedAnalyses, where
over-claiming leaves stale analyses for a later pass to miscompile against. In
llvmkit that claim is derived from the rung and is unspellable.
The honest limits: these guarantees cover the modeled surface only, the
erased Dyn forms deliberately trade them back for runtime checks so parsed and
dynamic IR still works, and none of it helps if you need codegen — upstream is
the only option there.
Python and Java bindings are planned. They have not been written yet for one
reason: the API was not stable enough to wrap. Wrapping a moving surface means
rewriting the wrapper on every break, which is the failure mode
llvmlite describes chasing upstream LLVM's
unstable C++ API. 0.0.4 is where that surface stops moving week to week — not
a stability promise (the crate is pre-1.0 and every 0.0.x is mutually
incompatible), but settled enough that a wrapper is worth writing.
Keeping the surface wrappable has been a standing constraint on every API decision along the way, which is why the shape below fell out rather than having to be retrofitted:
- Nothing is reachable only from inside a closure.
Module::branded::<B>,Module::dynamic, andmodule_new!all return an owned module. A binding'sModule.__init__can call one and store the result; there is nowith_new(|m| ...)scope for a foreign call stack to sit inside. - No lifetime appears in a storable type. Every id is
Copy + Sendand'static; a wrapper object can hold one in a field for as long as it likes. The borrowing views (IntValue<'ctx, ..>) are minted per operation fromm.view(id)and never need to cross the boundary. DynBrandis the rung a binding uses. A dynamic language has no place to put a brand type, andModule::dynamicasks for none: it is exempt from the uniqueness registry, so many live modules are legal, and separation falls back to the runtimeModuleIdtag withIrError::ForeignValueIdas the verdict — an error a wrapper can raise as an exception, not UB.- Misuse of a handle or id is an
IrErroror a deterministic panic, never a dangling read.#![forbid(unsafe_code)]holds across the workspace, so the worst a forged or stale handle can do is get rejected, or name a tombstoned arena slot (an erased value is not yet detected; see item 1 under "Where llvmkit improves on upstream LLVM"). That holds without exception, metadata included: a metadata node is named by aMetadataId<B>carrying the owning module'sModuleId, and a foreign one isIrError::ForeignMetadataIdat the arena boundary.
What a wrapper will still build itself: an id table. Ids are opaque — their
(ModuleId, slot) payload is private, and there is deliberately no
from_raw_parts — so a binding keeps its own Vec/HashMap of live ids and
hands the host language an index into it, which is what
wgpu's and MLIR's C APIs do anyway.
A module's identity is a type. Module<B, S> is an owned, Send,
lifetime-free value; the B: ModuleBrand parameter rides on every handle, id,
and builder minted from it, and it is what separates one module from another.
Normal code never names the brand — values, constants, basic blocks, globals,
and builders infer it from the Module or type receiver they came from. Generic
extension code names B: ModuleBrand explicitly when it must accept any module.
There are three ways to obtain a brand, trading ergonomics against how much of the separation is static:
| Constructor | Brand | Separation |
|---|---|---|
module_new!("name") |
a fresh, unnameable type per expansion site | compile-time |
Module::branded::<MyBrand, _>("name") |
a 'static type you declare and can name |
compile-time |
Module::dynamic("name") |
DynBrand, shared by every such module |
run-time only |
module_new! is the default: it declares a brand inside its own block scope, so
no two expansion sites can ever collide and nothing outside can name the type.
Name a brand yourself when a module must appear in a struct field, a function
signature, or a return type — somewhere the type has to be written down.
Module::dynamic is for a module count that is a run-time decision (a loop
over translation units, a worker pool, a Vec<Module<DynBrand>>), where no
single static type could name each module individually.
What is compile-time. A process-global registry admits at most one live
Module per brand type, so a brand names exactly one module; a second claim is
BrandError::InUse, and Module::branded_once retires its brand permanently
on drop (BrandError::Retired) so a stale 'static id can never be replayed
against fresh storage. Because of that lock, two distinct brand types are
two distinct modules, and handing a handle or id from one to the other's
builders, mutators, or resolvers is a type error — no runtime check is involved,
and there is no IrError variant for it to return. DynBrand is exempt from
the registry, which is precisely why it buys no compile-time separation.
What is run-time. Ids are storable: they carry the brand but not the borrow,
so they outlive the handle they came from — and therefore they also carry a
ModuleId tag, checked whenever an id is resolved back to a handle. That tag is
the backstop for the two cases the type system cannot see: two DynBrand
modules (the same type by construction), and a named brand re-issued to a fresh
module after the previous one dropped. Neither can become a silent miscompile.
Which form the rejection takes depends on the surface: the fallible id-taking
APIs — builders, pass mutators, and the instruction insert and move entries —
return IrError::ForeignValueId; Module::try_view(id) returns None; and
Module::view(id) panics, treating a foreign id as a deterministic contract
violation in the same way indexing a slice out of bounds is.
Compile-time separation is the guarantee llvmkit leads with; the runtime tag is what keeps the deliberately-erased case sound instead of undefined.
Instruction<'ctx, state::Attached, B> is the lifecycle authority for erase,
detach, move, and RAUW operations. Those methods consume the handle, so a used
lifecycle capability cannot be reused. Copyable discovery APIs return
InstructionView instead: blocks, value use-lists, and per-opcode handles expose
read-only inspection without minting a new mutation handle. Cursor-driven
mutation uses BlockCursor::step on an unterminated block.
An instruction can also be in no block — InstructionView::parent answers
Option, porting the null Instruction::getParent of an instruction built
without an insert position or taken out by removeFromParent. The entries that
need a block therefore do not take a bare view: IrBuilder::position_before
and Instruction::move_before / move_after / insert_before / insert_after
take a PlacedInstruction<'ctx, B>, an instruction paired with the block it
was in. It has no public constructor, and the three mints are
let w = view.placed().ok_or(IrError::InstructionHasNoParent)?; // checked
let w = attached.placed(); // total: Attached is minted in a block
for w in block.placed_instructions() { /* … */ } // checkless: the block is the walk'sso "this instruction was never in a block" is a compile error rather than a
run-time refusal. What a witness proves is was placed, never is placed:
llvmkit mutates through &Module with interior mutability, so nothing stops a
detach_from_parent between the mint and the call. The five entries re-read
the anchor's current block and refuse a stale witness with
IrError::InstructionHasNoParent — which is now the only way to reach that
variant there. Making staleness unrepresentable needs exclusive access to a
function's layout; that design is recorded in docs/future-work.md, not built.
An instruction can also be created in no block, as upstream's Create with
no insert position makes one. CallInst::create_detached and
InvokeInst::create_detached return the linear Instruction<Detached> beside
the typed view of the same call site, and any call site copies itself with
other operand bundles through the sealed CallBase trait — the port of
CallBase::Create(CB, Bundles), whose llvm_unreachable default arm has
nothing left to catch, since only CallInst, InvokeInst and CallBrInst
implement it:
let bundle = OperandBundleDef::new(OperandBundleTag::Deopt, [v]);
let (copy, copy_view) = call.with_operand_bundles(&m, [bundle])?;
assert_eq!(copy_view.calling_conv(), call.calling_conv());
// The copy is in no block until it is placed — or dropped.
let anchor = call.as_view().placed().ok_or(IrError::InstructionHasNoParent)?;
copy.insert_after(&m, anchor)?;The copy keeps everything else upstream's does — arguments, tail-call kind,
calling convention, fast-math flags, attribute list and debug location — and
set_tail_call_kind / set_attributes change those afterwards.
Run the examples:
# Lex a file from disk
cargo run -p llvmkit-asmparser --example lex_file -- crates/llvmkit-asmparser/examples/demo.ll
# Build and print IR programmatically
cargo run -p llvmkit-ir --example build_add_function
cargo run -p llvmkit-ir --example cpu_state_add
cargo run -p llvmkit-ir --example factorial
cargo run -p llvmkit-ir --example factorial_auto_ssa
cargo run -p llvmkit-ir --example concurrent_counter
cargo run -p llvmkit-ir --example derived_struct_function
# Typed vectors and arrays: length/element mismatches become compile errors
cargo run -p llvmkit-ir --example typed_vector_array
# Build IR, run a built-in analysis, and drive custom passes
cargo run -p llvmkit-ir --example pass_manager_demo
cargo run -p llvmkit-ir --example authored_passllvmkit-ir ships a capability-graded pass layer for querying
analyses and running LLVM-like passes over the modeled IR. A pass declares a
capability rung — how much of the IR it is allowed to touch — plus the
analyses it needs; the driver derives everything else, including which analyses
survive the run and whether the output module is still Verified. There is
no pass-registration step: a pass is a value you hand to a driver or drop
into a tuple.
| Rung | Level | May mutate | Analyses preserved after a run |
|---|---|---|---|
Inspect |
function or module | nothing (read-only) | all |
PatchBody |
function | instructions inside existing blocks | CFG-shaped analyses |
ReshapeCfg |
function | blocks, terminators, PHIs — the whole CFG | none |
RewriteModule |
module | globals, functions, bodies | none |
The rung is the only preservation knob, and it is structural: a PatchBody
mutator physically has no method that edits a terminator, so "CFG analyses
preserved" is true by construction — never a PreservedAnalyses value the
author hand-writes and might get wrong. A lying PreservedAnalyses (mutate the
IR, then report everything preserved, leaving stale analyses for a later pass to
miscompile against) is the class of bug LLVM catches only with opt-in
verification; here it is unspellable. The none for ReshapeCfg /
RewriteModule is the structural floor: a ReshapeCfg pass can still keep a
specific CFG analysis (e.g. the dominator tree) by opting into a witnessed
incremental-repair hook (CfgIncremental / FnReshape::analysis_repaired) —
the driver marks it preserved only after watching it repair, never on the
author's say-so. See
Type Safety: llvmkit vs. LLVM C++.
Built-in analyses available today:
DominatorTreeAnalysisKnownBitsAnalysisDemandedBitsAnalysis
Initial built-in transforms available today:
SimplifyDemandedBitsPassInstSimplifyPassDcePass
Core pass / analysis infrastructure available today:
FunctionPass/ModulePass(the two authoring traits)#[function_pass]/#[module_pass](zero-cost authoring macros)Inspect/PatchBody/ReshapeCfg/RewriteModule(capability rungs)run_function_pass/run_module_pass(single-pass drivers)function_pipeline/module_pipeline/for_each_function(compile-time tuple pipelines)DynFunctionPipeline/DynModulePipeline/DynReadOnlyFunctionPipeline/DynReadOnlyModulePipeline(runtime-assembled)Analyses(the bundled function + module analysis managers)PreservedAnalysesPassInstrumentationCallbacksmatchers— aPatternMatch.h-style combinator DSL (m_add/m_c_add/m_one_use/m_all_ones/ …); matchers return their bindings, so a partial match isNonerather than a half-filled slotInstructionView::classify()→ exhaustiveClassified { Inst, Term }withCastKind/PhiKindsub-enums and grammar-typed operands (load.pointer() -> PointerValue,CallInst::classify_callee() -> Callee)CfgIncremental/FnReshape::analysis_repaired— witnessed CFG-analysis preservation across aReshapeCfgpass
A pass is one impl block. Declare type Access (the rung), type Requires
(a tuple of analysis markers, prefetched before the run), and const NAME, then
write fn run(cx) -> IrResult<FnReport<B>> (a module pass returns
IrResult<ModReport>). The #[function_pass] / #[module_pass] macros are
zero-cost sugar that expand to exactly that trait impl — FnCx<Self> /
FnReport in the macro form are readability sentinels the macro rewrites, so
they are not imported:
use llvmkit_ir::{function_pass, DominatorTreeAnalysis, IrResult};
struct EntryReachable;
#[function_pass(name = "entry-reachable", access = Inspect, requires = [DominatorTreeAnalysis])]
impl EntryReachable {
fn run(&mut self, cx: FnCx<Self>) -> IrResult<FnReport> {
// `requires = [..]` was prefetched, so the accessor is infallible.
let dt = cx.analysis::<DominatorTreeAnalysis, _>();
let entry = cx.function().entry_block().expect("definition has an entry");
let _reachable = dt.is_reachable_from_entry(entry);
// `Inspect` has no `cx.mutate()`; the only report it can build is
// all-preserved, and the module stays `Verified`.
Ok(cx.done())
}
}A mutating rung reaches its mutator through the consuming cx.mutate();
once you call it, the context is moved, so the all-preserved cx.done() is
gone — a pass that touches the IR cannot then claim it preserved everything.
The mutator's own done() reports exactly the rung's floor:
use llvmkit_ir::{function_pass, IrResult};
struct EraseDeadInstruction;
#[function_pass(name = "erase-dead", access = PatchBody)]
impl EraseDeadInstruction {
fn run(&mut self, cx: FnCx<Self>) -> IrResult<FnReport> {
let mut patch = cx.mutate(); // consumes `cx` — no all-preserved report left
// ... locate a dead instruction, then narrow it to a `NonTerminator`:
// if let Some(dead) = view.as_non_terminator() { patch.erase(&dead); }
// `erase` accepts *only* a `NonTerminator`, so erasing a terminator —
// which would break this rung's CFG-preserved floor — is a compile
// error, not a runtime rejection. It is infallible: no `?`.
Ok(patch.done()) // floor = CFG analyses preserved (PatchBody rung)
}
}The equivalent raw impl FunctionPass for .. form (what the macro expands to) is
shown end-to-end in crates/llvmkit-ir/examples/pass_manager_demo.rs, and
crates/llvmkit-ir/examples/authored_pass.rs runs both a macro-authored
function pass and module pass.
Both macros take the same attribute grammar and turn a plain inherent impl
into the raw trait impl — no registration, no boilerplate header, and zero
runtime cost (the expansion is the impl you would have written):
| Attribute | Required? | Becomes | Notes |
|---|---|---|---|
name = "..." |
yes | const NAME |
the instrumentation-facing pass name |
access = <Rung> |
yes | type Access |
Inspect / PatchBody / ReshapeCfg for #[function_pass]; Inspect / RewriteModule for #[module_pass]. A wrong-level rung fails the FnAccess / ModAccess bound at compile time |
requires = [A, B] |
no (default []) |
type Requires = (A, B,) |
analyses prefetched before run, read infallibly via cx.analysis::<A, _>() |
required |
no (bare flag) | const REQUIRED: bool = true |
marks a pass that must always run |
So this macro form:
#[function_pass(name = "entry-reachable", access = Inspect, requires = [DominatorTreeAnalysis])]
impl EntryReachable {
fn run(&mut self, cx: FnCx<Self>) -> IrResult<FnReport> { /* body */ }
}expands to exactly this hand-written impl — the <B> header, the
associated-item block, and run's higher-ranked 'm / 'ctx regions and their
where-clause are all supplied for you:
impl<B: ModuleBrand> FunctionPass<B> for EntryReachable {
type Access = Inspect;
type Requires = (DominatorTreeAnalysis,);
const NAME: &'static str = "entry-reachable";
fn run<'m, 'ctx>(
&mut self,
cx: FnCx<'m, '_, 'ctx, B, Inspect, (DominatorTreeAnalysis,)>,
) -> IrResult<FnReport<B>>
where
'ctx: 'm,
Self: 'ctx,
{ /* body */ }
}Misuse is caught at the offending token, not deep inside the expansion: a
missing name/access, an unknown key, a trait impl instead of an inherent
one, or a generic impl each produce a pinpointed compile_error!, and a
function pass that declares a module-only rung fails with
RewriteModule: FnAccess unsatisfied. The compile-fail suite locks two of
these (function_pass_missing_name and function_pass_wrong_level_access);
the other compile_error! sites in llvmkit-macros have no fixture of their
own.
The written FnCx<Self> / FnReport are readability sentinels the macro
rewrites, so they are never imported.
Every mode threads one &mut Analyses. The verified-state of the returned
module is derived — any mutating pass downgrades it to Module<Unverified>,
forcing an explicit re-verify() before the next verified-only stage (D8):
use llvmkit_ir::{
Analyses, DcePass, Dyn, FunctionId, InstSimplifyPass, IrResult, Module, ModuleBrand,
Unverified, Verified, function_pipeline, run_function_pass,
};
fn cleanup<'ctx, B: ModuleBrand + 'ctx>(
verified: Module<B, Verified>,
f: FunctionId<Dyn, B>,
) -> IrResult<()> {
let mut analyses = Analyses::new();
// 1. A single pass. `InstSimplifyPass` is `PatchBody`, so the driver
// returns `Module<Unverified>` and the re-verify is enforced by the type.
// The driver is handed an **id**, never a view: it consumes the module
// token, and a view would be a borrow of the token about to move.
let simplified: Module<B, Unverified> =
run_function_pass(InstSimplifyPass, verified, f, &mut analyses)?;
// 2. A compile-time tuple pipeline, run in written order. The output
// typestate folds the members' rungs: any mutator ⇒ Unverified.
let cleaned = function_pipeline((InstSimplifyPass, DcePass))
.run(simplified.verify()?, f, &mut analyses)?;
let _reverified = cleaned.verify()?;
Ok(())
}The third mode is runtime assembly, for opt-style CLIs where the pass list is
not known until run time: DynFunctionPipeline / DynModulePipeline (transform;
always Unverified out) and DynReadOnlyFunctionPipeline /
DynReadOnlyModulePipeline (read-only; push is bounded to Inspect, so a
mutating pass cannot be added and the module threads through Verified).
For runnable end-to-end versions, see
crates/llvmkit-ir/examples/pass_manager_demo.rs and
crates/llvmkit-ir/examples/authored_pass.rs.
| LLVM new PM concept | llvmkit API |
|---|---|
FunctionPass::run(Function &, FunctionAnalysisManager &) |
FunctionPass::run(cx: FnCx<..>) — one consuming context |
ModulePass::run(Module &, ModuleAnalysisManager &) |
ModulePass::run(cx: ModCx<..>) |
PreservedAnalyses::all() / none() hand-written by the pass |
derived from the pass's type Access rung — never hand-written |
FAM.getResult<A>(F) (fallible, null on undeclared) |
cx.analysis::<A, _>() — infallible; declared in type Requires, prefetched |
ModuleToFunctionPassAdaptor |
for_each_function(function_pipeline((..))) as a module-pipeline member |
| mutating IR in a pass | declare a mutating rung, call the consuming cx.mutate(), receive a mutator; the driver returns Module<B, Unverified> |
plugin registration (llvmGetPassPluginInfo) |
none — a pass is a plain value; no registration step |
Important boundary: the crate currently ships the capability-graded pass API,
built-in analyses, initial scalar cleanup transforms (SimplifyDemandedBitsPass,
InstSimplifyPass, DcePass), optimization-level markers, scoped pass /
pipeline names, and data-only pass-pipeline recipe types, not a full
optimization pipeline. There is no public LLVM-compatible PassBuilder, no
runnable default<O1> optimizer, no loop / CGSCC / legacy manager, no
instrumentation-driven skipping, and no broad transform library yet. See
docs/future-work.md (the "Pass API — deferred"
section) for the scoped-out items.
<repo root>/
├── Cargo.toml # [workspace] only
├── llvmkit/ # umbrella crate
├── docs/ # see docs/README.md for the index
│ ├── type-safety-vs-llvm.md # current: the main technical reference
│ ├── ir-struct-derive.md # current: IrStruct user guide
│ ├── future-work.md # current: the live backlog
│ └── design/ # dated design records (one not yet built)
└── crates/
├── llvmkit-support/ # Span, Spanned<T>, SourceMap
├── llvmkit-asmparser/ # Lexer + .ll parser
├── llvmkit-macros/ # Branded/IrStruct derives, #[function_pass]/#[module_pass]
├── llvmkit-tablegen/ # TableGen front end + intrinsic emitter
└── llvmkit-ir/ # Typed IR model, builder, verifier, passes
docs/ sits at the workspace root, so it is not part of the published .crate
and does not appear on docs.rs — it is a repository-facing tree. API
documentation ships as rustdoc.
Every Rust file that ports LLVM behavior pairs to a specific upstream LLVM
concept. See AGENTS.md for the detailed source-tree map and the
current port-status ledger, and UPSTREAM.md for the per-test and
fixture provenance registry. The in-tree fixture policy avoids generated stubs,
and the test and runtime paths do not depend on orig_cpp.
- Track LLVM's behavior. The Rust port aims to match upstream observable behavior on a per-file basis. Disagreements are bugs unless explicitly documented as Rust-side improvements.
- Make invalid IR unrepresentable. LLVM often relies on runtime checks;
llvmkitpushes those distinctions into the Rust type system whenever the modeled surface can support it. - No FFI, no
bindgen, nollvm-sys. All functionality is implemented in Rust. Nothing links againstlibLLVM. - Safe Rust only. Every workspace crate uses
#![forbid(unsafe_code)]. - One concept, one representation. CFG queries, dominance, analysis invalidation, and pass sequencing live in dedicated modules instead of being reimplemented ad hoc in verifier or examples.
Type safety is llvmkit's main differentiator. Eleven rules govern the public
surface; cite them by id (D1-D11) in reviews and commit messages.
See Type Safety: llvmkit vs. LLVM C++ for worked
examples that map common LLVM C++ failure modes to Doctrine IDs and compile-fail
locks.
- D1. State machines are typestates. If a value has more than one operational state, those states are distinct types.
- D2. Linear-typed handles for irreversible operations. Irreversible
actions consume
selfon non-Copyhandles. - D3. Erased forms are explicitly opt-in. Typed handles default to the
strongest static shape available; runtime-erased
Dynforms are explicit. - D4. Result types reflect operand types. Builder return types preserve the operand category and width/kind information whenever the call site knows it.
- D5. Operand registration is structural. Use-list and operand traversal updates live in one exhaustive place per construction / mutation primitive.
- D6. Aggregate types preserve element shape. Aggregate typing is modeled directly rather than flattened into weak runtime predicates.
- D7. Cross-module mixing is rejected. Every handle, id, and builder carries
the owning module's brand — a
'statictype parameterB. Two modules with distinct brand types cannot exchange operands: that is a type error, caught at compile time, with no runtime check involved. A process-global registry admits at most one liveModuleper brand —Module::brandedreturnsResult<_, BrandError>, whose only two outcomes areBrandError::InUseandBrandError::Retired— which is what makes a brand name one module unambiguously. Where two modules deliberately share a brand type — everyModule::dynamicmodule isDynBrand, and a named brand is re-issued after the previous module drops — the compile-time half cannot apply, and a mix-up is instead caught at the arena boundary by the runtimeModuleIdtag every id carries (IrError::ForeignValueId). Compile-time separation is the guarantee; the runtime tag is the backstop that keeps the erased case sound rather than silently miscompiling. See Same-module safety. Metadata is included. It used to be the one currency outside D7 — a bare arena index with neither a brand nor a tag, so an in-range node from another module mis-resolved silently. Since 0.0.4 the public currency isMetadataId<B>, which carries both halves like every other id: a mix-up across named brands is a compile error, and within one brand it isIrError::ForeignMetadataId.IrError::UnknownMetadataSlotnow reports only a native id whose slot is out of range. - D8. Verified guarantees are explicit. Verification consumes an
unverified token and produces
Module<B, Verified>. A pass pipeline's output typestate is derived from its members' capability rungs: an all-read-only (Inspect) run preserves that verified state at the type level, while any mutating pass returnsModule<B, Unverified>, so their output must be verified again before another verified-only pipeline can consume it. - D9. Iteration safety is structural. Mutating-while-iterating uses dedicated cursor APIs rather than relying on caller discipline.
- D10. No undefined behavior, by design. Legal API calls must produce defined IR behavior; deferred traps and invalid combinations surface as typed errors or explicit IR states, not silent UB.
- D11. Tests and fixtures are ported, not invented. A test's expectations
come from an upstream unit test, verifier fixture, or assembler fixture —
never from an oracle computed by another route, which would test the
derivation instead of LLVM's answer — and its source is recorded in
UPSTREAM.md in the commit that adds it. A genuinely
llvmkit-specific test (an internal representation choice, a parse/print
idempotence law, a compile-fail lock) is legitimate, but its row has to say
it has no upstream counterpart rather than implying one.
Coverage of the existing suite is a ratchet, not a finished job: tests
predating the registry are listed in
crates/llvmkit-ir/tests/fixtures/upstream_provenance_debt.txt, andtests/upstream_registry_drift.rsfails when a test has neither a row nor a line there — so the debt can be paid down but never grown. A line in that file means missing provenance, never "no upstream counterpart". The fixture and runtime paths do not depend onorig_cpp.
- LLVM Project
- LLVM Language Reference
- Using the New Pass Manager
- Writing an LLVM New PM Pass
- LLVM 22.1.4 release
This project is a derivative work of the LLVM Project and
is licensed under the Apache License v2.0 with LLVM Exceptions
(Apache-2.0 WITH LLVM-exception) — the same license LLVM ships under.