exprforge 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,17 +63,48 @@ const emitter = new ExprSyntaxEmitter({
63
63
  // That's both the most direct thing to paste into a real
64
64
  // `` fn`...` `` call, and exactly what the round-trip test reparses
65
65
  // with zero unwrapping first.
66
+ //
67
+ // ALWAYS includes the "name(params):" signature line -- every other
68
+ // emitter's formatFunction includes the full declaration per
69
+ // base.js's own documented contract ("Full source text for one
70
+ // function, including any language-specific signature/type/wrapper
71
+ // syntax"); this was the one target that didn't, dropping fn.name/
72
+ // fn.params on the floor entirely. That wasn't a deliberate
73
+ // minimalism choice, it was a leftover from before fn`...`'s
74
+ // optional signature line (see fn.js) existed at all -- without it,
75
+ // reparsing this emitter's own output via fn() could only ever
76
+ // recover a bare Node, never a runnable {name, params, body}, unlike
77
+ // literally every other target's output being immediately usable.
78
+ // Body lines (every let, the return) are indented 2 spaces deeper
79
+ // than the signature line itself -- a Python-esque pretty-print
80
+ // convention, not something the parser requires (whitespace is
81
+ // insignificant to fn's grammar; see expr.js's tokenizer). Applied
82
+ // here, not just in hand-written docs/examples, so every printed
83
+ // AST comes out reading the same way automatically.
66
84
  formatFunction: (fn, bodyStr, letBindings = []) => {
67
- const lines = letLines(letBindings);
68
- lines.push(`return ${bodyStr};`);
69
- return lines.join("\n") + "\n";
85
+ const body = [...letLines(letBindings), `return ${bodyStr};`].map((line) => ` ${line}`);
86
+ return [`${fn.name}(${fn.params.join(", ")}):`, ...body].join("\n") + "\n";
70
87
  },
88
+ // Each output field gets its own line (4 spaces -- one level deeper
89
+ // than "return {" itself, which sits at the usual 2), rather than
90
+ // cramming every field onto one line -- found the gap by comparing
91
+ // this against a hand-formatted multi-output example and noticing
92
+ // the printer didn't follow its own convention once a suite had
93
+ // more than a couple of fields (a real, wide, 5-output formula made
94
+ // this one very long line instead of something readable).
71
95
  formatSuite: (fn, outputStrs, letBindings = []) => {
72
- const lines = letLines(letBindings);
73
- const fields = Object.entries(outputStrs)
74
- .map(([name, valueStr]) => `${name}: ${valueStr}`)
75
- .join(", ");
76
- lines.push(`return { ${fields} };`);
96
+ const entries = Object.entries(outputStrs);
97
+ const fieldLines = entries.map(([name, valueStr], i) => {
98
+ const comma = i < entries.length - 1 ? "," : "";
99
+ return ` ${name}: ${valueStr}${comma}`;
100
+ });
101
+ const lines = [
102
+ `${fn.name}(${fn.params.join(", ")}):`,
103
+ ...letLines(letBindings).map((line) => ` ${line}`),
104
+ " return {",
105
+ ...fieldLines,
106
+ " };",
107
+ ];
77
108
  return lines.join("\n") + "\n";
78
109
  },
79
110
  });
@@ -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
 
@@ -50,8 +51,28 @@ const COMPARE_OPS = [">", "<", ">=", "<=", "==", "!="];
50
51
  // `label` is just which tag function's name shows up in error messages
51
52
  // -- fn.js passes "fn()" here so a lex error inside `` fn`...` `` isn't
52
53
  // misattributed to expr().
