exprforge 0.2.1 → 0.3.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 CHANGED
@@ -21,10 +21,12 @@
21
21
  Author a math expression once, as a small AST, and emit verified,
22
22
  identical-behavior implementations in JavaScript, TypeScript, Python, C#,
23
23
  Lua, QB64, C, Java, Go, Rust, Perl, PHP, Julia, Fortran, Zig, Scheme
24
- (Guile), and COBOL (GnuCOBOL).
24
+ (Guile), and COBOL (GnuCOBOL) — plus a native in-process evaluator and a
25
+ printer for exprforge's own readable syntax (see `fn`/`expr` below).
25
26
 
26
- No parser, no dependencies. You build the AST directly with plain JS
27
- functions; the same tree is walked once per target language.
27
+ No required dependencies. You can build the AST directly with plain JS
28
+ functions, or author it as readable infix text via `expr`/`fn` (see
29
+ below) — either way, the same tree is walked once per target.
28
30
 
29
31
  ## Why
30
32
 
@@ -52,6 +54,46 @@ Neither is "translate my code for me" — it's "prove two independent
52
54
  implementations of one formula actually match," which is a narrower,
53
55
  checkable claim.
54
56
 
57
+ ## Layered by design
58
+
59
+ Everything here is layered, and the layers don't reach back into each
60
+ other — worth being explicit about, especially if you're evaluating this
61
+ for something like a migration and want to know exactly what you're
62
+ trusting:
63
+
64
+ - **The AST and its emitters — the whole value proposition.** `ast.js`'s
65
+ builders (`num`, `v`, `add`, `mul`, `letIn`, `select`, `outputs`, ...)
66
+ build a plain tree of plain objects; every `emitters/<lang>.js` file
67
+ turns that tree into target-language source text. This is also the
68
+ *entire* dependency graph for it: every emitter requires only
69
+ `emitters/base.js` and `ast.js` — nothing else in this repo. No parser,
70
+ no custom syntax, no interpreter sits between your AST and the code it
71
+ emits. Every example in `samples/` is built this way, and the library
72
+ worked exactly this way for its first two published releases, before
73
+ anything below existed.
74
+ - **`expr`/`fn` — optional authoring sugar.** Nested builder calls
75
+ (`add(mul(v("a"), v("b")), num(1))`) get hard to read past a few terms.
76
+ `expr`/`fn` are a small hand-rolled tokenizer and recursive-descent
77
+ parser that turn ordinary infix text (`` expr`a * b + 1` ``) into
78
+ *exactly* the same tree the builders would — checked by structural unit
79
+ tests and a full print-reparse-evaluate round trip across every sample
80
+ this project has (see "Testing" below), not just asserted. It's
81
+ genuinely optional: nothing in the AST/emitter layer above calls into
82
+ it or imports it. Don't want a parser in your dependency graph, for a
83
+ security review or otherwise? Don't call `expr`/`fn` — build the tree
84
+ with the plain functions instead, and every emitter behaves identically
85
+ either way.
86
+ - **The native evaluator and the `expr`-syntax printer — additive
87
+ conveniences.** `evaluate()` (compute a result in-process, no
88
+ target-language toolchain needed) and the `expr` emitter target (print
89
+ any AST back out as readable text) sit off to the side the same way
90
+ `expr`/`fn` do — nothing else depends on them either.
91
+
92
+ If you only care about "does this correctly turn my AST into
93
+ COBOL/Java/whatever" — `ast.js` and the one `emitters/<lang>.js` file you
94
+ care about are the entire surface that matters. Everything else is there
95
+ if you want it, invisible if you don't.
96
+
55
97
  ## Install
56
98
 
57
99
  ```
@@ -61,12 +103,12 @@ npm install exprforge
61
103
  ## Usage
62
104
 
63
105
  ```js
64
- const { num, v, bin, mul, add, sub, emitAll } = require("exprforge");
106
+ const { expr, emitAll } = require("exprforge");
65
107
 
66
108
  const fn = {
67
109
  name: "lerp",
68
110
  params: ["a", "b", "t"],
69
- body: add(v("a"), mul(sub(v("b"), v("a")), v("t"))),
111
+ body: expr`(b - a) * t + a`,
70
112
  };
71
113
 
72
114
  const outputs = emitAll(fn);
@@ -74,6 +116,13 @@ console.log(outputs.rust.source);
74
116
  console.log(outputs.c.source);
