Skip to content

blackcoffee2/firn

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Firn

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.

Requirements

  • Dart SDK 3.0 or newer.
  • A local LLVM toolchain (clang) on your PATH, used to assemble and link the generated IR into a native binary.

Build and run

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

The pipeline

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.

The language

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.

Type mapping in the backend

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.

Tests

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.

About

A small statically-typed language that compiles to native executables through LLVM.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Languages