exprforge 0.2.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
@@ -10,10 +10,18 @@
10
10
  [![Java](https://github.com/theraccoonbear/exprforge/actions/workflows/test-java.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-java.yml)
11
11
  [![Go](https://github.com/theraccoonbear/exprforge/actions/workflows/test-go.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-go.yml)
12
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)
13
20
 
14
21
  Author a math expression once, as a small AST, and emit verified,
15
22
  identical-behavior implementations in JavaScript, TypeScript, Python, C#,
16
- 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).
17
25
 
18
26
  No parser, no dependencies. You build the AST directly with plain JS
19
27
  functions; the same tree is walked once per target language.
@@ -27,6 +35,23 @@ mainstream languages. This exists for two things SymPy doesn't do:
27
35
  - A conformance test harness that actually proves the emitted targets
28
36
  agree numerically, not just that they compile.
29
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
+
30
55
  ## Install
31
56
 
32
57
  ```
@@ -130,6 +155,29 @@ Write `emitters/<lang>.js` exporting an `Emitter` instance (see any
130
155
  existing file as a template), then add one line to
131
156
  `emitters/registry.js`. Nothing else changes — proven by the TypeScript
132
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.
133
181
 
134
182
  ## Named subexpressions and conditional values
135
183
 
@@ -214,11 +262,14 @@ multi-value idiom it has, since none of them agree:
214
262
  | Target | Shape |
215
263
  |---|---|
216
264
  | JS | object literal |
217
- | Go, Lua | native multiple return values |
218
- | C# | a native named value tuple (`(double rx, double ry)`) |
219
- | 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 |
220
268
  | Java, Python | a nested/local `Result` class |
221
- | 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) |
222
273
 
223
274
  Go specifically does **not** use *named* return values (`(rx, ry float64)`)
224
275
  even though Go supports them and it reads nicer: those are sugar for
@@ -254,20 +305,21 @@ Runs `node --test`. For each sample, that's two kinds of check:
254
305
 
255
306
  The compiled/interpreted-language checks need their toolchain on `PATH`
256
307
  and skip (not fail) when it's missing, so `npm test` degrades gracefully
257
- on any one machine. Every one of `tsc`/`qb64pe`/`dotnet`/`python3`/`lua`
258
- is treated exactly like gcc/go/rustc/javac: looked up on `PATH`, never a
259
- project dependency exprforge only ever generates source text for these,
260
- it doesn't execute or type-check any of it itself. `package.json` has
261
- 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.
262
314
 
263
315
  CI is one workflow file per target language (`.github/workflows/test-*.yml`),
264
316
  run in parallel — they have nothing to do with each other, so there's no
265
- reason to serialize installing nine different toolchains (QB64-PE alone,
266
- built from source and cached by version, takes several minutes) into one
267
- job, and splitting by file rather than by job within one file is also
268
- what gets each language its own real status badge above, not just one
269
- combined "did everything pass" badge. Each workflow installs only its own
270
- 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
271
323
  environment variable (read once in `test/conformance.test.js`) filters
272
324
  the target lists down to just that one language, plus the toolchain-
273
325
  independent JS/reference checks, which every workflow repeats — cheap,
@@ -304,6 +356,66 @@ compiling/running against a real toolchain rather than assumed to work:
304
356
  `math.atan2` (use two-argument `math.atan(y, x)`); there's no
305
357
  `math.round` or `math.trunc` or `math.sign` at any version (manual
306
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.
307
419
 
308
420
  One test (`normalizeX`) is deliberately excluded from the QB64 check
309
421
  only: it exists specifically to demonstrate the "don't guard division
@@ -0,0 +1,410 @@
1
+ // exprforge/emitters/cobol.js
2
+ //
3
+ // Targets GnuCOBOL (free-format source, USAGE COMP-2 for double precision)
4
+ // -- see test/conformance.test.js for the exact `cobc` invocation this is
5
+ // verified against.
6
+ //
7
+ // select()/cmp() needed a genuinely different emission strategy than every
8
+ // other target here, because of one real, confirmed-by-compiling
9
+ // structural limit: GnuCOBOL has no expression-level conditional at all --
10
+ // no ternary, no Fortran-style MERGE(). A user-defined `FUNCTION-ID`
11
+ // module CAN be called from inside an expression (`FUNCTION name(...)`),
12
+ // which looks like a way to build one (a tiny "pick a branch" helper
13
+ // function) -- but confirmed against a real compile+run that GnuCOBOL 3.2
14
+ // silently miscomputes (no error, just a wrong/garbage numeric result)
15
+ // when such a call receives a COMPLEX argument, i.e. one containing its
16
+ // own nested FUNCTION call, rather than a bare variable or literal. Every
17
+ // real select() usage has exactly that shape (see
18
+ // samples/spline-frame.js, math/index.js's safeDiv/clamp/normalize3).
19
+ //
20
+ // The fix: six small helper FUNCTION-ID modules (one per comparator, EF_CMP
21
+ // below), always prepended to the emitted source, PLUS forcing every
22
+ // argument passed to them through its own COMPUTE into a fresh temp
23
+ // variable first -- confirmed safe, since a bare-variable-argument call to
24
+ // a user-defined FUNCTION-ID was the one case that worked correctly in
25
+ // testing. That turns emitSelect from a pure "node -> expression string"
26
+ // function (every other emitter's shape, including this file's own calls
27
+ // table) into a STATEFUL one that also spills COMPUTE statements -- see
28
+ // the CobolEmitter class below, which is the one thing in this file that
29
+ // isn't just config passed to `new Emitter(...)` like every other target.
30
+ //
31
+ // No FUNCTION ATAN2 exists in GnuCOBOL either (FUNCTION ATAN is
32
+ // 1-argument only), and the standard quadrant-corrected formula needs
33
+ // exactly this same branching -- built from the same select-hoisting
34
+ // machinery as select() itself, not a separate mechanism.
35
+ const Emitter = require("./base.js");
36
+
37
+ // COBOL reserved words plus every intrinsic-function name this emitter's
38
+ // calls table depends on -- same role as QB64_RESERVED in emitters/qb64.js.
39
+ // COBOL is case-insensitive, so names are checked lowercased. Not
40
+ // exhaustive (COBOL's real reserved-word list runs into the hundreds), but
41
+ // covers the words a generated variable/parameter/function name could
42
+ // plausibly collide with in practice.
43
+ const COBOL_RESERVED = new Set([
44
+ "identification", "division", "program-id", "function-id", "environment",
45
+ "configuration", "repository", "data", "working-storage", "linkage", "section",
46
+ "procedure", "using", "returning", "intent", "usage", "comp-2", "value", "by",
47
+ "reference", "content", "if", "else", "end-if", "compute", "move", "to", "call",
48
+ "goback", "stop", "run", "display", "perform", "until", "end-perform", "and",
49
+ "or", "not", "true", "false", "zero", "zeros", "zeroes", "spaces", "high-value",
50
+ "low-value", "function", "sqrt", "abs", "sin", "cos", "tan", "asin", "acos",
51
+ "atan", "exp", "log", "log10", "integer", "integer-part", "sign", "min", "max", "mod",
52
+ ]);
53
+
54
+ function checkReservedNames(names) {
55
+ for (const name of names) {
56
+ if (COBOL_RESERVED.has(name.toLowerCase())) {
57
+ throw new Error(
58
+ `emitter for .cob: "${name}" is a reserved COBOL word/intrinsic and can't be used as a ` +
59
+ `function/variable/parameter name -- rename it (see COBOL_RESERVED in emitters/cobol.js)`,
60
+ );
61
+ }
62
+ }
63
+ }
64
+
65
+ function fn1(name) {
66
+ return ([x]) => `FUNCTION ${name}(${x})`;
67
+ }
68
+
69
+ function fn2(name) {
70
+ return ([a, b]) => `FUNCTION ${name}(${a}, ${b})`;
71
+ }
72
+
73
+ // One helper FUNCTION-ID per comparator, each named ef-cmp-<suffix> --
74
+ // hyphenated, never underscored (confirmed against a real compiler that a
75
+ // user-defined FUNCTION call breaks on an underscored name -- see
76
+ // samples/spline-frame.js's "wy_wire" param for why that's not just a
77
+ // theoretical concern). "ne" uses NOT = rather than the symbolic <>,
78
+ // simply because NOT = is unambiguously standard COBOL and <> wasn't worth
79
+ // separately confirming for a helper this narrow.
80
+ const CMP_HELPERS = {
81
+ ">": { suffix: "gt", test: "L > R" },
82
+ "<": { suffix: "lt", test: "L < R" },
83
+ ">=": { suffix: "ge", test: "L >= R" },
84
+ "<=": { suffix: "le", test: "L <= R" },
85
+ "==": { suffix: "eq", test: "L = R" },
86
+ "!=": { suffix: "ne", test: "L NOT = R" },
87
+ };
88
+
89
+ // The REPOSITORY paragraph every caller of an ef-cmp-* helper needs --
90
+ // shared between formatFunction/formatSuite below, and terminated with a
91
+ // period only after the LAST entry (REPOSITORY is one sentence, not one
92
+ // statement per line -- confirmed against a real compiler that a missing
93
+ // trailing period here breaks the DATA DIVISION that follows).
94
+ const CMP_REPOSITORY =
95
+ ` REPOSITORY.\n` +
96
+ Object.values(CMP_HELPERS)
97
+ .map(({ suffix }, i, arr) => ` FUNCTION ef-cmp-${suffix}${i === arr.length - 1 ? "." : ""}`)
98
+ .join("\n") +
99
+ "\n";
100
+
101
+ const CMP_HELPER_SOURCE = Object.values(CMP_HELPERS)
102
+ .map(
103
+ ({ suffix, test }) => ` IDENTIFICATION DIVISION.
104
+ FUNCTION-ID. ef-cmp-${suffix}.
105
+ DATA DIVISION.
106
+ LINKAGE SECTION.
107
+ 01 L USAGE COMP-2.
108
+ 01 R USAGE COMP-2.
109
+ 01 THEN-VAL USAGE COMP-2.
110
+ 01 ELSE-VAL USAGE COMP-2.
111
+ 01 RESULT USAGE COMP-2.
112
+ PROCEDURE DIVISION USING L R THEN-VAL ELSE-VAL RETURNING RESULT.
113
+ IF ${test}
114
+ MOVE THEN-VAL TO RESULT
115
+ ELSE
116
+ MOVE ELSE-VAL TO RESULT
117
+ END-IF
118
+ GOBACK.
119
+ END FUNCTION ef-cmp-${suffix}.
120
+ `,
121
+ )
122
+ .join("\n");
123
+
124
+ // GnuCOBOL caps physical source line length (confirmed against a real
125
+ // compiler: "source text exceeds 512 bytes, will be truncated", on
126
+ // samples/kitchen-sink.js's single expression summing all 22 Math
127
+ // functions -- the one AST big enough to ever hit this). Free-format
128
+ // COBOL allows a statement to simply continue on the next line with no
129
+ // continuation marker (confirmed against a real compiler), so a long line
130
+ // just gets broken at word boundaries, well under the real limit.
131
+ function wrapLine(line, maxWidth = 100) {
132
+ if (line.length <= maxWidth) return line;
133
+ const words = line.split(" ");
134
+ const wrapped = [];
135
+ let current = "";
136
+ for (const word of words) {
137
+ if (current && current.length + 1 + word.length > maxWidth) {
138
+ wrapped.push(current);
139
+ current = ` ${word}`;
140
+ } else {
141
+ current = current ? `${current} ${word}` : word;
142
+ }
143
+ }
144
+ if (current) wrapped.push(current);
145
+ return wrapped.join("\n");
146
+ }
147
+
148
+ // A small stateful pool that emitSelect below uses to hoist arguments into
149
+ // fresh temp variables before ever calling an ef-cmp-* helper -- see the
150
+ // file header for why that's required, not optional. Reset once per
151
+ // top-level emitExpr call (one per let-binding, one per output field, one
152
+ // for a select-free body) so temp declarations only need to cover what
153
+ // that one statement actually produced -- but the NAME counter itself is
154
+ // shared (passed in, not owned) across every pool created in one
155
+ // emitFunction call. Confirmed the hard way: an earlier version gave each
156
+ // pool its own counter starting at 0, so two different let-bindings could
157
+ // each mint an "ef-tmp-0", and GnuCOBOL correctly rejected the resulting
158
+ // duplicate WORKING-STORAGE declaration as "ambiguous; needs
159
+ // qualification".
160
+ class TempPool {
161
+ constructor(counter) {
162
+ this.counter = counter;
163
+ this.lines = [];
164
+ this.decls = [];
165
+ }
166
+
167
+ // Spills `valueStr` into a freshly named temp, recording both the
168
+ // COMPUTE that sets it and the 01-level declaration it'll need, and
169
+ // returns the bare name -- always safe to pass to a user-defined
170
+ // FUNCTION-ID call, unlike valueStr itself.
171
+ spill(valueStr) {
172
+ const name = `ef-tmp-${this.counter.next++}`;
173
+ this.decls.push(name);
174
+ this.lines.push(wrapLine(` COMPUTE ${name} = ${valueStr}`));
175
+ return name;
176
+ }
177
+ }
178
+
179
+ class CobolEmitter extends Emitter {
180
+ emitFunction(fn) {
181
+ const { collectLets } = require("../ast.js");
182
+ const { bindings, body } = collectLets(fn.body);
183
+ const counter = { next: 0 };
184
+
185
+ const letLines = [];
186
+ const letDecls = [];
187
+ for (const { name, node } of bindings) {
188
+ checkReservedNames([name]);
189
+ this._pool = new TempPool(counter);
190
+ const valueStr = this.emitExpr(node);
191
+ letLines.push(...this._pool.lines);
192
+ letDecls.push(...this._pool.decls, name);
193
+ letLines.push(wrapLine(` COMPUTE ${name} = ${valueStr}`));
194
+ }
195
+
196
+ if (body.type === "outputs") {
197
+ if (!this.formatSuiteImpl) {
198
+ throw new Error(`emitter for .${this.ext}: no formatSuite configured -- multi-output suites aren't supported for this target yet`);
199
+ }
200
+ const outputStrs = {};
201
+ const outputLines = [];
202
+ for (const [name, node] of Object.entries(body.fields)) {
203
+ this._pool = new TempPool(counter);
204
+ outputStrs[name] = this.emitExpr(node);
205
+ outputLines.push(...this._pool.lines);
206
+ letDecls.push(...this._pool.decls);
207
+ }
208
+ return this.formatSuiteImpl(fn, outputStrs, letLines, letDecls, outputLines);
209
+ }
210
+
211
+ this._pool = new TempPool(counter);
212
+ const bodyStr = this.emitExpr(body);
213
+ const bodyLines = this._pool.lines;
214
+ letDecls.push(...this._pool.decls);
215
+ return this.formatFunctionImpl(fn, bodyStr, letLines, letDecls, bodyLines);
216
+ }
217
+ }
218
+
219
+ // JS renders very small/large magnitudes in exponential notation
220
+ // (String(1e-9) === "1e-9"), and COBOL numeric literals don't accept that
221
+ // syntax at all -- confirmed against a real compiler ("'1e-9' is not
222
+ // defined"). Expanded to plain decimal digit-by-digit instead of via
223
+ // toFixed(): toFixed(20) reveals a binary float's true (imprecise) decimal
224
+ // expansion for values like 4.2 ("4.20000000000000017764"), where this
225
+ // instead shifts the SAME shortest-round-trip digits String(v) already
226
+ // picked, so e.g. 1e-9 becomes exactly "0.000000001", nothing more.
227
+ function expandExponential(s) {
228
+ const m = s.match(/^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/i);
229
+ if (!m) return s;
230
+ const [, sign, intPart, fracPart = "", expStr] = m;
231
+ const digits = intPart + fracPart;
232
+ const pointPos = intPart.length + Number(expStr);
233
+ let result;
234
+ if (pointPos <= 0) {
235
+ result = `0.${"0".repeat(-pointPos)}${digits}`;
236
+ } else if (pointPos >= digits.length) {
237
+ result = `${digits}${"0".repeat(pointPos - digits.length)}`;
238
+ } else {
239
+ result = `${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`;
240
+ }
241
+ return sign + result;
242
+ }
243
+
244
+ const emitter = new CobolEmitter({
245
+ ext: "cob",
246
+ formatNumber: (v) => {
247
+ const s = String(v);
248
+ return /e/i.test(s) ? expandExponential(s) : s;
249
+ },
250
+ calls: {
251
+ sqrt: fn1("SQRT"), abs: fn1("ABS"), sin: fn1("SIN"), cos: fn1("COS"), tan: fn1("TAN"),
252
+ asin: fn1("ASIN"), acos: fn1("ACOS"), atan: fn1("ATAN"),
253
+ exp: fn1("EXP"), log: fn1("LOG"), log10: fn1("LOG10"),
254
+ min: fn2("MIN"), max: fn2("MAX"),
255
+ pow: ([x, y]) => `(${x} ** ${y})`,
256
+ // No LOG2 intrinsic -- derive it (nesting two intrinsics is fine;
257
+ // only nesting a call inside a USER-DEFINED function's argument
258
+ // was the confirmed problem -- see the file header).
259
+ 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)`,
262
+ // FUNCTION INTEGER is floor (greatest integer <= x, confirmed
263
+ // against a real compiler, including for negatives). COMPUTE's
264
+ // automatic numeric conversion hands it back as COMP-2 with no
265
+ // explicit cast needed, unlike Fortran's REAL(..., 8) wrap.
266
+ floor: fn1("INTEGER"),
267
+ // No CEILING intrinsic -- negate, floor, negate back. Confirmed:
268
+ // ceil(2.2)=3, ceil(-2.2)=-2.
269
+ ceil: ([x]) => `(0 - FUNCTION INTEGER(0 - (${x})))`,
270
+ // FUNCTION INTEGER-PART truncates toward zero directly -- confirmed
271
+ // against a real compiler (2.7->2, -2.7->-2), no derivation needed.
272
+ trunc: fn1("INTEGER-PART"),
273
+ // No ROUND-as-an-expression intrinsic (COBOL's ROUNDED is a
274
+ // COMPUTE/ADD statement modifier, not composable inline). Built
275
+ // from FUNCTION SIGN and FUNCTION INTEGER instead -- both purely
276
+ // intrinsic, so unlike select() this doesn't need the hoisting
277
+ // machinery at all. GnuCOBOL's FUNCTION SIGN is 1-argument
278
+ // (SIGN(x) -> -1/0/1), NOT Fortran's 2-argument SIGN(A,B)
279
+ // "magnitude of A, sign of B" -- confirmed the hard way (a first
280
+ // version of this formula copied Fortran's 2-arg convention here
281
+ // by mistake and got "FUNCTION 'SIGN' has wrong number of
282
+ // arguments" from a real compiler). Also unlike Fortran's SIGN,
283
+ // confirmed zero-safe (SIGN(0.0) == 0.0 for real, not just by
284
+ // accident of a multiplied-away wrong case), so this needs no
285
+ // separate correction the way Fortran's round() does. Rounds ties
286
+ // away from zero, matching every other target here.
287
+ round: ([x]) => `(FUNCTION SIGN(${x}) * FUNCTION INTEGER(FUNCTION ABS(${x}) + 0.5))`,
288
+ // Confirmed zero-safe against a real compiler (SIGN(0.0) == 0.0,
289
+ // unlike Fortran's identically-named but 2-argument intrinsic --
290
+ // see round() above) -- no hoisting/spilling machinery needed,
291
+ // unlike every other emitter here that has to hand-build this.
292
+ sign: fn1("SIGN"),
293
+ // No FUNCTION ATAN2 -- the standard quadrant-corrected formula,
294
+ // built from ef-cmp-* the same way select() itself composes them
295
+ // (see emitSelect below). EVERY argument to EVERY ef-cmp-* call
296
+ // must be a bare, already-spilled name -- including ones built
297
+ // from ANOTHER ef-cmp-* call's result -- since a nested
298
+ // `FUNCTION ef-cmp-x(...)` used directly as an argument to another
299
+ // `FUNCTION ef-cmp-y(...)` hits the exact same confirmed bug as a
300
+ // nested intrinsic call would (see the file header); the first
301
+ // version of this formula got that wrong (nested an ef-cmp-lt
302
+ // call straight into ef-cmp-gt's argument list) and silently
303
+ // computed garbage, caught only by actually compiling and running
304
+ // it. So: absolutely nothing here is inlined -- every intermediate
305
+ // result, including literals, gets its own spill() first.
306
+ //
307
+ // Arrow function, not a plain one: base.js's emitExpr calls
308
+ // `this.calls[node.name](args)` unbound (unlike emitSelectImpl,
309
+ // which the Emitter constructor explicitly .bind(this)s) -- an
310
+ // arrow here closes over the `emitter` const below by reference
311
+ // instead, which is fully assigned by the time this ever actually
312
+ // runs (during some later emitFunction call), even though it's
313
+ // referenced before that `const` is declared in this same object
314
+ // literal.
315
+ atan2: (args) => {
316
+ const [yRaw, xRaw] = args;
317
+ const pool = emitter._pool;
318
+ const y = pool.spill(yRaw);
319
+ const x = pool.spill(xRaw);
320
+ const atanYX = pool.spill(`FUNCTION ATAN(${y} / ${x})`);
321
+ const zero = pool.spill("0.0");
322
+ const piOver2 = pool.spill("1.5707963267948966");
323
+ const negPiOver2 = pool.spill("-1.5707963267948966");
324
+ const atanPlusPi = pool.spill(`(${atanYX} + 3.141592653589793)`);
325
+ const atanMinusPi = pool.spill(`(${atanYX} - 3.141592653589793)`);
326
+ // x == 0 case: sign of y (0 conventionally maps to 0.0, same
327
+ // convention JS's/Python's atan2(0,0) use).
328
+ const yNegSubcase = pool.spill(`FUNCTION ef-cmp-lt(${y}, ${zero}, ${negPiOver2}, ${zero})`);
329
+ const xZeroCase = pool.spill(`FUNCTION ef-cmp-gt(${y}, ${zero}, ${piOver2}, ${yNegSubcase})`);
330
+ // x < 0 case: quadrant-corrected by the sign of y.
331
+ const xNegCase = pool.spill(`FUNCTION ef-cmp-ge(${y}, ${zero}, ${atanPlusPi}, ${atanMinusPi})`);
332
+ const xNegOrZeroCase = pool.spill(`FUNCTION ef-cmp-lt(${x}, ${zero}, ${xNegCase}, ${xZeroCase})`);
333
+ return `FUNCTION ef-cmp-gt(${x}, ${zero}, ${atanYX}, ${xNegOrZeroCase})`;
334
+ },
335
+ },
336
+ // See the file header and TempPool above for why this spills into
337
+ // temps instead of nesting inline: a user-defined FUNCTION-ID call
338
+ // (ef-cmp-*) confirmed miscomputes when given a complex argument, so
339
+ // every one of L/R/then/else gets its own COMPUTE into a fresh temp
340
+ // first, and the picker call itself only ever receives bare names.
341
+ emitSelect: function (condNode, thenStr, elseStr) {
342
+ const { suffix } = CMP_HELPERS[condNode.op];
343
+ const L = this._pool.spill(this.emitExpr(condNode.left));
344
+ const R = this._pool.spill(this.emitExpr(condNode.right));
345
+ const thenTmp = this._pool.spill(thenStr);
346
+ const elseTmp = this._pool.spill(elseStr);
347
+ return `FUNCTION ef-cmp-${suffix}(${L}, ${R}, ${thenTmp}, ${elseTmp})`;
348
+ },
349
+ // Both the scalar and suite cases use the SAME convention: a callable
350
+ // PROGRAM-ID with every output (the single return value, or every
351
+ // outputs() field) as a trailing BY REFERENCE parameter -- COBOL's
352
+ // default parameter-passing mode, confirmed reliable (BY VALUE is
353
+ // explicitly flagged "unfinished" by a real GnuCOBOL compile). This
354
+ // also sidesteps a second confirmed rough edge: calling a FUNCTION-ID
355
+ // module by name (`FUNCTION word(...)`) breaks if that name contains
356
+ // an underscore (confirmed against a real compiler -- see e.g.
357
+ // samples/spline-frame.js's "wy_wire" param), while CALL "name" takes
358
+ // the program name as a plain string literal, immune to that.
359
+ formatFunction: (fn, body, letLines, letDecls, bodyLines) => {
360
+ checkReservedNames([fn.name, ...fn.params]);
361
+ const linkageParams = [...fn.params, "ef-result"];
362
+ const paramDecls = linkageParams.map((p) => ` 01 ${p} USAGE COMP-2.`).join("\n");
363
+ const wsDecls = letDecls.map((n) => ` 01 ${n} USAGE COMP-2.`).join("\n");
364
+ return ` >>SOURCE FORMAT FREE\n` +
365
+ ` *> AUTO-GENERATED by ExprForge -- do not hand-edit.\n` +
366
+ CMP_HELPER_SOURCE + "\n" +
367
+ ` IDENTIFICATION DIVISION.\n` +
368
+ ` PROGRAM-ID. ${fn.name}.\n` +
369
+ ` ENVIRONMENT DIVISION.\n` +
370
+ ` CONFIGURATION SECTION.\n` +
371
+ CMP_REPOSITORY +
372
+ ` DATA DIVISION.\n` +
373
+ (wsDecls ? ` WORKING-STORAGE SECTION.\n${wsDecls}\n` : "") +
374
+ ` LINKAGE SECTION.\n` +
375
+ paramDecls + "\n" +
376
+ ` PROCEDURE DIVISION USING ${linkageParams.join(" ")}.\n` +
377
+ [...letLines, ...bodyLines].join("\n") + (letLines.length || bodyLines.length ? "\n" : "") +
378
+ wrapLine(` COMPUTE ef-result = ${body}`) + "\n" +
379
+ ` GOBACK.\n` +
380
+ ` END PROGRAM ${fn.name}.\n`;
381
+ },
382
+ formatSuite: (fn, outputStrs, letLines, letDecls, outputLines) => {
383
+ const outputNames = Object.keys(outputStrs);
384
+ checkReservedNames([fn.name, ...fn.params, ...outputNames]);
385
+ const linkageParams = [...fn.params, ...outputNames];
386
+ const paramDecls = linkageParams.map((p) => ` 01 ${p} USAGE COMP-2.`).join("\n");
387
+ const wsDecls = letDecls.map((n) => ` 01 ${n} USAGE COMP-2.`).join("\n");
388
+ const assigns = outputNames.map((n) => wrapLine(` COMPUTE ${n} = ${outputStrs[n]}`)).join("\n");
389
+ return ` >>SOURCE FORMAT FREE\n` +
390
+ ` *> AUTO-GENERATED by ExprForge -- do not hand-edit.\n` +
391
+ CMP_HELPER_SOURCE + "\n" +
392
+ ` IDENTIFICATION DIVISION.\n` +
393
+ ` PROGRAM-ID. ${fn.name}.\n` +
394
+ ` ENVIRONMENT DIVISION.\n` +
395
+ ` CONFIGURATION SECTION.\n` +
396
+ CMP_REPOSITORY +
397
+ ` DATA DIVISION.\n` +
398
+ (wsDecls ? ` WORKING-STORAGE SECTION.\n${wsDecls}\n` : "") +
399
+ ` LINKAGE SECTION.\n` +
400
+ paramDecls + "\n" +
401
+ ` PROCEDURE DIVISION USING ${linkageParams.join(" ")}.\n` +
402
+ (letLines.length ? letLines.join("\n") + "\n" : "") +
403
+ (outputLines.length ? outputLines.join("\n") + "\n" : "") +
404
+ assigns + "\n" +
405
+ ` GOBACK.\n` +
406
+ ` END PROGRAM ${fn.name}.\n`;
407
+ },
408
+ });
409
+
410
+ module.exports = emitter;