75
117
  ```
76
118
 
119
+ `` expr`(b - a) * t + a` `` and `add(v("a"), mul(sub(v("b"), v("a")), v("t")))`
120
+ build the *exact same tree* — `expr` (see below) is optional infix syntax
121
+ sugar over the same builders, not a different API. Every example below
122
+ still uses the builders directly, since that's what `expr` compiles down
123
+ to and what you'll reach for once a formula needs `${...}`-spliced
124
+ sub-expressions.
125
+
77
126
  ## Samples
78
127
 
79
128
  `samples/` has worked, non-trivial examples (also exported from the
@@ -236,6 +285,98 @@ See [`docs/planned-additions.md`](./docs/planned-additions.md) for the
236
285
  full design rationale, including why the naive "guard division with
237
286
  select" pattern is wrong.
238
287
 
288
+ ## Infix expression syntax (`` expr` ` ``)
289
+
290
+ `add(mul(v("a"), v("b")), num(1))` is exactly what gets built, but it's
291
+ not what a human reads at a glance. `expr` is a tagged template literal
292
+ that parses ordinary infix math syntax into that same tree — same nodes,
293
+ different spelling, no new capability:
294
+
295
+ ```js
296
+ const { v, expr } = require("exprforge");
297
+
298
+ expr`a * b + 1`
299
+ // identical tree to add(mul(v("a"), v("b")), num(1))
300
+
301
+ expr`(-b + sqrt(b^2 - 4*a*c)) / (2*a)`
302
+ // the quadratic formula, readable as the quadratic formula
303
+ ```
304
+
305
+ | Syntax | Lowers to |
306
+ |---|---|
307
+ | `+ - * /` | `add`/`sub`/`mul`/`div` — standard precedence, left-associative |
308
+ | `^` | `call("pow", base, exponent)` — **not** a `bin` node (there is no `"^"` operator in the AST; every emitter's `calls` table keys `pow` by name, even targets whose own syntax has a native `^`/`**`). Right-associative and binds *tighter* than unary minus, standard math convention: `-2^2` is `-4`, `2^3^2` is `512`. |
309
+ | `-x` | `neg(x)` |
310
+ | `name(args...)` | `call("name", ...args)` — not checked against the 22 known functions at parse time, same deferred-to-emission-time error every hand-built `call()` already gets |
311
+ | bare `name` | `v("name")` |
312
+ | `cond ? then : else` | `select(cmp(left, op, right), then, else)` — the **only** place a comparison (`> < >= <= == !=`) is valid, matching `cmp()`'s own documented constraint that it's never a general boolean expression. A bare `a > b` with no `?` is a parse-time error, not a deferred one. Chains naturally: `a>0 ? 1 : b>0 ? 2 : 3`. |
313
+ | `${...}` | Splices in an existing AST node as-is, or a plain JS number (auto-wrapped via `num()`). Anything else throws immediately. Plain strings aren't interpolatable — a bare identifier in the template text already means "variable", with no `${}` needed. |
314
+
315
+ Deliberately **not** in the grammar: `let`/`outputs` blocks (it's a pure
316
+ expression grammar, same "expression AST, not a program AST" boundary as
317
+ the rest of exprforge — wrap the result in `letIn`/`letChain`/`outputs`,
318
+ or reach for `fn` below, which adds exactly that) and `&&`/`||` (the AST
319
+ has no boolean-combinator node to lower them to).
320
+
321
+ ```js
322
+ // Named subexpressions still go around expr(), not inside it:
323
+ letIn("mag", expr`sqrt(x^2 + y^2)`, expr`x / mag`)
324
+ ```
325
+
326
+ ## Full-program syntax (`` fn`...` ``)
327
+
328
+ `expr` covers one expression; `fn` covers a whole function body —
329
+ `let` bindings plus a `return`, on top of the exact same expression
330
+ grammar (every expression inside a `fn` template is parsed by the same
331
+ engine `expr` uses). Lowers to real `letChain`/`outputs` calls, same
332
+ "same nodes, different spelling" guarantee as `expr` itself:
333
+
334
+ ```js
335
+ const { fn } = require("exprforge");
336
+
337
+ const body = fn`
338
+ let mag = sqrt(x^2 + y^2);
339
+ return { nx: x / mag, ny: y / mag };
340
+ `;
341
+ // identical tree to:
342
+ // letIn("mag", call("sqrt", ...), outputs({ nx: div(v("x"), v("mag")), ny: ... }))
343
+
344
+ const normalize2 = { name: "normalize2", params: ["x", "y"], body };
345
+ ```
346
+
347
+ | Syntax | Lowers to |
348
+ |---|---|
349
+ | `let name = expr;` | one `[name, valueNode]` pair, in order — a later `let` can reference an earlier one's name |
350
+ | `return expr;` | the chain's final expression |
351
+ | `return { name: expr, ... };` | `outputs({ name: node, ... })` as the chain's final expression |
352
+
353
+ Duplicate `let` names aren't rejected by the parser itself — same
354
+ deferred-to-`collectLets` behavior every hand-built `letIn`/`letChain`
355
+ already has. A `fn` body with no `let` statements at all is just
356
+ `return expr;`, equivalent to a bare `expr` call.
357
+
358
+ ## Printing an AST back out, and a native evaluator
359
+
360
+ Two things that fall out of `fn` existing: `emitters.expr` is a real,
361
+ registered 18th target that prints any AST *back out* as `fn`/`expr`
362
+ source text (the reverse of parsing it) — useful for debugging a
363
+ formula built from several composed helpers, or just getting a readable
364
+ string to log or paste into a future `fn`/`expr` call. And `evaluate(fn,
365
+ args)` (also exported from the main package) is a native tree-walking
366
+ interpreter over the same AST, computing a result directly in JS with no
367
+ codegen or compile step — the same node types every emitter already
368
+ handles, backed by the real `Math.*` functions.
369
+
370
+ ```js
371
+ const { emitAll, evaluate } = require("exprforge");
372
+
373
+ emitAll(normalize2).expr.source;
374
+ // "let mag = sqrt(((x^2) + (y^2)));\nreturn { nx: (x / mag), ny: (y / mag) };\n"
375
+
376
+ evaluate(normalize2, [3, 4]);
377
+ // { nx: 0.6, ny: 0.8 }
378
+ ```
379
+
239
380
  ## Multiple named outputs
240
381
 
241
382
  `outputs({ name: Node, ... })` computes several named values from ONE
@@ -295,13 +436,27 @@ pre-declared locals the way Go's named returns are.
295
436
  npm test
296
437
  ```
297
438
 
298
- Runs `node --test`. For each sample, that's two kinds of check:
439
+ Runs `node --test`. For each sample, that's three kinds of check:
299
440
 
300
441
  - Emitted JS vs. an independently hand-written reference implementation
301
442
  (catches a wrong formula in the AST itself).
302
443
  - Every other emitted target vs. that same JS, compiled (and, for
303
444
  TypeScript, also type-checked under `--strict`) and run, with the sample
304
445
  inputs as arguments (catches an emitter bug).
446
+ - The `expr`-syntax printer (`emitters/exprsyntax.js`) vs. `fn`'s own
447
+ parser: every sample AST in this suite is printed back out as `fn`/
448
+ `expr` source text, reparsed, and evaluated (via `evaluate()`) to
449
+ confirm the round trip behaves identically to the original. This is a
450
+ stronger claim than either piece being separately unit-tested — the
451
+ printer and the parser are two independent pieces of code that have to
452
+ agree with each other across every real formula this project has, not
453
+ just cases either one's own author thought to hand-write a test for.
454
+ It's also not hypothetical: this exact check caught a real bug during
455
+ development (a ternary printed without enough parens, so
456
+ `crossX / (rLen > eps ? rLen : 1)` reparsed with the wrong grouping)
457
+ that every other check here — including full cross-language conformance
458
+ — had no way to catch, since it's specific to the printer/parser pair
459
+ and nothing else in the pipeline touches that code path.
305
460
 
306
461
  The compiled/interpreted-language checks need their toolchain on `PATH`
307
462
  and skip (not fail) when it's missing, so `npm test` degrades gracefully
package/emitters/cobol.js CHANGED
@@ -62,6 +62,35 @@ function checkReservedNames(names) {
62
62
  }
63
63
  }
64
64
 
