exprforge 0.1.0 → 0.2.1

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
@@ -1,5 +1,6 @@
1
1
  # ExprForge šŸ”¢šŸ”Ø
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/exprforge.svg)](https://www.npmjs.com/package/exprforge)
3
4
  [![TypeScript](https://github.com/theraccoonbear/exprforge/actions/workflows/test-typescript.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-typescript.yml)
4
5
  [![Python](https://github.com/theraccoonbear/exprforge/actions/workflows/test-python.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-python.yml)
5
6
  [![C#](https://github.com/theraccoonbear/exprforge/actions/workflows/test-csharp.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-csharp.yml)
@@ -9,10 +10,18 @@
9
10
  [![Java](https://github.com/theraccoonbear/exprforge/actions/workflows/test-java.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-java.yml)
10
11
  [![Go](https://github.com/theraccoonbear/exprforge/actions/workflows/test-go.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-go.yml)
11
12
  [![Rust](https://github.com/theraccoonbear/exprforge/actions/workflows/test-rust.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-rust.yml)
13
+ [![Perl](https://github.com/theraccoonbear/exprforge/actions/workflows/test-perl.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-perl.yml)
14
+ [![PHP](https://github.com/theraccoonbear/exprforge/actions/workflows/test-php.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-php.yml)
15
+ [![Julia](https://github.com/theraccoonbear/exprforge/actions/workflows/test-julia.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-julia.yml)
16
+ [![Fortran](https://github.com/theraccoonbear/exprforge/actions/workflows/test-fortran.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-fortran.yml)
17
+ [![Zig](https://github.com/theraccoonbear/exprforge/actions/workflows/test-zig.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-zig.yml)
18
+ [![Scheme](https://github.com/theraccoonbear/exprforge/actions/workflows/test-scheme.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-scheme.yml)
19
+ [![COBOL](https://github.com/theraccoonbear/exprforge/actions/workflows/test-cobol.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-cobol.yml)
12
20
 
13
21
  Author a math expression once, as a small AST, and emit verified,
14
22
  identical-behavior implementations in JavaScript, TypeScript, Python, C#,
15
- Lua, QB64, C, Java, Go, and Rust.
23
+ Lua, QB64, C, Java, Go, Rust, Perl, PHP, Julia, Fortran, Zig, Scheme
24
+ (Guile), and COBOL (GnuCOBOL).
16
25
 
17
26
  No parser, no dependencies. You build the AST directly with plain JS
18
27
  functions; the same tree is walked once per target language.
@@ -26,6 +35,23 @@ mainstream languages. This exists for two things SymPy doesn't do:
26
35
  - A conformance test harness that actually proves the emitted targets
27
36
  agree numerically, not just that they compile.
28
37
 
38
+ Two shapes of real use this tends to fall into:
39
+
40
+ - **Keeping concurrent codebases in sync.** A client/server split (game
41
+ client prediction + authoritative server, or any two independently
42
+ deployed services) where both sides need to compute the *same* formula
43
+ and disagree — desync, or a cheat signal — the moment they drift. One
44
+ AST, not two hand-maintained implementations that quietly diverge.
45
+ - **De-risking a migration.** Replacing an older implementation (a COBOL
46
+ batch job, a Fortran numerical kernel) with a new one doesn't require
47
+ trusting a manual port — emit the same formula into both the legacy
48
+ target and the new one, and let the conformance suite prove they agree
49
+ before cutover, not after.
50
+
51
+ Neither is "translate my code for me" — it's "prove two independent
52
+ implementations of one formula actually match," which is a narrower,
53
+ checkable claim.
54
+
29
55
  ## Install
30
56
 
31
57
  ```
@@ -69,6 +95,9 @@ package individually, or together as `samples`):
69
95
  purely as a conformance-test fixture. It's what caught Go's and Rust's
70
96
  `sign()` disagreeing with everyone else at exactly zero (see below) —
71
97
  the other samples between them only ever exercised 5 of the 22.
98
+ - `samples/math-demo.js` — also not a worked example: a conformance-test
99
+ fixture exercising every `exprforge/math` helper (see below) in one
100
+ suite.
72
101
 
73
102
  `npm run build` emits all of them, for every target language, into `out/`.
74
103
 
@@ -80,12 +109,75 @@ floor ceil round trunc sign min max hypot`
80
109
  Add more by extending a target's `calls` table in `emitters/<lang>.js`.
81
110
  Requesting an unmapped function throws at build time, not silently.
82
111
 
112
+ ## Math utilities (`exprforge/math`)
113
+
114
+ A separate, additive export — `require("exprforge")` is unchanged — of
115
+ pre-built compositions of the core AST builders for common 3-D math
116
+ patterns, so consumers stop re-implementing the same safe-math and vector
117
+ code in every project (`samples/spline-frame.js` had local, hand-rolled
118
+ versions of most of these before this module existed).
119
+
120
+ ```js
121
+ const { num, v } = require("exprforge");
122
+ const { safeDiv, dot3, len3, cross3, normalize3, clamp, EPS } = require("exprforge/math");
123
+
124
+ // Safe-normalize x component, falling back to 0 near zero length.
125
+ safeDiv(v("x"), len3(v("x"), v("y"), v("z")), num(0));
126
+ ```
127
+
128
+ - `safeDiv(numerator, denominatorExpr, fallback)` — `numerator /
129
+ denominatorExpr` when `|denominatorExpr| > EPS`, else `fallback`. Clamps
130
+ the denominator before dividing rather than guarding the division
131
+ directly, since `select()` always evaluates both branches (see below).
132
+ - `dot3(ax, ay, az, bx, by, bz)` — `ax*bx + ay*by + az*bz`.
133
+ - `len3(x, y, z)` — `sqrt(x² + y² + z²)`.
134
+ - `cross3(ax, ay, az, bx, by, bz)` — 3-D cross product. Returns a plain JS
135
+ object `{ x, y, z }` of AST nodes (not a Node itself), for destructuring
136
+ into your own `letIn` chain.
137
+ - `normalize3(x, y, z, fx?, fy?, fz?)` — safe-normalize; same `{ x, y, z }`
138
+ shape as `cross3`. Falls back to `(fx, fy, fz)` (default `(0, 1, 0)`)
139
+ below `EPS` length. Computes the length once and shares it across all
140
+ three divisions.
141
+ - `clamp(val, lo, hi)` — clamps to `[lo, hi]` via nested `select`/`cmp`; no
142
+ runtime intrinsic.
143
+ - `EPS` — `num(0.000001)`, the epsilon every guard above uses; exported for
144
+ callers who want the same threshold in their own `cmp()` calls.
145
+
146
+ What deliberately stays out (project-specific conventions, not general
147
+ math): a "near-vertical" world-up check, baked-in-PI degree/radian
148
+ conversion, Rodrigues rotation, and a full Gram-Schmidt frame — see
149
+ `samples/spline-frame.js` for those, and
150
+ `docs/v0.2.0-math-utilities.md` for the full design rationale.
151
+
83
152
  ## Adding a language
84
153
 
85
154
  Write `emitters/<lang>.js` exporting an `Emitter` instance (see any
86
155
  existing file as a template), then add one line to
87
156
  `emitters/registry.js`. Nothing else changes — proven by the TypeScript
88
157
  emitter, added with no changes to `base.js`, `build.js`, or `index.js`.
158
+ `Emitter` is a real class (not just a factory function), so a target that
159
+ needs to intercept how expressions themselves get rendered — not just
160
+ `calls`/`emitSelect`/`formatFunction`, all ordinary config — can subclass
161
+ it instead: Perl/PHP override `emitExpr`'s `"var"` case to add the `$`
162
+ sigil every reference needs, Scheme overrides the `"bin"` case for prefix
163
+ notation. See `emitters/scheme.js` and `emitters/perl.js`.
164
+
165
+ ### Reserved-word collisions
166
+
167
+ Several emitters (QB64, Fortran, Zig, Scheme, COBOL) guard against a
168
+ generated variable/parameter/function name colliding with that language's
169
+ own reserved words or builtins — a `<LANG>_RESERVED` set checked at
170
+ emission time, throwing a clear error instead of producing code that fails
171
+ to compile somewhere downstream with no context (see e.g. `QB64_RESERVED`
172
+ in `emitters/qb64.js`). **These lists are not, and can't practically be,
173
+ exhaustive** — each covers the collisions that came up in this project's
174
+ own samples plus the obvious/common ones for that language, not every
175
+ reserved word in every language's full grammar. If you're naming your own
176
+ functions/params/`letIn` bindings, especially ones you know will target a
177
+ specific language, it's still on you to know that language's reserved
178
+ words — Perl/PHP mostly sidestep this (every variable is `$`-sigiled, so
179
+ it can't collide with a bareword keyword), but the sigil-free languages
180
+ above genuinely can't be fully guarded against in advance.
89
181
 
90
182
  ## Named subexpressions and conditional values
91
183
 
@@ -97,6 +189,33 @@ expression model without introducing control flow:
97
189
  components by it). Every `let` in a function gets lifted into an ordered
98
190
  list of local declarations ahead of the return statement/expression, in
99
191
  every target.
192
+
193
+ Chaining several is normally hand-nested `letIn` calls, one inside the
194
+ next, closing parens piling up at the end with no real hierarchy behind
195
+ them — just bookkeeping to get everything hoisted before it's used.
196
+ **`letChain(bindings, body)`** is that same nesting, built for you from a
197
+ flat, ordered list instead:
198
+
199
+ ```js
200
+ const { v, num, mul, add, letChain, outputs } = require("exprforge");
201
+
202
+ letChain(
203
+ [
204
+ ["t2", mul(v("t"), v("t"))],
205
+ ["t3", mul(v("t2"), v("t"))],
206
+ ],
207
+ outputs({ t2: v("t2"), t3: v("t3") }),
208
+ );
209
+ // same tree as letIn("t2", ..., letIn("t3", ..., outputs({...})))
210
+ ```
211
+
212
+ `bindings` is an ordered array of `[name, valueNode]` pairs, not a
213
+ `{name: valueNode}` object like `outputs()` takes — order is
214
+ load-bearing here (a later binding's value can reference an earlier
215
+ one's name), and that's clearer as an explicit sequence than resting on
216
+ an object's key order. Pure authoring sugar: builds the identical `let`
217
+ node structure `letIn` would, so it needs no emitter changes and
218
+ round-trips through `collectLets` the same way.
100
219
  - **`select(cond, then, else)` + `cmp(left, op, right)`** — conditional
101
220
  *value* selection. Every target has a genuinely different way to spell
102
221
  this: a native ternary where one exists (C, Java, C#), `if`-as-expression
@@ -143,11 +262,14 @@ multi-value idiom it has, since none of them agree:
143
262
  | Target | Shape |
144
263
  |---|---|
145
264
  | JS | object literal |
146
- | Go, Lua | native multiple return values |
147
- | C# | a native named value tuple (`(double rx, double ry)`) |
148
- | C / Rust | a small `...Result` struct, returned by value |
265
+ | Go, Lua, Scheme | native multiple return values (`(values ...)` in Scheme) |
266
+ | C#, Julia | a native named value tuple / named tuple |
267
+ | C / Rust / Zig | a small `...Result` struct, returned by value |
149
268
  | Java, Python | a nested/local `Result` class |
150
- | QB64 | a `SUB` with the outputs as trailing by-reference parameters |
269
+ | QB64, Fortran | a `SUB`/`subroutine` with the outputs as trailing by-reference (`intent(out)`) parameters |
270
+ | Perl | a hash ref (`{ rx => ..., ry => ... }`) |
271
+ | PHP | an associative array (`['rx' => ..., 'ry' => ...]`) |
272
+ | COBOL | a callable `PROGRAM-ID`, every output as a trailing `BY REFERENCE` parameter, invoked via `CALL "name" USING ...` — COBOL's *scalar* case uses this same shape too, not a `FUNCTION`-style return (see Testing below) |
151
273
 
152
274
  Go specifically does **not** use *named* return values (`(rx, ry float64)`)
153
275
  even though Go supports them and it reads nicer: those are sugar for
@@ -183,20 +305,21 @@ Runs `node --test`. For each sample, that's two kinds of check:
183
305
 
184
306
  The compiled/interpreted-language checks need their toolchain on `PATH`
185
307
  and skip (not fail) when it's missing, so `npm test` degrades gracefully
186
- on any one machine. Every one of `tsc`/`qb64pe`/`dotnet`/`python3`/`lua`
187
- is treated exactly like gcc/go/rustc/javac: looked up on `PATH`, never a
188
- project dependency — exprforge only ever generates source text for these,
189
- it doesn't execute or type-check any of it itself. `package.json` has
190
- zero dependencies of any kind, matching this.
308
+ on any one machine. Every one of `tsc`/`qb64pe`/`dotnet`/`python3`/`lua`/
309
+ `perl`/`php`/`julia`/`gfortran`/`zig`/`guile3.0`/`cobc` is treated exactly
310
+ like gcc/go/rustc/javac: looked up on `PATH`, never a project
311
+ dependency — exprforge only ever generates source text for these, it
312
+ doesn't execute or type-check any of it itself. `package.json` has zero
313
+ dependencies of any kind, matching this.
191
314
 
192
315
  CI is one workflow file per target language (`.github/workflows/test-*.yml`),
193
316
  run in parallel — they have nothing to do with each other, so there's no
194
- reason to serialize installing nine different toolchains (QB64-PE alone,
195
- built from source and cached by version, takes several minutes) into one
196
- job, and splitting by file rather than by job within one file is also
197
- what gets each language its own real status badge above, not just one
198
- combined "did everything pass" badge. Each workflow installs only its own
199
- toolchain and runs `EXPRFORGE_TEST_TARGETS=<Label> npm test`; that
317
+ reason to serialize installing sixteen different toolchains (QB64-PE
318
+ alone, built from source and cached by version, takes several minutes)
319
+ into one job, and splitting by file rather than by job within one file is
320
+ also what gets each language its own real status badge above, not just
321
+ one combined "did everything pass" badge. Each workflow installs only its
322
+ own toolchain and runs `EXPRFORGE_TEST_TARGETS=<Label> npm test`; that
200
323
  environment variable (read once in `test/conformance.test.js`) filters
201
324
  the target lists down to just that one language, plus the toolchain-
202
325
  independent JS/reference checks, which every workflow repeats — cheap,
@@ -233,6 +356,66 @@ compiling/running against a real toolchain rather than assumed to work:
233
356
  `math.atan2` (use two-argument `math.atan(y, x)`); there's no
234
357
  `math.round` or `math.trunc` or `math.sign` at any version (manual
235
358
  `floor(x+0.5)`, `math.modf(x)`, and an `and`/`or` chain respectively).
359
+ - **Perl / PHP**: every variable reference needs a `$` sigil, which
360
+ `base.js`'s shared `emitExpr` doesn't produce for anything — both
361
+ subclass `Emitter` to override just the `"var"` case (see "Adding a
362
+ language" above) rather than needing a new hook every other emitter
363
+ would have to ignore. Perl has no `log2()`/`trunc()`/`hypot()` in core
364
+ (POSIX supplies `trunc`/`hypot`, `log2` is derived); PHP has no
365
+ `trunc()` at all (`floor`/`ceil` picked by sign instead, not an `(int)`
366
+ cast, which would misbehave outside PHP's platform integer range).
367
+ - **Julia**: `round()` defaults to ties-to-even (banker's rounding), not
368
+ ties-away-from-zero like every other target here —
369
+ `round(x, RoundNearestTiesAway)` used explicitly to actually match,
370
+ not just avoid the untested case. `sign(-0.0)` returns `-0.0`, which is
371
+ numerically equal to `0.0` for the tolerance-based comparisons this
372
+ project uses, so it isn't a real divergence.
373
+ - **Fortran**: a literal without the `D0` exponent marker is parsed as
374
+ *single*-precision first, then widened — silently losing precision
375
+ before it reaches a `real(8)` variable, unlike every other target's
376
+ literals — so every literal gets it, not just ones already in
377
+ scientific notation. `FLOOR`/`CEILING` return the default `INTEGER`
378
+ kind, not `REAL`, wrapped back with `REAL(..., 8)`. No ternary, but
379
+ `MERGE(then, else, mask)` is a genuine expression-level conditional —
380
+ confirmed to evaluate both branches regardless of `mask`, matching
381
+ `select()`'s own contract exactly. The native 2-argument `SIGN(A, B)`
382
+ ("magnitude of A, sign of B") is *not* this project's `sign(x)` —
383
+ `SIGN(1.0, 0.0)` returns `1.0`, not `0.0` — built from `MERGE` instead.
384
+ - **Zig**: `std.debug.print` writes to **stderr** by design, not
385
+ stdout — the conformance harness has to use
386
+ `std.io.getStdOut().writer()` instead, or every result silently comes
387
+ back empty. A fully-literal expression with no runtime operand (e.g.
388
+ `sqrt(2.0)` alone) gets evaluated at Zig's extended `comptime_float`
389
+ precision instead of truncated to an actual IEEE double, unless
390
+ explicitly `@as(f64, ...)`-cast — every literal gets that cast, not
391
+ just ones that would otherwise hit this.
392
+ - **Scheme (Guile)**: a bare integer literal like `2` is *exact* in
393
+ Scheme's reader syntax, and exact arithmetic that never touches an
394
+ inexact (float) operand stays exact — `(/ 1 3)` prints as the fraction
395
+ `1/3`, not `0.333...`. Every literal gets `.0` appended unless it
396
+ already has a decimal point or exponent, forcing inexactness by literal
397
+ syntax alone rather than relying on some other operand in the same
398
+ expression happening to already be a float.
399
+ - **COBOL (GnuCOBOL)**: has no expression-level conditional at all — no
400
+ ternary, no `MERGE`-equivalent. `select()` is built from six small
401
+ helper `FUNCTION-ID` modules (one per comparator), but confirmed
402
+ against a real compile+run that a user-defined `FUNCTION` call
403
+ *silently miscomputes* — no error, just a wrong number — when given a
404
+ complex argument (one containing its own nested call); every argument
405
+ to a helper gets spilled into its own `COMPUTE`d temp first, always,
406
+ not just when an argument "looks complex." `BY VALUE` parameter passing
407
+ is explicitly flagged "unfinished" by the compiler — every function
408
+ uses `BY REFERENCE` (the default) instead, which is also why COBOL is
409
+ the one target where even a *scalar* function's return value is a
410
+ trailing by-reference parameter (see the outputs table above), not a
411
+ `FUNCTION`-style return: calling a user `FUNCTION` by name breaks if
412
+ that name contains an underscore (confirmed against a real compiler),
413
+ while `CALL "name"` takes it as a plain string literal, immune to that.
414
+ Source lines have a real ~512-byte cap — long expressions (e.g.
415
+ `samples/kitchen-sink.js`'s summed call to all 22 functions) get
416
+ wrapped at word boundaries. The native `FUNCTION SIGN` is
417
+ 1-argument (`SIGN(x)`), unlike Fortran's identically-named
418
+ 2-argument intrinsic — and unlike Fortran's, is genuinely zero-safe.
236
419
 
237
420
  One test (`normalizeX`) is deliberately excluded from the QB64 check
238
421
  only: it exists specifically to demonstrate the "don't guard division
package/ast.js CHANGED
@@ -62,6 +62,27 @@ function letIn(name, value, body) {
62
62
  return { type: "let", name, value, body };
63
63
  }
64
64
 
65
+ // Chains N letIn bindings without hand-nesting them (and hand-balancing the
66
+ // resulting N closing parens — the nesting depth reflects no real
67
+ // hierarchy, only that each binding must be lifted ahead of anything using
68
+ // it). Builds the exact same nested `let` structure letIn() would if
69
+ // written out by hand: pure authoring sugar, not a new node type, so
70
+ // collectLets and every emitter already understand the result unchanged.
71
+ //
72
+ // `bindings` is an ORDERED array of [name, valueNode] pairs, not a
73
+ // {name: valueNode} object like outputs() takes — order is load-bearing
74
+ // here (a later binding's value can reference an earlier one's name via
75
+ // v(name)), and a plain object's key order isn't reliably that: a binding
76
+ // named e.g. "0" would silently sort ahead of everything else. An array
77
+ // keeps "this is a strict sequence" explicit instead of resting on that.
78
+ //
79
+ // Doesn't check for duplicate names itself — collectLets already does,
80
+ // with the whole function body in view (see its doc comment); duplicating
81
+ // that check here would only see this one chain, not the whole picture.
82
+ function letChain(bindings, body) {
83
+ return bindings.reduceRight((acc, [name, value]) => letIn(name, value, acc), body);
84
+ }
85
+
65
86
  // Comparison predicate — only valid as the `cond` of a select(); not a
66
87
  // general boolean expression, and shouldn't appear anywhere else in a tree.
67
88
  function cmp(left, op, right) {
@@ -139,4 +160,4 @@ function collectLets(node) {
139
160
  return { bindings, body };
140
161
  }
141
162
 
142
- module.exports = { num, v, bin, call, add, mul, sub, div, neg, letIn, cmp, select, outputs, collectLets };
163
+ module.exports = { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets };