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/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
- throw new Error(`collectLets: duplicate let binding name "${name}" in one function`);
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
- module.exports = { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets };
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
- const template = this.calls[node.name];
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
- return template(args);
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
- emitFunction(fn) {
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(fn) {
323
- const { collectLets } = require("../ast.js");
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
@@ -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
- module.exports = {
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 intrinsic name maps 1:1 onto emitters/js.js's own `calls` table
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
- // intrinsics are called") straight to the real Math.* function -- this
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
- function evalNode(node, env) {
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
- return impl(...node.args.map((a) => evalNode(a, env)));
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
- function evaluate(fn, args) {
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 := primary ( "^" unary )?
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
- if ("+-*/^(),?:><;{}=".includes(ch)) {
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
- return this.parseTernary();
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.parsePrimary();
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" "{" IDENT ":" expression ("," IDENT ":" expression)* "}" ";"
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
- parser.expectOp(":");
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
- module.exports = { fn };
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 };