65
+ // Separate from COBOL_RESERVED above (general keywords/intrinsics, safe
66
+ // to forbid everywhere including internal let-bindings) and checked only
67
+ // against names that end up in a CALL/PROCEDURE DIVISION USING clause
68
+ // (fn.name, params, output fields) -- NOT let-bindings. A bare "c"
69
+ // (case-insensitive) breaks specifically in a USING identifier list --
70
+ // confirmed against a real compiler ("syntax error, unexpected C"),
71
+ // reproducible in isolation, and not shared by neighboring single letters
72
+ // (b/d/e/... all compile fine in the identical position; likely GnuCOBOL
73
+ // misparsing it as an attempted abbreviation of BY CONTENT). Deliberately
74
+ // NOT added to COBOL_RESERVED: a plain WORKING-STORAGE item named "c"
75
+ // referenced only in COMPUTE statements compiles fine (confirmed too),
76
+ // and samples/spline-frame.js already has a working "c" let-binding (for
77
+ // cos(rad)) that would break for no real reason if this were checked
78
+ // there as well.
79
+ const COBOL_USING_RESERVED = new Set(["c"]);
80
+
81
+ function checkUsingClauseNames(names) {
82
+ for (const name of names) {
83
+ if (COBOL_USING_RESERVED.has(name.toLowerCase())) {
84
+ throw new Error(
85
+ `emitter for .cob: "${name}" can't be a function/parameter/output name -- it breaks ` +
86
+ `GnuCOBOL's CALL ... USING clause specifically (confirmed against a real compiler), ` +
87
+ `even though it's fine as an internal let-binding name (see COBOL_USING_RESERVED in ` +
88
+ `emitters/cobol.js) -- rename it`,
89
+ );
90
+ }
91
+ }
92
+ }
93
+
65
94
  function fn1(name) {
66
95
  return ([x]) => `FUNCTION ${name}(${x})`;
67
96
  }
@@ -241,6 +270,35 @@ function expandExponential(s) {
241
270
  return sign + result;
242
271
  }
243
272
 
