Making progress

This commit is contained in:
Joseph Ferano 2026-09-10 18:07:50 +07:00
parent c476d05f7d
commit 542e5d5fbc
4 changed files with 149 additions and 3 deletions

91
NEXT.md
View File

@ -137,9 +137,85 @@ the program does not break the test that reads it. It runs at both `-O0` and
`call` to either failure function. (The two `declare`s stay in the header
unconditionally; LLVM drops the unused ones.)
## The IR, inspected
Verified by reading `flan emit` output rather than by trusting the tests:
- Every check site is `icmp``br` → cold block → `call``unreachable`. The
verifier accepts it; no dominance or phi problems.
- `-O2` folds the two literal-index checks in `bounds.flan` and keeps the other
seven, which is exactly the intent.
- **A redundant check is not eliminated, and `index_ty` is why.** calc-me's
`peek` already guards with `(< (.pos c) (len (.src c)))`, yet the bounds check
survives `-O2`. `len` truncates the i64 length to i32 and the index is a
*signed* i32, so the guard emits `icmp slt i32 %pos, (trunc %len)` while the
check emits `icmp ult i64 (sext %pos), %len`. LLVM cannot bridge those and is
right not to: the trunc loses bits above 2³¹, and `slt` does not imply
`pos >= 0`. Lengths as i64, or unsigned indices, would let the two merge —
but that is `index_ty`, a plan.org-level decision, so it is left alone.
- Cost, measured: a 50M-iteration serial dependency chain over a 1024-element
array, argv-seeded so nothing folds, runs at 0.110.12s checked against
0.120.13s unchecked. Indistinguishable. The branch predicts perfectly and
the loop is latency-bound.
- Cosmetic: the fail block is emitted *before* the continuation block, so at
`-O0` the cold path sits inline in the hot path. `cold` plus LLVM's block
placement fixes it at `-O2`; nothing fixes it at `-O0`.
## Where build time goes
`flan build calc-me.flan` is ~140ms, and ~95% of it is clang:
| Step | Cost |
|---|---|
| frontend: read → parse → check → emit | <10ms, below the timer |
| `clang` on the `.ll` | 60ms — `llc` does the same codegen in **20ms** |
| `clang` on `flan_rt.c` | 40ms — recompiled every build, and it never changes |
| link | 20ms |
Two cheap wins take it to ~60ms: cache `flan_rt.o`, and skip the clang driver
for the `.ll` (`llc` + link directly). This reproduces plan.org's own
measurement — the driver is the cost, not codegen — and it is a subset of the
dev path's machinery, so doing it now is not wasted work.
**There is still no REPL.** Nothing in `lib/` or `bin/` does redefinition,
`dlopen`, or nREPL; `build.ml`'s docstring describes the dev path and says
nothing at milestone 2 needs it yet. `build` is the only way to run code, so
its 140ms is what you actually pay.
## Diagnostics
A lowercase name is a type variable (plan.org, Types), which meant a mistyped
primitive — `f65` for `f64` — was reported as *unimplemented generics, see
plan.org*, sending you to the plan instead of to the character you mistyped.
`resolve_name` now tries `near_miss` first: one edit (substitution, insertion,
deletion, or a transposed pair) against the primitives, aliases, structs and
unions. Bounded at one edit, because two is a guess, and because a real
single-letter type variable like `t` must still reach the milestone-5 message.
```
(defn f [x f65]) unknown type f65 — did you mean f64?
(defn f [x stirng]) unknown type stirng — did you mean string?
(defn f [x t]) generic code over the type variable t … milestone 5
(defn f [x Widget]) unknown type Widget
```
`Types.primitive_names` exists now because the list had only ever been match
arms. Caveat found while testing: a bare `(defn f [] f65 0.0)` says *unknown
name* instead, because with a single body form the parser cannot tell a return
type from the first expression. Only the parameter position and `(Option …)`
are unambiguous.
## Next
1. **wasm32.** The backend is there (`llc` lists `wasm32`) and
Recommended order — start with milestone 4; wasm32 needs a system install only
you can authorize, and the REPL is worth more once there is a frame loop for it
to not stutter.
1. **Milestone 4 — sand.flan.** `dotimes`, `defer`, and typed raylib FFI with
keyword→enum coercion. Fixed 2-D arrays are done. `flan check sand.flan`
already fails on `(import rl ...)`, as it should. FFI is the part most likely
to expose layout bugs the calc-me surface cannot reach.
2. **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
@ -148,8 +224,17 @@ unconditionally; LLVM drops the unused ones.)
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.
WASI is the syscall interface wasm has to import to get stdout, argv and
exit at all; `flan_rt.c` calls libc (`fwrite`, `snprintf`, `strtod`,
`malloc`), so it needs `wasi-libc`. The alternative is a second freestanding
shim that imports host functions directly and links no libc — which is what
"one narrow host ABI, implemented twice" points at, and the ABI is small
enough to make it plausible. Note plan.org has the *web* build linking
raylib via emscripten, which brings its own sysroot: wasi-sdk is right for
the headless acceptance table, not necessarily for the eventual game build.
3. **The dev path / REPL.** `llc` + `ld -shared` + `dlopen` ≈ 16ms, a compiler
daemon plus an in-game reload agent (plan.org, Dev architecture). The build
wins above are a down payment on this.
## Watch for