53
- function tokenizeSegment(str, offset, tokens, label = "expr()") {
54
+ //
55
+ // `state.inComment` carries "# comment" status ACROSS segments -- these
56
+ // are tagged template literals, so a source like
57
+ // `` expr`a + b # comment ${x} more` `` tokenizes segment "a + b #
58
+ // comment " and segment " more" separately, with a HOLE for `x` spliced
59
+ // between them by expr()/fn() below. A comment open at the end of one
60
+ // segment has to stay open into the next, or "more" would wrongly
61
+ // become real tokens again. One `state` object is created once per
62
+ // top-level expr()/fn() call and threaded through every call here --
63
+ // never reset per segment.
64
+ function tokenizeSegment(str, offset, tokens, state = { inComment: false }, label = "expr()") {
54
65
  let i = 0;
66
+ if (state.inComment) {
67
+ const nl = str.indexOf("\n");
68
+ if (nl === -1) {
69
+ // The whole segment is still inside the comment -- nothing
70
+ // to tokenize, and still in-comment for whatever's next.
71
+ return;
72
+ }
73
+ i = nl + 1;
74
+ state.inComment = false;
75
+ }
55
76
  while (i < str.length) {
56
77
  const ch = str[i];
57
78
  const start = i;
@@ -59,6 +80,20 @@ function tokenizeSegment(str, offset, tokens, label = "expr()") {
59
80
  i++;
60
81
  continue;
61
82
  }
83
+ // "#" comments run to the next newline (or off the end of this
84
+ // segment, in which case state.inComment stays set for the next
85
+ // one -- see above). Not part of the OP set below: this
86
+ // produces no token at all, the same category as whitespace,
87
+ // not an operator.
88
+ if (ch === "#") {
89
+ const nl = str.indexOf("\n", i);
90
+ if (nl === -1) {
91
+ state.inComment = true;
92
+ return;
93
+ }
94
+ i = nl + 1;
95
+ continue;
96
+ }
62
97
  // NUMBER: 123, 123.45, .5, 1e-9, 1.5E+10
63
98
  if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(str[i + 1] || ""))) {
64
99
  i++;
@@ -97,8 +132,12 @@ function tokenizeSegment(str, offset, tokens, label = "expr()") {
97
132
  // {...};) to reuse this same tokenizer instead of forking it.
98
133
  // Inert for expr(): nothing that parses successfully today could
99
134
  // contain them anyway ("=" alone was always a lex error before,
100
- // since only "==" was recognized).
101
- 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)) {
102
141
  tokens.push({ type: "OP", value: ch, pos: offset + start });
103
142
  i++;
104
143
  continue;
@@ -121,6 +160,21 @@ function holeToNode(value, label = "expr()") {
121
160
  );
122
161
  }
123
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
+
124
178
  class Parser {
125
179
  // `label` -- see tokenizeSegment above; also threaded through to
126
180
  // holeToNode so a bad interpolation inside `` fn`...` `` reports
@@ -130,6 +184,7 @@ class Parser {
130
184
  this.source = source;
131
185
  this.i = 0;
132
186
  this.label = label;
187
+ this.depth = 0;
133
188
  }
134
189
 
135
190
  peek() {
@@ -167,8 +222,22 @@ class Parser {
167
222
  throw new Error(`${this.label}: ${message} -- found ${tokDesc} at position ${t.pos} in \`${this.source}\``);
168
223
  }
169
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.
170
231
  parseExpression() {
171
- 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
+ }
172
241
  }
173
242
 
174
243
  // Only place a comparison is ever accepted -- matches cmp()'s own
@@ -236,7 +305,7 @@ class Parser {
236
305
  // into `unary`, not `power`, which is also what lets the exponent
237
306
  // itself carry a leading unary minus (2^-1 = 0.5).
238
307
  parsePower() {
239
- const base = this.parsePrimary();
308
+ const base = this.parsePostfix();
240
309
  if (this.isOp("^")) {
241
310
  this.next();
242
311
  const exponent = this.parseUnary();
@@ -245,6 +314,26 @@ class Parser {
245
314
  return base;
246
315
  }
247
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
+
248
337
  parsePrimary() {
249
338
  const t = this.peek();
250
339
  if (t.type === "NUMBER") {
@@ -296,13 +385,22 @@ class Parser {
296
385
  // already-evaluated JS value through untouched.
297
386
  function expr(strings, ...values) {
298
387
  const tokens = [];
388
+ const state = { inComment: false };
299
389
  let source = "";
300
390
  for (let i = 0; i < strings.length; i++) {
301
- tokenizeSegment(strings[i], source.length, tokens);
391
+ tokenizeSegment(strings[i], source.length, tokens, state);
302
392
  source += strings[i];
303
393
  if (i < values.length) {
304
- tokens.push({ type: "HOLE", value: values[i], pos: source.length });
305
394
  source += "${...}";
395
+ // A value interpolated inside an open "#" comment is
396
+ // silently dropped -- never reaches holeToNode, so it's
397
+ // never validated, even if it would otherwise be an
398
+ // invalid interpolation (a string, undefined, ...). This is
399
+ // deliberate: the whole point of a comment is that its
400
+ // contents don't matter.
401
+ if (!state.inComment) {
402
+ tokens.push({ type: "HOLE", value: values[i], pos: source.length });
403
+ }
306
404
  }
307
405
  }
308
406
  tokens.push({ type: "EOF", value: null, pos: source.length });
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("}")) {
@@ -147,13 +161,19 @@ function parseProgram(parser) {
147
161
  // parser.parseExpression()).
148
162
  function fn(strings, ...values) {
149
163
  const tokens = [];
164
+ const state = { inComment: false };
150
165
  let source = "";
151
166
  for (let i = 0; i < strings.length; i++) {
152
- tokenizeSegment(strings[i], source.length, tokens, "fn()");
167
+ tokenizeSegment(strings[i], source.length, tokens, state, "fn()");
153
168
  source += strings[i];
154
169
  if (i < values.length) {
155
- tokens.push({ type: "HOLE", value: values[i], pos: source.length });
156
170
  source += "${...}";
171
+ // See expr.js's tokenizeSegment/expr() for why this is
172
+ // silently dropped rather than pushed -- same rule, same
173
+ // reasoning, shared state object.
174
+ if (!state.inComment) {
175
+ tokens.push({ type: "HOLE", value: values[i], pos: source.length });
176
+ }
157
177
  }
158
178
  }
159
179
  tokens.push({ type: "EOF", value: null, pos: source.length });
@@ -166,4 +186,9 @@ function fn(strings, ...values) {
166
186
  return node;
167
187
  }
168
188
 
169
- 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 };
package/index.js CHANGED
@@ -1,31 +1,126 @@
1
1
  // exprforge/index.js
2
- const { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets } = require("./ast.js");
2
+ const { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, field, collectLets, checkUnboundVars } = require("./ast.js");
3
3
  const { forComponents } = require("./util.js");
4
4
  const { expr } = require("./expr.js");
5
5
  const { fn } = require("./fn.js");
6
6
  const { evaluate } = require("./evaluate.js");
7
+ const { loadMacro, loadExtern, expandMacros, createRegistry } = require("./macros.js");
8
+ const { loadExpr, loadExprSource } = require("./load-expr.js");
7
9
  const emitters = require("./emitters/registry.js");
8
10
  const { catmullRomAst } = require("./samples/catmull-rom.js");
9
11
  const { fibonacciAst } = require("./samples/fibonacci.js");
10
12
  const { splineFrameAsts } = require("./samples/spline-frame.js");
11
13
  const { kitchenSinkAst } = require("./samples/kitchen-sink.js");
12
14
  const { mathDemoAst } = require("./samples/math-demo.js");
15
+ const { macroDemoAst } = require("./samples/macro-demo.js");
13
16
 
14
17
  /**
15
- * Run every registered emitter against one AST function definition.
16
- * Returns { [lang]: { ext, source } }.
18
+ * Run ONE emitter against one AST function definition. Returns
19
+ * { ext, source }. Throws if `lang` isn't a registered emitter name, or if
20
+ * that emitter itself throws for this fn (an unmapped Math function, a
21
+ * reserved-name collision, ...) -- the caller picked exactly this one
22
+ * target, so there's no "other languages" for a per-language error to be
23
+ * isolated from; let it propagate. Prefer this over emitAll (deprecated,
24
+ * below) when you only need one or a few targets -- it doesn't pay for
25
+ * every registered emitter to get one.
26
+ *
27
+ * `registry` (see macros.js's createRegistry()) defaults to the
28
+ * process-wide default when omitted -- pass a session's own (see
29
+ * createSession() below) to resolve macros/externs registered in that
30
+ * session instead; ordinary callers never need to pass it directly.
17
31
  */
18
- function emitAll(fnDef) {
32
+ function emit(fnDef, lang, registry = undefined) {
33
+ const emitter = emitters[lang];
34
+ if (!emitter) {
35
+ throw new Error(
36
+ `emit(): no emitter registered for language "${lang}" -- known languages: ${Object.keys(emitters).sort().join(", ")}`,
37
+ );
38
+ }
39
+ return { ext: emitter.ext, source: emitter.emitFunction(fnDef, registry) };
40
+ }
41
+
42
+ /**
43
+ * Run several emitters against one AST function definition, each in
44
+ * isolation from the others: one language's emitter throwing shows up as
45
+ * { source: null, error } for THAT language only, alongside every other
46
+ * requested language's real { source, error: null } result -- a single
47
+ * bad target (e.g. a parameter name COBOL's reserved-word check rejects)
48
+ * no longer takes the whole batch down with it. `langs` defaults to every
49
+ * registered language; pass an explicit array to avoid running (and
50
+ * paying for) emitters you don't need.
51
+ *
52
+ * `registry` -- see emit()'s own doc comment above -- same meaning here,
53
+ * applied to every language in `langs`.
54
+ */
55
+ function emitMany(fnDef, langs = Object.keys(emitters), registry = undefined) {
19
56
  const result = {};
20
- for (const [lang, emitter] of Object.entries(emitters)) {
21
- result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fnDef) };
57
+ for (const lang of langs) {
58
+ const emitter = emitters[lang];
59
+ if (!emitter) {
60
+ result[lang] = { ext: null, source: null, error: `no emitter registered for language "${lang}"` };
61
+ continue;
62
+ }
63
+ try {
64
+ result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fnDef, registry), error: null };
65
+ } catch (e) {
66
+ result[lang] = { ext: emitter.ext, source: null, error: e instanceof Error ? e.message : String(e) };
67
+ }
22
68
  }
23
69
  return result;
24
70
  }
25
71
 
72
+ /**
73
+ * @deprecated Runs every registered emitter unconditionally, whether you
74
+ * need all of them or not -- prefer emit(fnDef, lang) for one target, or
75
+ * emitMany(fnDef, langs) for an explicit subset (or emitMany(fnDef) with
76
+ * no `langs` for the same "every language" behavior this has always had).
77
+ * Kept working, not scheduled for removal -- existing callers reading
78
+ * result[lang].ext/.source see no change; the only behavior change is the
79
+ * bug fix this shares with emitMany: a single emitter throwing used to
80
+ * abort the whole batch (nothing for ANY language came back), and now
81
+ * surfaces as { source: null, error } for that language alone.
82
+ */
83
+ function emitAll(fnDef) {
84
+ return emitMany(fnDef);
85
+ }
86
+
87
+ /**
88
+ * Creates an isolated "session": its own private macro/extern registry
89
+ * (see macros.js's createRegistry()), plus every macro/extern/evaluate/
90
+ * emit-shaped function bound to use it instead of the process-wide
91
+ * default registry loadMacro/loadExtern/evaluate/emit/emitMany use when
92
+ * called bare. Purely additive -- loadMacro/loadExtern/etc. above are
93
+ * completely unaffected by a session's existence, and a session's own
94
+ * registrations are invisible to them and to every OTHER session,
95
+ * garbage-collected normally once the session object itself is no
96
+ * longer referenced. No removal API: rebuild a fresh session (via a new
97
+ * createSession() call) instead of trying to unregister one macro/extern
98
+ * out of an existing one -- see the design discussion this came out of
99
+ * (github.com/theraccoonbear/exprforge/issues/21).
100
+ *
101
+ * Useful whenever a program legitimately needs more than one independent
102
+ * "namespace" of macros/externs at once -- e.g. a multi-tenant service
103
+ * evaluating math defined by different untrusted users, where one
104
+ * user's loadMacro("helper", ...) must never resolve inside another
105
+ * user's expression just because they picked the same name.
106
+ */
107
+ function createSession() {
108
+ const registry = createRegistry();
109
+ return {
110
+ loadMacro: (name, def) => loadMacro(name, def, registry),
111
+ loadExtern: (name, def) => loadExtern(name, def, registry),
112
+ expandMacros: (fnOrNode, extraRegistry = null) => expandMacros(fnOrNode, extraRegistry, registry),
113
+ evaluate: (fn, args) => evaluate(fn, args, registry),
114
+ emit: (fnDef, lang) => emit(fnDef, lang, registry),
115
+ emitMany: (fnDef, langs = Object.keys(emitters)) => emitMany(fnDef, langs, registry),
116
+ loadExpr: (path) => loadExpr(path, registry),
117
+ loadExprSource: (source, label = "loadExprSource()") => loadExprSource(source, label, registry),
118
+ };
119
+ }
120
+
26
121
  module.exports = {
27
122
  // AST builders — use these to define your own formulas.
28
- num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets,
123
+ num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, field, collectLets, checkUnboundVars,
29
124
  // Authoring convenience — not an AST primitive, see util.js.
30
125
  forComponents,
31
126
  // Infix syntax sugar over the builders above — same Nodes, see expr.js.
@@ -37,6 +132,28 @@ module.exports = {
37
132
  // A native interpreter over the AST -- evaluate(fn, args) computes a
38
133
  // result directly in JS, no codegen/compile step. See evaluate.js.
39
134
  evaluate,
135
+ // Register a macro: a name usable inside fn`...`/expr`...` text
136
+ // beyond the built-in primitives, inline-expanded at build time,
137
+ // never emitted as a real call. See macros.js's own header comment.
138
+ loadMacro,
139
+ // Register an extern: same usable-by-name mechanism, but a real
140
+ // per-target native call instead -- caller-owned risk, ExprForge
141
+ // can't verify it. See macros.js's own header comment.
142
+ loadExtern,
143
+ // Runs the same macro/field-access expansion evaluate() and every
144
+ // emitter already run internally -- exposed for callers who want the
145
+ // expanded tree itself (e.g. to inspect or re-emit it without
146
+ // re-running expansion). Ordinary callers never need this.
147
+ expandMacros,
148
+ // Parses a .expr file (the exprsyntax emitter's own round-trip
149
+ // format) into its function definitions, letting later functions in
150
+ // the file reference earlier ones as inline macros. See load-expr.js.
151
+ loadExpr,
152
+ // Same parser, given source text directly instead of a file path --
153
+ // no filesystem involved, so this is the one usable from a browser
154
+ // (a text editor buffer, an HTTP response, ...). loadExpr(path) is
155
+ // now just this plus a readFileSync.
156
+ loadExprSource,
40
157
  // Built-in example formulas — see samples/ for the source.
41
158
  catmullRomAst,
42
159
  fibonacciAst,
@@ -47,15 +164,31 @@ module.exports = {
47
164
  // Also not a worked example -- a conformance-test fixture for
48
165
  // require("exprforge/math"). See samples/math-demo.js.
49
166
  mathDemoAst,
167
+ // Also not a worked example -- a conformance-test fixture for
168
+ // macros.js's AST-fn-def macro tier specifically (loadMacro(name,
169
+ // fn`...`), not the plain-JS-function tier mathDemoAst already
170
+ // covers). See samples/macro-demo.js.
171
+ macroDemoAst,
50
172
  samples: {
51
173
  catmullRom: catmullRomAst,
52
174
  fibonacci: fibonacciAst,
53
175
  splineFrame: splineFrameAsts,
54
176
  kitchenSink: kitchenSinkAst,
55
177
  mathDemo: mathDemoAst,
178
+ macroDemo: macroDemoAst,
56
179
  },
57
180
  // Per-language emitter instances, keyed by name (js, qb64, c, java, go, rust).
58
181
  emitters,
59
- // Convenience: run every emitter at once.
182
+ // One target, explicit -- see this function's own doc comment above.
183
+ emit,
184
+ // Several targets at once, each isolated from the others' failures.
185
+ emitMany,
186
+ // Deprecated: every target at once, no way to ask for fewer. Prefer
187
+ // emit()/emitMany() above -- kept working, not removed.
60
188
  emitAll,
189
+ // Creates an isolated session: its own private macro/extern registry,
190
+ // plus loadMacro/loadExtern/evaluate/emit/emitMany/loadExpr/
191
+ // loadExprSource bound to use it. See this function's own doc
192
+ // comment above.
193
+ createSession,
61
194
  };