273
+ // "**" must never end up nested inside ANOTHER call's argument -- that's
274
+ // the actual, general form of the confirmed bug (see the file header):
275
+ // some GnuCOBOL 4.x builds route a "**" expression through a broken
276
+ // internal decimal codegen path (missing header for cob_decimal) when
277
+ // it's nested inside a FUNCTION(...) argument, e.g. sqrt(a^2 + b^2) --
278
+ // confirmed against real CI, not reproducible against this project's own
279
+ // (older) local GnuCOBOL. A plain top-level "**" (kitchen-sink's
280
+ // pow(x, y), summed directly, never nested as another call's argument)
281
+ // is fine.
282
+ //
283
+ // Rather than rely on every caller remembering to keep a pow() result out
284
+ // of anywhere risky, pow ALWAYS spills its own result here: call("pow", a,
285
+ // b) unconditionally evaluates to a bare temp name, so "**" can only ever
286
+ // appear in its own dedicated COMPUTE statement, never nested inside
287
+ // anything else regardless of what the surrounding expression does with
288
+ // the result. hypot's internal sum-of-squares reuses this directly, since
289
+ // it builds "**" itself rather than going through call("pow", ...).
290
+ //
291
+ // Arrow function callers below, not plain ones: base.js's emitExpr calls
292
+ // `this.calls[node.name](args)` unbound -- this closes over the `emitter`
293
+ // const the same way atan2 already does (see its own comment further
294
+ // down), fully assigned by the time this ever actually runs.
295
+ function spillPow(baseRaw, exponentRaw) {
296
+ const pool = emitter._pool;
297
+ const base = pool.spill(baseRaw);
298
+ const exponent = pool.spill(exponentRaw);
299
+ return pool.spill(`(${base} ** ${exponent})`);
300
+ }
301
+
244
302
  const emitter = new CobolEmitter({
245
303
  ext: "cob",
246
304
  formatNumber: (v) => {
@@ -252,13 +310,21 @@ const emitter = new CobolEmitter({
252
310
  asin: fn1("ASIN"), acos: fn1("ACOS"), atan: fn1("ATAN"),
253
311
  exp: fn1("EXP"), log: fn1("LOG"), log10: fn1("LOG10"),
254
312
  min: fn2("MIN"), max: fn2("MAX"),
255
- pow: ([x, y]) => `(${x} ** ${y})`,
313
+ pow: ([x, y]) => spillPow(x, y),
256
314
  // No LOG2 intrinsic -- derive it (nesting two intrinsics is fine;
257
315
  // only nesting a call inside a USER-DEFINED function's argument
258
316
  // was the confirmed problem -- see the file header).
259
317
  log2: ([x]) => `(FUNCTION LOG(${x}) / FUNCTION LOG(2.0))`,
260
- // No HYPOT intrinsic -- derive it the same way.
261
- hypot: ([a, b]) => `FUNCTION SQRT((${a}) ** 2 + (${b}) ** 2)`,
318
+ // No HYPOT intrinsic -- derive it via spillPow (see its own
319
+ // comment): the sum-of-squares itself also gets spilled to its
320
+ // own temp before FUNCTION SQRT ever sees it, so SQRT's argument
321
+ // is always a bare name, never a "**"-containing expression.
322
+ hypot: ([aRaw, bRaw]) => {
323
+ const aSq = spillPow(aRaw, "2.0");
324
+ const bSq = spillPow(bRaw, "2.0");
325
+ const sumSq = emitter._pool.spill(`${aSq} + ${bSq}`);
326
+ return `FUNCTION SQRT(${sumSq})`;
327
+ },
262
328
  // FUNCTION INTEGER is floor (greatest integer <= x, confirmed
263
329
  // against a real compiler, including for negatives). COMPUTE's
264
330
  // automatic numeric conversion hands it back as COMP-2 with no
@@ -358,6 +424,7 @@ const emitter = new CobolEmitter({
358
424
  // the program name as a plain string literal, immune to that.
359
425
  formatFunction: (fn, body, letLines, letDecls, bodyLines) => {
360
426
  checkReservedNames([fn.name, ...fn.params]);
427
+ checkUsingClauseNames([fn.name, ...fn.params]);
361
428
  const linkageParams = [...fn.params, "ef-result"];
362
429
  const paramDecls = linkageParams.map((p) => ` 01 ${p} USAGE COMP-2.`).join("\n");
363
430
  const wsDecls = letDecls.map((n) => ` 01 ${n} USAGE COMP-2.`).join("\n");
@@ -382,6 +449,7 @@ const emitter = new CobolEmitter({
382
449
  formatSuite: (fn, outputStrs, letLines, letDecls, outputLines) => {
383
450
  const outputNames = Object.keys(outputStrs);
384
451
  checkReservedNames([fn.name, ...fn.params, ...outputNames]);
452
+ checkUsingClauseNames([fn.name, ...fn.params, ...outputNames]);
385
453
  const linkageParams = [...fn.params, ...outputNames];
386
454
  const paramDecls = linkageParams.map((p) => ` 01 ${p} USAGE COMP-2.`).join("\n");
387
455
  const wsDecls = letDecls.map((n) => ` 01 ${n} USAGE COMP-2.`).join("\n");
@@ -0,0 +1,81 @@
1
+ // exprforge/emitters/exprsyntax.js
2
+ //
3
+ // Prints an AST back out as fn`...`/expr`...`-compatible source text --
4
+ // the reverse direction of expr.js/fn.js. Not a third-party language:
5
+ // there's no external compiler/interpreter to run this output through,
6
+ // so it's verified by round-trip instead (emit -> reparse via fn() ->
7
+ // deepStrictEqual the original, see test/exprsyntax-emitter.test.js).
8
+ // Registered as a real target anyway (emitters.expr) since fn/expr
9
+ // syntax is a real, standalone, human-pasteable output format in its
10
+ // own right, not just a test fixture.
11
+ //
12
+ // "call" is overridden wholesale rather than populated via a `calls`
13
+ // table: every intrinsic passes through uniformly as `name(args...)`
14
+ // except pow, which recovers fn/expr's own `^` sugar -- both forms
15
+ // lower back to the identical call("pow", ...) node (see expr.js's
16
+ // grammar comment on rule 2), so this is a readability choice, not a
17
+ // correctness one. A side effect: this is the one emitter that never
18
+ // needs a table update when a new intrinsic is added anywhere else in
19
+ // the project.
20
+ const Emitter = require("./base.js");
21
+
22
+ class ExprSyntaxEmitter extends Emitter {
23
+ emitExpr(node) {
24
+ if (node.type === "call" && node.name === "pow" && node.args.length === 2) {
25
+ const [base, exponent] = node.args.map((a) => this.emitExpr(a));
26
+ return `(${base}^${exponent})`;
27
+ }
28
+ if (node.type === "call") {
29
+ const args = node.args.map((a) => this.emitExpr(a));
30
+ return `${node.name}(${args.join(", ")})`;
31
+ }
32
+ return super.emitExpr(node);
33
+ }
34
+ }
35
+
36
+ function letLines(letBindings) {
37
+ return letBindings.map(({ name, valueStr }) => `let ${name} = ${valueStr};`);
38
+ }
39
+
40
+ const emitter = new ExprSyntaxEmitter({
41
+ ext: "fn",
42
+ // fn/expr accepts JS-style numeric literal syntax directly -- it's
43
+ // literally the same NUMBER lexing rule (see expr.js's tokenizer).
44
+ formatNumber: (v) => String(v),
45
+ // base.js's default _defaultSelect wraps just the comparison in its
46
+ // own parens ("((L op R) ? then : else)") -- fn/expr's ternary
47
+ // grammar has no parenthesized-COMPARISON form (see expr.js's
48
+ // grammar comment: `ternary := additive compOp additive "?" ...`),
49
+ // so "(L op R)" alone would try to parse as a complete, paren-
50
+ // wrapped ternary condition with no "?" in sight and hit the parser's
51
+ // own "comparison must be used as a ternary condition" error. This
52
+ // wraps the WHOLE ternary in one outer paren pair instead -- still a
53
+ // valid `primary := ... | "(" expression ")" | ...`, and unlike the
54
+ // unwrapped form, safe to embed as a sub-expression anywhere (e.g.
55
+ // `crossX / (rLen > eps ? rLen : 1)`) without the surrounding
56
+ // operator's precedence reaching into the ternary's own condition.
57
+ emitSelect: function (condNode, thenStr, elseStr) {
58
+ const L = this.emitExpr(condNode.left);
59
+ const R = this.emitExpr(condNode.right);
60
+ return `(${L} ${condNode.op} ${R} ? ${thenStr} : ${elseStr})`;
61
+ },
62
+ // Output is bare fn/expr source text -- no JS wrapper/boilerplate.
63
+ // That's both the most direct thing to paste into a real
64
+ // `` fn`...` `` call, and exactly what the round-trip test reparses
65
+ // with zero unwrapping first.
66
+ formatFunction: (fn, bodyStr, letBindings = []) => {
67
+ const lines = letLines(letBindings);
68
+ lines.push(`return ${bodyStr};`);
69
+ return lines.join("\n") + "\n";
70
+ },
71
+ 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} };`);
77
+ return lines.join("\n") + "\n";
78
+ },
79
+ });
80
+
81
+ module.exports = emitter;
@@ -19,4 +19,5 @@ module.exports = {
19
19
  zig: require("./zig.js"),
20
20
  scheme: require("./scheme.js"),
21
21
  cobol: require("./cobol.js"),
22
+ expr: require("./exprsyntax.js"),
22
23
  };
package/evaluate.js ADDED
@@ -0,0 +1,109 @@
1
+ // exprforge/evaluate.js
2
+ //
3
+ // A native tree-walking interpreter over the exact same AST every
4
+ // emitter compiles from -- evaluate(fn, args) computes a result (or a
5
+ // {name: value} object for a multi-output suite) directly in JS, no
6
+ // codegen/compile/subprocess step involved. Reuses collectLets (ast.js)
7
+ // for the same let-lifting every emitter already goes through, so this
8
+ // walks nodes in the identical dependency order every target does, and
9
+ // there's exactly one node-shape contract (ast.js's own header comment)
10
+ // for both this file and every emitters/<lang>.js to agree with.
11
+ //
12
+ // Every intrinsic name maps 1:1 onto emitters/js.js's own `calls` table
13
+ // keys (the simplest existing source of truth for "what the ~22
14
+ // intrinsics are called") straight to the real Math.* function -- this
15
+ // target has no codegen step to route an intermediate string through.
16
+ const { collectLets } = require("./ast.js");
17
+
18
+ const CMP_OPS = {
19
+ ">": (a, b) => a > b,
20
+ "<": (a, b) => a < b,
21
+ ">=": (a, b) => a >= b,
22
+ "<=": (a, b) => a <= b,
23
+ "==": (a, b) => a === b,
24
+ "!=": (a, b) => a !== b,
25
+ };
26
+
27
+ const BIN_OPS = {
28
+ "+": (a, b) => a + b,
29
+ "-": (a, b) => a - b,
30
+ "*": (a, b) => a * b,
31
+ "/": (a, b) => a / b,
32
+ };
33
+
34
+ const CALLS = {
35
+ sqrt: Math.sqrt, abs: Math.abs, sin: Math.sin, cos: Math.cos, tan: Math.tan,
36
+ asin: Math.asin, acos: Math.acos, atan: Math.atan, log: Math.log,
37
+ log2: Math.log2, log10: Math.log10, exp: Math.exp, floor: Math.floor,
38
+ ceil: Math.ceil, round: Math.round, trunc: Math.trunc, sign: Math.sign,
39
+ pow: Math.pow, atan2: Math.atan2, min: Math.min, max: Math.max, hypot: Math.hypot,
40
+ };
41
+
42
+ // Handles every node type EXCEPT "let"/"outputs" -- those are only ever
43
+ // valid pre-collectLets (a function's top-level let-chain/body shape),
44
+ // never nested inside a bin/call/select, same constraint every emitter
45
+ // already relies on (see ast.js's own comments on letIn/outputs).
46
+ function evalNode(node, env) {
47
+ switch (node.type) {
48
+ case "num":
49
+ return node.value;
50
+ case "var":
51
+ if (!(node.name in env)) {
52
+ throw new Error(`evaluate(): unbound variable "${node.name}"`);
53
+ }
54
+ return env[node.name];
55
+ case "bin": {
56
+ const op = BIN_OPS[node.op];
57
+ if (!op) throw new Error(`evaluate(): unknown bin op "${node.op}"`);
58
+ return op(evalNode(node.left, env), evalNode(node.right, env));
59
+ }
60
+ case "call": {
61
+ const impl = CALLS[node.name];
62
+ if (!impl) throw new Error(`evaluate(): no mapping for Math function "${node.name}"`);
63
+ return impl(...node.args.map((a) => evalNode(a, env)));
64
+ }
65
+ case "select": {
66
+ const cmpFn = CMP_OPS[node.cond.op];
67
+ 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);
70
+ }
71
+ default:
72
+ throw new Error(
73
+ `evaluate(): unexpected node type "${node.type}" -- "let"/"outputs" must already ` +
74
+ `be lifted out by collectLets before evalNode runs`,
75
+ );
76
+ }
77
+ }
78
+
79
+ // evaluate(fn, args) -- fn is a {name, params, body} definition (the
80
+ // same shape emitAll() consumes), args is a plain array positional to
81
+ // fn.params. Returns a number for a plain body, or a {name: value}
82
+ // object for a multi-output (outputs()) body -- matching the shape
83
+ // test/conformance.test.js's own parseSuiteOutput() already expects
84
+ // back from every other target.
85
+ function evaluate(fn, args) {
86
+ if (args.length !== fn.params.length) {
87
+ throw new Error(`evaluate(): ${fn.name} expects ${fn.params.length} argument(s), got ${args.length}`);
88
+ }
89
+ const env = {};
90
+ fn.params.forEach((name, i) => {
91
+ env[name] = args[i];
92
+ });
93
+
94
+ const { bindings, body } = collectLets(fn.body);
95
+ for (const { name, node } of bindings) {
96
+ env[name] = evalNode(node, env);
97
+ }
98
+
99
+ if (body.type === "outputs") {
100
+ const result = {};
101
+ for (const [name, node] of Object.entries(body.fields)) {
102
+ result[name] = evalNode(node, env);
103
+ }
104
+ return result;
105
+ }
106
+ return evalNode(body, env);
107
+ }
108
+
109
+ module.exports = { evaluate };
package/expr.js ADDED
@@ -0,0 +1,313 @@
1
+ // exprforge/expr.js
2
+ //
3
+ // Infix syntax sugar over ast.js's own builders -- NOT a new node type or
4
+ // a new capability. `` expr`a * b + 1` `` builds exactly the same tree
5
+ // `add(mul(v("a"), v("b")), num(1))` would, by calling num/v/add/sub/mul/
6
+ // div/neg/call/cmp/select directly (never constructing a raw {type: ...}
7
+ // object by hand), so every guarantee those builders already have
8
+ // (collectLets round-tripping, emitter compatibility) carries over for
9
+ // free. Unlike util.js's forComponents, this DOES return a Node -- it's
10
+ // closer in spirit to letChain (also in ast.js): a different way to
11
+ // spell the same tree, not a different tree.
12
+ //
13
+ // A tagged template literal, not a plain string function: `expr` is
14
+ // called by JS itself as expr(strings, ...values) -- see the grammar
15
+ // comment below for why that's the whole interpolation mechanism, with
16
+ // no `${` text syntax of its own to parse.
17
+ //
18
+ // Grammar:
19
+ //
20
+ // expression := ternary
21
+ // ternary := additive ( compOp additive "?" expression ":" expression )?
22
+ // compOp := ">" | "<" | ">=" | "<=" | "==" | "!="
23
+ // additive := multiplicative ( ("+"|"-") multiplicative )*
24
+ // multiplicative := unary ( ("*"|"/") unary )*
25
+ // unary := "-" unary | power
26
+ // power := primary ( "^" unary )?
27
+ // primary := NUMBER | IDENT ("(" args ")")? | "(" expression ")" | HOLE
28
+ // args := expression ("," expression)*
29
+ //
30
+ // Deliberately NOT supported (see the plan doc's "Scope decisions"):
31
+ // - No let/outputs blocks -- this is a pure expression grammar, same
32
+ // "expression AST, not a program AST" boundary as ast.js itself.
33
+ // Wrap the result in letIn/letChain/outputs instead.
34
+ // - No &&/|| -- the AST has no boolean-combinator node to lower them
35
+ // to. A comparison is ONLY ever valid as a ternary's condition
36
+ // (matching cmp()'s own documented constraint in ast.js), enforced
37
+ // here at PARSE time with a clear error, not deferred to the
38
+ // "cmp used outside select()" throw every emitter already has.
39
+ // - "^" lowers to call("pow", left, right), never a bin node -- bin.op
40
+ // is only ever "+"|"-"|"*"|"/" (see ast.js), and every emitter's
41
+ // `calls` table keys "pow" by name, even for targets whose own
42
+ // syntax has a native ^/** operator.
43
+ const { num, v, add, sub, mul, div, neg, call, cmp, select } = require("./ast.js");
44
+
45
+ const COMPARE_OPS = [">", "<", ">=", "<=", "==", "!="];
46
+
47
+ // Tokenizes one template-literal string segment, appending {type, value,
48
+ // pos} tokens to `tokens` (pos is an offset into the reconstructed full
49
+ // source string built in expr() below, used only for error messages).
50
+ // `label` is just which tag function's name shows up in error messages
51
+ // -- fn.js passes "fn()" here so a lex error inside `` fn`...` `` isn't
52
+ // misattributed to expr().
53
+ function tokenizeSegment(str, offset, tokens, label = "expr()") {
54
+ let i = 0;
55
+ while (i < str.length) {
56
+ const ch = str[i];
57
+ const start = i;
58
+ if (/\s/.test(ch)) {
59
+ i++;
60
+ continue;
61
+ }
62
+ // NUMBER: 123, 123.45, .5, 1e-9, 1.5E+10
63
+ if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(str[i + 1] || ""))) {
64
+ i++;
65
+ while (i < str.length && /[0-9]/.test(str[i])) i++;
66
+ if (str[i] === ".") {
67
+ i++;
68
+ while (i < str.length && /[0-9]/.test(str[i])) i++;
69
+ }
70
+ if (str[i] === "e" || str[i] === "E") {
71
+ let j = i + 1;
72
+ if (str[j] === "+" || str[j] === "-") j++;
73
+ if (/[0-9]/.test(str[j] || "")) {
74
+ i = j;
75
+ while (i < str.length && /[0-9]/.test(str[i])) i++;
76
+ }
77
+ }
78
+ tokens.push({ type: "NUMBER", value: Number(str.slice(start, i)), pos: offset + start });
79
+ continue;
80
+ }
81
+ // IDENT: variable names and function names, e.g. wy_wire, sqrt.
82
+ if (/[A-Za-z_]/.test(ch)) {
83
+ i++;
84
+ while (i < str.length && /[A-Za-z0-9_]/.test(str[i])) i++;
85
+ tokens.push({ type: "IDENT", value: str.slice(start, i), pos: offset + start });
86
+ continue;
87
+ }
88
+ // Two-character comparison operators before their one-character
89
+ // prefixes, so ">=" doesn't get lexed as ">" followed by "=".
90
+ if ((ch === ">" || ch === "<" || ch === "=" || ch === "!") && str[i + 1] === "=") {
91
+ tokens.push({ type: "OP", value: str.slice(i, i + 2), pos: offset + start });
92
+ i += 2;
93
+ continue;
94
+ }
95
+ // ";", "{", "}", "=" aren't used by expr()'s own grammar -- they're
96
+ // here for fn.js's statement syntax (let name = ...; / return
97
+ // {...};) to reuse this same tokenizer instead of forking it.
98
+ // Inert for expr(): nothing that parses successfully today could
99
+ // contain them anyway ("=" alone was always a lex error before,
100
+ // since only "==" was recognized).
101
+ if ("+-*/^(),?:><;{}=".includes(ch)) {
102
+ tokens.push({ type: "OP", value: ch, pos: offset + start });
103
+ i++;
104
+ continue;
105
+ }
106
+ throw new Error(`${label}: unexpected character "${ch}" at position ${offset + start}`);
107
+ }
108
+ }
109
+
110
+ // A HOLE's value is resolved to a Node right where it's produced (not
111
+ // deferred into the parser), so a bad interpolation fails immediately
112
+ // with a clear error rather than surfacing as a confusing parse error
113
+ // somewhere else in the tree. `label` -- see tokenizeSegment above.
114
+ function holeToNode(value, label = "expr()") {
115
+ if (typeof value === "number") return num(value);
116
+ if (value && typeof value === "object" && typeof value.type === "string") return value;
117
+ const shown = typeof value === "string" ? `"${value}"` : JSON.stringify(value);
118
+ throw new Error(
119
+ `${label}: interpolated value must be an AST node or a plain number, got ${shown} -- ` +
120
+ `a bare variable name doesn't need interpolation, just write it directly in the template text`,
121
+ );
122
+ }
123
+
124
+ class Parser {
125
+ // `label` -- see tokenizeSegment above; also threaded through to
126
+ // holeToNode so a bad interpolation inside `` fn`...` `` reports
127
+ // "fn():" too, not just lex/parse errors.
128
+ constructor(tokens, source, label = "expr()") {
129
+ this.tokens = tokens;
130
+ this.source = source;
131
+ this.i = 0;
132
+ this.label = label;
133
+ }
134
+
135
+ peek() {
136
+ return this.tokens[this.i];
137
+ }
138
+
139
+ next() {
140
+ return this.tokens[this.i++];
141
+ }
142
+
143
+ isOp(value) {
144
+ const t = this.peek();
145
+ return t.type === "OP" && t.value === value;
146
+ }
147
+
148
+ expectOp(value) {
149
+ if (!this.isOp(value)) this.error(`expected "${value}"`);
150
+ return this.next();
151
+ }
152
+
153
+ error(message) {
154
+ const t = this.peek();
155
+ const tokDesc = t.type === "EOF" ? "end of input" : `"${t.value}"`;
156
+ throw new Error(`${this.label}: ${message} -- found ${tokDesc} at position ${t.pos} in \`${this.source}\``);
157
+ }
158
+
159
+ parseExpression() {
160
+ return this.parseTernary();
161
+ }
162
+
163
+ // Only place a comparison is ever accepted -- matches cmp()'s own
164
+ // documented constraint in ast.js exactly (only valid as select()'s
165
+ // cond). A bare comparison with no trailing "?" is a parse error
166
+ // here, not deferred to the "cmp used outside select()" throw every
167
+ // emitter already has.
168
+ parseTernary() {
169
+ const left = this.parseAdditive();
170
+ const t = this.peek();
171
+ if (t.type === "OP" && COMPARE_OPS.includes(t.value)) {
172
+ const op = this.next().value;
173
+ const right = this.parseAdditive();
174
+ if (!this.isOp("?")) {
175
+ this.error(`comparison ("${op}") must be used as a ternary condition ("cond ${op} ... ? then : else")`);
176
+ }
177
+ this.next(); // consume "?"
178
+ const thenNode = this.parseExpression();
179
+ this.expectOp(":");
180
+ const elseNode = this.parseExpression();
181
+ return select(cmp(left, op, right), thenNode, elseNode);
182
+ }
183
+ if (this.isOp("?")) {
184
+ this.error(`"?" needs an explicit comparison as its condition (e.g. "a > 0 ? x : y") -- a bare value can't be a select() condition`);
185
+ }
186
+ return left;
187
+ }
188
+
189
+ parseAdditive() {
190
+ let node = this.parseMultiplicative();
191
+ while (this.isOp("+") || this.isOp("-")) {
192
+ const op = this.next().value;
193
+ const right = this.parseMultiplicative();
194
+ node = op === "+" ? add(node, right) : sub(node, right);
195
+ }
196
+ return node;
197
+ }
198
+
199
+ parseMultiplicative() {
200
+ let node = this.parseUnary();
201
+ while (this.isOp("*") || this.isOp("/")) {
202
+ const op = this.next().value;
203
+ const right = this.parseUnary();
204
+ node = op === "*" ? mul(node, right) : div(node, right);
205
+ }
206
+ return node;
207
+ }
208
+
209
+ // Unary minus binds LOOSER than "^" (-2^2 = -4, not 4) -- standard
210
+ // math convention, and the reason `power` sits below `unary` here
211
+ // rather than the other way around.
212
+ parseUnary() {
213
+ if (this.isOp("-")) {
214
+ this.next();
215
+ return neg(this.parseUnary());
216
+ }
217
+ if (this.isOp("+")) {
218
+ this.next(); // unary plus: no-op
219
+ return this.parseUnary();
220
+ }
221
+ return this.parsePower();
222
+ }
223
+
224
+ // Right-associative (2^3^2 = 2^(3^2) = 512): the exponent recurses
225
+ // into `unary`, not `power`, which is also what lets the exponent
226
+ // itself carry a leading unary minus (2^-1 = 0.5).
227
+ parsePower() {
228
+ const base = this.parsePrimary();
229
+ if (this.isOp("^")) {
230
+ this.next();
231
+ const exponent = this.parseUnary();
232
+ return call("pow", base, exponent);
233
+ }
234
+ return base;
235
+ }
236
+
237
+ parsePrimary() {
238
+ const t = this.peek();
239
+ if (t.type === "NUMBER") {
240
+ this.next();
241
+ return num(t.value);
242
+ }
243
+ if (t.type === "HOLE") {
244
+ this.next();
245
+ return holeToNode(t.value, this.label);
246
+ }
247
+ if (t.type === "IDENT") {
248
+ this.next();
249
+ if (this.isOp("(")) {
250
+ this.next();
251
+ const args = [];
252
+ if (!this.isOp(")")) {
253
+ args.push(this.parseExpression());
254
+ while (this.isOp(",")) {
255
+ this.next();
256
+ args.push(this.parseExpression());
257
+ }
258
+ }
259
+ this.expectOp(")");
260
+ // Not validated against the 22 known Math functions here
261
+ // -- deferred to the same "no mapping for Math function"
262
+ // check every hand-built call() node already goes
263
+ // through in emitters/base.js, so there's only one list
264
+ // of known function names to keep in sync, not two.
265
+ return call(t.value, ...args);
266
+ }
267
+ return v(t.value);
268
+ }
269
+ if (this.isOp("(")) {
270
+ this.next();
271
+ const node = this.parseExpression();
272
+ this.expectOp(")");
273
+ return node;
274
+ }
275
+ this.error("expected a number, identifier, function call, or parenthesized expression");
276
+ }
277
+ }
278
+
279
+ // The tagged-template tag function itself: `` expr`a + b` `` is called by
280
+ // JS as expr(["a + b"], ) -- with interpolations, `` expr`${x} + b` ``
281
+ // is called as expr(["", " + b"], x). strings.length is always
282
+ // values.length + 1. There is no "${" text syntax to lex: JS has already
283
+ // done that splitting before this function ever runs, so a HOLE token is
284
+ // just spliced into the token stream at each boundary, carrying the
285
+ // already-evaluated JS value through untouched.
286
+ function expr(strings, ...values) {
287
+ const tokens = [];
288
+ let source = "";
289
+ for (let i = 0; i < strings.length; i++) {
290
+ tokenizeSegment(strings[i], source.length, tokens);
291
+ source += strings[i];
292
+ if (i < values.length) {
293
+ tokens.push({ type: "HOLE", value: values[i], pos: source.length });
294
+ source += "${...}";
295
+ }
296
+ }
297
+ tokens.push({ type: "EOF", value: null, pos: source.length });
298
+
299
+ const parser = new Parser(tokens, source);
300
+ const node = parser.parseExpression();
301
+ if (parser.peek().type !== "EOF") {
302
+ parser.error("unexpected trailing input");
303
+ }
304
+ return node;
305
+ }
306
+
307
+ // Parser/tokenizeSegment/holeToNode are exported alongside expr itself so
308
+ // fn.js (full function-body syntax: let/return on top of this same
309
+ // expression grammar) can reuse this tokenizer and parsing engine
310
+ // directly instead of forking it -- "fn's contain expr's" literally, not
311
+ // just as a description. Nothing here is part of expr()'s own public
312
+ // contract; treat these as internal to the expr/fn syntax family.
313
+ module.exports = { expr, Parser, tokenizeSegment, holeToNode };
package/fn.js ADDED
@@ -0,0 +1,117 @@
1
+ // exprforge/fn.js
2
+ //
3
+ // Full function-body syntax on top of expr.js's expression grammar: adds
4
+ // `let` bindings and a `return` statement, so a whole function body
5
+ // (let-chain + a single or multi-output result) can be authored as text
6
+ // instead of nested letChain()/outputs() calls. Every individual
7
+ // expression inside a `fn` template -- each let's value, the returned
8
+ // expression(s) -- is parsed by the *same* Parser class expr.js uses,
9
+ // via its parseExpression() entry point. fn's own grammar is a thin
10
+ // statement-sequence wrapper around that, lowering to the real ast.js
11
+ // builders (letChain, outputs), never a new node shape:
12
+ //
13
+ // program := stmt* returnStmt
14
+ // stmt := "let" IDENT "=" expression ";"
15
+ // returnStmt := "return" expression ";"
16
+ // | "return" "{" IDENT ":" expression ("," IDENT ":" expression)* "}" ";"
17
+ //
18
+ // "let"/"return" are recognized contextually -- an IDENT token whose
19
+ // value happens to be "let"/"return" at statement-start position. They
20
+ // are NOT reserved words in expr.js's own grammar, so nothing about
21
+ // expr()'s behavior changes: `` expr`let * 2` `` still means
22
+ // v("let") * 2 today, same as before this file existed.
23
+ //
24
+ // Duplicate let-names are deliberately NOT checked here -- letChain()
25
+ // doesn't check either (ast.js); collectLets() already does, at
26
+ // emission time. Same "defer semantic validation to emission" precedent
27
+ // expr.js itself follows for function/call names.
28
+ const { letChain, outputs } = require("./ast.js");
29
+ const { Parser, tokenizeSegment } = require("./expr.js");
30
+
31
+ function isKeyword(parser, word) {
32
+ const t = parser.peek();
33
+ return t.type === "IDENT" && t.value === word;
34
+ }
35
+
36
+ function expectIdent(parser, context) {
37
+ const t = parser.peek();
38
+ if (t.type !== "IDENT") {
39
+ parser.error(`expected an identifier ${context}`);
40
+ }
41
+ parser.next();
42
+ return t.value;
43
+ }
44
+
45
+ function parseLetStatement(parser) {
46
+ parser.next(); // consume "let", already confirmed present by the caller
47
+ const name = expectIdent(parser, 'after "let"');
48
+ parser.expectOp("=");
49
+ const value = parser.parseExpression();
50
+ parser.expectOp(";");
51
+ return [name, value];
52
+ }
53
+
54
+ function parseReturnStatement(parser) {
55
+ parser.next(); // consume "return", already confirmed present by the caller
56
+ if (parser.isOp("{")) {
57
+ parser.next();
58
+ const fields = {};
59
+ const readField = () => {
60
+ const name = expectIdent(parser, 'as an output name inside "return { ... }"');
61
+ parser.expectOp(":");
62
+ fields[name] = parser.parseExpression();
63
+ };
64
+ if (!parser.isOp("}")) {
65
+ readField();
66
+ while (parser.isOp(",")) {
67
+ parser.next();
68
+ readField();
69
+ }
70
+ }
71
+ parser.expectOp("}");
72
+ parser.expectOp(";");
73
+ return outputs(fields);
74
+ }
75
+ const node = parser.parseExpression();
76
+ parser.expectOp(";");
77
+ return node;
78
+ }
79
+
80
+ function parseProgram(parser) {
81
+ const bindings = [];
82
+ while (isKeyword(parser, "let")) {
83
+ bindings.push(parseLetStatement(parser));
84
+ }
85
+ if (!isKeyword(parser, "return")) {
86
+ parser.error('expected "return" (a fn`...` body is zero or more "let" statements followed by a "return")');
87
+ }
88
+ const body = parseReturnStatement(parser);
89
+ return bindings.length > 0 ? letChain(bindings, body) : body;
90
+ }
91
+
92
+ // Same token-splicing loop expr() uses in expr.js -- see that file's
93
+ // header comment for why there's no "${" text syntax to lex separately;
94
+ // the only difference here is the entry point (parseProgram instead of
95
+ // parser.parseExpression()).
96
+ function fn(strings, ...values) {
97
+ const tokens = [];
98
+ let source = "";
99
+ for (let i = 0; i < strings.length; i++) {
100
+ tokenizeSegment(strings[i], source.length, tokens, "fn()");
101
+ source += strings[i];
102
+ if (i < values.length) {
103
+ tokens.push({ type: "HOLE", value: values[i], pos: source.length });
104
+ source += "${...}";
105
+ }
106
+ }
107
+ tokens.push({ type: "EOF", value: null, pos: source.length });
108
+
109
+ const parser = new Parser(tokens, source, "fn()");
110
+ const node = parseProgram(parser);
111
+ if (parser.peek().type !== "EOF") {
112
+ parser.error("unexpected trailing input");
113
+ }
114
+ return node;
115
+ }
116
+
117
+ module.exports = { fn };
package/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // exprforge/index.js
2
2
  const { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets } = require("./ast.js");
3
3
  const { forComponents } = require("./util.js");
4
+ const { expr } = require("./expr.js");
5
+ const { fn } = require("./fn.js");
6
+ const { evaluate } = require("./evaluate.js");
4
7
  const emitters = require("./emitters/registry.js");
5
8
  const { catmullRomAst } = require("./samples/catmull-rom.js");
6
9
  const { fibonacciAst } = require("./samples/fibonacci.js");
@@ -12,10 +15,10 @@ const { mathDemoAst } = require("./samples/math-demo.js");
12
15
  * Run every registered emitter against one AST function definition.
13
16
  * Returns { [lang]: { ext, source } }.
14
17
  */
15
- function emitAll(fn) {
18
+ function emitAll(fnDef) {
16
19
  const result = {};
17
20
  for (const [lang, emitter] of Object.entries(emitters)) {
18
- result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fn) };
21
+ result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fnDef) };
19
22
  }
20
23
  return result;
21
24
  }
@@ -25,6 +28,15 @@ module.exports = {
25
28
  num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets,
26
29
  // Authoring convenience — not an AST primitive, see util.js.
27
30
  forComponents,
31
+ // Infix syntax sugar over the builders above — same Nodes, see expr.js.
32
+ expr,
33
+ // Full function-body syntax (let/return) on top of expr's grammar —
34
+ // see fn.js. "fn's contain expr's": every expression inside a fn`...`
35
+ // template is parsed by the exact same engine expr() uses.
36
+ fn,
37
+ // A native interpreter over the AST -- evaluate(fn, args) computes a
38
+ // result directly in JS, no codegen/compile step. See evaluate.js.
39
+ evaluate,
28
40
  // Built-in example formulas — see samples/ for the source.
29
41
  catmullRomAst,
30
42
  fibonacciAst,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exprforge",
3
- "version": "0.2.1",
4
- "description": "Author a math expression once as an AST, emit identical-behavior implementations in JS, TypeScript, Python, C#, Lua, QB64, C, Java, Go, Rust, Perl, PHP, Julia, Fortran, Zig, Scheme, and COBOL.",
3
+ "version": "0.3.0",
4
+ "description": "Author a math expression once as an AST (or readable infix text via expr/fn), emit identical-behavior implementations in JS, TypeScript, Python, C#, Lua, QB64, C, Java, Go, Rust, Perl, PHP, Julia, Fortran, Zig, Scheme, and COBOL, plus a native evaluator and its own readable syntax printer.",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
7
7
  "exports": {
@@ -13,6 +13,9 @@
13
13
  "index.js",
14
14
  "ast.js",
15
15
  "util.js",
16
+ "expr.js",
17
+ "fn.js",
18
+ "evaluate.js",
16
19
  "build.js",
17
20
  "emitters/",
18
21
  "samples/",