Finch/docs/en/for-engineers.md
Claude 1f45bd8a01
docs (en, pl) and editors: masks, tails, shuffles, lookup, dot_add, @cpu, cpu_has, Nix
- technicians' guides: section 31 rewritten around kernels (masks, the end of a list, moving lanes,
  int8 dot products, choosing instructions at build and at run time)
- cheat sheets and engineers' guides: the new operations and their IR; how features are found
- Kate and VS Code: @cpu, boolxN and the new built-ins highlighted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Psc42moPLwP43Cmb9oZqSR
2026-11-01 15:46:04 +00:00

35 KiB
Raw Permalink Blame History

Finch for Engineers

The internals of Finch 2.6, whose compiler is written in Finch. How source becomes tokens, an AST and LLVM IR as text, which clang compiles and links with a small C runtime; how C headers are read with libclang from Finch itself and how the C calling conventions are reproduced by hand; the type and ownership rules; how the compiler builds itself from a seed; and the language server.

Assumed: you can read Finch (see the technicians' guide) and have at least seen LLVM IR.

🇵🇱 Polska wersja: dla-inzynierow.md


Contents

  1. Architecture
  2. Source tree
  3. Front end: lexer, parser, modules
  4. Code generation
  5. C import and the C ABI
  6. Driver, linking and targets
  7. Type system
  8. Ownership and memory
  9. Generics, enums and match
  10. Bootstrapping
  11. The language server
  12. Semantics vs. C
  13. Known limitations

1. Architecture

The compiler has no LLVM library inside it. It writes LLVM IR as text and lets clang do optimisation, code generation and linking. That is what makes it small (about 12k lines) and what lets it be written in Finch itself.

 main.fch ─► loader ── lex ── parse ──► Program   (+ every imported module, recursively)
                                          │
        `import "x.h"` ─► cimport.fch (libclang, called from Finch) ─► C functions, structs, constants
                                          │
                                          ▼
                       gen.fch ─► generate(): declare structs, declare functions, define bodies
                                  type check and emit IR in one walk; ABI classification for C calls
                                          │   (IR text)
                                          ▼
        linker.fch ─► clang -c prog.ll  ─►  clang prog.o rt-<hash>.o [your .c/.o/.a] -lm [pkg-config / -l]
                                          │
                                          ▼
                        executable   (finch run: run from a temp file, then delete it)

Design decisions that shape the rest:

  • Fail fast. The first error prints file:line:col: error: … with the source line and a caret, and the compiler stops. No error recovery, so later phases never see half-valid input.
  • One walk. gen.fch type checks and emits IR in one pass over the AST. Each expression yields an IR operand, its Finch type (a string kind: int, f32, str, array, map, struct, enum, named…) and flags.
  • Let clang do the work. Locals are allocas promoted by mem2reg. Optimisation is clang's -O2 (or -O0 with -g).
  • Values, not references. Arrays, strings and structs have value semantics; ownership is decided at compile time (§8).
  • A small C runtime (runtime/finch_rt.c, ~1150 lines) does what is tedious in IR: strings, array growth, maps, input, files, panics. It is compiled once and cached in ~/.cache/finch.

2. Source tree

File Lines Responsibility
src/lexer.fch 470 Tokens, positions, """ text, "\(x)" turned into ("…" + str(x) + "…") tokens
src/ast.fch 170 One Node struct with a kind string, children, type names, positions (line, endLine)
src/parser.fch 910 Recursive descent: declarations, statements, expressions, generics ([T]), enum, match, export, link, const, test "…" { }
src/gen.fch 7030 Type check + IR: structs, functions, methods, scopes and drops, statements, expressions, built-ins, maps, errors as values, generics (monomorphisation), enums and match, vectors (SIMD), -g debug metadata, the index mode
src/cimport.fch 790 libclang: functions, structs and unions (fields, offsets, padding), enums, constants, globals, macros (also struct-valued)
src/platform.fch 120 Target: triple, data layout, C compiler, signedness of char per system
src/linker.fch 480 The cache (runtime, program object, C/C++ files), libraries (link, pkg-config), link-error advice
src/main.fch 530 Driver: CLI, loader with module lookup and overlays, build/run/test/fmt/ir/index/lsp
src/manifest.fch 240 finch.toml: a small TOML reader, per-target lists
src/fmt.fch 225 finch fmt and LSP formatting
src/lsp.fch 950 The language server (§11)
src/docs.fch 140 One description of the built-ins, shared by hover and completion
std/*.fch 1040 json, path, time (+_windows), proc (+_windows), fs, thread
runtime/finch_rt.c 1150 The C runtime: text, arrays, maps, files, float printing, fs, threads, the test runner, the call chain
bootstrap/ build.sh, install.sh, the seeds (§10)

3. Front end: lexer, parser, modules

Lexer. Newlines end statements; there is no indentation rule. Tokens carry file, line and column, which every error uses.

Parser. Recursive descent, one function per construct. Notable points: types are parsed into a name string ([]str, map[str]int, ptr[char], Stack[int]); fn Type.method declares a method; fn name[T] and struct Name[T] declare templates; enum and match have their own nodes; export fn marks a C-visible function; a block records its endLine (used by the language server). The old fn name(...) type order is a parse error with a hint.

Modules. import name looks, in order, at name_<os>.fch next to the importing file (windows, linux, macos), name.fch there, the folders of FINCH_PATH, then the standard library std/. Each module is parsed once; its names are used as module.name. import "x.h" goes to the C importer instead.

A module is identified by its file: the key is its path from the main file's folder (or from a FINCH_PATH folder or std/) without .fch and without an _<os> suffix, e.g. ui/button. Program.imports holds the name each file uses (button, or btn for import ui/button as btn) and Program.modules the key; modFor() maps one to the other. Symbols use the key with / turned into $ (@finch.ui$button.make).

Constants. const NAME = expr is a top-level node; constValue() evaluates the expression at each use, in the defining module with no local scopes, so it folds to a literal (integer literal operators are folded in arith). checkConst() allows only literals, names, operators, members and numeric conversions; constBusy catches cycles.

Lexer extras. 1e9 / 2.5e-3 are float tokens (the text is normalised to have a . for LLVM); """ strings are dedented by the closing line's indentation in longString().


4. Code generation

gen.fch is one big Gen struct plus functions over it. generate() runs:

  1. declare(): every Finch and C struct gets an LLVM type (C structs with their real padding; recursive-by-value structs are refused), then every function gets its signature, with a borrow analysis for parameters (§8.2).
  2. define() for each function: the body, type checked and emitted. Scopes track what must be dropped when they end. Instantiating a generic adds functions while this goes on, so the loop runs until no new ones appear.
  3. The C entry point main(argc, argv) (or, for --shared, finch_module_on_fatal and the export fn wrappers).
  4. assemble(): the declarations, struct types, string constants, helpers and bodies, as one .ll text.

With -g, DWARF metadata is attached as the IR is written (use -O0).

Generics are monomorphised: a call max(1, 2) infers T, then the template's AST is generated again with T bound, under its shown name (max[int], a symbol max.int). match over an enum is exhaustive (a missing variant is an error, else covers the rest); on a value it is a chain of comparisons. Enum values are i32; Color(n) is range checked; printing uses a generated name table.

Errors as values: -> T! returns a pair (value, error text); try returns early on error, or handles it, error("…") makes one.


5. C import and the C ABI

cimport.fch calls libclang directly from Finch (it is itself written with import "clang-c/Index.h"), parses the header with the target's flags, and walks the cursors. Types map as: const char * parameters → str, other char * → ptr[char], a char * result → str; integer types by size and signedness (char is unsigned on ARM64 Linux); structs and enums by layout.

LLVM IR does not do the C calling convention for you, so gen.fch reproduces it:

  • classify – System V x86-64: a struct up to 16 bytes goes in registers by eightbyte class (INTEGER/SSE), larger in memory;
  • classifyArm64 – AAPCS64, including homogeneous float aggregates (HFA);
  • the Microsoft x64 rules (1, 2, 4, 8-byte structs by value in a register, the rest by pointer);
  • makePlan turns the classification into a call plan; cThunk makes addr(fn) callable from C (callbacks).

The same code is what lets the compiler call libclang, whose API passes CXCursor, CXType and CXString by value.

Macros. A #define that is one number or a string literal is read from its tokens. One whose tokens could be a constant expression (no braces, semicolons or statement keywords) is evaluated by clang: a second parse of the header adds static const __auto_type __finch_m_NAME = (NAME); for each, and clang_Cursor_Evaluate gives an int, a double or a string. Those that are not constants only produce errors in that second parse and are dropped.

Unions are a CStruct with isUnion: the LLVM type is { [n x iA] } (size and alignment from clang), every field has offset 0, and a field is read through memory. They can't be passed by value to C (unsupported says why).


6. Driver, linking and targets

finch build|run|ir|index file.fch [-o out] [-O0] [-g] [-l lib] [-I dir] [--target t] [--cpu c] [--shared] [--overlay path=file]. ir prints the IR; index prints the records the language server uses (§11); --shared makes a shared library of the export fns.

--cpu <name> (or cpu in finch.toml) adds -march=<name> on x86-64 or -mcpu=<name> on ARM64 to the clang that compiles the IR (not the runtime); it is part of the cache key.

--target windows|linux|arm64|macos|android|<triple> changes the triple and data layout, the C char signedness, the ABI rules above, the OS variant of modules (name_windows.fch), and the C compiler: FINCH_CC or clang --target=…. Linking asks pkg-config for flags for link "lib"; when the linker fails the output is analysed and the likely missing link "…" is suggested. The runtime object is keyed by a hash of its source and the target.

Environment: FINCH_PATH (module folders), FINCH_RT (runtime source), FINCH_CC (C compiler/linker; the IR is always compiled by clang, never by CC), FINCH_LIBCLANG (for bootstrap/build.sh).

The compiler finds runtime/ and std/ next to itself (../, ./) or under ../share/finch/ (bootstrap/install.sh).

The build cache (host.cacheDir(): $XDG_CACHE_HOME/finch, ~/.cache/finch, %LOCALAPPDATA%\finch):

  • ir/<hash of main file + target + flags>.o with a .key holding a hash of clang, the flags and the whole IR. One slot per program, so the cache does not grow with every edit; the same IR gives the same object without running clang.
  • c/<hash of source + compiler + flags + target>.o for link "x.c" / .cpp, built with -MMD; the .key is a hash of every file the .d lists (the source and its headers). Any change in one of them rebuilds it.
  • Objects are written to a .part<pid> file and renamed, so two builds at once don't see half a file. Hashes are two independent 32-bit ones (FNV-1a and djb2), 64 bits in all.

finch.toml (src/manifest.fch) is searched from the main file's folder (or the working folder) upwards. It is read into values["section.key"] and lists["section.key"]; list(key, targets) concatenates the top-level list and those of [target.<os>] and [target.<os>-<cpu>]. Paths are resolved against the manifest's folder. Entries feed the same places as the command line: paths → the loader, include → -I (libclang and C files), links → link, libdirs and ldflags → the link command, cflags → C/C++ files.

C++ files: a .cpp/.cc/.cxx in links is compiled by cxxFor(cc) (FINCH_CXX, or clang → clang++, …gcc → …g++, cc → c++), and then that driver links, which brings in the C++ standard library.

Android: --target android is aarch64-linux-android24; the C compiler is the NDK's toolchains/llvm/prebuilt/<host>/bin/aarch64-linux-android24-clang (from ANDROID_NDK_HOME/ANDROID_NDK_ROOT), the link adds -llog and no -lpthread (bionic has it in libc). The OS variant of modules is linux.

finch test compiles like run, with Options.test: every function is still defined, but @main calls finch_test_run(name, fn) for each test block of the user's modules (__test_<n>, a void function made by the parser), then finch_test_summary(). The runtime runs each test under setjmp; fatal() (every runtime error and a failed assert) longjmps back through fatal_hook, so one test failing doesn't end the run. Memory the failed test owned at that point is not freed.

finch fmt is line-based: it tracks { }, ( )/[ ], strings (with \( )), '…', """ and /* */ per line. A line that starts at bracket depth 0 and doesn't continue a line ending in an operator or , is re-indented to 4 × brace depth; other lines (continuations, """ bodies) are shifted by the same amount as the line that started the statement, so their shape (and the """ dedent) is kept.


7. Type system

LLVM representation

Finch LLVM
bool i1
char i8 u8 / i16 u16 / i32 u32 / int u64 i8 / i16 / i32 / i64
f32 / float float / double
str, []T { ptr, i64, i64 } (pointer, length, capacity)
map[K]V { ptr, i64, i64, i64, ptr, i64, i64 } (entries, len, used, cap, index, icap, adds; see src/gen.fch)
ptr, ptr[T], null ptr (opaque)
[N]T [N x T]
f32x8, i8x32, … <8 x float>, <32 x i8> (§7, Vectors)
Finch struct named struct type, natural layout
a method's self ptr (the first parameter)
the result of -> T! { i1 ok, T value, str error } (i8 in place of T for -> !)
C struct named packed struct with explicit padding bytes (§5)

Signedness lives only in the Finch type and selects sdiv/udiv, ashr/lshr, icmp s*/u*, sext/zext, sitofp/uitofp, fptosi/fptoui.

Values

struct Val {
    str v           // the IR operand: %t12, 42, @.str.3, zeroinitializer
    ast.Type ty
    bool fresh
    bool lit
}
  • lit: a number written in the code or an imported C constant. It may become any integer type it fits (fits()), or f32. It survives unary minus and literal-only arithmetic that folds to a constant; explicit conversions clear it (u8(250) is a typed constant, so u8(250) + 10 wraps to 4).
  • fresh: an owning value (str/array/owning struct) that nobody holds yet: the result of a call, a literal, a concatenation, a constructor. It must be stored (moved) or released (§8).

Implicit conversions: coerce(v, want)

Identical types; null → any pointer; typed pointer ↔ untyped ptr; integer → integer if the value is a fitting literal or the type widens() (same signedness and more bits, or unsigned → strictly bigger signed); integer → float; f32 → f64; literal f64 → f32. Nothing else: the error suggests the explicit conversion. Owning types convert only to themselves. Array literals take their element type from the slot they flow into (exprWant()), so []f32 xs = [1, 2.5] works.

Binary operands: unify(l, r)

A literal side adapts to the other (also a float literal next to an f32: x * 2.0 stays f32); otherwise the side that widens grows; i32 + u32 is an error; int + float → float; f32 + f64 → f64. There is no C integer promotion: u8 + u8 stays u8. Two integer literals fold to a literal, so FLAG_A | FLAG_B still fits a u64 or u32.

Explicit conversions: convert()

From → To IR
int/char ↔ int/char trunc/sext/zext by source signedness
bool → int zext
float ↔ int fptosi/fptoui, sitofp/uitofp
float ↔ float fpext/fptrunc
int → bool icmp ne 0
number/bool/char → str finch_str_from_int/uint/float/char/bool (fresh)
str → int/float finch_str_to_int/float (panics on bad text)
[]u8/[]char → str finch_str_from_bytes
ptr → str finch_str_from_c (borrowed, cap = -1)
ptr ↔ ptr nothing
int → ptr / ptr → int inttoptr / ptrtoint

Comparisons

Pointers/null: icmp eq/ne ptr only. str: finch_str_eq (length + memcmp) for ==/!=, finch_str_cmp for ordering. Floats: oeq olt ole ogt oge and une for !=. Integers/char by signedness (char unsigned). Bool: equality only. Arrays/structs: error.

Vectors

ast.vecType(name) recognises <lane>x<N> (lanes i8…u64, f32, f64; N = 2…64; at most 512 bits) and gives Type(kind: "vec", elem: [lane], count: N), which is an LLVM vector <N x T>. The parser takes such a name wherever a type goes; a call f32x8(…) goes to vecMake().

Finch IR
f32x8(x) insertelement into lane 0 + shufflevector … zeroinitializer (a splat)
f32x8(a, …, h) a chain of insertelement
f32x8(list, i) bounds check of i … i+7, then load <8 x float>, ptr …, align 4 (the lane's alignment)
f32x8(i32x8 v) sitofp, fptosi, sext, trunc… on the whole vector
v + w, v * 2.0 fadd/add… on vectors; a scalar operand is coerced to the lane type, then splatted
v[i] a place: getelementptr <lane>, ptr %v, i64 i after a bounds check; or extractelement on a temporary
v < w, v == w … fcmp/icmp → <N x i1>, a mask (boolxN: vec with bool lanes)
select(m, a, b) select <N x i1>
m.any(), .all(), .count() llvm.vector.reduce.or/.and; zext + llvm.vector.reduce.add
f32x8(list, i, fill), v.store_part(list, i) llvm.masked.load/llvm.masked.store with the mask <0, 1, …> < len - i
v.shuffle(…), .lo(), .hi(), concat(a, b) shufflevector with a constant mask
table.lookup(idx) and with len - 1, then extractelement/insertelement per lane: the pattern the x86 and ARM backends turn into pshufb/tbl
acc.dot_add(a, b) llvm.x86.avx512.vpdpbusd.{128,256,512} or llvm.aarch64.neon.{usdot,sdot,udot} when the function's features have them; else zext/sext, mul, four strided shufflevectors and adds
min, max, abs llvm.minnum/maxnum, llvm.smin/umin…, llvm.fabs/llvm.abs
v.sum(), .min(), .max() llvm.vector.reduce.fadd (with reassoc), .add, .fmin, .smin…
v.sqrt(), .floor(), .ceil(), .round() the llvm.* intrinsic of that name
v.fma(b, c), fma(a, b, c) llvm.fma (one rounding; a libm call per lane without FMA hardware)
v.muladd(b, c), muladd(a, b, c) llvm.fmuladd (fused where the target has FMA, fmul + fadd where not)
v.store(list, i) bounds check, store <8 x float> …, align 4

Integer / and % are refused: neither x86 nor ARM has a lane-wise integer divide, and LLVM would scalarise it. Ordering comparisons are refused because their result would be a mask, which has no Finch type.

Alignment. LLVM gives <8 x float> an ABI alignment of 32 (its size), but malloc and Finch's alloca … align 16 promise only 16. llty() records every vector wider than 16 bytes in g.wide, and resolveStructBody records a struct that holds one; load() and store() add , align 16 for those types. Without it LLVM may emit vmovaps on a 16-byte aligned address and crash.

Which instructions is clang's choice: --cpu <name> adds -march=<name> (x86-64) or -mcpu=<name> (ARM64) to the IR's compile, so the same IR becomes SSE2, AVX2 + FMA, AVX-512 or NEON code. Lanes wider than the registers are split.

Some operations need to know the features at code generation time (dot_add picks vpdpbusd only where it exists). linker.cpuFeatures() asks clang for its predefined macros with the same target and --cpu (clang -march=X -dM -E empty.c: __AVXVNNI__, __AVX512VNNI__, __ARM_FEATURE_MATMUL_INT8 …), maps them to Finch's feature names and caches the answer per clang and flags; they reach gen as Options.features. @cpu("…") on a function (Node.cpu) adds its own: define … "target-features"="+avx2,+fma" (with +evex512 for any AVX-512 feature, which LLVM 18+ needs for 512-bit registers), and g.curFeatures is the union while that function is generated. Features of the other architecture are dropped (cpuOf). checkCpuCall refuses a vector wider than 128 bits as a parameter or result between functions whose @cpu differ, because caller and callee would lower it to different registers. cpu_has(name) calls finch_cpu_has in the runtime: CPUID leaves 1 and 7 with XGETBV for the AVX/AVX-512 state on x86, getauxval(AT_HWCAP/AT_HWCAP2) on Linux ARM64, sysctlbyname("hw.optional.arm.FEAT_…") on macOS.

Clang is always run as NIX_ENFORCE_NO_NATIVE=0 clang … on Unix: Nix's cc-wrapper otherwise drops -march=native inside nix-shell. If --cpu native still yields no SSE4.2 macro on x86, the driver warns.


8. Ownership and memory

8.1 The model

Every value of an owning type (str, []T, or a Finch struct with an owning field) has exactly one owner: a variable, an array element, a struct field, or a heap cell from new(). The owner drops the value when it goes away. There is no reference counting and no garbage collector; every decision is static.

Situation What happens
x := expr / T x = expr / x = expr / field, element, push own(v): a fresh value is moved in; a borrowed one is deep-copied
x = … (owning) the new value is computed first, the old one dropped after the store
end of a block, return, break, continue owned variables of every scope being left are dropped (newest first), defers run
return local the variable is moved out (marked moved while the cleanups for that return are emitted)
passing an argument lent: the callee gets a shallow copy and never drops it; fresh arguments are released by the caller after the call
a parameter the function changes copied on entry and owned by the callee (decided by mutates(), below)
a temporary nobody keeps (print(a + b), f(g()), an expression statement) release(v): dropped right after use
a.pop(), a.remove(i) the element is moved out (fresh)
for x in list x borrows each element; if the body may change list, each element is copied instead
a method call v.m() self is v (its address is passed): no copy; changes are visible to the caller
maps own their keys and values like arrays own elements; m[k] = v moves/copies v in and drops the old value

A string literal is a fresh value with cap = 0: moving it costs nothing, dropping it is skipped (release() ignores constants), and writing into it (s[i] = c) first makes a heap copy (finch_str_own). Strings from C are borrowed with cap = -1 and always copied before being kept.

8.2 Parameter borrowing: mutates()

When declaring a function, every owning parameter is checked with mutates(name, body): an assignment whose root variable (rootVar(): through .field and [i]) is the parameter, a changing method on it (push pop insert remove clear resize sort reverse), or addr(…) of it. Unchanged parameters are borrowed (FnSig.borrow); changed ones are copied once at entry. The analysis is syntactic and conservative: anything that might change the parameter makes a copy, which is always correct. The same analysis decides whether for x in list must copy elements.

Methods take part: declare() first finds which methods change self (mutates("self", body)), repeating until nothing changes, because calling a changing method on self changes self too. Their names go into g.mm, which mutates() treats like push: p.move(1) changes p. Only then is borrow computed for every function and method.

8.3 Copy and drop

dropAt(addr, T) / copyAt(dst, src, T) work on addresses:

  • str: finch_str_drop (frees if cap > 0) / finch_str_copy (shares literals, duplicates the rest).
  • []T and owning structs: generated helpers finch.drop.<key> and finch.copy.<key>, created once per type (dropFn, copyFn, keyed by typeKey(), e.g. arr_S_main_Player). Arrays drop each owning element and then finch_arr_free; copies call finch_arr_clone_raw and then deep-copy owning elements over the raw bits. Structs drop/copy only their owning fields.

Helpers are emitted in their own functions (beginHelper() / endHelper() set the function being written aside and switch off debug locations), may call each other recursively (struct Node { []Node kids }), and are inlined by O2.

8.4 What it looks like

fn greet(str name) -> str {
    msg := "Hi, " + name
    return msg
}

fn main() {
    a := ["x"]
    b := a
    b.push(greet("Ola"))
    print(b[1])
}

The -O0 IR of main (abridged):

  %3 = call ptr @finch_alloc(i64 24)                         ; ["x"]: one element on the heap
  store { ptr, i64, i64 } { ptr @str.1, i64 1, i64 0 }, ptr %4  ; the literal, cap 0
  ...
  store { ptr, i64, i64 } %7, ptr %a                          ; moved into a (fresh)
  %a1 = load { ptr, i64, i64 }, ptr %a
  store { ptr, i64, i64 } %a1, ptr %1
  call void @finch.copy.arr_str(ptr %2, ptr %1)               ; b := a  copies
  %8 = load { ptr, i64, i64 }, ptr %2
  store { ptr, i64, i64 } %8, ptr %b
  %9 = call { ptr, i64, i64 } @finch.greet({ ptr, i64, i64 } { ptr @str.2, i64 3, i64 0 })
  ...
  call void @finch_arr_reserve(ptr %b, i64 %12, i64 24)       ; push: grow if needed
  store { ptr, i64, i64 } %9, ptr %15                         ; greet's result moved in, no copy
  ...
  %20 = icmp uge i64 1, %19                                   ; b[1]: bounds check
  br i1 %20, label %out.of.range, label %in.range
  ...
  call void @finch.drop.arr_str(ptr %b)                       ; end of main: newest first
  call void @finch.drop.arr_str(ptr %a)
  ret void

and greet returns msg without copying (ret … %5 after loading the slot: the variable was moved out).

8.5 defer

defer stmt records the statement in the current scope (Scope.defers, with orders: how many variables existed then) and a Cleanup{isDefer}; it emits nothing there. Whenever the scope's cleanups are emitted (falling off the end of the block, return, break/continue leaving it) the body is generated at that exit, with deferLimit = order so that lookup() hides variables declared after the defer: those are already dropped by then (cleanups run in reverse). return/break/continue inside a deferred statement are errors (inDefer). Defer bodies are therefore duplicated per exit path, which is what makes them free on the normal path.

8.6 Manual memory

new(v) allocates sizeof(T) with finch_alloc, stores own(v) and returns ptr[T]. free(p) drops the pointee if T is owning (skipping null) and calls libc free. Pointers never own anything: addr(x) can dangle after x's block ends; this is not checked.

8.7 Verifying it

MEMCHECK=1 tests/run.sh builds every test program and runs it under valgrind with --leak-check=full --errors-for-leak-kinds=all: zero leaks and zero invalid accesses across all of them, including returns from inside loops, continue/break with owned locals, defers, mutation during iteration, nested arrays of structs, and C struct passing.


9. Generics, enums and match

See §4 for how they are generated. For the rules a user sees, read sections 23 and 24 of the technicians' guide. The tests are tests/new/generics.fch, tests/new/enums.fch and their refusals in tests/new/fail/.


10. Bootstrapping

The compiler is built from a seed: its own IR, committed as bootstrap/<system>.ll (see bootstrap/README.md). bootstrap/build.sh:

seed .ll ──clang──► finch0 ──builds src/──► finch1 ──builds src/──► build/finch

and stops unless finch1 and finch produce byte-identical IR (a fixpoint: the compiler compiles itself the same way whatever built it). bootstrap/build.sh seed rewrites the seed from the current compiler; do it whenever the compiler's own source starts to use a feature the committed seed lacks.

A seed is per system because the C calling convention for the libclang calls is baked into it (§5). There are seeds for Linux x86-64 and ARM64, macOS ARM64 and x86-64 (made with Apple's SDK headers), and Windows x86-64 (made with llvm-mingw's headers). CI builds the compiler from each one on that system and runs the tests there.


11. The language server

finch lsp speaks JSON-RPC over stdin/stdout (Content-Length framing, std/json). It does not type check; it runs finch index file.fch --overlay path=copy … for every change, where the overlays are the editor's unsaved text. In index mode the compiler prints, besides IR-free output, tab-separated records:

Record Meaning
@R file line col len defFile defLine defCol hover a reference: what hover shows and where the definition is
@V name type file line col fnFile fnLine fnEnd a variable and the function it lives in
@F module name recv file line col endLine signature params a function or method
@S / @SF, @E, @I structs (and fields), enums, imports
@W a warning

The first error is printed as path:line:col: error: message and becomes a diagnostic. When code does not compile, the server keeps answering completion and signature help from the last version that did. tests/lsp_test.py is the acceptance test.


12. Semantics vs. C

Situation C Finch
Signed integer overflow UB defined wraparound (no nsw)
Integer division by zero, INT_MIN / -1 UB compile error if constant, otherwise runtime error
Array index out of range UB runtime error (also for [N]T fields of C structs)
Null dereference UB runtime error for .value, .field, [i]
Use after free / double free of arrays, strings, structs common bugs impossible: no manual freeing of those
Memory leak of arrays, strings, structs common bug impossible without new
x & 1 == 0 x & (1 == 0) (x & 1) == 0
Implicit narrowing, signed/unsigned mixing silent compile errors
Uninitialized variables UB impossible (zero or defaults)
Missing return UB if used compile error
Order of evaluation mostly unspecified left to right; in an assignment the right side first
Shift ≥ width, out-of-range float→int UB poison, as in LLVM (not checked)
a * b + c on floats may be fused into an FMA (-ffp-contract) never fused: two roundings

Floating point

Float operations are emitted as plain fadd/fsub/fmul/fdiv/fneg with no fast-math flags, so LLVM may not reassociate, contract or assume no NaNs; linker.fch also compiles the IR with -ffp-contract=on, which fuses only the llvm.fmuladd calls Finch asks for. The only flagged instruction is v.sum()'s llvm.vector.reduce.fadd (reassoc). fma → llvm.fma, muladd → llvm.fmuladd, on scalars and vectors (fmaCall, fusedOp).

f32_bits/f32_from_bits/f64_… are bitcast (also between vectors). f16_to_f32 is bitcast i16 → half + fpext, f32_to_f16 is fptrunc + bitcast: one instruction with F16C or on ARM64, __extendhfsf2/__truncsfhf2 from the C compiler's runtime library elsewhere. bf16_to_f32 is zext + shl 16 + bitcast; f32_to_bf16 rounds to nearest even in integer ops (u + 0x7fff + ((u >> 16) & 1), then >> 16) and keeps NaNs quiet (| 0x40), so it needs no library. | Dangling pointer from addr() / new + free | UB | still UB |

The chain of calls on runtime errors

With Options.trace (default for run, test, -g, -O0; --trace/--no-trace; never with --shared) every Finch function gets a frame on its stack:

%frame = alloca { ptr, ptr, ptr, i64 }          ; caller's frame, name, file, line
store ptr (load @finch_trace), ...field 0
store ptr @"name", ...field 1 ; ptr @"file", field 2 ; i64 <line>, field 3
store ptr %frame, ptr @finch_trace

@finch_trace is a thread_local global in the program's module (initial-exec model unless shared or Windows); each statement stores its line into field 3. After every call to a Finch function the caller stores its own frame back into @finch_trace, which also undoes frames left by a callee that returned early, so no epilogue is needed on the many return paths. C-callable wrappers (addr(fn) thunks, export fn) save and restore @finch_trace around their call. main calls finch_trace_init(top, set) with two tiny functions that read and write it, which is how the runtime (print_trace() in fatal(), and the test runner) gets at a thread-local it cannot name. Cost: about 3x on fib(40), which is nothing but calls; that is why a plain finch build leaves it out.

Threads and atomics

std/thread.fch is a thin wrapper over the runtime (finch_thread_spawn/join, finch_mutex_*: pthreads, or CreateThread and CRITICAL_SECTION on Windows). The thread function is any fn f(ptr), passed with addr(f), which makes the C-callable thunk. atomic_add/sub/swap lower to atomicrmw add/sub/xchg … seq_cst, atomic_load/store to load/store atomic … seq_cst, align <size>, and atomic_cas to cmpxchg … seq_cst seq_cst returning the i1. They take a ptr[T] to any integer type. Finch's arrays, text and maps are not synchronised; sharing one between threads needs a mutex.

Warnings

warn() prints file:line:col: warning: … to stderr (so finch ir stays pure IR) or, with index, an @W record the language server publishes with severity 2. One per source position. Two are given: a struct parameter, and the element of for x in list, whose plain data (byteSize(): numbers, nested structs, C [N]T; str/arrays/maps count 0, as only their handle is copied) is 256 bytes or more.

Struct arguments by address

argWant() (call arguments only, not assignments): when a parameter is ptr[S] and the argument is a plain variable of type S, the argument is the variable's address, as if addr(x) had been written. Only variables, so nothing is evaluated twice and temporaries are never pointed to.

13. Known limitations

  • One error per run, no recovery.
  • -g debug info is best with -O0.
  • Generics are monomorphised (code size grows with instantiations); there are no trait bounds, a wrong use is an error inside the instantiated body.
  • A failed test's owned memory is not freed (the test runner longjmps out of it).
  • Vectors have no integer division, and lookup on baseline x86-64 (no SSSE3) is done lane by lane.