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/ast.js
CHANGED
|
@@ -11,24 +11,87 @@
|
|
|
11
11
|
// { type: "cmp", op: ">" | "<" | ">=" | "<=" | "==" | "!=", left: Node, right: Node }
|
|
12
12
|
// { type: "select", cond: CmpNode, then: Node, else: Node }
|
|
13
13
|
// { type: "outputs", fields: { [name: string]: Node } }
|
|
14
|
+
// { type: "field", target: Node, field: string }
|
|
15
|
+
//
|
|
16
|
+
// "field" is postfix "." access (e.g. b.rx) — parser sugar produced only
|
|
17
|
+
// by expr.js/fn.js's grammar, and eliminated by macros.js's
|
|
18
|
+
// expandMacros() before a tree ever reaches checkUnboundVars,
|
|
19
|
+
// evaluate(), or any emitter. It only makes semantic sense when `target`
|
|
20
|
+
// resolves to a name bound to a multi-output *macro* call (see
|
|
21
|
+
// macros.js) — that's checked there, not here, same "defer semantic
|
|
22
|
+
// validation to the consumer" precedent call() already follows for
|
|
23
|
+
// function names. A "field" node reaching evaluate()/an emitter directly
|
|
24
|
+
// means expandMacros() was skipped or didn't run to completion; both
|
|
25
|
+
// throw their own "unknown node type" error in that case.
|
|
14
26
|
//
|
|
15
27
|
// Every "bin" node is emitted with explicit parens in every target, so
|
|
16
28
|
// operation order (and therefore floating-point rounding behavior) is
|
|
17
29
|
// identical everywhere.
|
|
30
|
+
//
|
|
31
|
+
// SECURITY: every builder below validates its own name/op/value
|
|
32
|
+
// argument(s) -- see assertSafeIdentifier/assertSafeOp/assertFiniteNumber
|
|
33
|
+
// just below. This is specifically about these builders being the "raw
|
|
34
|
+
// AST" layer -- the one this project's own README recommends reaching
|
|
35
|
+
// for when you want the least amount of magic between your formula and
|
|
36
|
+
// the code it emits. Without validation, that would have been the LEAST
|
|
37
|
+
// safe layer to build from untrusted input, not the most: fn`...`/
|
|
38
|
+
// expr`...`'s own tokenizer already only ever produces safe identifier
|
|
39
|
+
// characters, so these builders were the one place a malicious/malformed
|
|
40
|
+
// name -- e.g. `v('x); process.exit(1); //')` -- could reach emitted
|
|
41
|
+
// output completely unchecked, verbatim, in all 16 targets at once.
|
|
42
|
+
// Confirmed, not assumed, before this existed.
|
|
43
|
+
|
|
44
|
+
// Matches fn`...`/expr`...`'s own tokenizer IDENT rule exactly (see
|
|
45
|
+
// expr.js's tokenizeSegment) -- anything that couldn't have come out of
|
|
46
|
+
// the real parser isn't allowed in here either.
|
|
47
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
48
|
+
|
|
49
|
+
function assertSafeIdentifier(name, context) {
|
|
50
|
+
if (typeof name !== "string" || !IDENTIFIER.test(name)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${context}: ${JSON.stringify(name)} isn't a safe identifier -- must start with a letter or "_", ` +
|
|
53
|
+
`followed only by letters/digits/"_" (the same rule fn\`...\`/expr\`...\`'s own tokenizer already ` +
|
|
54
|
+
`enforces on anything parsed from text; this only matters when building a tree directly, bypassing ` +
|
|
55
|
+
`the parser)`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const BIN_OPS = new Set(["+", "-", "*", "/"]);
|
|
61
|
+
const CMP_OPS = new Set([">", "<", ">=", "<=", "==", "!="]);
|
|
62
|
+
|
|
63
|
+
function assertSafeOp(op, allowed, context) {
|
|
64
|
+
if (!allowed.has(op)) {
|
|
65
|
+
throw new Error(`${context}: ${JSON.stringify(op)} isn't one of the allowed operators (${[...allowed].join(" ")})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertFiniteNumber(value, context) {
|
|
70
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${context}: ${JSON.stringify(value)} isn't a finite number -- NaN/Infinity/non-numeric values ` +
|
|
73
|
+
`can't be emitted as a literal in every target`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
18
77
|
|
|
19
78
|
function num(value) {
|
|
79
|
+
assertFiniteNumber(value, "num()");
|
|
20
80
|
return { type: "num", value };
|
|
21
81
|
}
|
|
22
82
|
|
|
23
83
|
function v(name) {
|
|
84
|
+
assertSafeIdentifier(name, "v()");
|
|
24
85
|
return { type: "var", name };
|
|
25
86
|
}
|
|
26
87
|
|
|
27
88
|
function bin(op, left, right) {
|
|
89
|
+
assertSafeOp(op, BIN_OPS, "bin()");
|
|
28
90
|
return { type: "bin", op, left, right };
|
|
29
91
|
}
|
|
30
92
|
|
|
31
93
|
function call(name, ...args) {
|
|
94
|
+
assertSafeIdentifier(name, "call()");
|
|
32
95
|
return { type: "call", name, args };
|
|
33
96
|
}
|
|
34
97
|
|
|
@@ -59,6 +122,7 @@ function neg(x) {
|
|
|
59
122
|
// then divide three components by it). Lifted out by collectLets before
|
|
60
123
|
// emission — see there for how `v(name)` ends up referring to it.
|
|
61
124
|
function letIn(name, value, body) {
|
|
125
|
+
assertSafeIdentifier(name, "letIn()");
|
|
62
126
|
return { type: "let", name, value, body };
|
|
63
127
|
}
|
|
64
128
|
|
|
@@ -86,6 +150,7 @@ function letChain(bindings, body) {
|
|
|
86
150
|
// Comparison predicate — only valid as the `cond` of a select(); not a
|
|
87
151
|
// general boolean expression, and shouldn't appear anywhere else in a tree.
|
|
88
152
|
function cmp(left, op, right) {
|
|
153
|
+
assertSafeOp(op, CMP_OPS, "cmp()");
|
|
89
154
|
return { type: "cmp", op, left, right };
|
|
90
155
|
}
|
|
91
156
|
|
|
@@ -107,9 +172,28 @@ function select(cond, thenNode, elseNode) {
|
|
|
107
172
|
// return, an object literal, output parameters) — see formatSuite in each
|
|
108
173
|
// emitters/<lang>.js.
|
|
109
174
|
function outputs(fields) {
|
|
175
|
+
for (const name of Object.keys(fields)) assertSafeIdentifier(name, "outputs()");
|
|
110
176
|
return { type: "outputs", fields };
|
|
111
177
|
}
|
|
112
178
|
|
|
179
|
+
// Postfix "." field access into a multi-output intrinsic's result — see
|
|
180
|
+
// the "field" node-shape comment at the top of this file for what this
|
|
181
|
+
// actually means and who consumes it.
|
|
182
|
+
function field(target, name) {
|
|
183
|
+
assertSafeIdentifier(name, "field()");
|
|
184
|
+
return { type: "field", target, field: name };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The prefix macros.js's own gensym'd internal let-names always start
|
|
188
|
+
// with (see substituteAndRename's "let" case there) — defined HERE, not
|
|
189
|
+
// there, specifically so collectLets below can recognize a collision
|
|
190
|
+
// against one WITHOUT macros.js needing to require this file back (it
|
|
191
|
+
// already does the other direction: macros.js requires ast.js). Kept as
|
|
192
|
+
// one shared constant rather than a duplicated string literal in both
|
|
193
|
+
// files, so it can't quietly drift out of sync between "the name this
|
|
194
|
+
// generates" and "the name this recognizes".
|
|
195
|
+
const MACRO_GENSYM_PREFIX = "efMacro_";
|
|
196
|
+
|
|
113
197
|
// Lifts every `let` node out of the tree into a flat, ordered list of
|
|
114
198
|
// { name, node } bindings, replacing each with a plain v(name) reference.
|
|
115
199
|
// The list is in dependency order — safe to declare/assign top-to-bottom.
|
|
@@ -152,7 +236,23 @@ function collectLets(node) {
|
|
|
152
236
|
const seen = new Set();
|
|
153
237
|
for (const { name } of bindings) {
|
|
154
238
|
if (seen.has(name)) {
|
|
155
|
-
|
|
239
|
+
// A colliding name that happens to start with macros.js's own
|
|
240
|
+
// gensym prefix is almost certainly NOT something you wrote
|
|
241
|
+
// on purpose -- vanishingly unlikely to be an intentional
|
|
242
|
+
// collision, and confusing to debug as a plain "duplicate
|
|
243
|
+
// name" if you don't already know that prefix means
|
|
244
|
+
// "internally generated" -- named explicitly here rather
|
|
245
|
+
// than left for you to work out. The gensym'd name itself is
|
|
246
|
+
// never the one to rename (it's already unique per macro
|
|
247
|
+
// invocation, see toMacro's own comment in macros.js) -- only
|
|
248
|
+
// your OWN same-named binding actually needs to change.
|
|
249
|
+
const hint = name.startsWith(MACRO_GENSYM_PREFIX)
|
|
250
|
+
? ` -- this looks like an internal name macro expansion generates automatically ` +
|
|
251
|
+
`(see macros.js's own gensym'd "let" renaming), not something you wrote; if you ` +
|
|
252
|
+
`have your own let/param actually named "${name}", rename YOURS to something that ` +
|
|
253
|
+
`doesn't start with "${MACRO_GENSYM_PREFIX}"`
|
|
254
|
+
: "";
|
|
255
|
+
throw new Error(`collectLets: duplicate let binding name "${name}" in one function${hint}`);
|
|
156
256
|
}
|
|
157
257
|
seen.add(name);
|
|
158
258
|
}
|
|
@@ -160,4 +260,92 @@ function collectLets(node) {
|
|
|
160
260
|
return { bindings, body };
|
|
161
261
|
}
|
|
162
262
|
|
|
163
|
-
|
|
263
|
+
// Every v(name) reference anywhere in `node`, regardless of whether
|
|
264
|
+
// anything actually declares it -- a pure structural walk, no binding
|
|
265
|
+
// awareness at all. `refs` accumulates across recursive calls so this
|
|
266
|
+
// can be called repeatedly against several subtrees (e.g. once per
|
|
267
|
+
// let-binding's own value, plus once for the final body) and still
|
|
268
|
+
// build one combined set. Mirrors collectLets's own node-type walk
|
|
269
|
+
// above exactly, since it needs to see the identical tree shape.
|
|
270
|
+
function collectVarRefs(node, refs = new Set()) {
|
|
271
|
+
if (node.type === "var") {
|
|
272
|
+
refs.add(node.name);
|
|
273
|
+
} else if (node.type === "bin") {
|
|
274
|
+
collectVarRefs(node.left, refs);
|
|
275
|
+
collectVarRefs(node.right, refs);
|
|
276
|
+
} else if (node.type === "call") {
|
|
277
|
+
for (const a of node.args) collectVarRefs(a, refs);
|
|
278
|
+
} else if (node.type === "cmp") {
|
|
279
|
+
collectVarRefs(node.left, refs);
|
|
280
|
+
collectVarRefs(node.right, refs);
|
|
281
|
+
} else if (node.type === "select") {
|
|
282
|
+
collectVarRefs(node.cond, refs);
|
|
283
|
+
collectVarRefs(node.then, refs);
|
|
284
|
+
collectVarRefs(node.else, refs);
|
|
285
|
+
} else if (node.type === "let") {
|
|
286
|
+
collectVarRefs(node.value, refs);
|
|
287
|
+
collectVarRefs(node.body, refs);
|
|
288
|
+
} else if (node.type === "outputs") {
|
|
289
|
+
for (const fieldNode of Object.values(node.fields)) collectVarRefs(fieldNode, refs);
|
|
290
|
+
}
|
|
291
|
+
// num: nothing to add.
|
|
292
|
+
return refs;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Confirms every var() reference anywhere in fn.body -- inside a
|
|
296
|
+
// let-binding's own value, or in the final body/outputs -- corresponds
|
|
297
|
+
// to something actually declared: a parameter, or a let binding
|
|
298
|
+
// somewhere else in the same function. "Somewhere else", not
|
|
299
|
+
// "somewhere earlier": collectLets's own doc comment already
|
|
300
|
+
// establishes there's no real lexical scoping here -- every let-binding
|
|
301
|
+
// is one flat, function-wide name -- so "declared anywhere in this
|
|
302
|
+
// function" is the right, and only meaningful, check, not an
|
|
303
|
+
// order-sensitive one.
|
|
304
|
+
//
|
|
305
|
+
// Catches a typo'd or forgotten identifier at the earliest possible
|
|
306
|
+
// point, for every target and for evaluate() uniformly, rather than
|
|
307
|
+
// relying on evaluate() happening to hit it at runtime (which it might
|
|
308
|
+
// never do -- e.g. a reference inside a select() branch that a
|
|
309
|
+
// particular call's arguments never take would never surface that way)
|
|
310
|
+
// or on whichever target language's own compiler/runtime eventually
|
|
311
|
+
// notices, with wildly inconsistent timing and clarity (a real compile
|
|
312
|
+
// error in Java, a silent-until-called ReferenceError in JS). Confirmed
|
|
313
|
+
// the gap first, not assumed: before this existed, emitAll() silently
|
|
314
|
+
// succeeded across all 18 targets for a body referencing a completely
|
|
315
|
+
// undeclared name.
|
|
316
|
+
//
|
|
317
|
+
// Also validates fn.name and every fn.params entry as safe identifiers
|
|
318
|
+
// (see assertSafeIdentifier above) -- the SAME concern as every builder
|
|
319
|
+
// above, just here instead of at a constructor call site, since
|
|
320
|
+
// {name, params, body} is a plain object literal shape with no
|
|
321
|
+
// constructor function of its own to hook into. This is the one place
|
|
322
|
+
// every real consumption path (evaluate(), every emitter's
|
|
323
|
+
// emitFunction(), cobol.js's own override) already runs unconditionally,
|
|
324
|
+
// so it's the natural single checkpoint for this too.
|
|
325
|
+
function checkUnboundVars(fn) {
|
|
326
|
+
assertSafeIdentifier(fn.name, "fn.name");
|
|
327
|
+
for (const p of fn.params) assertSafeIdentifier(p, "fn.params");
|
|
328
|
+
|
|
329
|
+
const { bindings, body } = collectLets(fn.body);
|
|
330
|
+
const declared = new Set([...fn.params, ...bindings.map((b) => b.name)]);
|
|
331
|
+
|
|
332
|
+
const referenced = new Set();
|
|
333
|
+
for (const { node } of bindings) collectVarRefs(node, referenced);
|
|
334
|
+
collectVarRefs(body, referenced);
|
|
335
|
+
|
|
336
|
+
for (const name of referenced) {
|
|
337
|
+
if (!declared.has(name)) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`checkUnboundVars: "${name}" is referenced in "${fn.name}" but never declared -- ` +
|
|
340
|
+
`not a parameter (${fn.params.length ? fn.params.join(", ") : "none"}) and no ` +
|
|
341
|
+
`"let ${name} = ..." binding exists anywhere in this function`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
module.exports = {
|
|
348
|
+
num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, field, collectLets,
|
|
349
|
+
checkUnboundVars,
|
|
350
|
+
MACRO_GENSYM_PREFIX,
|
|
351
|
+
};
|
package/emitters/base.js
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
// suite gets emitted through this emitter; omitted
|
|
27
27
|
// otherwise.
|
|
28
28
|
|
|
29
|
-
const { collectLets } = require("../ast.js");
|
|
29
|
+
const { collectLets, checkUnboundVars } = require("../ast.js");
|
|
30
|
+
const { expandMacros, resolveExternForEmitter, withContext } = require("../macros.js");
|
|
30
31
|
|
|
31
32
|
class Emitter {
|
|
32
33
|
constructor(config) {
|
|
@@ -64,11 +65,23 @@ class Emitter {
|
|
|
64
65
|
}
|
|
65
66
|
case "call": {
|
|
66
67
|
const args = node.args.map((a) => this.emitExpr(a));
|
|
67
|
-
|
|
68
|
+
// `this.lang` is injected by emitters/registry.js (the
|
|
69
|
+
// registered key, e.g. "js"/"cobol") -- not set for an
|
|
70
|
+
// Emitter built standalone outside the registry, in which
|
|
71
|
+
// case an extern simply never resolves here, same as an
|
|
72
|
+
// unmapped name.
|
|
73
|
+
const builtin = this.calls[node.name];
|
|
74
|
+
const template = builtin || (this.lang && resolveExternForEmitter(node.name, this.lang, this._registry));
|
|
68
75
|
if (!template) {
|
|
69
76
|
throw new Error(`emitter for .${this.ext}: no mapping for Math function "${node.name}"`);
|
|
70
77
|
}
|
|
71
|
-
|
|
78
|
+
// Only an extern's own per-target template is caller-
|
|
79
|
+
// supplied code that could have a bug worth attributing
|
|
80
|
+
// -- a built-in primitive's template is exprforge's own,
|
|
81
|
+
// not worth wrapping.
|
|
82
|
+
return builtin
|
|
83
|
+
? template(args)
|
|
84
|
+
: withContext(`emitter for .${this.ext}: while emitting extern "${node.name}"`, () => template(args));
|
|
72
85
|
}
|
|
73
86
|
case "select": {
|
|
74
87
|
const thenStr = this.emitExpr(node.then);
|
|
@@ -89,7 +102,26 @@ class Emitter {
|
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
|
|
105
|
+
// `registry` (see macros.js's createRegistry()) defaults to the
|
|
106
|
+
// process-wide default when omitted -- pass a session's own (see
|
|
107
|
+
// index.js's createSession()) to resolve macros/externs against that
|
|
108
|
+
// session instead. Stashed on `this` for emitExpr's "call" case to
|
|
109
|
+
// read, same instance-state pattern emitters/cobol.js's own
|
|
110
|
+
// `this._pool` already established.
|
|
111
|
+
emitFunction(fn, registry = undefined) {
|
|
112
|
+
this._registry = registry;
|
|
113
|
+
// Resolves every macro call and field() access into plain
|
|
114
|
+
// arithmetic first -- see macros.js's own header comment. Must
|
|
115
|
+
// run before checkUnboundVars: expansion is what introduces the
|
|
116
|
+
// flattened let-bindings a multi-output macro's fields become,
|
|
117
|
+
// and eliminates "field" nodes, which checkUnboundVars/
|
|
118
|
+
// collectLets don't know how to walk.
|
|
119
|
+
fn = expandMacros(fn, null, registry);
|
|
120
|
+
// Checked once, before any per-target work starts -- see
|
|
121
|
+
// checkUnboundVars's own comment in ast.js for why this matters
|
|
122
|
+
// (a typo'd/forgotten identifier used to silently succeed here,
|
|
123
|
+
// for every target, with no error at all).
|
|
124
|
+
checkUnboundVars(fn);
|
|
93
125
|
const { bindings, body } = collectLets(fn.body);
|
|
94
126
|
const letBindings = bindings.map(({ name, node }) => ({
|
|
95
127
|
name,
|
package/emitters/cobol.js
CHANGED
|
@@ -319,8 +319,27 @@ class TempPool {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
class CobolEmitter extends Emitter {
|
|
322
|
-
emitFunction
|
|
323
|
-
|
|
322
|
+
// `registry` -- see base.js's own emitFunction comment and
|
|
323
|
+
// macros.js's createRegistry()/index.js's createSession() -- same
|
|
324
|
+
// `this._registry` instance-state convention as base.js, read by the
|
|
325
|
+
// inherited emitExpr's "call" case for extern resolution.
|
|
326
|
+
emitFunction(fn, registry = undefined) {
|
|
327
|
+
this._registry = registry;
|
|
328
|
+
const { collectLets, checkUnboundVars } = require("../ast.js");
|
|
329
|
+
const { expandMacros } = require("../macros.js");
|
|
330
|
+
// This class overrides emitFunction entirely (never calls
|
|
331
|
+
// super.emitFunction), so it needs its own copy of the same two
|
|
332
|
+
// steps base.js's Emitter.emitFunction runs -- see
|
|
333
|
+
// expandMacros's and checkUnboundVars's own comments (in
|
|
334
|
+
// macros.js and ast.js respectively). Both run against the
|
|
335
|
+
// ORIGINAL fn, before renameConflictingParams runs below, so an
|
|
336
|
+
// error message reports whatever name the caller actually wrote,
|
|
337
|
+
// not an internally-renamed one (doesn't change which names are
|
|
338
|
+
// considered declared/resolved either way -- the rename is
|
|
339
|
+
// identity-preserving over the set of bound names, just changes
|
|
340
|
+
// their string).
|
|
341
|
+
fn = expandMacros(fn, null, registry);
|
|
342
|
+
checkUnboundVars(fn);
|
|
324
343
|
// Must run before collectLets, and before anything else in this
|
|
325
344
|
// function -- collectLets flattens the tree's let structure away,
|
|
326
345
|
// and every check/emission step below assumes fn.params is
|
package/emitters/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// exprforge/emitters/registry.js
|
|
2
2
|
// Add a new language: write emitters/<lang>.js exporting an Emitter instance,
|
|
3
3
|
// then add one line here. Nothing else in the project needs to change.
|
|
4
|
-
|
|
4
|
+
const emitters = {
|
|
5
5
|
js: require("./js.js"),
|
|
6
6
|
ts: require("./typescript.js"),
|
|
7
7
|
qb64: require("./qb64.js"),
|
|
@@ -21,3 +21,14 @@ module.exports = {
|
|
|
21
21
|
cobol: require("./cobol.js"),
|
|
22
22
|
expr: require("./exprsyntax.js"),
|
|
23
23
|
};
|
|
24
|
+
|
|
25
|
+
// Each emitter learns its own registered key here, once -- base.js's
|
|
26
|
+
// emitExpr uses this.lang to resolve an extern (see loadExtern() in
|
|
27
|
+
// macros.js), and this is the one place that already knows the
|
|
28
|
+
// name<->instance mapping, so it's the only file that needs to change
|
|
29
|
+
// for this instead of every emitters/<lang>.js.
|
|
30
|
+
for (const [lang, emitter] of Object.entries(emitters)) {
|
|
31
|
+
emitter.lang = lang;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = emitters;
|
package/evaluate.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
// there's exactly one node-shape contract (ast.js's own header comment)
|
|
10
10
|
// for both this file and every emitters/<lang>.js to agree with.
|
|
11
11
|
//
|
|
12
|
-
// Every
|
|
12
|
+
// Every primitive name maps 1:1 onto emitters/js.js's own `calls` table
|
|
13
13
|
// keys (the simplest existing source of truth for "what the ~22
|
|
14
|
-
//
|
|
14
|
+
// primitives are called") straight to the real Math.* function -- this
|
|
15
15
|
// target has no codegen step to route an intermediate string through.
|
|
16
|
-
const { collectLets } = require("./ast.js");
|
|
16
|
+
const { collectLets, checkUnboundVars } = require("./ast.js");
|
|
17
|
+
const { expandMacros, resolveExternForEvaluate, withContext } = require("./macros.js");
|
|
17
18
|
|
|
18
19
|
const CMP_OPS = {
|
|
19
20
|
">": (a, b) => a > b,
|
|
@@ -43,7 +44,12 @@ const CALLS = {
|
|
|
43
44
|
// valid pre-collectLets (a function's top-level let-chain/body shape),
|
|
44
45
|
// never nested inside a bin/call/select, same constraint every emitter
|
|
45
46
|
// already relies on (see ast.js's own comments on letIn/outputs).
|
|
46
|
-
|
|
47
|
+
// `registry` -- see macros.js's createRegistry()/index.js's
|
|
48
|
+
// createSession() -- resolves an extern against the right session's own
|
|
49
|
+
// registrations, defaulting to the process-wide one when not passed
|
|
50
|
+
// (evaluate() below never passes `undefined` on purpose either way, see
|
|
51
|
+
// there).
|
|
52
|
+
function evalNode(node, env, registry) {
|
|
47
53
|
switch (node.type) {
|
|
48
54
|
case "num":
|
|
49
55
|
return node.value;
|
|
@@ -55,18 +61,25 @@ function evalNode(node, env) {
|
|
|
55
61
|
case "bin": {
|
|
56
62
|
const op = BIN_OPS[node.op];
|
|
57
63
|
if (!op) throw new Error(`evaluate(): unknown bin op "${node.op}"`);
|
|
58
|
-
return op(evalNode(node.left, env), evalNode(node.right, env));
|
|
64
|
+
return op(evalNode(node.left, env, registry), evalNode(node.right, env, registry));
|
|
59
65
|
}
|
|
60
66
|
case "call": {
|
|
61
|
-
const impl = CALLS[node.name];
|
|
67
|
+
const impl = CALLS[node.name] || resolveExternForEvaluate(node.name, registry);
|
|
62
68
|
if (!impl) throw new Error(`evaluate(): no mapping for Math function "${node.name}"`);
|
|
63
|
-
|
|
69
|
+
const args = node.args.map((a) => evalNode(a, env, registry));
|
|
70
|
+
// Only an extern's own `evaluate` entry is caller-supplied
|
|
71
|
+
// code that could have a bug worth attributing -- a built-in
|
|
72
|
+
// primitive (impl === CALLS[node.name]) is exprforge's own
|
|
73
|
+
// Math.* wrapper, not worth wrapping.
|
|
74
|
+
return CALLS[node.name]
|
|
75
|
+
? impl(...args)
|
|
76
|
+
: withContext(`evaluate(): while running extern "${node.name}"`, () => impl(...args));
|
|
64
77
|
}
|
|
65
78
|
case "select": {
|
|
66
79
|
const cmpFn = CMP_OPS[node.cond.op];
|
|
67
80
|
if (!cmpFn) throw new Error(`evaluate(): unknown cmp op "${node.cond.op}"`);
|
|
68
|
-
const cond = cmpFn(evalNode(node.cond.left, env), evalNode(node.cond.right, env));
|
|
69
|
-
return evalNode(cond ? node.then : node.else, env);
|
|
81
|
+
const cond = cmpFn(evalNode(node.cond.left, env, registry), evalNode(node.cond.right, env, registry));
|
|
82
|
+
return evalNode(cond ? node.then : node.else, env, registry);
|
|
70
83
|
}
|
|
71
84
|
default:
|
|
72
85
|
throw new Error(
|
|
@@ -81,8 +94,28 @@ function evalNode(node, env) {
|
|
|
81
94
|
// fn.params. Returns a number for a plain body, or a {name: value}
|
|
82
95
|
// object for a multi-output (outputs()) body -- matching the shape
|
|
83
96
|
// test/conformance.test.js's own parseSuiteOutput() already expects
|
|
84
|
-
// back from every other target.
|
|
85
|
-
|
|
97
|
+
// back from every other target. `registry` (see macros.js's
|
|
98
|
+
// createRegistry()) defaults to the process-wide default when omitted --
|
|
99
|
+
// pass a session's own (see index.js's createSession()) to resolve
|
|
100
|
+
// macros/externs against that session instead; ordinary callers never
|
|
101
|
+
// need to pass it.
|
|
102
|
+
function evaluate(fn, args, registry = undefined) {
|
|
103
|
+
// Resolves every macro call and field() access into plain arithmetic
|
|
104
|
+
// FIRST -- checkUnboundVars/collectLets/evalNode below know nothing
|
|
105
|
+
// about either (see macros.js's own header comment); an extern's
|
|
106
|
+
// call node is left alone here, and resolved above in evalNode's own
|
|
107
|
+
// "call" case instead.
|
|
108
|
+
fn = expandMacros(fn, null, registry);
|
|
109
|
+
// Checked once, up front, exhaustively -- NOT relying on evalNode's
|
|
110
|
+
// own runtime "unbound variable" throw below to happen to hit it,
|
|
111
|
+
// which it might never do for a given call: a bad reference inside
|
|
112
|
+
// a select() branch these particular args don't take would silently
|
|
113
|
+
// never surface that way. See checkUnboundVars's own comment in
|
|
114
|
+
// ast.js. evalNode's runtime check stays in place too, as a cheap
|
|
115
|
+
// internal backstop -- it should be unreachable now that this runs
|
|
116
|
+
// first, same "check early, keep the deeper check anyway" precedent
|
|
117
|
+
// emitters/cobol.js already follows for its own reserved-name checks.
|
|
118
|
+
checkUnboundVars(fn);
|
|
86
119
|
if (args.length !== fn.params.length) {
|
|
87
120
|
throw new Error(`evaluate(): ${fn.name} expects ${fn.params.length} argument(s), got ${args.length}`);
|
|
88
121
|
}
|
|
@@ -93,17 +126,17 @@ function evaluate(fn, args) {
|
|
|
93
126
|
|
|
94
127
|
const { bindings, body } = collectLets(fn.body);
|
|
95
128
|
for (const { name, node } of bindings) {
|
|
96
|
-
env[name] = evalNode(node, env);
|
|
129
|
+
env[name] = evalNode(node, env, registry);
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
if (body.type === "outputs") {
|
|
100
133
|
const result = {};
|
|
101
134
|
for (const [name, node] of Object.entries(body.fields)) {
|
|
102
|
-
result[name] = evalNode(node, env);
|
|
135
|
+
result[name] = evalNode(node, env, registry);
|
|
103
136
|
}
|
|
104
137
|
return result;
|
|
105
138
|
}
|
|
106
|
-
return evalNode(body, env);
|
|
139
|
+
return evalNode(body, env, registry);
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
module.exports = { evaluate };
|
package/expr.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
// additive := multiplicative ( ("+"|"-") multiplicative )*
|
|
24
24
|
// multiplicative := unary ( ("*"|"/") unary )*
|
|
25
25
|
// unary := "-" unary | power
|
|
26
|
-
// power :=
|
|
26
|
+
// power := postfix ( "^" unary )?
|
|
27
|
+
// postfix := primary ( "." IDENT )*
|
|
27
28
|
// primary := NUMBER | IDENT ("(" args ")")? | "(" expression ")" | HOLE
|
|
28
29
|
// args := expression ("," expression)*
|
|
29
30
|
//
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
// is only ever "+"|"-"|"*"|"/" (see ast.js), and every emitter's
|
|
41
42
|
// `calls` table keys "pow" by name, even for targets whose own
|
|
42
43
|
// syntax has a native ^/** operator.
|
|
43
|
-
const { num, v, add, sub, mul, div, neg, call, cmp, select } = require("./ast.js");
|
|
44
|
+
const { num, v, add, sub, mul, div, neg, call, cmp, select, field } = require("./ast.js");
|
|
44
45
|
|
|
45
46
|
const COMPARE_OPS = [">", "<", ">=", "<=", "==", "!="];
|
|
46
47
|
|
|
@@ -131,8 +132,12 @@ function tokenizeSegment(str, offset, tokens, state = { inComment: false }, labe
|
|
|
131
132
|
// {...};) to reuse this same tokenizer instead of forking it.
|
|
132
133
|
// Inert for expr(): nothing that parses successfully today could
|
|
133
134
|
// contain them anyway ("=" alone was always a lex error before,
|
|
134
|
-
// since only "==" was recognized).
|
|
135
|
-
|
|
135
|
+
// since only "==" was recognized). "." is new too -- postfix field
|
|
136
|
+
// access (b.rx), see parsePostfix() below; harmless to add here
|
|
137
|
+
// since a bare "." was always a lex error before (the NUMBER
|
|
138
|
+
// branch above already claims every "." that's followed by a
|
|
139
|
+
// digit, e.g. ".5", so there's no ambiguity with number literals).
|
|
140
|
+
if ("+-*/^(),?:><;{}=.".includes(ch)) {
|
|
136
141
|
tokens.push({ type: "OP", value: ch, pos: offset + start });
|
|
137
142
|
i++;
|
|
138
143
|
continue;
|
|
@@ -155,6 +160,21 @@ function holeToNode(value, label = "expr()") {
|
|
|
155
160
|
);
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
// How deep parseExpression() can recurse into itself -- nested
|
|
164
|
+
// parens/function-call args/ternary branches, the only places genuine
|
|
165
|
+
// nesting comes from (the additive/multiplicative/unary/power/postfix
|
|
166
|
+
// precedence chain always runs once per primary regardless of how deep
|
|
167
|
+
// the tree ends up, so it isn't what this is bounding). 100 is
|
|
168
|
+
// deliberately generous, not tight: the deepest real formula in this
|
|
169
|
+
// project (Cardano's cubic, samples/ via the playground's own example)
|
|
170
|
+
// doesn't come close to double digits. It exists so a pathologically
|
|
171
|
+
// nested input -- "((((((...))))))" or "f(f(f(f(...))))" thousands
|
|
172
|
+
// deep, plausible if this ever parses genuinely untrusted, unbounded-
|
|
173
|
+
// size text -- fails with one clear, controlled error instead of a raw
|
|
174
|
+
// "Maximum call stack size exceeded" RangeError once the real JS stack
|
|
175
|
+
// (which this sits comfortably below at 100) actually gives out.
|
|
176
|
+
const MAX_EXPRESSION_DEPTH = 100;
|
|
177
|
+
|
|
158
178
|
class Parser {
|
|
159
179
|
// `label` -- see tokenizeSegment above; also threaded through to
|
|
160
180
|
// holeToNode so a bad interpolation inside `` fn`...` `` reports
|
|
@@ -164,6 +184,7 @@ class Parser {
|
|
|
164
184
|
this.source = source;
|
|
165
185
|
this.i = 0;
|
|
166
186
|
this.label = label;
|
|
187
|
+
this.depth = 0;
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
peek() {
|
|
@@ -201,8 +222,22 @@ class Parser {
|
|
|
201
222
|
throw new Error(`${this.label}: ${message} -- found ${tokDesc} at position ${t.pos} in \`${this.source}\``);
|
|
202
223
|
}
|
|
203
224
|
|
|
225
|
+
// Every recursive re-entry (a parenthesized group, a function-call
|
|
226
|
+
// argument, a ternary's then/else branch) goes through here, so
|
|
227
|
+
// tracking depth at this ONE point -- not at every precedence-level
|
|
228
|
+
// method, which would overcount a wide-but-shallow expression like
|
|
229
|
+
// "a + b + c + ..." as if it were deeply nested -- correctly bounds
|
|
230
|
+
// genuine nesting without rejecting a merely long one.
|
|
204
231
|
parseExpression() {
|
|
205
|
-
|
|
232
|
+
this.depth++;
|
|
233
|
+
if (this.depth > MAX_EXPRESSION_DEPTH) {
|
|
234
|
+
this.error(`expression nested too deeply (max ${MAX_EXPRESSION_DEPTH} levels of parens/calls/ternaries)`);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return this.parseTernary();
|
|
238
|
+
} finally {
|
|
239
|
+
this.depth--;
|
|
240
|
+
}
|
|
206
241
|
}
|
|
207
242
|
|
|
208
243
|
// Only place a comparison is ever accepted -- matches cmp()'s own
|
|
@@ -270,7 +305,7 @@ class Parser {
|
|
|
270
305
|
// into `unary`, not `power`, which is also what lets the exponent
|
|
271
306
|
// itself carry a leading unary minus (2^-1 = 0.5).
|
|
272
307
|
parsePower() {
|
|
273
|
-
const base = this.
|
|
308
|
+
const base = this.parsePostfix();
|
|
274
309
|
if (this.isOp("^")) {
|
|
275
310
|
this.next();
|
|
276
311
|
const exponent = this.parseUnary();
|
|
@@ -279,6 +314,26 @@ class Parser {
|
|
|
279
314
|
return base;
|
|
280
315
|
}
|
|
281
316
|
|
|
317
|
+
// Postfix "." field access (b.rx, chainable: a.b.c) -- binds tighter
|
|
318
|
+
// than "^", same as function-call parens already do inside
|
|
319
|
+
// parsePrimary. Accepted generally here, for any primary, same "defer
|
|
320
|
+
// semantic validation" precedent call() already follows for unknown
|
|
321
|
+
// function names -- whether `target` actually resolves to something
|
|
322
|
+
// with that field is checked later, by macros.js's expandMacros(),
|
|
323
|
+
// which is the only thing that ever consumes a "field" node (see its
|
|
324
|
+
// and ast.js's own comments).
|
|
325
|
+
parsePostfix() {
|
|
326
|
+
let node = this.parsePrimary();
|
|
327
|
+
while (this.isOp(".")) {
|
|
328
|
+
this.next();
|
|
329
|
+
const t = this.peek();
|
|
330
|
+
if (t.type !== "IDENT") this.error('expected a field name after "."');
|
|
331
|
+
this.next();
|
|
332
|
+
node = field(node, t.value);
|
|
333
|
+
}
|
|
334
|
+
return node;
|
|
335
|
+
}
|
|
336
|
+
|
|
282
337
|
parsePrimary() {
|
|
283
338
|
const t = this.peek();
|
|
284
339
|
if (t.type === "NUMBER") {
|
package/fn.js
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// signature := IDENT "(" (IDENT ("," IDENT)*)? ")" ":"
|
|
15
15
|
// stmt := "let" IDENT "=" expression ";"
|
|
16
16
|
// returnStmt := "return" expression ";"
|
|
17
|
-
// | "return" "{"
|
|
17
|
+
// | "return" "{" field ("," field)* "}" ";"
|
|
18
|
+
// field := IDENT (":" expression)?
|
|
19
|
+
//
|
|
20
|
+
// A field with no ":" is shorthand for "name: name" (return { rx, ry, rz };
|
|
21
|
+
// means return { rx: rx, ry: ry, rz: rz };) -- same convention JS object
|
|
22
|
+
// literals use for a property whose value is a same-named variable, and
|
|
23
|
+
// fields can freely mix shorthand and explicit form in one return.
|
|
18
24
|
//
|
|
19
25
|
// "let"/"return" are recognized contextually -- an IDENT token whose
|
|
20
26
|
// value happens to be "let"/"return" at statement-start position. They
|
|
@@ -40,7 +46,7 @@
|
|
|
40
46
|
// was a deliberate API choice, not an oversight -- see the GitHub issue
|
|
41
47
|
// this shipped from for the alternative considered (a separate,
|
|
42
48
|
// always-full-definition tag) and why this was preferred.
|
|
43
|
-
const { letChain, outputs } = require("./ast.js");
|
|
49
|
+
const { letChain, outputs, v } = require("./ast.js");
|
|
44
50
|
const { Parser, tokenizeSegment } = require("./expr.js");
|
|
45
51
|
|
|
46
52
|
function isKeyword(parser, word) {
|
|
@@ -73,7 +79,15 @@ function parseReturnStatement(parser) {
|
|
|
73
79
|
const fields = {};
|
|
74
80
|
const readField = () => {
|
|
75
81
|
const name = expectIdent(parser, 'as an output name inside "return { ... }"');
|
|
76
|
-
|
|
82
|
+
// No ":" -- shorthand for "name: name" (return { rx, ry, rz };
|
|
83
|
+
// means return { rx: rx, ry: ry, rz: rz };), matching JS object
|
|
84
|
+
// literal shorthand. Explicit ":" still works, and either form
|
|
85
|
+
// can appear anywhere in the same field list.
|
|
86
|
+
if (!parser.isOp(":")) {
|
|
87
|
+
fields[name] = v(name);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
parser.next(); // consume ":"
|
|
77
91
|
fields[name] = parser.parseExpression();
|
|
78
92
|
};
|
|
79
93
|
if (!parser.isOp("}")) {
|
|
@@ -172,4 +186,9 @@ function fn(strings, ...values) {
|
|
|
172
186
|
return node;
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
|
|
189
|
+
// parseProgram is also exported for load-expr.js: loading a .expr file
|
|
190
|
+
// means parsing zero or more of these back-to-back over one shared token
|
|
191
|
+
// stream (see load-expr.js's own header comment), which needs the same
|
|
192
|
+
// "signature? stmt* returnStmt" grammar this file already implements --
|
|
193
|
+
// reused directly, not forked.
|
|
194
|
+
module.exports = { fn, parseProgram };
|