# Where this is Milestones 2 and 3 of `plan.org` were merged: the interpreter was dropped (open decision #7, settled — see below) and the compiled path is the only backend. **calc-me.flan compiles and runs.** ``` reader ✅ → parse ✅ → check ✅ → emit ✅ → clang ✅ ``` | File | What it does | |---|---| | `lib/loc.ml` | source locations + `Loc.Error`, the frontend's one exception | | `lib/form.ml` | reader output: `Sym Kw Int Float Str Byte List Vec Map` | | `lib/reader.ml` | hand-written S-expression reader, no menhir/ocamllex | | `lib/ast.ml` | AST: `texpr`, `expr`, `place`, `pattern`, `decl` | | `lib/parse.ml` | forms → AST; special forms, desugaring, declarations | | `lib/types.ml` | resolved types; structural equality, `Never` fits anywhere | | `lib/tast.ml` | the typed IR the backend consumes | | `lib/check.ml` | AST → typed IR; two passes, bidirectional | | `lib/prelude.ml` | `print-str`/`print-f64`/`print-line`, written in Flan | | `lib/emit.ml` | typed IR → LLVM IR text | | `lib/build.ml` | `.ll` + the shim → clang → executable | | `runtime/flan_rt.c` | the whole host ABI: argv, stdout, exit, 4 conversions | | `bin/main.ml` | `flan read \| parse \| check \| emit \| build \| run` | | `test/test_flan.ml` | reader, parser and checker | | `test/test_acceptance.ml` | 20 expression/result pairs + 3 whole programs + the traps | | `test/programs/*.flan` | the milestone-2 surface calc-me does not reach | ``` $ flan run calc-me.flan "1 + 2 * (3 - 0.5) / 2" 3.5 ``` `dune build && dune test` is green, and the whole-program cases run at `-O2` *and* `-O0` — `mem2reg` launders a sloppy alloca, so -O0 is what tests the IR actually emitted. `flan emit` is byte-reproducible. `flan check sand.flan` fails on `(import rl ...)`, which is milestone 4 — as it should. ## Why there is no interpreter Open decision #7 is settled: **the compiled path is the only backend.** The two arguments for a permanent interpreter had both already expired in `plan.org` — the instrumentation step debugger that wanted it is cut, and compiled redefinition measured at ~16ms, which is perceptually instant for expression eval too. CCL and SBCL both do full interactive development without leaning on an interpreter; what makes a live image work is a fast compiler callable at runtime. The remaining argument was that milestone 3 needs an oracle to check the compiler against. It does not: the acceptance test is a hand-written table of expression/result pairs, so the table *is* the oracle. Consequences, both already applied: milestone 2's "measured interpreted calls per second" exit criterion is dropped — milestone 4 runs on the compiled build and nothing depended on that number — and the host ABI moved onto the critical path, which is why `runtime/flan_rt.c` exists now rather than at milestone 3. ## The layout, which is the whole backend design ``` i8..i64 / u8..u64 i8..i64 signedness lives in the ops f32 f64 float double bool i1 [T] and string { ptr, i64 } ptr+len, non-owning [n T] [n x T] inline, a value (Ptr T) ptr opaque pointers (Option T) { i8, T } tag 0 None, 1 Some a struct a literal struct, declaration order Unit and Never {} ``` No object headers anywhere, so a Flan struct is exactly its C struct and nothing marshals. Two consequences carry the semantics: - **Every slot is an `alloca`.** Reading a local is a `load`, assigning is a `store`, and a `store` of an aggregate *is* the copy `spec-memory.md` requires — value structs and fixed arrays copy, a slice copies only its view. `addr` of a local is then just the alloca, and `mem2reg` removes the ones nobody addressed. `test/programs/values.flan` pins this down: mutate the original, the copy is unchanged. - **A place is a pointer, a value is a load from it.** `(set (.pos c) …)` through a `(Ptr Cursor)` becomes a `getelementptr` on the pointer, not on a copy. This is the split that would have made a tree-walker silently wrong. Non-local exit is lowered explicitly: `return` and `some` are branches to a `ret`, never platform unwinding, so wasm32 needs no exception proposal. ## Bounds checks — done `at` and `slice` no longer emit a bare `getelementptr`. A failure is a branch to a `noreturn cold` call and then `unreachable` — the same explicit shape as `return` and `some`, so wasm32 needs nothing extra for it either. The message carries the source location, because `Tast.expr` keeps a `Loc.t` and a language that threads locations through the whole frontend should not trap anonymously: ``` $ flan run test/programs/bounds.flan 2 test/programs/bounds.flan:25:29: slice [2 1) is out of bounds for length 5 (exit 134) ``` Three check sites, and the third is the one with the trap in it: - **`at` on `[n T]`** — the bound is static, so LLVM folds the check away for a literal index. A literal that is *out* of bounds still only traps at runtime; rejecting it in `check.ml` is a separate job. - **`at` on a slice or string** — the bound is the runtime len. - **`slice`** — *two* comparisons, `lo <= hi` and `hi <= len`, both non-strict because a slice ending at len (or an empty one at `lo = len`) is legal and its one-past-the-end gep is defined. `lo <= hi` is not redundant: without it a reversed range yields `hi - lo` as a huge unsigned length, which is a worse hole than the missing check was. All comparisons are unsigned. Indices are i32 sign-extended to i64 for the gep, so a negative one arrives as a huge unsigned value and one test catches both directions; the runtime still prints the signed value in the message. `Build.opts.checks` is on by default and **is not tied to `opts.opt`** — dev traps, release does not, and that is a release decision rather than an optimisation one. Keeping them separate is what lets the acceptance table go on running the same programs at `-O0` and `-O2` with identical checks. The CLI flag is `--no-bounds-checks`, on `build` and `emit`. The write path is its own case. `(set (at arr n) …)` lowers through `place`/`Pindex`, not through `At`, so a refactor that split them would break the write check silently — the test covers both. `test/programs/bounds.flan` is one program with one case per argument, because a trap ends the process. The acceptance test asserts the exit code, that the message names the file, and the reason — but not line and column, so editing the program does not break the test that reads it. It runs at both `-O0` and `-O2`, and one more case checks the IR directly: `--no-bounds-checks` emits no `call` to either failure function. (The two `declare`s stay in the header unconditionally; LLVM drops the unused ones.) ## Next 1. **wasm32.** The backend is there (`llc` lists `wasm32`) and `Build.opts.target` already plumbs `--target`, but there is **no wasi sysroot on this machine** — `clang --target=wasm32-wasi` cannot find `stdio.h`. Install `wasi-sdk`/`wasi-libc` (`dnf search wasi` for the Fedora package name), then run the same acceptance table on both targets in CI. That is milestone 3's real remaining work. The bounds work above was written to survive the port — no unwinding, and `exit(134)` rather than `abort()`, so the same trap assertion should hold on wasm32 — but that is intent, not a tested result: nothing here has ever been built for wasm32. 2. **Then milestone 4** — sand.flan: fixed 2-D arrays (done), `dotimes`, `defer`, and typed raylib FFI with keyword→enum coercion. ## Watch for The rule that caught the two misparse bugs applies unchanged: **anything that binds a name, alters control flow, or is not yet implemented must be recognised explicitly and rejected if unsupported.** `check.ml` rejects `Vec`, `Map`, `Result`/`try`, union values, closures, `dotimes`, `defer`, keywords at call sites, imports, generics and function values *by name*, each with the milestone it belongs to. The tests assert on the reason, not just on the failure. ## Untracked on purpose `old-ocaml/` — the pre-rewrite menhir/ocamllex frontend, kept as reference and excluded from the build by the root `dune` file. Its contents are also in git history at `2c232dd`.