exprforge 0.4.0 → 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/README.md +545 -99
- package/ast.js +190 -2
- package/emitters/base.js +36 -4
- package/emitters/cobol.js +21 -2
- package/emitters/registry.js +12 -1
- package/evaluate.js +47 -14
- package/expr.js +61 -6
- package/fn.js +23 -4
- package/index.js +141 -8
- package/load-expr.js +112 -0
- package/macros.js +707 -0
- package/math/index.js +21 -0
- package/package.json +4 -1
- package/primitives.js +24 -0
- package/samples/macro-demo.js +56 -0
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
|
+
};
|