From 542e5d5fbcfb16da7ae2cc201f026ee3230eb17e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Thu, 10 Sep 2026 18:07:50 +0700 Subject: [PATCH] Making progress --- NEXT.md | 91 +++++++++++++++++++++++++++++++++++++++++++++-- lib/check.ml | 37 +++++++++++++++++++ lib/types.ml | 6 ++++ test/test_flan.ml | 18 ++++++++++ 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/NEXT.md b/NEXT.md index 29b6544..0bfd181 100644 --- a/NEXT.md +++ b/NEXT.md @@ -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.11–0.12s checked against + 0.12–0.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 diff --git a/lib/check.ml b/lib/check.ml index 84ddfa0..f397518 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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. *) diff --git a/lib/types.ml b/lib/types.ml index cbb7f36..1f52dc9 100644 --- a/lib/types.ml +++ b/lib/types.ml @@ -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) diff --git a/test/test_flan.ml b/test/test_flan.ml index 6cfa3c2..a5dff4d 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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