exprforge 0.5.1 → 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 CHANGED
@@ -19,6 +19,7 @@
19
19
  [![Zig](https://github.com/theraccoonbear/exprforge/actions/workflows/test-zig.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-zig.yml)
20
20
  [![Scheme](https://github.com/theraccoonbear/exprforge/actions/workflows/test-scheme.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-scheme.yml)
21
21
  [![COBOL](https://github.com/theraccoonbear/exprforge/actions/workflows/test-cobol.yml/badge.svg)](https://github.com/theraccoonbear/exprforge/actions/workflows/test-cobol.yml)
22
+ [![Test Coverage](https://github.com/theraccoonbear/exprforge/actions/workflows/test-coverage.yml/badge.svg)](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
@@ -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) useful for debugging a formula built
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 ...;` definitions, each
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). Every definition needs a `name(params):` signature line
815
- (nothing later in the file, or the caller, could refer to one that
816
- didn't), and a `.expr` file can reference globally loaded macros too, not
817
- just earlier definitions in the same file the two sources merge.
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
 
@@ -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 other
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 [`${fn.name}(${fn.params.join(", ")}):`, ...body].join("\n") + "\n";
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
- `${fn.name}(${fn.params.join(", ")}):`,
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
- function parseSignature(parser) {
127
- const name = expectIdent(parser, "as the function name starting a fn`...` signature");
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 fn`...` signature"));
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 fn`...` signature"));
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
- const signature = looksLikeSignature(parser) ? parseSignature(parser) : null;
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
- return signature ? { name: signature.name, params: signature.params, body: result } : result;
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. Two entry points: loadExprSource(text) parses text
8
- // directly (no filesystem involved -- usable anywhere source text comes
9
- // from, including a browser); loadExpr(path) reads a real file first and
10
- // delegates to it.
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
- // Functions defined earlier are available to functions defined LATER (in
13
- // the same source) as inline macros -- the exact same "inline expansion,
14
- // not runtime calls" model loadMacro() itself uses (see macros.js's own
15
- // header comment), and for the same reasons: no call graph, no linking
16
- // problem, no runtime coupling. And, structurally, no recursion: a
17
- // definition is only added to this source's own local registry AFTER
18
- // it's been fully parsed and expanded (see the loop below), so it's
19
- // never resolvable through its own name while its own body is being
20
- // expanded, whether directly or transitively through another
21
- // not-yet-defined function.
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
- // Each definition MUST have a "name(params):" signature line -- a
24
- // bare-Node definition with no signature has no name for a later
25
- // definition (or the caller) to refer to it by, so it can't usefully
26
- // appear alongside others.
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 ...;" definitions back-to-back, in the same grammar fn`...`
47
- * uses for one. Returns an object keyed by function name, each value the
48
- * fully-expanded {name, params, body} -- ready to pass straight into
49
- * evaluate()/emit()/emitMany(), with every reference to an earlier
50
- * definition in the same source already inlined (see this file's own
51
- * header comment). `label` identifies the source in error messages (e.g.
52
- * a file path, or just "playground" for an in-browser text buffer that
53
- * was never written to disk at all -- this is the one entry point here
54
- * that has no `fs` dependency, so it's the one usable from a browser).
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 has no "name(params):" signature line, or if
57
- * two definitions share a name.
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
- const raw = parseProgram(parser);
72
- if (!raw || typeof raw.name !== "string") {
73
- throw new Error(
74
- `${label}: every definition needs a "name(params):" signature line -- found one with no signature`,
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
- defs[raw.name] = expanded;
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.5.1",
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": [