Firn is a small, statically-typed language that compiles to native executables through LLVM. The type checker runs before code generation, so the emitted LLVM IR is directly typed — no tagged runtime values, no per-operation type checks.
- Dart SDK 3.0 or newer.
- A local LLVM toolchain (
clang) on yourPATH, used to assemble and link the generated IR into a native binary.
Fetch dependencies, then compile the example program and run it:
dart pub get
dart run bin/firn.dart example/add.firn
./example/add
This prints big.
To inspect the generated LLVM IR without invoking the toolchain:
dart run bin/firn.dart example/add.firn --emit-ir
To choose the output binary's path:
dart run bin/firn.dart example/add.firn -o build/add
source -> lexer -> tokens -> parser -> AST -> type checker -> typed AST -> codegen -> LLVM IR -> clang -> binary
Each stage lives in its own file under lib/src/:
| File | Responsibility |
|---|---|
token.dart |
Token kinds and the Token class |
lexer.dart |
Source text to tokens |
ast.dart |
AST node definitions |
parser.dart |
Tokens to AST |
type.dart |
FirnType definitions |
type_checker.dart |
AST to typed AST, reporting type errors |
ir_builder.dart |
Helpers for assembling LLVM IR text |
codegen.dart |
Typed AST to an LLVM IR string (pure) |
compiler.dart |
Drives the pipeline and invokes the toolchain (impure) |
The codegen/compiler split is deliberate: codegen takes a typed AST and
returns an IR string with no file or process side effects, so it can be tested
by asserting on the IR text. compiler owns the impure steps — writing the
.ll file and running clang — keeping side effects in one place.
function add(a: int, b: int): int {
return a + b;
}
let x: int = add(5, 10); // annotation explicit; checked against initializer
let y = add(5, 10); // annotation omitted; inferred as int
if (x > 10) {
print("big");
} else {
print("small");
}
Function signatures are always fully annotated, which is what keeps type
checking simple: the type of any call is known by reading the callee's
signature, with no constraint solving. Local let bindings may omit their
annotation, in which case the type is inferred directly from the initializer —
local inference only, not whole-program inference.
The starting type set is int, bool, and string. Type names are ordinary
identifiers, resolved to types during checking, so they are not reserved words.
Because every value's type is known at compile time, each Firn type maps directly onto an LLVM type and each operation selects an exact instruction:
| Firn type | LLVM type |
|---|---|
int |
i32 |
bool |
i1 |
string |
i8* (pointer to a null-terminated byte array) |
Locals are given stack slots via alloca, with store on assignment and
load on use; LLVM's mem2reg pass promotes these to SSA registers, which
avoids constructing phi nodes by hand for ordinary variables. print lowers
to a printf call through libc, which clang links automatically.
dart test
Each stage is tested at its own boundary: the lexer on its tokens, the parser on
its tree, the type checker on the types and errors it produces, and the backend
on both the IR text it emits and the observable output of the compiled binary.
The binary-output tests are skipped automatically when clang is not available,
so the rest of the suite still runs.