A gensym is never the same name twice in one compiler process, however many macro modules it loads

This commit is contained in:
Joseph Ferano 2026-09-25 07:04:10 +07:00
parent 6b9d1fa644
commit 3a3674efb7
5 changed files with 62 additions and 9 deletions

View File

@ -380,12 +380,28 @@ let dir_of (l : loaded) (loc : Loc.t) =
always has a signature, and the one thing that could put a name in [fns]
without one is the two lists coming apart — in which case expanding
unchecked is the wrong half to lose. *)
(* The gensym counter, process-wide. Every module links its own runtime and
so its own [flan_gensym_n], and a build loads several — one per round when
a macro calls a macro, then the one the program is expanded with, and a
session loads one per expansion. A counter that restarted in each would
hand a later module the name an earlier one had already baked into a
macro's code. So the count lives here and is written into the module
before every call and read back after, whether the call returns or
raises. *)
let gensym_n = ref 0L
let with_gensym (l : loaded) f =
let cell = Dynload.dl_sym l.handle "flan_gensym_n" in
Dynload.poke_i64 cell 0 !gensym_n;
Fun.protect ~finally:(fun () -> gensym_n := Dynload.peek_i64 cell 0) f
let checked_call (l : loaded) n ~loc (args : Form.t list) : Form.t =
(match List.assoc_opt n l.sigs with
| Some sg -> Expand.check_call ~name:n ~loc sg args
| None -> ());
dir_of l loc;
Expand.call ~loc:(Loc.from_macro n loc) (List.assoc n l.fns) args
with_gensym l (fun () ->
Expand.call ~loc:(Loc.from_macro n loc) (List.assoc n l.fns) args)
let fuel = 200

View File

@ -2137,19 +2137,19 @@ let source = {flan|
;; explicit gensym is the settled decision (plan.org, open decision 2); this is
;; the escape hatch that makes it liveable.
;;
;; The counter lives in the loaded module rather than in the compiler, which is
;; the one place this departs from the sketch. A module is dlopened once
;; per compiler process and every macro in a program shares it, so the counter
;; is process-wide in practice; a second module would restart it, and the day
;; there is one, the fix is to seed this from the module's index.
(defonce gensym-n i64 0)
;; The counter is C data in the runtime, flan_gensym_n, because a build loads
;; more than one macro module — one per round when macros call macros, and
;; another for every expansion in a session — and each links its own copy of
;; the runtime. lib/macro.ml keeps the count across them: it writes it into
;; the module before every macro call and reads it back after, so no two
;; modules in one compiler process draw the same name.
(declare gensym-next [] i64 "flan_gensym_next")
(defn gensym [] Form
(set gensym-n (+ gensym-n 1))
(let [v (vec-new u8)]
(push v 126) ; ~
(push v 103) ; g
(let [d (i64->bytes gensym-n)]
(let [d (i64->bytes (gensym-next))]
(dotimes [i (length d)]
(push v (at d i))))
(Form.Sym {.s (string (slice v))})))

View File

@ -3193,6 +3193,14 @@ const uint8_t *flan_getenv(const uint8_t *name, int64_t n, int64_t *len) {
char flan_macro_dir[FLAN_PATH_MAX] = { 0 };
int64_t flan_macro_dir_n = 0;
/* The prelude's gensym counter. C data rather than a Flan global for the same
* reason as the two above: lib/macro.ml writes it into a module before every
* macro call and reads it back after, which is what keeps it counting across
* every module a compiler process loads rather than restarting in each. */
int64_t flan_gensym_n = 0;
int64_t flan_gensym_next(void) { return ++flan_gensym_n; }
/* The bytes are the caller's to read and nobody's to free: an expansion is
* bounded by the size of the program being compiled, which is exactly the
* budget lib/dynload.ml's `owned` note already spends on a macro's own

View File

@ -0,0 +1,24 @@
;;;; Two gensyms from two macro modules are never the same name.
;;;;
;;;; `outer` calls `baked` in its body, so `baked` is compiled in a round of
;;;; its own and `outer`'s body is expanded against that module before `outer`
;;;; is compiled. The gensym `baked` draws there becomes a literal in
;;;; `outer`'s code; the one `outer` draws itself comes from the later module
;;;; the program is expanded with. `main` comes first so that its expansion is
;;;; that module's first draw. Were the two the same name, the second binding
;;;; would shadow the first and this would print 200.
(defn main [] i32
(println (outer 1))
0)
;; Expands to code that builds the symbol this expansion drew.
(defmacro baked []
(match (gensym)
(Form.Sym s) `(Form.Sym {.s ~(Form.Str {.s s})})
_ `(form-nil)))
(defmacro outer [a]
(let [g (baked)
h (gensym)]
`(let [~g ~a ~h 100] (+ ~g ~h))))

View File

@ -3893,6 +3893,11 @@ level "1"
outputs ~dev:true "a macro's parameter list, dev"
"programs/macro-params.flan" macro_params_out;
(* A gensym drawn in one round's module and one drawn in the module the
program is expanded with are different names. 200 is the two colliding. *)
outputs "gensym counts across macro modules"
"programs/macro-gensym-rounds.flan" "101\n";
(* A macro declared in an imported *package*, which is the half the
refusal at [a package's macro is not visible unqualified] above leaves
out. The program calls six of them qualified and one of its own