164 lines
8.1 KiB
Markdown
164 lines
8.1 KiB
Markdown
# 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`.
|