View File

@ -120,6 +120,37 @@ let rec resolve env ?(seen = []) (t : Ast.texpr) : Types.t =
fail loc
"%s takes no type arguments — generics are milestone 5" name)
(* One edit away from a type that exists — a substitution, an insertion, a
deletion or a transposition of neighbours. Bounded at one, because two edits
is no longer a typo, it is a guess. *)
and near_miss env n =
let one_edit a b =
let la = String.length a and lb = String.length b in
if abs (la - lb) > 1 then false
else begin
(* Walk both until they diverge, then require the tails to match with the
single edit applied. *)
let i = ref 0 in
while !i < la && !i < lb && a.[!i] = b.[!i] do incr i done;
let ta s k = String.sub s k (String.length s - k) in
if la = lb then
!i < la
&& (ta a (!i + 1) = ta b (!i + 1)
(* stirng/string: two neighbours swapped. *)
|| (!i + 1 < la && a.[!i] = b.[!i + 1] && a.[!i + 1] = b.[!i]
&& ta a (!i + 2) = ta b (!i + 2)))
else if la < lb then ta a !i = ta b (!i + 1)
else ta a (!i + 1) = ta b !i
end
in
let candidates =
Types.primitive_names
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.aliases []
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.structs []
@ Hashtbl.fold (fun k _ acc -> k :: acc) env.unions []
in
List.find_opt (fun c -> c <> n && one_edit n c) candidates
and resolve_name env ~seen loc n =
match Types.ikind_of_name n with
| Some k -> Types.Int k
@ -138,6 +169,12 @@ and resolve_name env ~seen loc n =
else resolve env ~seen:(n :: seen) (Hashtbl.find env.aliases n)
| _ when Hashtbl.mem env.structs n || Hashtbl.mem env.unions n ->
Types.Named n
(* A typo in a primitive is lowercase too, and the type-variable rule
below would otherwise report [f65] as unimplemented generics and send
you to plan.org instead of to the character you mistyped. *)
| _ when near_miss env n <> None ->
fail loc "unknown type %s — did you mean %s?" n
(Option.get (near_miss env n))
(* Lowercase is a type variable, Capitalized is concrete — no sigil
(plan.org, Types). A variable parses, but nothing at milestone 2 can
give a value one, so it is rejected here rather than later. *)

View File

@ -48,6 +48,12 @@ let ikind_of_name = function
let fkind_of_name = function
| "f32" -> Some F32 | "f64" -> Some F64 | _ -> None
(* Every name the resolver accepts as a primitive type. The list exists so a
near-miss can be reported as the typo it is. *)
let primitive_names =
[ "i8"; "i16"; "i32"; "i64"; "u8"; "u16"; "u32"; "u64";
"f32"; "f64"; "bool"; "string"; "Unit"; "Never" ]
let ikind_name k =
(if signed k then "i" else "u") ^ string_of_int (bits k)

View File

@ -403,6 +403,24 @@ let () =
rejects_check "if branches disagree"
"(defn f [] i32 (if true 1 true))" ~needle:"expected i32";
(* ── Unknown types ─────────────────────────────────────────────── *)
(* A lowercase name is a type variable (plan.org, Types), so a mistyped
primitive would otherwise be reported as unimplemented generics and send
you to plan.org instead of to the character you mistyped. *)
rejects_check "a mistyped primitive" "(defn f [x f65])"
~needle:"did you mean f64?";
rejects_check "a transposed primitive" "(defn f [x stirng])"
~needle:"did you mean string?";
rejects_check "a mistyped struct"
"(defstruct Cursor [x i32]) (defn f [c Curser])"
~needle:"did you mean Cursor?";
(* Nothing close: the type-variable rule still applies, and still names the
milestone. *)
rejects_check "a real type variable" "(defn f [x t])"
~needle:"milestone 5";
rejects_check "an unknown concrete type" "(defn f [x Widget])"
~needle:"unknown type Widget";
(* ── Static bounds ─────────────────────────────────────────────── *)
(* A literal index into a fixed array is known now, so it is an error now
rather than a trap later; everything else is the emitted bounds check's