mirror of
https://github.com/Nexoniarz/Finch.git
synced 2026-11-03 00:44:27 +00:00
- a * b + c is two roundings on every processor and --cpu: the IR has no fast-math flags, and clang compiles it with -ffp-contract=on (checked in the assembly: never fused, also with FMA hardware) - fma(a, b, c) / v.fma(b, c): one rounding, always (llvm.fma) - muladd(a, b, c) / v.muladd(b, c): the fastest way here (llvm.fmuladd), what v.fma was before - f32_bits / f32_from_bits / f64_bits / f64_from_bits: bitcasts, also of vectors - f16_to_f32 / f32_to_f16 (LLVM half), bf16_to_f32 / f32_to_bf16 (integer ops, ties to even, NaN kept) - docs (en, pl): what is guaranteed about floats, the new built-ins Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Psc42moPLwP43Cmb9oZqSR
576 lines
33 KiB
Markdown
576 lines
33 KiB
Markdown
# 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](for-technicians.md)) and have at least seen LLVM IR.
|
||
|
||
> 🇵🇱 Polska wersja: [dla-inzynierow.md](../pl/dla-inzynierow.md)
|
||
|
||
---
|
||
|
||
## Contents
|
||
|
||
1. [Architecture](#1-architecture)
|
||
2. [Source tree](#2-source-tree)
|
||
3. [Front end: lexer, parser, modules](#3-front-end-lexer-parser-modules)
|
||
4. [Code generation](#4-code-generation)
|
||
5. [C import and the C ABI](#5-c-import-and-the-c-abi)
|
||
6. [Driver, linking and targets](#6-driver-linking-and-targets)
|
||
7. [Type system](#7-type-system)
|
||
8. [Ownership and memory](#8-ownership-and-memory)
|
||
9. [Generics, enums and match](#9-generics-enums-and-match)
|
||
10. [Bootstrapping](#10-bootstrapping)
|
||
11. [The language server](#11-the-language-server)
|
||
12. [Semantics vs. C](#12-semantics-vs-c)
|
||
13. [Known limitations](#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 `alloca`s 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 fn`s.
|
||
|
||
`--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`) `longjmp`s 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
|
||
|
||
```c
|
||
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` | `fcmp oeq`/`icmp eq` → `<N x i1>` → `llvm.vector.reduce.and` |
|
||
| `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.
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
```c
|
||
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):
|
||
|
||
```llvm
|
||
%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](for-technicians.md). 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/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:
|
||
|
||
```llvm
|
||
%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 `longjmp`s out of it).
|
||
- Vectors have no lane masks (`v < w`), shuffles or integer division.
|
||
|