exprforge 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/macros.js ADDED
@@ -0,0 +1,707 @@
1
+ // exprforge/macros.js
2
+ //
3
+ // Two independent ways to teach fn()/expr() bodies about a name beyond
4
+ // the fixed ~22 built-in Math primitives (see ast.js's call() node and
5
+ // every emitters/<lang>.js's own `calls` table) -- see GitHub issue #21
6
+ // for the full design discussion this shipped from.
7
+ //
8
+ // Vocabulary, deliberately not "intrinsic" for either tier: in real
9
+ // compilers an intrinsic is something the COMPILER already knows about,
10
+ // which is backwards for something a caller registers themselves.
11
+ //
12
+ // MACROS (loadMacro(name, def)): inline-expanded into the caller's AST
13
+ // at build time by expandMacros() below, NEVER emitted as a real call in
14
+ // any target -- "the emitter stays dumb, walks an AST, writes math" is
15
+ // load-bearing here, not just an implementation convenience: no call
16
+ // graph, no declaration-order/linking problem, no runtime coupling, and
17
+ // (since a macro can only ever reference macros ALREADY registered by
18
+ // the time IT'S registered -- see toMacro below and load-expr.js) no
19
+ // recursion either, structurally, not by a runtime guard. Safe by
20
+ // construction: a macro's result is built entirely from this library's
21
+ // own ast.js primitives, so it's exactly as trustworthy as anything else
22
+ // this library already emits.
23
+ //
24
+ // One classic macro trade-off DOES carry over, deliberately not hidden:
25
+ // expansion is pure substitution, so if a macro's body references one of
26
+ // its own parameters more than once, the caller's argument expression
27
+ // gets duplicated in the output everywhere that parameter appears -- not
28
+ // shared, not auto-let-bound. Same shape of trade-off `safeDiv`'s own
29
+ // doc comment (math/index.js) already warns about for its
30
+ // twice-referenced `denominatorExpr`; general to every macro now, not
31
+ // one helper. Pass an already-let-bound v(name) as the argument instead
32
+ // of a raw expensive expression if that duplication matters to you.
33
+ //
34
+ // EXTERNS (loadExtern(name, def)): a real, per-target native call --
35
+ // same mechanism as the built-in primitives, just supplied by the caller
36
+ // instead of shipped here. ExprForge can't verify the named symbol
37
+ // actually exists in a given target, or that it behaves identically
38
+ // across every target you provide a mapping for -- that's entirely on
39
+ // the caller, the same way linking an unfamiliar library is in any other
40
+ // compiled language. Prefer a macro whenever the math itself CAN be
41
+ // written in ExprForge; reach for extern only for something that
42
+ // genuinely can't be (a call into an existing native library, for
43
+ // instance).
44
+ const { v, letIn, call, collectLets, MACRO_GENSYM_PREFIX } = require("./ast.js");
45
+ const { PRIMITIVE_ARITY } = require("./primitives.js");
46
+
47
+ const PRIMITIVE_NAMES = new Set(Object.keys(PRIMITIVE_ARITY));
48
+
49
+ // A registry is just the pair of Maps loadMacro()/loadExtern() actually
50
+ // mutate -- { macros, externs }, both name -> entry, same shapes as
51
+ // before this existed. Every session-aware function below (loadMacro,
52
+ // loadExtern, expandMacros, resolveExternForEvaluate,
53
+ // resolveExternForEmitter, and (via index.js) evaluate/emit/emitMany/
54
+ // loadExpr/loadExprSource) takes one as an optional trailing argument,
55
+ // defaulting to `defaultRegistry` -- the same module-level Maps this
56
+ // file has always used, so every EXISTING call site (every test, every
57
+ // sample, math/index.js's own top-level registrations, the playground)
58
+ // keeps working completely unchanged. createSession() (see index.js) is
59
+ // what actually creates and threads a NON-default one through: a fresh,
60
+ // independently-namespaced registry that never touches `defaultRegistry`
61
+ // at all, garbage-collected normally once you drop the session, with no
62
+ // removal API needed for that -- see the README's "Sessions" section.
63
+ function createRegistry() {
64
+ return {
65
+ macros: new Map(), // name -> { arity: number|null, fn, alreadyExpanded: boolean }
66
+ externs: new Map(), // name -> { evaluate?: (...args:number[])=>number, [lang]: (argStrs:string[])=>string }
67
+ };
68
+ }
69
+
70
+ const defaultRegistry = createRegistry();
71
+
72
+ // Re-runs `fn`, and if it throws, re-throws with `context` prefixed onto
73
+ // the message -- a macro function, or an extern's own `evaluate`/
74
+ // per-target template, throwing (a bug in the CALLER's own
75
+ // implementation, not exprforge's) otherwise propagates with zero
76
+ // indication of which registered macro/extern/target was actually
77
+ // responsible, which gets genuinely painful to trace back once more than
78
+ // one or two of these exist in a real codebase. The original Error is
79
+ // preserved as `.cause`, not discarded -- nothing informative is lost,
80
+ // just given a clearer heading. Shared here (not duplicated per call
81
+ // site) since evaluate.js and emitters/base.js both already require this
82
+ // file for resolveExternForEvaluate/resolveExternForEmitter.
83
+ function withContext(context, fn) {
84
+ try {
85
+ return fn();
86
+ } catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ throw new Error(`${context}: ${message}`, { cause: err });
89
+ }
90
+ }
91
+
92
+ // A Node always has a string `.type` (see ast.js's own node-shape
93
+ // comment); a macro's multi-output result is a plain {fieldName: Node}
94
+ // object with no `.type` of its own -- same duck-typing convention
95
+ // expr.js's holeToNode already uses to tell a Node apart from a plain
96
+ // interpolated value.
97
+ function isNode(x) {
98
+ return !!x && typeof x === "object" && typeof x.type === "string";
99
+ }
100
+
101
+ function isFnDefShape(def) {
102
+ return !!def && typeof def === "object" && typeof def.name === "string" &&
103
+ Array.isArray(def.params) && isNode(def.body);
104
+ }
105
+
106
+ // A macro's multi-output result, normalized to one shape regardless of
107
+ // which tier produced it: `letPrefix` is a (possibly empty) ORDERED list
108
+ // of {name, node} bindings that must be spliced in ONCE, shared, ahead
109
+ // of every field -- not per-field -- and `fields` is {name: Node}, each
110
+ // value typically just a bare var() reference into `letPrefix` (see
111
+ // toMacro below for why an AST-fn-def-shaped macro needs a non-empty
112
+ // letPrefix at all: cross3-in-fn-DSL-text returns `{ rx, ry, rz }` from
113
+ // a body that computes rx/ry/rz via its OWN "let" statements first, not
114
+ // as bare inline expressions). A plain-JS-function macro
115
+ // (dot3/cross3/normalize3/... in math/index.js) has no such separate
116
+ // prefix -- it returns a bare {field: Node} object directly, normalized
117
+ // here to letPrefix: [].
118
+ function makeMultiOutput(fields, letPrefix = []) {
119
+ return { __exprforgeMultiOutput: true, letPrefix, fields };
120
+ }
121
+
122
+ function isMultiOutputResult(x) {
123
+ return !!x && typeof x === "object" && x.__exprforgeMultiOutput === true;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------
127
+ // Registration
128
+ // ---------------------------------------------------------------------
129
+
130
+ /**
131
+ * Register a macro: a name usable inside fn()/expr() template text (and
132
+ * in .expr files loaded via loadExpr()), inline-expanded at build time --
133
+ * see this file's own header comment for what that guarantees (and the
134
+ * one classic trade-off -- argument duplication -- that comes with it).
135
+ *
136
+ * `def` is either:
137
+ * - a plain JS function `(...argNodes) => Node | Record<string, Node>`
138
+ * -- exprforge/math's own dot3/len3/cross3/normalize3/safeDiv are
139
+ * registered exactly this way (see math/index.js) -- their existing
140
+ * signatures already match.
141
+ * - an AST function definition `{ name, params, body }` (e.g. straight
142
+ * out of fn`...`) -- sugar for wrapping it through toMacro() below.
143
+ *
144
+ * Throws if `name` collides with a built-in primitive or an
145
+ * already-registered macro/extern -- names are a single shared
146
+ * namespace, so a silent shadow never happens.
147
+ *
148
+ * Registers into this module's own default, process-wide registry
149
+ * unless called as `session.loadMacro(...)` (see index.js's
150
+ * createSession()), in which case it registers into that session's own,
151
+ * independently-namespaced one instead -- see createRegistry() above.
152
+ */
153
+ function loadMacro(name, def, registry = defaultRegistry) {
154
+ assertNameAvailable("loadMacro", name, registry);
155
+ if (typeof def === "function") {
156
+ // def.length -- JS's own count of parameters before the first
157
+ // one with a default value (or a rest parameter) -- is treated
158
+ // as a MINIMUM, not an exact count: math/index.js's normalize3
159
+ // is registered exactly this way and has three trailing default
160
+ // parameters (fx/fy/fz), so normalize3.length is 3 even though
161
+ // it's valid to call with 3-6 arguments. Anything at or above
162
+ // this floor is JS's own call already; there's no upper bound to
163
+ // enforce beyond what JS itself already does with extra
164
+ // arguments (silently ignored, same as calling any other JS
165
+ // function with too many).
166
+ registry.macros.set(name, { arity: { min: def.length, max: null }, fn: def, alreadyExpanded: false });
167
+ return;
168
+ }
169
+ if (isFnDefShape(def)) {
170
+ registry.macros.set(name, toMacro(def, null, registry));
171
+ return;
172
+ }
173
+ throw new Error(
174
+ `loadMacro: "def" for "${name}" must be a function or an {name, params, body} AST function ` +
175
+ `definition -- for a real per-target native call instead, use loadExtern()`,
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Register an extern: a name usable the same way a macro is, but that
181
+ * resolves to a real per-target native call instead of being expanded --
182
+ * see this file's own header comment for the (caller-owned) risk that
183
+ * comes with it.
184
+ *
185
+ * `def` is a plain mapping object, e.g.
186
+ * `{ evaluate: (x) => ..., js: ([x]) => `myLib.f(${x})`, zig: ([x]) => ... }`,
187
+ * plus an optional `arity` (a non-negative integer): unlike a macro,
188
+ * there's no JS function signature to read a parameter count off of here
189
+ * -- every per-target entry receives a single `argStrs` array, not N
190
+ * positional Nodes, and `evaluate` isn't guaranteed to be present at all
191
+ * -- so arity has to be stated explicitly if you want it checked.
192
+ * Omitting it keeps today's behavior: no arg-count validation at all,
193
+ * same as an unmapped call name never getting one either.
194
+ *
195
+ * Only the targets you provide a key for resolve; every other target
196
+ * still throws "no mapping" for this name, same as an unmapped built-in
197
+ * primitive would.
198
+ *
199
+ * Throws if `name` collides with a built-in primitive or an
200
+ * already-registered macro/extern.
201
+ */
202
+ function loadExtern(name, def, registry = defaultRegistry) {
203
+ assertNameAvailable("loadExtern", name, registry);
204
+ if (!def || typeof def !== "object") {
205
+ throw new Error(
206
+ `loadExtern: "def" for "${name}" must be a plain per-target mapping object, e.g. ` +
207
+ `{ evaluate: (x) => ..., js: ([x]) => \`myLib.f(\${x})\`, ... }`,
208
+ );
209
+ }
210
+ if (def.arity !== undefined && (!Number.isInteger(def.arity) || def.arity < 0)) {
211
+ throw new Error(`loadExtern: "arity" for "${name}", if given, must be a non-negative integer`);
212
+ }
213
+ registry.externs.set(name, def);
214
+ }
215
+
216
+ function assertNameAvailable(fnName, name, registry) {
217
+ if (typeof name !== "string" || !name) {
218
+ throw new Error(`${fnName}: name must be a non-empty string`);
219
+ }
220
+ if (PRIMITIVE_NAMES.has(name)) {
221
+ throw new Error(`${fnName}: "${name}" is already one of the built-in primitives -- choose a different name`);
222
+ }
223
+ if (registry.macros.has(name) || registry.externs.has(name)) {
224
+ throw new Error(`${fnName}: "${name}" is already registered -- names must be unique across macros and externs`);
225
+ }
226
+ }
227
+
228
+ function resolveExternForEvaluate(name, registry = defaultRegistry) {
229
+ const entry = registry.externs.get(name);
230
+ return entry && typeof entry.evaluate === "function" ? entry.evaluate : undefined;
231
+ }
232
+
233
+ function resolveExternForEmitter(name, lang, registry = defaultRegistry) {
234
+ const entry = registry.externs.get(name);
235
+ return entry && typeof entry[lang] === "function" ? entry[lang] : undefined;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------
239
+ // Turning an AST function definition into a macro -- used by loadMacro()
240
+ // above for a {name, params, body} def, and by load-expr.js for every
241
+ // function defined in a .expr file (each becomes available as an inline
242
+ // macro to whatever's defined AFTER it in the same file -- see that
243
+ // file's own header comment).
244
+ // ---------------------------------------------------------------------
245
+
246
+ let gensymCounter = 0;
247
+
248
+ // Deep-substitutes every var(paramName) reference in `node` with the
249
+ // corresponding actual argument Node from `subst`, and alpha-renames
250
+ // every "let" this fn-def introduces to a fresh gensym'd name -- so
251
+ // invoking the SAME macro more than once in one consuming function (or
252
+ // nesting one inside another) never collides, either with each other or
253
+ // with the consuming function's own let names. Mirrors ast.js's
254
+ // collectLets/collectVarRefs's own node-type walk, and
255
+ // emitters/cobol.js's renameVarRefs -- same tree shape, same reason to
256
+ // walk it exhaustively.
257
+ function substituteAndRename(node, subst, renames) {
258
+ switch (node.type) {
259
+ case "num":
260
+ return node;
261
+ case "var": {
262
+ if (node.name in subst) return subst[node.name];
263
+ if (node.name in renames) return v(renames[node.name]);
264
+ return node;
265
+ }
266
+ case "bin":
267
+ return { ...node, left: substituteAndRename(node.left, subst, renames), right: substituteAndRename(node.right, subst, renames) };
268
+ case "call":
269
+ return { ...node, args: node.args.map((a) => substituteAndRename(a, subst, renames)) };
270
+ case "cmp":
271
+ return { ...node, left: substituteAndRename(node.left, subst, renames), right: substituteAndRename(node.right, subst, renames) };
272
+ case "select":
273
+ return {
274
+ ...node,
275
+ cond: substituteAndRename(node.cond, subst, renames),
276
+ then: substituteAndRename(node.then, subst, renames),
277
+ else: substituteAndRename(node.else, subst, renames),
278
+ };
279
+ case "let": {
280
+ // MACRO_GENSYM_PREFIX ("efMacro_", defined in ast.js -- see
281
+ // its own comment there for why it lives there and not here)
282
+ // starts with a letter, not "_" -- confirmed against a real
283
+ // Fortran compiler ("Invalid character in name") that a
284
+ // leading underscore isn't a valid identifier start there,
285
+ // same finding math/index.js's normalize3 already documents
286
+ // (and follows) for its own gensym'd binding name; this one
287
+ // missed it on the first pass, caught by actually emitting a
288
+ // macro-with-an-internal-let to Fortran and inspecting the
289
+ // declared name, not just running evaluate() against it (see
290
+ // test/macros.test.js).
291
+ const fresh = `${MACRO_GENSYM_PREFIX}${node.name}_${gensymCounter++}`;
292
+ const value = substituteAndRename(node.value, subst, renames);
293
+ const body = substituteAndRename(node.body, subst, { ...renames, [node.name]: fresh });
294
+ return letIn(fresh, value, body);
295
+ }
296
+ case "outputs": {
297
+ const fields = {};
298
+ for (const [name, fieldValue] of Object.entries(node.fields)) {
299
+ fields[name] = substituteAndRename(fieldValue, subst, renames);
300
+ }
301
+ return { ...node, fields };
302
+ }
303
+ default:
304
+ throw new Error(
305
+ `macros: internal error -- a "${node.type}" node reached substitution; expandMacros() should ` +
306
+ `have already resolved it (macro calls/field access) before this ever runs`,
307
+ );
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Wraps an AST function definition (e.g. straight out of fn`...`) as a
313
+ * macro: `(...argNodes) => Node | Record<string, Node>`, substituting
314
+ * `fnDef.params` with the actual argument Nodes and alpha-renaming every
315
+ * one of `fnDef`'s own let-bindings fresh on each invocation (see
316
+ * substituteAndRename above).
317
+ *
318
+ * `fnDef.body` is expanded against `extraRegistry` FIRST, once, here at
319
+ * registration time -- not deferred to each invocation -- which is what
320
+ * makes recursion structurally impossible rather than merely
321
+ * discouraged: fnDef isn't resolvable through `extraRegistry` (or the
322
+ * global macro registry) yet while its own body is being expanded
323
+ * (load-expr.js only adds it AFTER this returns), so it can never
324
+ * reference itself, directly or transitively through another
325
+ * not-yet-defined macro.
326
+ *
327
+ * `alreadyExpanded: true` on the returned entry matters beyond that:
328
+ * whatever's still an unresolved "call" node in the pre-expanded body at
329
+ * this point (a genuine typo, a not-yet-registered forward/self
330
+ * reference) is meant to STAY unresolved forever, even after that name
331
+ * eventually gets registered -- expandMacros() must never re-examine an
332
+ * already-expanded macro's own output against the (by-then-different)
333
+ * live registry, or a forward/self reference could silently start
334
+ * "working" once its target happened to get registered, which is
335
+ * exactly the declared-order guarantee this whole design depends on. A
336
+ * plain-JS-function macro (loadMacro(name, someFunction)) has no such
337
+ * pre-expansion step -- it runs fresh on every call and its result CAN
338
+ * legitimately reference other macros, so `alreadyExpanded: false` there
339
+ * means expandMacros() still walks its result once.
340
+ */
341
+ function toMacro(fnDef, extraRegistry = null, registry = defaultRegistry) {
342
+ const resolvedBody = expandBody(fnDef.body, { extraRegistry, aliases: new Map(), registry });
343
+ return {
344
+ // Exact, unlike a plain-JS macro's min-only arity below -- fn.js's
345
+ // own grammar has no default-parameter syntax, so every AST
346
+ // fn-def's param count is unambiguous.
347
+ arity: { min: fnDef.params.length, max: fnDef.params.length },
348
+ alreadyExpanded: true,
349
+ fn: (...argNodes) => {
350
+ const subst = {};
351
+ fnDef.params.forEach((p, i) => {
352
+ subst[p] = argNodes[i];
353
+ });
354
+ const renamed = substituteAndRename(resolvedBody, subst, {});
355
+ // collectLets separates ANY leading let-chain (0, 1, or many
356
+ // levels -- fn`...`'s own grammar always produces a
357
+ // let-chain wrapping either a plain expression or an
358
+ // outputs() -- see fn.js) from what it ultimately returns.
359
+ // Every let name in `bindings` was already gensym'd uniquely
360
+ // for THIS call by substituteAndRename above, so it's always
361
+ // safe to splice `bindings` back in verbatim, however this
362
+ // result ends up being used.
363
+ const { bindings, body } = collectLets(renamed);
364
+ if (body.type !== "outputs") return renamed; // single value, unchanged
365
+ // Multi-output: `bindings` becomes the shared letPrefix
366
+ // (e.g. cross3-in-fn-DSL-text's own "let rx = ...; let ry =
367
+ // ...; let rz = ...;"), and each outputs() field -- almost
368
+ // always just a bare var() reference into `bindings` -- is
369
+ // exactly the value expandBody needs per field. See
370
+ // makeMultiOutput's own comment for why this split matters.
371
+ return makeMultiOutput(body.fields, bindings);
372
+ },
373
+ };
374
+ }
375
+
376
+ // ---------------------------------------------------------------------
377
+ // Expansion -- eliminates every macro "call" and "field" node from a
378
+ // tree, leaving only built-in primitive calls, extern calls (both left
379
+ // as plain "call" nodes -- resolved later, by evaluate()/an emitter's
380
+ // own `calls` table), and ast.js's other ordinary node types.
381
+ // ---------------------------------------------------------------------
382
+
383
+ function lookupMacro(name, ctx) {
384
+ return (ctx.extraRegistry && ctx.extraRegistry.get(name)) || ctx.registry.macros.get(name);
385
+ }
386
+
387
+ // Shared by macro calls (`arity` always set -- see loadMacro/toMacro
388
+ // above) and extern calls (`arity` only set when the caller opted in via
389
+ // loadExtern's own optional `arity` field) -- `arity` of `null`/
390
+ // `undefined` means "not checked here", not "zero arguments".
391
+ // `max: null` means "no upper bound" (a plain-JS macro's own floor, via
392
+ // def.length -- see loadMacro).
393
+ function checkArity(name, arity, argCount) {
394
+ if (!arity) return;
395
+ const { min, max } = arity;
396
+ if (argCount < min || (max !== null && argCount > max)) {
397
+ const expected = max === null ? `at least ${min}` : min === max ? `${min}` : `${min}-${max}`;
398
+ throw new Error(`expandMacros: "${name}" expects ${expected} argument(s), got ${argCount}`);
399
+ }
400
+ }
401
+
402
+ // Extern arity is opt-in (loadExtern's own `arity` field) and checked
403
+ // here -- the one place every "call" node, macro or not, already passes
404
+ // through during expansion -- rather than deferred to evaluate()/an
405
+ // emitter, which would report it (if at all) as a confusing runtime
406
+ // crash inside whatever an extern's own per-target template does with a
407
+ // missing/extra argString, not a clear arg-count error.
408
+ function checkExternArity(name, argCount, registry) {
409
+ const entry = registry.externs.get(name);
410
+ if (!entry || entry.arity === undefined) return;
411
+ checkArity(name, { min: entry.arity, max: entry.arity }, argCount);
412
+ }
413
+
414
+ // Unlike extern arity (opt-in) and macro arity (declared per-registration),
415
+ // every built-in primitive's arity is fixed and already known (see
416
+ // primitives.js) -- checked unconditionally here, the same tier as
417
+ // checkUnboundVars: a real correctness bug, not a style preference (a
418
+ // wrong arg count used to silently emit e.g. "Math.sqrt(a)" for
419
+ // call("sqrt", a, b, c) -- args b/c just silently dropped, not caught
420
+ // anywhere, in any target, until this existed). Deliberately checked
421
+ // here, not duplicated separately in evaluate.js/emitters/base.js: every
422
+ // real entry point (evaluate(), every emitter's emitFunction() --
423
+ // including the "expr" printer, which stays lenient about UNMAPPED
424
+ // names but was never meant to accept a structurally malformed call
425
+ // either) already runs expandMacros() first, so one check here covers
426
+ // all of them.
427
+ function checkPrimitiveArity(name, argCount) {
428
+ if (!(name in PRIMITIVE_ARITY)) return;
429
+ checkArity(name, { min: PRIMITIVE_ARITY[name], max: PRIMITIVE_ARITY[name] }, argCount);
430
+ }
431
+
432
+ // Resolves ONE call node against the macro registry, or returns
433
+ // undefined if `callNode.name` isn't a macro at all (a built-in
434
+ // primitive, an extern, or simply unmapped -- none of those are this
435
+ // function's concern; the caller leaves the node as-is).
436
+ function tryResolveMacroCall(callNode, ctx) {
437
+ const entry = lookupMacro(callNode.name, ctx);
438
+ if (!entry) return undefined;
439
+
440
+ // An AST-fn-def-shaped macro's own self/forward references are
441
+ // already ruled out structurally, by registration ordering (see
442
+ // toMacro's own comment) -- but a plain-JS-function macro's RESULT
443
+ // gets re-walked below (a fresh call every time, so it CAN
444
+ // legitimately reference some other, unrelated macro), and if that
445
+ // result calls right back into THIS SAME name -- directly, or
446
+ // through a cycle of several plain-function macros -- that walk
447
+ // would recurse without ever making progress. `ctx.expanding` tracks
448
+ // "names currently being resolved on the path from here to the
449
+ // result I'm walking" -- set only around that one re-walk below, so
450
+ // using the SAME macro twice in separate, independent positions
451
+ // (e.g. sqrt(x) + foo(a) + foo(b)) is unaffected; only a name
452
+ // reappearing inside its OWN just-computed result trips this.
453
+ // Deliberately unconditional, even for a result that would
454
+ // eventually reach a real base case if it were allowed to keep
455
+ // going (JS itself could express that) -- this library's own "no
456
+ // recursion" guarantee (see the README) doesn't carve out an
457
+ // exception for the convergent case, so neither does this.
458
+ const expanding = ctx.expanding || new Set();
459
+ if (expanding.has(callNode.name)) {
460
+ throw new Error(
461
+ `expandMacros: "${callNode.name}" can't call itself, directly or through a cycle -- macros are ` +
462
+ `inline-expanded, not real function calls, so a self/cyclic reference would have to expand forever`,
463
+ );
464
+ }
465
+
466
+ const expandedArgs = callNode.args.map((a) => expandExpr(a, ctx));
467
+ checkArity(callNode.name, entry.arity, expandedArgs.length);
468
+
469
+ const result = withContext(`expandMacros: while expanding macro "${callNode.name}"`, () => entry.fn(...expandedArgs));
470
+
471
+ // An AST-fn-def-shaped macro (toMacro, above) is already fully
472
+ // resolved once, at registration time -- its result must be used
473
+ // VERBATIM, never re-walked against the (by-now-different) live
474
+ // registry. See toMacro's own comment on `alreadyExpanded` for why
475
+ // that specifically matters, not just as an optimization.
476
+ if (entry.alreadyExpanded) {
477
+ if (isNode(result)) return result;
478
+ if (isMultiOutputResult(result)) return result;
479
+ throw new Error(`expandMacros: "${callNode.name}" must return an AST Node or a plain object of named Nodes, got ${typeof result}`);
480
+ }
481
+
482
+ const expandingCtx = { ...ctx, expanding: new Set(expanding).add(callNode.name) };
483
+ if (isNode(result)) return expandExpr(result, expandingCtx);
484
+ // A plain-JS-function macro (dot3/cross3/normalize3/... in
485
+ // math/index.js, or a caller's own) returns a BARE {field: Node}
486
+ // object -- normalized here to makeMultiOutput's shape with an
487
+ // empty letPrefix (nothing shared to splice ahead of the fields;
488
+ // any internal let a field needs, it carries in its own subtree --
489
+ // see normalize3's own comment on why that's still safe).
490
+ const multi = isMultiOutputResult(result) ? result : (result && typeof result === "object" ? makeMultiOutput(result) : null);
491
+ if (multi) {
492
+ const expandedFields = {};
493
+ for (const [field, fieldNode] of Object.entries(multi.fields)) {
494
+ if (!isNode(fieldNode)) {
495
+ throw new Error(`expandMacros: "${callNode.name}"'s "${field}" field must be an AST Node, got ${typeof fieldNode}`);
496
+ }
497
+ expandedFields[field] = expandExpr(fieldNode, expandingCtx);
498
+ }
499
+ const expandedPrefix = multi.letPrefix.map(({ name, node }) => ({ name, node: expandExpr(node, expandingCtx) }));
500
+ return makeMultiOutput(expandedFields, expandedPrefix);
501
+ }
502
+ throw new Error(`expandMacros: "${callNode.name}" must return an AST Node or a plain object of named Nodes, got ${typeof result}`);
503
+ }
504
+
505
+ // Expands one expression-position Node: resolves any macro call anywhere
506
+ // in it (recursively) and rewrites "field" access into a plain variable
507
+ // reference, using `ctx.aliases` (populated by expandBody below as it
508
+ // walks past each multi-output "let"). A macro call that resolves to a
509
+ // multi-output record is only valid as a "let"'s direct value (see
510
+ // expandBody) -- reaching one here, nested inside a larger expression,
511
+ // is a clear user error, not resolved silently.
512
+ function expandExpr(node, ctx) {
513
+ switch (node.type) {
514
+ case "num":
515
+ return node;
516
+ case "var": {
517
+ // A bare reference to a name that's ONLY ever a multi-output
518
+ // alias prefix (see expandBody's own "let" case) is a
519
+ // near-certain mistake: that name was never actually bound to
520
+ // a value, only used to build its fields' flat names (e.g.
521
+ // "z" in "let z = someMultiOutputMacro(...); return z;" --
522
+ // only "z__x"/"z__y" etc. really exist). Left unchecked, this
523
+ // survives as an ordinary-looking "var" node and only fails
524
+ // later, at checkUnboundVars, with a message that can't know
525
+ // WHY the name isn't declared ("never declared" reads as "you
526
+ // forgot the let", when you very much did write one) --
527
+ // caught here instead, with the actual reason and the fields
528
+ // that ARE available.
529
+ const aliasMap = ctx.aliases.get(node.name);
530
+ if (aliasMap) {
531
+ throw new Error(
532
+ `expandMacros: "${node.name}" is bound to a multi-output macro result (fields: ` +
533
+ `${Object.keys(aliasMap).join(", ")}) -- reference a field directly (e.g. ` +
534
+ `"${node.name}.${Object.keys(aliasMap)[0]}"), not the bare name`,
535
+ );
536
+ }
537
+ return node;
538
+ }
539
+ case "field": {
540
+ if (node.target.type !== "var") {
541
+ throw new Error(
542
+ `expandMacros: "." field access is only supported directly on a variable bound to a ` +
543
+ `multi-output macro result (e.g. "b.rx"), not on a "${node.target.type}"`,
544
+ );
545
+ }
546
+ const aliasMap = ctx.aliases.get(node.target.name);
547
+ if (!aliasMap || !(node.field in aliasMap)) {
548
+ const known = aliasMap ? ` (has: ${Object.keys(aliasMap).join(", ")})` : "";
549
+ throw new Error(
550
+ `expandMacros: "${node.target.name}.${node.field}" -- "${node.target.name}" isn't bound ` +
551
+ `to a multi-output macro result with a "${node.field}" field${known}`,
552
+ );
553
+ }
554
+ return v(aliasMap[node.field]);
555
+ }
556
+ case "bin":
557
+ return { ...node, left: expandExpr(node.left, ctx), right: expandExpr(node.right, ctx) };
558
+ case "cmp":
559
+ return { ...node, left: expandExpr(node.left, ctx), right: expandExpr(node.right, ctx) };
560
+ case "select":
561
+ return {
562
+ ...node,
563
+ cond: expandExpr(node.cond, ctx),
564
+ then: expandExpr(node.then, ctx),
565
+ else: expandExpr(node.else, ctx),
566
+ };
567
+ case "let":
568
+ // A "let" nested inside an expression position (spliced in
569
+ // by a macro's own return value, e.g. normalize3's internal
570
+ // length binding -- never authored directly by fn/expr text,
571
+ // which only ever has "let" at statement position, see
572
+ // expandBody) -- its value can't itself be ANOTHER
573
+ // multi-output macro call (nothing in this codebase's own
574
+ // macros does that, and there's no field-access syntax to
575
+ // destructure it if it did), so this simpler branch is
576
+ // sufficient; expandBody is the one that needs the
577
+ // multi-output special case.
578
+ return letIn(node.name, expandExpr(node.value, ctx), expandExpr(node.body, ctx));
579
+ case "call": {
580
+ const resolved = tryResolveMacroCall(node, ctx);
581
+ if (resolved === undefined) {
582
+ // Not a macro -- built-in primitive, extern, or simply
583
+ // unmapped; leave the call itself alone, just expand its
584
+ // args in case one of THEM has a macro call/field access
585
+ // inside it. A primitive's fixed arity, or an extern's
586
+ // own (opt-in) arity, is checked here too -- this is the
587
+ // one place every "call" node already passes through,
588
+ // whether or not it ends up being a macro.
589
+ const args = node.args.map((a) => expandExpr(a, ctx));
590
+ checkPrimitiveArity(node.name, args.length);
591
+ checkExternArity(node.name, args.length, ctx.registry);
592
+ return call(node.name, ...args);
593
+ }
594
+ if (isMultiOutputResult(resolved)) {
595
+ throw new Error(
596
+ `expandMacros: "${node.name}(...)" returns multiple named outputs -- bind it with ` +
597
+ `"let name = ${node.name}(...);" first, then access fields as name.field, rather than ` +
598
+ `using it directly inside another expression`,
599
+ );
600
+ }
601
+ return resolved;
602
+ }
603
+ case "outputs": {
604
+ // outputs() is normally only ever a function's top-level body
605
+ // (see ast.js), but a macro's OWN return value could in
606
+ // principle be built with one -- handled here defensively
607
+ // rather than assumed unreachable.
608
+ const fields = {};
609
+ for (const [name, fieldValue] of Object.entries(node.fields)) fields[name] = expandExpr(fieldValue, ctx);
610
+ return { ...node, fields };
611
+ }
612
+ default:
613
+ throw new Error(`expandMacros: cannot expand unknown node type "${node.type}"`);
614
+ }
615
+ }
616
+
617
+ // Expands a function BODY -- the pre-collectLets nested let-chain/outputs/
618
+ // expression shape fn()/expr() produce. The one thing expandExpr alone
619
+ // can't do: when a "let"'s value is itself a call to a MULTI-output
620
+ // macro, that one let has to become several flat lets (one per field,
621
+ // named "letName__field"), with every later "name.field" reference in
622
+ // `node.body` rewritten to the matching flat name -- see ctx.aliases,
623
+ // populated here and consumed by expandExpr's "field" case above.
624
+ function expandBody(node, ctx) {
625
+ if (node.type === "let") {
626
+ if (node.value.type === "call") {
627
+ const resolved = tryResolveMacroCall(node.value, ctx);
628
+ if (resolved !== undefined && isMultiOutputResult(resolved)) {
629
+ const flatNames = {};
630
+ for (const field of Object.keys(resolved.fields)) flatNames[field] = `${node.name}__${field}`;
631
+ const nextAliases = new Map(ctx.aliases);
632
+ nextAliases.set(node.name, flatNames);
633
+ let restBody = expandBody(node.body, { ...ctx, aliases: nextAliases });
634
+ // Per-field flat lets first (innermost, closest to
635
+ // restBody) -- each just aliases one of `resolved`'s
636
+ // fields under its caller-visible flat name...
637
+ const fieldEntries = Object.entries(resolved.fields);
638
+ for (let i = fieldEntries.length - 1; i >= 0; i--) {
639
+ const [field, valueNode] = fieldEntries[i];
640
+ restBody = letIn(flatNames[field], valueNode, restBody);
641
+ }
642
+ // ...then the SHARED letPrefix (e.g. cross3-in-fn-DSL-
643
+ // text's own "let rx = ...; let ry = ...; let rz = ...;"
644
+ // -- see makeMultiOutput's comment) wraps all of that
645
+ // ONCE, outermost -- never duplicated per field. Every
646
+ // name in it is already gensym'd unique for this one
647
+ // call (see toMacro), so nesting order here doesn't
648
+ // matter for correctness (collectLets hoists everything
649
+ // into one flat list regardless -- see its own comment
650
+ // in ast.js), only for readability.
651
+ for (let i = resolved.letPrefix.length - 1; i >= 0; i--) {
652
+ const { name, node: valueNode } = resolved.letPrefix[i];
653
+ restBody = letIn(name, valueNode, restBody);
654
+ }
655
+ return restBody;
656
+ }
657
+ if (resolved !== undefined) {
658
+ // Single-value macro call -- splice directly as this
659
+ // let's value, already fully expanded.
660
+ return letIn(node.name, resolved, expandBody(node.body, ctx));
661
+ }
662
+ }
663
+ return letIn(node.name, expandExpr(node.value, ctx), expandBody(node.body, ctx));
664
+ }
665
+ if (node.type === "outputs") {
666
+ const fields = {};
667
+ for (const [name, fieldValue] of Object.entries(node.fields)) fields[name] = expandExpr(fieldValue, ctx);
668
+ return { ...node, fields };
669
+ }
670
+ return expandExpr(node, ctx);
671
+ }
672
+
673
+ /**
674
+ * Eliminates every macro "call" and every "field" node from `fnOrNode` --
675
+ * the one required step between parsing (fn()/expr()/loadExpr()) and
676
+ * anything that consumes a tree directly (checkUnboundVars, evaluate(),
677
+ * any emitter's emitFunction()) -- all three of those call this
678
+ * unconditionally themselves, first, so ordinary callers never need to
679
+ * call it by hand. Accepts either a bare Node or a full
680
+ * {name, params, body} -- same dual shape fn() itself produces -- and
681
+ * returns the same shape back. `extraRegistry` (a Map<name, {arity, fn,
682
+ * alreadyExpanded}>) is load-expr.js's own hook for "functions defined
683
+ * earlier in this same .expr file" -- see that file's header comment;
684
+ * ordinary callers never need to pass it. `registry` (a {macros, externs}
685
+ * pair, see createRegistry() above) defaults to this file's own module-
686
+ * level registry -- pass a session's own (see index.js's createSession())
687
+ * to resolve against that session's macros/externs instead of the
688
+ * process-wide default ones.
689
+ */
690
+ function expandMacros(fnOrNode, extraRegistry = null, registry = defaultRegistry) {
691
+ const ctx = { extraRegistry, aliases: new Map(), registry };
692
+ if (isFnDefShape(fnOrNode)) {
693
+ return { name: fnOrNode.name, params: fnOrNode.params, body: expandBody(fnOrNode.body, ctx) };
694
+ }
695
+ return expandBody(fnOrNode, ctx);
696
+ }
697
+
698
+ module.exports = {
699
+ loadMacro,
700
+ loadExtern,
701
+ expandMacros,
702
+ resolveExternForEvaluate,
703
+ resolveExternForEmitter,
704
+ toMacro,
705
+ withContext,
706
+ createRegistry,
707
+ };