A promise the compiler cannot check gets its own refusal, and a session expands its buffer's macros

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.
This commit is contained in:
Joseph Ferano 2026-09-13 20:33:47 +07:00
parent af3daaabe2
commit b9f5b5c44c
11 changed files with 382 additions and 40 deletions

104
BUILT.md
View File

@ -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.

24
NEXT.md
View File

@ -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.

View File

@ -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()

View File

@ -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

View File

@ -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.
;;

View File

@ -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 = "<eval>") ?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

View File

@ -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

View File

@ -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)

View File

@ -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;

View File

@ -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

View File

@ -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