exprforge 0.5.0 → 0.6.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 +95 -19
- package/ast.js +19 -0
- package/emitters/exprsyntax.js +25 -5
- package/fn.js +91 -8
- package/load-expr.js +75 -40
- package/macros.js +23 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-zig.yml)
|
|
20
20
|
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-scheme.yml)
|
|
21
21
|
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-cobol.yml)
|
|
22
|
+
[](https://github.com/theraccoonbear/exprforge/actions/workflows/test-coverage.yml)
|
|
22
23
|
|
|
23
24
|
## Brief
|
|
24
25
|
|
|
@@ -140,13 +141,13 @@ editor buffer accepts:
|
|
|
140
141
|
const { loadExprSource, evaluate, emit } = require("exprforge");
|
|
141
142
|
|
|
142
143
|
const defs = loadExprSource(`
|
|
143
|
-
cross3(ax, ay, az, bx, by, bz):
|
|
144
|
+
macro cross3(ax, ay, az, bx, by, bz):
|
|
144
145
|
let rx = ay * bz - az * by;
|
|
145
146
|
let ry = az * bx - ax * bz;
|
|
146
147
|
let rz = ax * by - ay * bx;
|
|
147
148
|
return { rx, ry, rz };
|
|
148
149
|
|
|
149
|
-
crossLength(ax, ay, az, bx, by, bz):
|
|
150
|
+
fn crossLength(ax, ay, az, bx, by, bz):
|
|
150
151
|
let c = cross3(ax, ay, az, bx, by, bz);
|
|
151
152
|
return sqrt(c.rx^2 + c.ry^2 + c.rz^2);
|
|
152
153
|
`);
|
|
@@ -155,6 +156,20 @@ evaluate(defs.crossLength, [1, 0, 0, 0, 1, 0]); // 1
|
|
|
155
156
|
emit(defs.crossLength, "rust").source; // a real fn crossLength(...) -- no trace of cross3 left
|
|
156
157
|
```
|
|
157
158
|
|
|
159
|
+
Every definition starts with `fn` or `macro` — never optional, never
|
|
160
|
+
implied. `fn` means "hand this back to me": `defs.crossLength` exists
|
|
161
|
+
because it's marked `fn`. `macro` means "inline this into whatever
|
|
162
|
+
references it later in this same buffer, but don't hand it back on its
|
|
163
|
+
own": `cross3` is fully usable *inside* `crossLength` — that's the whole
|
|
164
|
+
point — but `defs.cross3` doesn't exist; `Object.keys(defs)` here is just
|
|
165
|
+
`["crossLength"]`. There's no default either way, on purpose: a helper
|
|
166
|
+
you only ever meant as an internal step for something else can't
|
|
167
|
+
accidentally end up looking like part of your file's real, callable
|
|
168
|
+
output just because nothing said otherwise. Mark it `fn` instead if you
|
|
169
|
+
*do* want `cross3` usable standalone too — both marks register the
|
|
170
|
+
definition identically for inlining purposes; the only difference is
|
|
171
|
+
whether it also lands in what this call returns.
|
|
172
|
+
|
|
158
173
|
`cross3` never appears in `crossLength`'s emitted output, in any target —
|
|
159
174
|
by the time `loadExprSource` returns, `defs.crossLength` is
|
|
160
175
|
self-contained arithmetic, `cross3`'s formula copied in and simplified
|
|
@@ -288,7 +303,7 @@ package individually, or together as `samples`):
|
|
|
288
303
|
fixture exercising every `exprforge/math` helper (see below) in one
|
|
289
304
|
suite.
|
|
290
305
|
- `samples/macro-demo.js` — also not a worked example: a conformance-test
|
|
291
|
-
fixture specifically for the
|
|
306
|
+
fixture specifically for the `` loadMacro(name, fn`...`) `` AST-function
|
|
292
307
|
tier, proving its internal gensym'd let-renaming produces valid
|
|
293
308
|
identifiers on every real target, not just `evaluate()` (which can't
|
|
294
309
|
see codegen at all — see "Macros and externs").
|
|
@@ -386,7 +401,7 @@ const rodrigues = fn`
|
|
|
386
401
|
Safe by construction — if `def` returns real `ast.js` Nodes, the result
|
|
387
402
|
is exactly as trustworthy as anything else this library emits. `def`
|
|
388
403
|
can also be an AST function definition directly (e.g. straight out of
|
|
389
|
-
|
|
404
|
+
`` fn`...` ``: `` loadMacro("foo", fn`foo(x): return x * 2;`) ``), sugar
|
|
390
405
|
for the same thing — this is exactly how "Examples, flashiest first"
|
|
391
406
|
above registers `cross3`.
|
|
392
407
|
|
|
@@ -418,10 +433,10 @@ const rodrigues = fn`
|
|
|
418
433
|
duplication matters to you.
|
|
419
434
|
|
|
420
435
|
- **`loadExtern(name, def)`** — `def` is a plain per-target mapping
|
|
421
|
-
object instead of a function, e.g.
|
|
422
|
-
=>
|
|
423
|
-
native call**, same mechanism as the 22 built-in primitives —
|
|
424
|
-
supplied by you instead of shipped here. Only the targets you provide a
|
|
436
|
+
object instead of a function, e.g.
|
|
437
|
+
`` { evaluate: (x) => ..., js: ([x]) => `myLib.f(${x})`, zig: ([x]) => `mylib.f(${x})` } ``.
|
|
438
|
+
A **real native call**, same mechanism as the 22 built-in primitives —
|
|
439
|
+
just supplied by you instead of shipped here. Only the targets you provide a
|
|
425
440
|
key for resolve; every other target still throws "no mapping" for that
|
|
426
441
|
name, same as an unmapped primitive. **ExprForge can't verify the named
|
|
427
442
|
symbol actually exists in a given target, or that it behaves
|
|
@@ -446,7 +461,7 @@ itself, and here's exactly what happens:
|
|
|
446
461
|
- **A macro can't call itself, directly or through a cycle.** This is
|
|
447
462
|
enforced two different ways depending on how the macro was defined, and
|
|
448
463
|
it's worth knowing which one applies: a macro defined as an AST
|
|
449
|
-
function (straight
|
|
464
|
+
function (straight `` fn`...` `` text, or every function in a `.expr`
|
|
450
465
|
file/buffer) is resolved and **inline-expanded once, at
|
|
451
466
|
registration/load time**, against whatever's registered so far — a
|
|
452
467
|
definition only becomes referenceable *after* it's fully registered,
|
|
@@ -770,13 +785,28 @@ grammar above; and that `let`/`return` are ordinary identifiers
|
|
|
770
785
|
means `v("let") * 2`, not a syntax error, since `expr`'s own grammar has
|
|
771
786
|
no `stmt`/`signature` rules to make either one special.
|
|
772
787
|
|
|
788
|
+
`loadExprSource`/`loadExpr` (below) parse the exact same `program`
|
|
789
|
+
grammar, repeatedly, over one shared buffer — with one deliberate
|
|
790
|
+
difference: `signature` is no longer optional, and gains a mandatory
|
|
791
|
+
leading keyword:
|
|
792
|
+
|
|
793
|
+
```
|
|
794
|
+
signature := ("fn" | "macro") IDENT "(" (IDENT ("," IDENT)*)? ")" ":"
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
`fn`/`macro` are contextual the same way `let`/`return` already are —
|
|
798
|
+
special only in this exact position, ordinary identifiers everywhere
|
|
799
|
+
else (a parameter, or even a signature name, genuinely called `fn` still
|
|
800
|
+
works: `` fn`fn(x): return x * 2;` `` parses as a function named `fn`,
|
|
801
|
+
unaffected, since a *single* `` fn`...` `` call never runs in this
|
|
802
|
+
stricter mode at all — see "Loading a `.expr` file" below for what the
|
|
803
|
+
two keywords mean and why the keyword is mandatory there specifically.
|
|
804
|
+
|
|
773
805
|
## Printing an AST back out, and a native evaluator
|
|
774
806
|
|
|
775
807
|
Two things that fall out of `fn` existing: `emitters.expr` is a real,
|
|
776
808
|
registered target that prints any AST *back out* as `fn`/`expr` source
|
|
777
|
-
text (the reverse of parsing it)
|
|
778
|
-
from several composed helpers, or just getting a readable string to log
|
|
779
|
-
or paste into a future `fn`/`expr` call. And `evaluate(fn, args)` (also
|
|
809
|
+
text (the reverse of parsing it). And `evaluate(fn, args)` (also
|
|
780
810
|
exported from the main package) is a native tree-walking interpreter
|
|
781
811
|
over the same AST, computing a result directly in JS with no codegen or
|
|
782
812
|
compile step — the same node types every emitter already handles,
|
|
@@ -786,17 +816,36 @@ backed by the real `Math.*` functions.
|
|
|
786
816
|
const { emit, evaluate } = require("exprforge");
|
|
787
817
|
|
|
788
818
|
emit(normalize2, "expr").source;
|
|
789
|
-
// "normalize2(x, y):\n let mag = sqrt(((x^2) + (y^2)));\n return { nx: (x / mag), ny: (y / mag) };\n"
|
|
819
|
+
// "fn normalize2(x, y):\n let mag = sqrt(((x^2) + (y^2)));\n return { nx: (x / mag), ny: (y / mag) };\n"
|
|
790
820
|
|
|
791
821
|
evaluate(normalize2, [3, 4]);
|
|
792
822
|
// { nx: 0.6, ny: 0.8 }
|
|
793
823
|
```
|
|
794
824
|
|
|
825
|
+
Read this output for what it actually is, not as a pretty-printer of
|
|
826
|
+
whatever you originally typed: `expandMacros()` runs before *every*
|
|
827
|
+
emitter, "expr" included (same as Rust's/COBOL's/etc. own `crossLength`
|
|
828
|
+
never mentions `cross3` either — see "Examples, flashiest first" above)
|
|
829
|
+
— so a macro-free formula like `normalize2` above prints back out
|
|
830
|
+
genuinely readable, but a formula built from several composed
|
|
831
|
+
macros/helpers prints its fully-reduced canonical form instead:
|
|
832
|
+
gensym'd internal let names, multi-output fields flattened to
|
|
833
|
+
`name__field`, all of it. That's not a readability regression to fix —
|
|
834
|
+
it's the same "converges on the true, expanded form" property every
|
|
835
|
+
other target already has, just visible here because "expr" is the one
|
|
836
|
+
target whose reduced output happens to also be valid input to itself
|
|
837
|
+
again. What that buys you is real, just not "pretty debug output": a
|
|
838
|
+
concrete, load-bearing way to confirm a composed formula actually
|
|
839
|
+
reduces to what you expect (`test/conformance.test.js`'s own round-trip
|
|
840
|
+
check — print, reparse, re-evaluate, compare — is exactly this, run
|
|
841
|
+
against every sample this project has).
|
|
842
|
+
|
|
795
843
|
### Loading a `.expr` file (`loadExpr`)
|
|
796
844
|
|
|
797
845
|
`loadExpr(path)` goes the other direction from `emit(fn, "expr")` above:
|
|
798
846
|
reads a `.expr` file (that same round-trip text format) and parses it as
|
|
799
|
-
zero or more `name(params): let ...; return ...;`
|
|
847
|
+
zero or more `fn name(params): let ...; return ...;` / `macro
|
|
848
|
+
name(params): let ...; return ...;` definitions, each `fn`-marked one
|
|
800
849
|
usable directly with `evaluate()`/`emit()`/`emitMany()` — this is the
|
|
801
850
|
file-backed sibling of `loadExprSource` in "Examples, flashiest first"
|
|
802
851
|
above:
|
|
@@ -804,17 +853,23 @@ above:
|
|
|
804
853
|
```js
|
|
805
854
|
const { loadExpr, evaluate } = require("exprforge");
|
|
806
855
|
|
|
807
|
-
const defs = loadExpr("./formulas/vectors.expr");
|
|
856
|
+
const defs = loadExpr("./formulas/vectors.expr"); // "fn hyp(a, b): return sqrt(a^2 + b^2);"
|
|
808
857
|
evaluate(defs.hyp, [3, 4]); // 5
|
|
809
858
|
```
|
|
810
859
|
|
|
811
860
|
A function defined earlier in the file is available to a function defined
|
|
812
861
|
**later** in the same file — as an inline macro, the exact same
|
|
813
862
|
"expanded, not called" model `loadMacro` itself uses above (see that
|
|
814
|
-
section for why)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
863
|
+
section for why) — **regardless of whether it's marked `fn` or `macro`**.
|
|
864
|
+
The keyword only decides what's in the object this call actually returns:
|
|
865
|
+
`fn` means "hand this back to me too," `macro` means "inline-only, never
|
|
866
|
+
returned on its own" (see "Examples, flashiest first" above for the full
|
|
867
|
+
`cross3`/`crossLength` walkthrough — `cross3` is `macro`, `crossLength`
|
|
868
|
+
is `fn`, and only `defs.crossLength` exists). Neither keyword is optional
|
|
869
|
+
— every definition states one explicitly; a bare `name(params):` with
|
|
870
|
+
neither throws, naming both keywords and what each means. A `.expr` file
|
|
871
|
+
can also reference globally loaded macros, not just earlier definitions
|
|
872
|
+
in the same file — the two sources merge.
|
|
818
873
|
|
|
819
874
|
`loadExpr(path)` is a thin `fs.readFileSync` wrapper around
|
|
820
875
|
**`loadExprSource(text, label?)`** — the same parser, given source text
|
|
@@ -968,6 +1023,27 @@ and a redundant sanity check each time. Unset locally, so a plain
|
|
|
968
1023
|
`npm test` still runs everything your own machine's installed toolchains
|
|
969
1024
|
allow.
|
|
970
1025
|
|
|
1026
|
+
**Coverage**: `npm run test:coverage` runs the same suite through Node's
|
|
1027
|
+
own built-in instrumentation (`--experimental-test-coverage` — no
|
|
1028
|
+
external dependency), honoring whatever toolchains are on your machine,
|
|
1029
|
+
with no threshold enforced. CI's own "Test Coverage" workflow (badge
|
|
1030
|
+
above) is deliberately narrower and stricter: it runs only the
|
|
1031
|
+
toolchain-free `Interpreter` slice (`EXPRFORGE_TEST_TARGETS=Interpreter`,
|
|
1032
|
+
same filter every `test-*.yml` workflow already uses), gated on a fixed
|
|
1033
|
+
threshold, since that's the one environment where the number means the
|
|
1034
|
+
same thing on every run — installing zero, one, or a different subset of
|
|
1035
|
+
the 17 per-language toolchains would make an aggregate threshold either
|
|
1036
|
+
flaky or meaningless. Core logic (`ast.js`/`evaluate.js`/`expr.js`/
|
|
1037
|
+
`fn.js`/`index.js`/`load-expr.js`/`macros.js`/`math/`/`primitives.js`/
|
|
1038
|
+
`samples/`/`util.js`) sits at 95-100% in every environment, toolchains or
|
|
1039
|
+
not; per-target emitter coverage is intentionally excluded from that
|
|
1040
|
+
mental model — code that only really runs inside a real compiled/
|
|
1041
|
+
interpreted program can't be exercised without that target's own
|
|
1042
|
+
toolchain, and that verification already happens for real, by actually
|
|
1043
|
+
compiling and running the output, in the 17 other workflows — a lower
|
|
1044
|
+
coverage *percentage* there isn't itself a problem this gate is
|
|
1045
|
+
positioned to catch.
|
|
1046
|
+
|
|
971
1047
|
A few of these needed real debugging to get right, all found by actually
|
|
972
1048
|
compiling/running against a real toolchain rather than assumed to work:
|
|
973
1049
|
|
package/ast.js
CHANGED
|
@@ -323,6 +323,25 @@ function collectVarRefs(node, refs = new Set()) {
|
|
|
323
323
|
// emitFunction(), cobol.js's own override) already runs unconditionally,
|
|
324
324
|
// so it's the natural single checkpoint for this too.
|
|
325
325
|
function checkUnboundVars(fn) {
|
|
326
|
+
// checkUnboundVars is called both internally (every real consumption
|
|
327
|
+
// path runs it after expandMacros, see macros.js's own comment on
|
|
328
|
+
// expandMacros) AND directly by callers who want the check on its
|
|
329
|
+
// own (e.g. the playground's useExprForge.ts) -- unlike the internal
|
|
330
|
+
// callers, a direct caller hasn't necessarily gone through
|
|
331
|
+
// expandMacros' own equivalent guard first, so this needs its own
|
|
332
|
+
// copy: without it, `fn.name` below crashes with a raw "Cannot read
|
|
333
|
+
// properties of undefined (reading 'name')" for the same "looked up
|
|
334
|
+
// a macro-only name loadExprSource() never returned" mistake
|
|
335
|
+
// expandMacros' own guard exists to catch clearly instead.
|
|
336
|
+
if (!fn || typeof fn !== "object" || typeof fn.name !== "string" || !Array.isArray(fn.params) ||
|
|
337
|
+
!fn.body || typeof fn.body !== "object" || typeof fn.body.type !== "string") {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`checkUnboundVars: expected a {name, params, body} function definition, got ` +
|
|
340
|
+
`${fn === null ? "null" : typeof fn} -- if this came from a loadExprSource()/loadExpr() result ` +
|
|
341
|
+
`object, double check the definition you're looking up was actually marked "fn" (exported), not ` +
|
|
342
|
+
`"macro" (private -- never included in what that call returns)`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
326
345
|
assertSafeIdentifier(fn.name, "fn.name");
|
|
327
346
|
for (const p of fn.params) assertSafeIdentifier(p, "fn.params");
|
|
328
347
|
|
package/emitters/exprsyntax.js
CHANGED
|
@@ -64,8 +64,8 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
64
64
|
// `` fn`...` `` call, and exactly what the round-trip test reparses
|
|
65
65
|
// with zero unwrapping first.
|
|
66
66
|
//
|
|
67
|
-
// ALWAYS includes the "name(params):" signature line -- every
|
|
68
|
-
// emitter's formatFunction includes the full declaration per
|
|
67
|
+
// ALWAYS includes the "fn name(params):" signature line -- every
|
|
68
|
+
// other emitter's formatFunction includes the full declaration per
|
|
69
69
|
// base.js's own documented contract ("Full source text for one
|
|
70
70
|
// function, including any language-specific signature/type/wrapper
|
|
71
71
|
// syntax"); this was the one target that didn't, dropping fn.name/
|
|
@@ -75,6 +75,24 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
75
75
|
// reparsing this emitter's own output via fn() could only ever
|
|
76
76
|
// recover a bare Node, never a runnable {name, params, body}, unlike
|
|
77
77
|
// literally every other target's output being immediately usable.
|
|
78
|
+
//
|
|
79
|
+
// Always "fn", never "macro" -- and this is a real, permanent,
|
|
80
|
+
// one-way loss, not an oversight: fn.name/fn.params/fn.body is ALL
|
|
81
|
+
// this function ever receives, and "was this originally marked
|
|
82
|
+
// fn/macro" isn't part of that shape at all (see load-expr.js's own
|
|
83
|
+
// loop -- the exported/private flag is read once, to decide whether
|
|
84
|
+
// to copy the result into loadExprSource's returned object, and then
|
|
85
|
+
// discarded; it was never stored on the def itself, by design --
|
|
86
|
+
// every def looks identical regardless of how it was produced,
|
|
87
|
+
// that's the whole "sugar lowers to the same primitives" guarantee).
|
|
88
|
+
// So there is nothing left here to recover a "macro" marking FROM --
|
|
89
|
+
// "fn" is simply the only correct thing to print for "you asked to
|
|
90
|
+
// print this one, standalone" once that context is gone. Same shape
|
|
91
|
+
// of loss as `2^3` reprinting as `2^3` but `pow(2, 3)` never coming
|
|
92
|
+
// back as `pow` (see emitExpr's own pow special-case above), or
|
|
93
|
+
// comments/whitespace never surviving either -- this printer
|
|
94
|
+
// round-trips VALUES, never original source text/structure.
|
|
95
|
+
//
|
|
78
96
|
// Body lines (every let, the return) are indented 2 spaces deeper
|
|
79
97
|
// than the signature line itself -- a Python-esque pretty-print
|
|
80
98
|
// convention, not something the parser requires (whitespace is
|
|
@@ -83,7 +101,7 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
83
101
|
// AST comes out reading the same way automatically.
|
|
84
102
|
formatFunction: (fn, bodyStr, letBindings = []) => {
|
|
85
103
|
const body = [...letLines(letBindings), `return ${bodyStr};`].map((line) => ` ${line}`);
|
|
86
|
-
return [
|
|
104
|
+
return [`fn ${fn.name}(${fn.params.join(", ")}):`, ...body].join("\n") + "\n";
|
|
87
105
|
},
|
|
88
106
|
// Each output field gets its own line (4 spaces -- one level deeper
|
|
89
107
|
// than "return {" itself, which sits at the usual 2), rather than
|
|
@@ -91,7 +109,9 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
91
109
|
// this against a hand-formatted multi-output example and noticing
|
|
92
110
|
// the printer didn't follow its own convention once a suite had
|
|
93
111
|
// more than a couple of fields (a real, wide, 5-output formula made
|
|
94
|
-
// this one very long line instead of something readable).
|
|
112
|
+
// this one very long line instead of something readable). Signature
|
|
113
|
+
// line always "fn" here too -- see formatFunction's own comment
|
|
114
|
+
// above for why (same reasoning, same permanent one-way loss).
|
|
95
115
|
formatSuite: (fn, outputStrs, letBindings = []) => {
|
|
96
116
|
const entries = Object.entries(outputStrs);
|
|
97
117
|
const fieldLines = entries.map(([name, valueStr], i) => {
|
|
@@ -99,7 +119,7 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
99
119
|
return ` ${name}: ${valueStr}${comma}`;
|
|
100
120
|
});
|
|
101
121
|
const lines = [
|
|
102
|
-
|
|
122
|
+
`fn ${fn.name}(${fn.params.join(", ")}):`,
|
|
103
123
|
...letLines(letBindings).map((line) => ` ${line}`),
|
|
104
124
|
" return {",
|
|
105
125
|
...fieldLines,
|
package/fn.js
CHANGED
|
@@ -28,6 +28,42 @@
|
|
|
28
28
|
// expr()'s behavior changes: `` expr`let * 2` `` still means
|
|
29
29
|
// v("let") * 2 today, same as before this file existed.
|
|
30
30
|
//
|
|
31
|
+
// parseProgram() has a SECOND, stricter mode -- parseProgram(parser,
|
|
32
|
+
// { requireExportKeyword: true }) -- used only by load-expr.js's
|
|
33
|
+
// repeated-parse loop (loadExprSource()/loadExpr()), never by fn() below.
|
|
34
|
+
// In that mode the grammar becomes:
|
|
35
|
+
//
|
|
36
|
+
// program := signature stmt* returnStmt // no longer optional
|
|
37
|
+
// signature := ("fn" | "macro") IDENT "(" (IDENT ("," IDENT)*)? ")" ":"
|
|
38
|
+
//
|
|
39
|
+
// Why this exists, and why it's scoped to load-expr.js specifically: a
|
|
40
|
+
// single fn`...` call never auto-exports anything -- you already hold
|
|
41
|
+
// the one thing it returns, in an ordinary JS variable, and decide
|
|
42
|
+
// yourself what happens to it. loadExprSource()/loadExpr() are different:
|
|
43
|
+
// they parse a WHOLE BUFFER of back-to-back definitions and hand back a
|
|
44
|
+
// {name: FnDef} object covering every one of them, automatically, with
|
|
45
|
+
// no per-definition opt-in -- so a definition meant purely as an internal
|
|
46
|
+
// building block for a later one in the same buffer (e.g. cross3, when
|
|
47
|
+
// only crossLength's fully-inlined result is meant to be used/emitted)
|
|
48
|
+
// used to land in that returned object too, indistinguishable from a
|
|
49
|
+
// definition you actually meant to use standalone. "fn"/"macro" make
|
|
50
|
+
// that choice explicit, per definition, with no default to get wrong:
|
|
51
|
+
// "fn" means "yes, include this in what you hand back"; "macro" means
|
|
52
|
+
// "no -- inline-expand this into whatever references it later in this
|
|
53
|
+
// same buffer, same as loadMacro()'s own AST-fn-def tier, but never
|
|
54
|
+
// return it on its own." Nothing else differs between the two -- both
|
|
55
|
+
// go through the exact same registration path (toMacro/fileRegistry, see
|
|
56
|
+
// load-expr.js), so a "macro"-marked definition is, structurally, no
|
|
57
|
+
// different from one registered via loadMacro(name, fn`...`) directly;
|
|
58
|
+
// the keyword only decides whether load-expr.js's own loop additionally
|
|
59
|
+
// copies the result into the object it returns.
|
|
60
|
+
//
|
|
61
|
+
// A single fn`...` call's own (still-optional) signature line never
|
|
62
|
+
// accepts "fn"/"macro" -- there's nothing there for them to opt out of,
|
|
63
|
+
// so recognizing them would just be unearned ceremony; `` fn`fn(x):
|
|
64
|
+
// return x * 2;` `` still means what it always has, a function literally
|
|
65
|
+
// named "fn", identical to before this mode existed.
|
|
66
|
+
//
|
|
31
67
|
// Duplicate let-names are deliberately NOT checked here -- letChain()
|
|
32
68
|
// doesn't check either (ast.js); collectLets() already does, at
|
|
33
69
|
// emission time. Same "defer semantic validation to emission" precedent
|
|
@@ -116,6 +152,12 @@ function parseReturnStatement(parser) {
|
|
|
116
152
|
// still ends up an error either way, just reported as a missing ":"
|
|
117
153
|
// rather than a missing "return"; not worth deeper lookahead to improve
|
|
118
154
|
// one malformed-input error message.)
|
|
155
|
+
//
|
|
156
|
+
// Only used in fn()'s own (non-strict) mode -- see this file's header
|
|
157
|
+
// comment. requireExportKeyword mode never calls this: a signature is no
|
|
158
|
+
// longer optional there, so there's no "is this a signature or a
|
|
159
|
+
// statement" ambiguity left to resolve by lookahead at all -- see
|
|
160
|
+
// parseProgram below.
|
|
119
161
|
function looksLikeSignature(parser) {
|
|
120
162
|
const t = parser.peek();
|
|
121
163
|
if (t.type !== "IDENT" || t.value === "let" || t.value === "return") return false;
|
|
@@ -123,24 +165,56 @@ function looksLikeSignature(parser) {
|
|
|
123
165
|
return next.type === "OP" && next.value === "(";
|
|
124
166
|
}
|
|
125
167
|
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
// `requireExportKeyword` -- see this file's own header comment -- is the
|
|
169
|
+
// ONLY thing that changes here: false (fn()'s own mode, the default)
|
|
170
|
+
// parses exactly the signature grammar this always has, no "fn"/"macro"
|
|
171
|
+
// recognized at all (they're ordinary identifiers there, same as any
|
|
172
|
+
// other word); true (load-expr.js's mode) requires the very first token
|
|
173
|
+
// to literally be "fn" or "macro", consumed here before the name. This
|
|
174
|
+
// couldn't be optional-but-recognized in requireExportKeyword mode
|
|
175
|
+
// without real ambiguity (a definition genuinely named "fn"/"macro"
|
|
176
|
+
// would be indistinguishable from the keyword) -- making it MANDATORY
|
|
177
|
+
// removes that ambiguity entirely instead of needing to resolve it: a
|
|
178
|
+
// signature is required, so the first token is unconditionally expected
|
|
179
|
+
// to be the keyword, full stop.
|
|
180
|
+
function parseSignature(parser, { requireExportKeyword = false } = {}) {
|
|
181
|
+
let exported = true; // meaningless outside requireExportKeyword mode -- see parseProgram
|
|
182
|
+
if (requireExportKeyword) {
|
|
183
|
+
const t = parser.peek();
|
|
184
|
+
if (t.type !== "IDENT" || (t.value !== "fn" && t.value !== "macro")) {
|
|
185
|
+
parser.error(
|
|
186
|
+
'every definition needs to start with "fn" (exported -- included in what this call returns) ' +
|
|
187
|
+
'or "macro" (private -- inline-expanded into whatever references it later in this same source, ' +
|
|
188
|
+
'never returned on its own) -- e.g. "fn crossLength(...):" or "macro cross3(...):"',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
exported = t.value === "fn";
|
|
192
|
+
parser.next(); // consume "fn"/"macro"
|
|
193
|
+
}
|
|
194
|
+
const name = expectIdent(parser, "as the function name starting a signature");
|
|
128
195
|
parser.expectOp("(");
|
|
129
196
|
const params = [];
|
|
130
197
|
if (!parser.isOp(")")) {
|
|
131
|
-
params.push(expectIdent(parser, "as a parameter name in a
|
|
198
|
+
params.push(expectIdent(parser, "as a parameter name in a signature"));
|
|
132
199
|
while (parser.isOp(",")) {
|
|
133
200
|
parser.next();
|
|
134
|
-
params.push(expectIdent(parser, "as a parameter name in a
|
|
201
|
+
params.push(expectIdent(parser, "as a parameter name in a signature"));
|
|
135
202
|
}
|
|
136
203
|
}
|
|
137
204
|
parser.expectOp(")");
|
|
138
205
|
parser.expectOp(":");
|
|
139
|
-
return { name, params };
|
|
206
|
+
return { name, params, exported };
|
|
140
207
|
}
|
|
141
208
|
|
|
142
|
-
function parseProgram(parser) {
|
|
143
|
-
|
|
209
|
+
function parseProgram(parser, { requireExportKeyword = false } = {}) {
|
|
210
|
+
// requireExportKeyword mode: a signature is mandatory, so it's parsed
|
|
211
|
+
// unconditionally -- parseSignature itself throws a clear error if
|
|
212
|
+
// the buffer doesn't actually start with "fn"/"macro". Non-strict
|
|
213
|
+
// (fn()'s own) mode keeps the original "maybe there's no signature
|
|
214
|
+
// at all" lookahead, completely unchanged.
|
|
215
|
+
const signature = requireExportKeyword
|
|
216
|
+
? parseSignature(parser, { requireExportKeyword: true })
|
|
217
|
+
: (looksLikeSignature(parser) ? parseSignature(parser) : null);
|
|
144
218
|
|
|
145
219
|
const bindings = [];
|
|
146
220
|
while (isKeyword(parser, "let")) {
|
|
@@ -152,7 +226,16 @@ function parseProgram(parser) {
|
|
|
152
226
|
const body = parseReturnStatement(parser);
|
|
153
227
|
const result = bindings.length > 0 ? letChain(bindings, body) : body;
|
|
154
228
|
|
|
155
|
-
|
|
229
|
+
if (!signature) return result;
|
|
230
|
+
const def = { name: signature.name, params: signature.params, body: result };
|
|
231
|
+
// `exported` is only ever meaningful to load-expr.js's own loop (the
|
|
232
|
+
// one caller that runs in requireExportKeyword mode) -- omitted
|
|
233
|
+
// entirely from fn()'s own return shape below, so a plain fn`...`
|
|
234
|
+
// call's result is byte-for-byte identical to before this mode
|
|
235
|
+
// existed; nothing downstream (evaluate()/emit()/checkUnboundVars/...)
|
|
236
|
+
// has ever known or needed to know about it.
|
|
237
|
+
if (requireExportKeyword) def.exported = signature.exported;
|
|
238
|
+
return def;
|
|
156
239
|
}
|
|
157
240
|
|
|
158
241
|
// Same token-splicing loop expr() uses in expr.js -- see that file's
|
package/load-expr.js
CHANGED
|
@@ -4,26 +4,39 @@
|
|
|
4
4
|
// test/conformance.test.js's assertExprSyntaxRoundTrips) as zero or more
|
|
5
5
|
// function definitions, using the exact same grammar/engine fn`...`
|
|
6
6
|
// already uses (see fn.js's parseProgram), just applied repeatedly
|
|
7
|
-
// instead of once.
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// instead of once, in fn.js's stricter requireExportKeyword mode (see
|
|
8
|
+
// its own header comment for the full rationale). Two entry points:
|
|
9
|
+
// loadExprSource(text) parses text directly (no filesystem involved --
|
|
10
|
+
// usable anywhere source text comes from, including a browser);
|
|
11
|
+
// loadExpr(path) reads a real file first and delegates to it.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
13
|
+
// Every definition MUST have a "fn name(params):" or "macro name(params):"
|
|
14
|
+
// signature line -- no bare "name(params):" (fn.js's own requireExportKeyword
|
|
15
|
+
// mode rejects it outright), and no signature-less bare-Node definition
|
|
16
|
+
// either (which would have no name for a later definition, or the
|
|
17
|
+
// caller, to refer to it by anyway). "fn" and "macro" are otherwise
|
|
18
|
+
// identical -- both get registered into this source's own local macro
|
|
19
|
+
// registry below, so BOTH are available to whatever's defined later in
|
|
20
|
+
// the same source as an inline macro (the exact same "inline expansion,
|
|
21
|
+
// not runtime calls" model loadMacro() itself uses, see macros.js's own
|
|
22
|
+
// header comment, and for the same reasons: no call graph, no linking
|
|
23
|
+
// problem, no runtime coupling). The ONLY difference: a "macro"
|
|
24
|
+
// definition is never copied into the object this returns -- it exists
|
|
25
|
+
// purely to be inlined into something else in this same source, the
|
|
26
|
+
// same role a helper registered via loadMacro(name, fn`...`) directly
|
|
27
|
+
// already plays; a "fn" definition is both registered AND returned, so
|
|
28
|
+
// it's directly usable on its own (evaluate()/emit()/emitMany()) too.
|
|
29
|
+
// There's no default: every definition states which one it is, so a
|
|
30
|
+
// definition meant only as an internal building block for another one
|
|
31
|
+
// (e.g. cross3, when only crossLength's fully-inlined result actually
|
|
32
|
+
// gets used) can never accidentally show up in what this call hands
|
|
33
|
+
// back just because nothing said otherwise.
|
|
22
34
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
35
|
+
// And, structurally, no recursion, for either kind: a definition is only
|
|
36
|
+
// added to this source's own local registry AFTER it's been fully parsed
|
|
37
|
+
// and expanded (see the loop below), so it's never resolvable through
|
|
38
|
+
// its own name while its own body is being expanded, whether directly or
|
|
39
|
+
// transitively through another not-yet-defined function.
|
|
27
40
|
const fs = require("node:fs");
|
|
28
41
|
const { Parser, tokenizeSegment } = require("./expr.js");
|
|
29
42
|
const { parseProgram } = require("./fn.js");
|
|
@@ -42,19 +55,27 @@ function tokenizeFile(source, label) {
|
|
|
42
55
|
|
|
43
56
|
/**
|
|
44
57
|
* Parses `source` (plain text, not a file path -- see loadExpr below for
|
|
45
|
-
* the file-reading variant) as zero or more "name(params): let ...;
|
|
46
|
-
* return ...;"
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
58
|
+
* the file-reading variant) as zero or more "fn name(params): let ...;
|
|
59
|
+
* return ...;" / "macro name(params): let ...; return ...;" definitions
|
|
60
|
+
* back-to-back, in the same grammar fn`...` uses for one (fn.js's
|
|
61
|
+
* requireExportKeyword mode -- see its own header comment). Returns an
|
|
62
|
+
* object keyed by the name of every "fn"-marked definition ONLY, each
|
|
63
|
+
* value the fully-expanded {name, params, body} -- ready to pass
|
|
64
|
+
* straight into evaluate()/emit()/emitMany(). A "macro"-marked
|
|
65
|
+
* definition is registered for inlining into later definitions in the
|
|
66
|
+
* same source (see this file's own header comment) but never appears in
|
|
67
|
+
* the returned object. `label` identifies the source in error messages
|
|
68
|
+
* (e.g. a file path, or just "playground" for an in-browser text buffer
|
|
69
|
+
* that was never written to disk at all -- this is the one entry point
|
|
70
|
+
* here that has no `fs` dependency, so it's the one usable from a
|
|
71
|
+
* browser).
|
|
55
72
|
*
|
|
56
|
-
* Throws if any definition
|
|
57
|
-
*
|
|
73
|
+
* Throws if any definition doesn't start with "fn"/"macro" (a bare
|
|
74
|
+
* "name(params):" signature, or no signature at all, are both
|
|
75
|
+
* rejected), or if two definitions share a name -- regardless of
|
|
76
|
+
* whether either or both are "fn" vs "macro"; the two share one
|
|
77
|
+
* namespace, same as loadMacro()/loadExtern() already do for the
|
|
78
|
+
* process-wide registry.
|
|
58
79
|
*
|
|
59
80
|
* `registry` (see macros.js's createRegistry()) defaults to the
|
|
60
81
|
* process-wide default when omitted -- pass a session's own (see
|
|
@@ -66,28 +87,42 @@ function loadExprSource(source, label = "loadExprSource()", registry = undefined
|
|
|
66
87
|
|
|
67
88
|
const fileRegistry = new Map(); // name -> {arity, fn, alreadyExpanded} -- see toMacro in macros.js
|
|
68
89
|
const defs = {};
|
|
90
|
+
// Tracked independently of `defs` -- a "macro"-marked definition
|
|
91
|
+
// never lands in `defs` at all (see above), so `defs` alone can't
|
|
92
|
+
// catch two macro-marked definitions (or a macro and a fn) sharing a
|
|
93
|
+
// name; every parsed name, exported or not, goes through this Set.
|
|
94
|
+
const seenNames = new Set();
|
|
69
95
|
|
|
70
96
|
while (parser.peek().type !== "EOF") {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (defs[raw.name]) {
|
|
97
|
+
// requireExportKeyword: true -- see fn.js's own header comment.
|
|
98
|
+
// Throws its own clear error if this definition doesn't start
|
|
99
|
+
// with "fn"/"macro"; there's no longer a "no signature at all"
|
|
100
|
+
// case to separately detect here the way there used to be.
|
|
101
|
+
const raw = parseProgram(parser, { requireExportKeyword: true });
|
|
102
|
+
if (seenNames.has(raw.name)) {
|
|
78
103
|
throw new Error(`${label}: duplicate function name "${raw.name}" -- names must be unique in one file`);
|
|
79
104
|
}
|
|
105
|
+
seenNames.add(raw.name);
|
|
80
106
|
|
|
81
107
|
// Expanded against whatever's already in fileRegistry (earlier
|
|
82
108
|
// definitions in this same source) PLUS every macro/extern
|
|
83
109
|
// registered in `registry` (expandMacros merges both -- see
|
|
84
|
-
// macros.js).
|
|
110
|
+
// macros.js). `raw` carries an extra `exported` field (see
|
|
111
|
+
// fn.js's parseProgram) that expandMacros' own fn-def branch
|
|
112
|
+
// ignores -- it only ever reads/returns name/params/body, so
|
|
113
|
+
// `expanded` below comes back with exactly those three keys
|
|
114
|
+
// regardless.
|
|
85
115
|
const expanded = expandMacros(raw, fileRegistry, registry);
|
|
86
|
-
|
|
116
|
+
if (raw.exported) {
|
|
117
|
+
defs[raw.name] = expanded;
|
|
118
|
+
}
|
|
87
119
|
|
|
88
120
|
// Available to whatever's defined AFTER this point in the source
|
|
89
121
|
// -- never to itself (expanded above, against fileRegistry
|
|
90
|
-
// BEFORE this line adds it) or to anything defined earlier.
|
|
122
|
+
// BEFORE this line adds it) or to anything defined earlier. Both
|
|
123
|
+
// "fn" and "macro" definitions are registered here identically
|
|
124
|
+
// -- see this file's own header comment for why "exported" only
|
|
125
|
+
// ever affects `defs` above, nothing about inlining eligibility.
|
|
91
126
|
// `expanded` has nothing left to resolve (macro calls/field
|
|
92
127
|
// access are already gone), so no extraRegistry/registry needs
|
|
93
128
|
// passing here.
|
package/macros.js
CHANGED
|
@@ -688,6 +688,29 @@ function expandBody(node, ctx) {
|
|
|
688
688
|
* process-wide default ones.
|
|
689
689
|
*/
|
|
690
690
|
function expandMacros(fnOrNode, extraRegistry = null, registry = defaultRegistry) {
|
|
691
|
+
// Every real caller (evaluate(), every emitter's emitFunction()) runs
|
|
692
|
+
// this first, unconditionally, before touching fnOrNode.type/.name/
|
|
693
|
+
// .body itself -- so this is the one place positioned to catch a
|
|
694
|
+
// caller passing something that isn't actually a Node or a
|
|
695
|
+
// {name, params, body} at all (undefined, null, a typo'd lookup that
|
|
696
|
+
// silently evaluated to undefined, ...) with ONE clear message,
|
|
697
|
+
// instead of letting it fall through to expandBody below and crash
|
|
698
|
+
// with a raw "Cannot read properties of undefined (reading 'type')"
|
|
699
|
+
// several frames later -- or, worse, having emitMany() (see index.js)
|
|
700
|
+
// catch and report that same confusing crash once per language,
|
|
701
|
+
// 18 near-identical unhelpful errors instead of one. Concretely
|
|
702
|
+
// motivated by the "macro"-marked definitions loadExprSource() never
|
|
703
|
+
// returns (see load-expr.js) -- a caller looking up a macro-only name
|
|
704
|
+
// in the returned object gets `undefined` back, and previously the
|
|
705
|
+
// very next thing that happened with it was exactly this crash.
|
|
706
|
+
if (!isFnDefShape(fnOrNode) && !isNode(fnOrNode)) {
|
|
707
|
+
throw new Error(
|
|
708
|
+
`expandMacros: expected an AST Node ({type: ...}) or a {name, params, body} function ` +
|
|
709
|
+
`definition, got ${fnOrNode === null ? "null" : typeof fnOrNode} -- if this came from a ` +
|
|
710
|
+
`loadExprSource()/loadExpr() result object, double check the definition you're looking up was ` +
|
|
711
|
+
`actually marked "fn" (exported), not "macro" (private -- never included in what that call returns)`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
691
714
|
const ctx = { extraRegistry, aliases: new Map(), registry };
|
|
692
715
|
if (isFnDefShape(fnOrNode)) {
|
|
693
716
|
return { name: fnOrNode.name, params: fnOrNode.params, body: expandBody(fnOrNode.body, ctx) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "exprforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
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",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "node build.js",
|
|
29
29
|
"test": "node --test",
|
|
30
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude=\"test/**\"",
|
|
30
31
|
"prepublishOnly": "npm test"
|
|
31
32
|
},
|
|
32
33
|
"keywords": [
|