From b9f5b5c44c42291799ae3a14e66b68847f52d092 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 13 Sep 2026 20:33:47 +0700 Subject: [PATCH] A promise the compiler cannot check gets its own refusal, and a session expands its buffer's macros MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loose ends from NEXT.md. slice-from-ptr's run-time refusal borrowed @flan_slice_error and reported a range and a length the caller never wrote. It has flan_slice_promise_error now: signals BoundsError, walks the handlers, offers the break loop, falls through to a message and a status like the two beside it. The sentence names what was promised and what was passed, and a second line says what is not checked. The condition fields stay (0, n, 0) — the violated condition as a range, and not (0, n, n), which reads as in bounds. And a session now holds the buffer's own defmacros: seeded in Session.create from the same read that produced decls, and added by Session.eval so a defmacro typed at the editor joins the set the way a defn does. Not a re-read of the file, which would put unsaved-versus-saved skew inside expansion. The commit stays below the checker. Macro.program dedupes the ambient set against the forms being parsed, left-wins, because unqualified names can now collide. --- BUILT.md | 104 ++++++++++++++++++++++++++++++++++++ NEXT.md | 24 +++++---- lib/emit.ml | 17 +++--- lib/macro.ml | 20 ++++++- lib/prelude.ml | 8 ++- lib/session.ml | 70 +++++++++++++++++++++--- runtime/flan_rt.c | 45 ++++++++++++++++ test/programs/printers.flan | 10 ++++ test/test_acceptance.ml | 17 ++++-- test/test_repl.ml | 21 ++++++++ test/test_session.ml | 86 ++++++++++++++++++++++++++--- 11 files changed, 382 insertions(+), 40 deletions(-) diff --git a/BUILT.md b/BUILT.md index 7f9b654..865f2f8 100644 --- a/BUILT.md +++ b/BUILT.md @@ -5387,3 +5387,107 @@ macros" now reads "no macros of its own", because `rl/with-drawing` is the bindi because nothing headless can. `rl-with-reject.flan` is the arity half: a macro cannot signal while it expands, so the prelude's idiom is to answer a symbol that is not a name and reads as the sentence the caller needs (`with-mode-2d-takes-a-camera-and-a-body`), with the expander's note beneath it naming the macro at the call site. + +## `slice-from-ptr` refuses in its own words, because its promise is the only context there is + +`(slice-from-ptr p n)` shipped with a run-time check that was right and a message that was not. The check is +`icmp sge i64 n, 0`, signed, and it has to be signed: `check_slice`'s comparisons are unsigned, and a negative +`i32` sign-extended to `i64` is a huge `u64` that passes both of its clauses. What came out of it was + +``` +slice [0 -2) is out of bounds for length 0 +``` + +— a range and a length the caller never wrote. The arithmetic behind the reuse was defensible: the condition +violated is `0 <= n`, which is a reversed range spelled the other way, which is why `@flan_slice_error` accepted +it. The sentence was not. It is about a container, and there is no container here. + +**This is the one form in the language where the compiler cannot check the thing that matters.** Everywhere else +the length belongs to the compiler — an array has one, a slice carries one, a `Vec` stores one. Here the caller is +the only thing that knows how many elements live behind that pointer, and writing `n` *is* the promise. So its +refusal is the place that promise has to be spelled out, and it was the one place it was not. + +`flan_slice_promise_error` in `runtime/flan_rt.c` now says it: + +``` +bounds.flan:34:28: slice-from-ptr was promised -2 elements behind the pointer, and a count of elements is never negative + the caller promises the pointer addresses n elements and nothing else can know it, so the sign of n is the whole of + what this check can see +``` + +Two lines, and the second one is deliberate: it says what is *not* checked, so that a caller does not read a trap +here as proof that the pointer itself was looked at. + +It is shaped like the two beside it and not like `exit`. It signals `BoundsError` through `flan_bounds_signal`, +which walks the handlers and offers the break loop, and only falls through to the message and the status when +nothing answered — the same "an index out of range is a condition" rule, so a `flan dev` session survives one. + +**The three condition fields are `(0, n, 0)`**, the violated condition written as a range, which is what those +fields can carry. Deliberately not `(0, n, n)`: that reads as a range in bounds, and a handler testing +`high <= length` would wave the failure through. One condition type still covers every bad index in the language, +so a handler writes one clause and not three. + +On the emit side it is one `signal_block` call and one `declare`; nothing about the lowering of the form changed, +and it stays behind `f.md.checks` for the reason the other two do — dropping bounds checks is a release decision, +not an optimisation one, so it is on at `-O0` and `-O2` alike. `test/programs/bounds.flan`'s `-2` arm asserts the +new sentence at both, and the `Emit.program ~checks:false` assertion names the new symbol alongside the old two. +`lib/x86.ml` needs nothing: it lowers `flan_slice_error` for `slice`, and it has no `SliceFromPtr` arm at all. + +## A session expands the buffer's own macros, and a `defmacro` joins it like a `defn` + +`(tenfold 7)` at `C-x C-e` was an unknown name, and `(defn x [] i32 (tenfold 7))` at `C-c C-c` was the same +unknown name, in the very file that declares `tenfold`. The cause is one line of `Macro.program`: it collects +macros by scanning the forms it is handed, and an evaluation hands it the one form that was sent. The prelude's +macros worked because they are ambient, and an imported package's worked because `Session` holds them — the +buffer's own were the set nobody held. + +**The design question was what "the buffer's own macros" means to a session, and the answer is the one already +written at the top of `lib/session.ml`: the declarations the program was built from, plus every change accepted +since.** Applied to macros, that is both halves of the fix and neither is optional: + +- **Seeded in `Session.create`** from the forms `Load.program` was handed. That is the same read that produced + `t.decls`, not a second one — so `tenfold` is there from the first evaluation, and no macro can arrive from a + version of the file the session was never told about. +- **Added in `Session.eval`**, so a `defmacro` typed at the editor joins the set and the *next* evaluation can + call it. That is exactly the shape `defn` already has, and a `defmacro` was already an ordinary declaration on + this path — it parses to a `Defn` and installs a body like any other. The only thing missing was the session + remembering that the name is a macro. + +**The session does not re-read the file, and that was the live alternative.** It has `~origin`, the buffer's own +path, and re-reading would pick up macros the session has never been told about. It would also read whatever is +*saved*, while the buffer on screen is whatever is *typed* — so an expansion would silently use a body the reader +is not looking at. Unsaved-versus-saved skew inside macro expansion is the quiet-wrongness class this codebase +refuses everywhere else, and it is worse here than elsewhere because a macro decides what the code *is*. + +The commit point is untouched. The new set is computed into a `ref` at the top and assigned at `t.macros <- ...` +with everything else, below the checker — so a form that does not check leaves the session exactly as it was, +macros included. `test_session` pins that with a `defmacro` whose body calls an unknown function: the evaluation +is refused, and a later call to the name is still an unknown name. + +Ordering is left-wins throughout, which is what `Load.macro_union` already encodes: the forms just sent, then what +`Load` just read off disk, then what the session was holding. That is what makes an *edited* macro expand with its +new body rather than with the stale copy. + +**One thing had to change below `Session`.** `Macro.program` merged the ambient set with the file's own and said +in a comment that an import's names carry a slash, so nothing ambient could collide with a name written here. That +stopped being true the moment a session held unqualified names — and it stopped being true on the most ordinary +action there is: `C-c C-c` over a `defmacro` the session already knows sends a form declaring a name the ambient +set also has. Two forms declaring one name reach `Check.program` as a duplicate declaration, refused with a +sentence nobody would connect to editing a macro. The merge dedupes now, on the same left-wins rule and at the one +point that joins the two sets. + +`Expand.quasiquote` is applied on the way in, and it is load-bearing rather than tidiness. `Parse.parse_forms` +desugars every form before the expander sees it and `Load.qualify_macro` desugars a package's macro on its way +out, but `Parse.imported_macros` is read by `Macro.program` directly, past that map. An undesugared body still has +its quasiquote in it, which makes a quasiquoted call look like a real one — the false ring the first cycle test +walked into. + +The names stay unqualified. A buffer writes its own macro's bare name, so that is the name the session has to +answer to; a file inside a package the program also imports ends up holding both, the bare one from here and +`alias/name` from `Load`, which is what the two call sites each need. + +Both editor paths work and they are different wraps — `Parse.expr`'s for `C-x C-e` and `Parse.decl`'s for +`C-c C-c`. `test/test_session.ml` covers both, plus a `defmacro` the file never had followed by a call to it, +plus editing that macro and calling it again; the assertions are on the IR and not on the absence of an +exception, because an expression that did not expand raises while one that expanded to the *wrong* thing does +not. diff --git a/NEXT.md b/NEXT.md index 93a6d58..6428080 100644 --- a/NEXT.md +++ b/NEXT.md @@ -8,7 +8,13 @@ and the author's words are that they feel like extra language features. `any` is tagged union (two words, a pointer and a typeid, no GC). `drop` is `spec-memory.md`'s hook for owning something that is not memory. Their old entries stand; do not schedule either. -## Queued: `slice-from-ptr`'s run-time refusal names a range the caller never wrote +## ~~Queued: `slice-from-ptr`'s run-time refusal names a range the caller never wrote~~ — **done** + +**Built.** `flan_slice_promise_error` is its own function in `runtime/flan_rt.c`, shaped like the two +beside it — it signals `BoundsError`, walks the handlers, offers the break loop, and only then falls +through to a message and a status. The sentence names the promise and the number that broke it, and a +second line says what is *not* checked. See [`BUILT.md`](BUILT.md), "`slice-from-ptr` refuses in its +own words". The original entry follows. Small, and known rough at the time it shipped. `(slice-from-ptr p n)` with a **computed** negative length is caught at run time — the check is `icmp sge i64 n, 0`, signed, because `check_slice`'s own @@ -171,15 +177,15 @@ the wrap `Parse.decl` already had, and the decision it was waiting on came out a expands to a declaration is **refused by name**, in `Parse.expr`'s head dispatch, so a `defn` nested anywhere in the expansion and a `defvar` typed by hand get the same sentence. The spin refusal fires on this path; the ring never can, because a ring is refused while its own package is parsed. What -expands is the prelude's macros and the imported packages' — not the file's own, which is a session -limit `C-c C-c` shares and which is now pinned rather than fixed. +expands is the prelude's macros and the imported packages' — and, since the lane recorded just below, +the file's own as well. -Two things left behind it, neither this lane's. **A session expands no macro the buffer itself -declares** — `Macro.program` scans the forms it is handed, and an evaluation hands it the one form -that was sent, so `(tenfold 7)` is an unknown name at `C-x C-e` and `(defn x [] i32 (tenfold 7))` is -one at `C-c C-c`. Fixing it means the session keeping its file's own `defmacro`s the way it keeps the -imports', which is a `Session` change and a decision about what a `defmacro` typed at the REPL does. -And **`Dev.eval_expr` catches only `Loc.Error`** around `Session.eval_expr`: the two non-termination +Two things left behind it, neither this lane's. ~~**A session expands no macro the buffer itself +declares**~~ — **done**, see [`BUILT.md`](BUILT.md), "A session expands the buffer's own macros". The +session seeds its file's own `defmacro`s in `Session.create`, from the same read that produced +`decls`, and an evaluated `defmacro` joins the set so the next evaluation can call it — the shape +`defn` already has. Re-reading the file was the alternative and was refused: it would put +unsaved-versus-saved skew inside macro expansion. The commit point stays below the checker. And **`Dev.eval_expr` catches only `Loc.Error`** around `Session.eval_expr`: the two non-termination refusals are that, so they answer as replies, but `C-x C-e` can now reach `Build.macro_module` for the first time, and a macro that fails to *compile* raises `Failure` from the clang driver or `Loc.Errors` from the checker. Neither is caught, and an unhandled exception there is a dead session. diff --git a/lib/emit.ml b/lib/emit.ml index 6d093da..9b47bb5 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -1856,10 +1856,15 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) = decision, not an optimisation one, so this is on at -O0 and -O2 alike and off only when checks as a whole were asked off. - It reuses @flan_slice_error rather than growing the runtime a function: - the violated condition is 0 <= n, which is the same shape as a reversed - slice, so the range reported is [0 n) against a length of 0 and the - message reads "slice [0 -5) is out of bounds for length 0". *) + It has its own runtime function, @flan_slice_promise_error, and that is + the whole of what it needed. It used to borrow @flan_slice_error — the + violated condition is 0 <= n, which is a reversed range spelled the other + way — and the sentence that came out named a range and a length the + caller never wrote. Since this is the one form whose real condition the + compiler cannot check, its refusal is the place the caller's promise has + to be stated, and it was the one place it was not. The signalled + BoundsError is unchanged: same three fields, so a handler writes one + clause for every bad index in the language. *) | Tast.SliceFromPtr, [ p; n ] -> let pv = value f p in let nv = value f n in @@ -1870,8 +1875,7 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) = ins f "%s = icmp sge i64 %s, 0" ok n64; signal_block f e.Tast.loc ~guard:(fun () -> guard f) ok (fun id len -> ins f - "call void @flan_slice_error(ptr %s, i64 %d, i64 0, i64 %s, i64 0, \ - ptr %s)" + "call void @flan_slice_promise_error(ptr %s, i64 %d, i64 %s, ptr %s)" id len n64 xfer_param) end; let a = fresh f in @@ -2430,6 +2434,7 @@ declare void @flan_transfer_fail(ptr, i64) noreturn cold ; it, which is the one path out. The trailing ptr is the transfer channel. declare void @flan_bounds_error(ptr, i64, i64, i64, ptr) cold declare void @flan_slice_error(ptr, i64, i64, i64, i64, ptr) cold +declare void @flan_slice_promise_error(ptr, i64, i64, ptr) cold declare ptr @flan_context_allocator() declare ptr @flan_context_temp() declare ptr @flan_heap_allocator() diff --git a/lib/macro.ml b/lib/macro.ml index 97c6b9f..ed4b43e 100644 --- a/lib/macro.ml +++ b/lib/macro.ml @@ -311,13 +311,29 @@ let program (forms : Form.t list) : Form.t list = (* An import's macros, and they go in beside [mine] rather than beside [prelude]: a package macro may call another macro of its own package, so it is exactly as much a candidate for the rounds below as one written - here. Their names carry a slash, so nothing they hold can collide with - [mine] or with the prelude's. *) + here. + + This used to say that an import's names carry a slash, so nothing + ambient could collide with a name written here. That stopped being true + when a session started holding the buffer's own [defmacro]s — see + [Session.eval] — and it stopped being true on the most ordinary action + there is: [C-c C-c] over a [defmacro] the session already knows sends a + form declaring a name the ambient set also has. Two forms declaring one + name reach [Check.program] as a duplicate declaration, refused with a + sentence nobody would connect to this. + + So the merge dedupes, and the direction is the one [Load.macro_union] + already uses a level up: a name declared in the forms being parsed + shadows the ambient copy. That is also what makes an *edited* macro + expand with its new body rather than with the session's stale one. *) let imported = List.filter_map (fun f -> Option.map (fun n -> (n, f)) (macro_name f)) !Parse.imported_macros in + let imported = + List.filter (fun (n, _) -> not (List.mem_assoc n mine)) imported + in let mine = imported @ mine in let all = prelude @ List.map fst mine in (* The common case by a wide margin, and the reason a build that uses no diff --git a/lib/prelude.ml b/lib/prelude.ml index d5f1ee6..b2212b6 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -51,8 +51,12 @@ let source = {flan| ;; allocator is known. ;; ;; It is signalled with `error`, from the runtime rather than from Flan: -;; flan_bounds_error and flan_slice_error in runtime/flan_rt.c, which every -;; bounds check now branches to. **The three fields there are a C struct that +;; flan_bounds_error, flan_slice_error and flan_slice_promise_error in +;; runtime/flan_rt.c — the last of those is (slice-from-ptr p n), whose +;; message is about the caller's promise because there is no container to +;; report, and which fills these fields with (0, n, 0): the condition it +;; violated, 0 <= n, written as a range. Between them they are where every +;; bounds check branches. **The three fields there are a C struct that ;; has to agree with this one field for field**, the same hand-kept agreement ;; flan_name_id keeps with Check.type_id. ;; diff --git a/lib/session.ml b/lib/session.ml index 364a40a..2516e17 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -35,10 +35,18 @@ type t = { mutable env : Check.env; (* the same, as the checker sees it *) host : Tast.program; (* what the process was built from *) pkgs : Load.pkg list; (* alias, directory, names owned *) - (* The defmacros the imports brought in, qualified. Held rather than - re-derived because C-c C-c parses one form with no import in sight, and a - macro that works on the first build and not on the reload is worse than - one that never existed. *) + (* Every [defmacro] this session can expand a call to: the imports', under + their aliases, and the buffer's own, under the names the buffer writes. + Held rather than re-derived because an evaluation parses one form with no + import and no [defmacro] in sight, and a macro that works on the first + build and not on the reload is worse than one that never existed. + + It is exactly this record's own rule applied to macros — what the program + was built from, plus every change accepted since. The file's own set is + seeded in [create] from the forms [Load] was handed, which is the same + read that produced [decls]; nothing here ever goes back to disk, so a + macro cannot arrive from a version of the file the session was never + told about. *) mutable macros : Form.t list; mutable thunks : int; (* expression evaluations so far *) (* Whether the modules this session emits carry DWARF. It belongs to the @@ -71,11 +79,40 @@ let rec same_const (a : Tast.expr) (b : Tast.expr) = && List.for_all2 same_const xs ys | _ -> false +(* The [defmacro]s among a set of top-level forms, as [t.macros] has to hold + them. + + [Expand.quasiquote] is not decoration. [Parse.parse_forms] desugars every + form it is handed before the expander sees it, and [Load.qualify_macro] + desugars a package's macro on its way out for the same reason — but + [Parse.imported_macros] is read by [Macro.program] directly, past that map. + An undesugared body still has its quasiquote in it, which makes a + quasiquoted call look like a real one: the false ring BUILT.md records the + first cycle test walking into. + + Unqualified, and that is the point: a buffer writes its own macro's bare + name, so that is the name the session has to answer to. A file inside a + package that the program also imports has both — the bare one from here and + [alias/name] from [Load] — which is what the two call sites each need. *) +let own_macros (forms : Form.t list) : Form.t list = + List.filter_map + (fun f -> + match f.Form.v with + | Form.List ({ Form.v = Form.Sym "defmacro"; _ } :: _) -> + Some (Expand.quasiquote f) + | _ -> None) + forms + let create ?(debug = false) ~file () = - let l = Load.program ~file (Reader.read_file file) in + let forms = Reader.read_file file in + let l = Load.program ~file forms in let p, env = Check.program_with_env l.Load.decls in ({ file; decls = l.Load.decls; program = p; env; host = p; pkgs = l.Load.pkgs; - macros = l.Load.macros; thunks = 0; debug }, l) + (* The file's own first, so that if the file being edited is itself a + package the program imports, the bare name wins for a form typed into + that buffer. [macro_union] keeps the left. *) + macros = Load.macro_union (own_macros forms) l.Load.macros; + thunks = 0; debug }, l) (* Which package a file being edited belongs to, if any. @@ -349,8 +386,25 @@ let eval ?(origin = "") ?pause t src : change = macros included. Nothing between here and there reads [t.macros]: [Load.program] puts the imported set in front of the parse it drives itself, and the checker below is handed declarations that are already - parsed. *) - macros := Load.macro_union l.Load.macros t.macros; + parsed. + + And the forms just sent come in front of both, which is the half that + makes a [defmacro] typed at the editor mean anything. A [defmacro] is + an ordinary declaration on this path already — it parses to a [Defn] + and installs a body like any other — so the only thing missing was the + session remembering that the name is a macro. It does now, and the + shape that follows is the one [defn] already has: the declaration + joins the session, and the *next* evaluation can call it. Not a re-read + of the file, which would also pick up macros the session was never told + about and would put unsaved-versus-saved skew into expansion. + + Left-wins again, and here it is what lets a macro be edited: the + incoming body replaces the one the session was holding under that + name. [Macro.program] dedupes the same way on the same rule, because + while this parse runs the old copy is still ambient. *) + macros := + Load.macro_union (own_macros forms) + (Load.macro_union l.Load.macros t.macros); let ds = l.Load.decls in match package_of t origin with | None -> ds diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index eeaa806..8406e70 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -540,6 +540,51 @@ void flan_slice_error(const uint8_t *loc, int64_t loclen, int64_t lo, flan_slice_fail(loc, loclen, lo, hi, len); } +/* ── (slice-from-ptr p n), which has its own refusal ─────────────────── + * + * It used to borrow flan_slice_error, and what came out named a range and a + * length the caller never wrote: "slice [0 -2) is out of bounds for length 0". + * The arithmetic behind that was defensible — the condition violated is + * 0 <= n, which is a reversed range spelled the other way — but the sentence + * was about a container, and there is no container here. + * + * This is the one form in the language where the compiler cannot check the + * thing that matters. Everywhere else the length is the compiler's: an array + * has one, a slice carries one, a Vec stores one. Here the caller is the only + * thing that knows how many elements live behind that pointer, and writing n + * *is* the promise. So this refusal is where that promise has to be spelled + * out, because it is the only context the reader has. + * + * What is checked is the one half that can be: that the promise is not absurd. + * A count of elements is not negative. The message says what is not checked + * too, so that a caller does not read a trap here as proof that the pointer + * was looked at. + * + * It signals BoundsError like the other two, with the same three int64s, so a + * handler writes one clause and not three. The triple is (0, n, 0): the + * violated condition written as a range, which is what those fields can carry. + * Deliberately not (0, n, n) — that reads as a range in bounds, and a handler + * testing high <= length would wave the failure through. */ +_Noreturn void flan_slice_promise_fail(const uint8_t *loc, int64_t loclen, + int64_t n) { + fflush(stdout); + fprintf(stderr, + "%.*s: slice-from-ptr was promised %lld elements behind the pointer, " + "and a count of elements is never negative\n", + (int)loclen, (const char *)loc, (long long)n); + fprintf(stderr, + " the caller promises the pointer addresses n elements and nothing " + "else can know it, so the sign of n is the whole of what this check " + "can see\n"); + rt_die(); +} + +void flan_slice_promise_error(const uint8_t *loc, int64_t loclen, int64_t n, + void *xfer) { + if (flan_bounds_signal(xfer, 0, n, 0)) return; + flan_slice_promise_fail(loc, loclen, n); +} + /* ── Allocators, spec-memory.md ──────────────────────────────────────── * * One type-erased procedure plus an opaque data pointer, which is Odin's diff --git a/test/programs/printers.flan b/test/programs/printers.flan index 817a62c..88aff65 100644 --- a/test/programs/printers.flan +++ b/test/programs/printers.flan @@ -23,6 +23,16 @@ ;;; against arithmetic the compiler could have done itself. (defconst step-by i64 3) +;;; Called by nothing here either, and for the same reason: it is the buffer's +;;; *own* macro, and what it is for is to be called from an expression typed at +;;; the editor. A session used to hold the prelude's macros and its imports' +;;; and not these, so (tenfold 7) was an unknown name in the very file that +;;; declares it. Nothing in this program names it, so no macro module is built +;;; for the build itself -- the first one is paid by the evaluation that calls +;;; it. +(defmacro tenfold [args] + `(* ~(at args 0) 10)) + (defn main [] i32 (agent/start "/tmp/flan-printers-fallback.sock") (set big 0xFFFFFFFFFFFFFFFF) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 112a177..a6a4c31 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -1201,11 +1201,17 @@ let () = length to compare n against — the caller's number *is* the length — so the only check possible is that it is not negative, and it is a signed one: the two above are unsigned, and a negative i32 sign-extended to - i64 passes both of them. The message is @flan_slice_error's, reused - rather than growing the runtime a function: the condition violated is - 0 <= n, which is a reversed range spelled the other way. *) + i64 passes both of them. + + Its own sentence, from its own runtime function. It used to borrow + @flan_slice_error and say "slice [0 -2) is out of bounds for length + 0", which named a range and a length the caller never wrote. This is + the one form whose real condition the compiler cannot check, so the + refusal is where the caller's promise gets stated. The asserted + substring stays inside one output line; the second line is the part + about what is *not* checked. *) traps "slice-from-ptr with a negative length" "-2" - "slice [0 -2) is out of bounds for length 0"; + "slice-from-ptr was promised -2 elements behind the pointer"; (try Sys.remove exe with Sys_error _ -> ()) in bounds (); @@ -1224,7 +1230,8 @@ let () = end; let off = Emit.program ~checks:false p in if contains off "call void @flan_bounds_error(" - || contains off "call void @flan_slice_error(" then begin + || contains off "call void @flan_slice_error(" + || contains off "call void @flan_slice_promise_error(" then begin incr failures; print_endline "FAIL --no-bounds-checks: a check survived" end; diff --git a/test/test_repl.ml b/test/test_repl.ml index 1a57366..0615a4e 100644 --- a/test/test_repl.ml +++ b/test/test_repl.ml @@ -160,6 +160,27 @@ let () = answers a value and one that answers unit. *) value "a prelude macro" "(clamp 9 0 3)" "3"; value "a prelude macro for its effect" "(unless false 1 2)" "()"; + (* And the buffer's own, which is the set nobody held. [Macro.program] + collects macros by scanning the forms it is handed and an evaluation + hands it one form, so [tenfold] — declared in printers.flan, a few + lines above [main] — was an unknown name in its own file while the + prelude's and an import's both worked. The session seeds it from the + same read that built the program. Over a real socket, because that is + the path a person is on; test_session has the in-process halves. *) + value "the file's own macro" "(tenfold 7)" "70"; + (* A macro the file never had, typed at the editor and then called. This + is the shape the fix chose: a [defmacro] evaluated into a session + *joins* it, exactly as a [defn] does, and the next evaluation can call + it. Two round trips, because that is the whole of the claim. *) + (let r = + request c + (Printf.sprintf "(:op \"eval\" :code %s :file \"/tmp/buf.flan\")" + (quote "(defmacro thrice [args] `(* ~(at args 0) 3))")) + in + if status r <> "ok" then + fail "evaluating a defmacro over the socket: %s" + (Option.value ~default:(status r) (field r "message"))); + value "a macro defined at the editor" "(thrice 14)" "42"; (* The one that proves it ran inside the process: the program increments [ticks] every frame, so two evaluations of it must disagree. A copy diff --git a/test/test_session.ml b/test/test_session.ml index f9aad25..0df37ad 100644 --- a/test/test_session.ml +++ b/test/test_session.ml @@ -394,15 +394,85 @@ let () = | exception Loc.Error { Loc.dmsg = m; _ } -> fail "C-u C-x C-e on a macro call: %s" m); - (* Not the file's own macro, and deliberately not: [Macro.program] collects - those by scanning the forms it is handed, and the forms handed to an - evaluation are the one thing that was sent. That limit is the session's - and not this path's — C-c C-c has always had it too, for the same reason — - so it is left where it is rather than half-fixed here. Pinned so that the - day it changes, it changes on purpose. *) + (* ── And the file's own macro, which used to be pinned as refused ────── + [Macro.program] collects macros by scanning the forms it is handed, and + the forms handed to an evaluation are the one thing that was sent — so + [tenfold], declared in the buffer being edited, was an unknown name at + both C-x C-e and C-c C-c while the prelude's and a package's both worked. + The session holds it now, seeded in [Session.create] from the same read + that produced [decls]. + + On the IR, not on the absence of an exception: an expression that did not + expand raises, but one that expanded to the wrong thing does not, and the + arithmetic is the only witness. [tenfold] multiplies its argument by + ten. *) (match Session.eval_expr ~origin:"programs/pkg-macro.flan" tm "(tenfold 7)" with - | _ -> fail "the file's own macro expanded in a session — a welcome change, \ - but BUILT.md says it does not" + | c -> + if not (has c.Session.ir "7, 10") then + fail "the file's own macro through C-x C-e did not expand to its body" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "the file's own macro through C-x C-e: %s" m); + (* The other path, which is a different expander wrap: [Parse.decl]'s. *) + (match Session.eval ~origin:"programs/pkg-macro.flan" tm + "(defn tenfolded [] i32 (tenfold 7))" + with + | c -> + if not (List.mem "tenfolded" c.Session.fns) then + fail "the file's own macro through C-c C-c reported %s" + (String.concat " " c.Session.fns); + if not (has c.Session.ir "7, 10") then + fail "the file's own macro through C-c C-c did not expand to its body" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "the file's own macro through C-c C-c: %s" m); + + (* A [defmacro] the file never had, typed at the editor. This is the shape + the fix chose — a macro evaluated into the session *joins* it, exactly as + a [defn] does, and the next evaluation can call it — and it is the only + case here that the create-time seed cannot explain. Two evaluations, + because that is what the claim is about. *) + (match Session.eval ~origin:"programs/pkg-macro.flan" tm + "(defmacro thrice [args] `(* ~(at args 0) 3))" + with + | _ -> () + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "evaluating a defmacro: %s" m); + (match Session.eval_expr ~origin:"programs/pkg-macro.flan" tm "(thrice 5)" with + | c -> + if not (has c.Session.ir "5, 3") then + fail "a defmacro evaluated into the session did not expand to its body" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "a defmacro evaluated into the session: %s" m); + (* And re-evaluating it over the top expands with the *new* body. Left-wins + in [Session.eval]'s union and in [Macro.program]'s merge, which is the + ordinary editing action and the one that would have reached + [Check.program] as a duplicate declaration without the second of those. *) + (match Session.eval ~origin:"programs/pkg-macro.flan" tm + "(defmacro thrice [args] `(* ~(at args 0) 4))" + with + | _ -> () + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "re-evaluating a defmacro: %s" m); + (match Session.eval_expr ~origin:"programs/pkg-macro.flan" tm "(thrice 5)" with + | c -> + if has c.Session.ir "5, 3" then + fail "an edited defmacro expanded with its old body" + else if not (has c.Session.ir "5, 4") then + fail "an edited defmacro did not expand to its new body" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "an edited defmacro: %s" m); + + (* The robustness lane's property, held for macros too: the commit is below + the checker, so a [defmacro] that does not check leaves the session + holding nothing of it. The body calls an unknown name, so the form parses + and fails at the checker — which is the only interesting place to fail, + because a parse failure never reaches the union either. *) + (match Session.eval ~origin:"programs/pkg-macro.flan" tm + "(defmacro nope [args] (no-such-function args))" + with + | _ -> fail "a defmacro whose body does not check was accepted" + | exception Loc.Error _ -> ()); + (match Session.eval_expr ~origin:"programs/pkg-macro.flan" tm "(nope 1)" with + | _ -> fail "a refused defmacro was left in the session's macro set" | exception Loc.Error _ -> ()); (* An expression that expands to a declaration. A macro may build one as a