exprforge 0.4.0 → 0.5.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/index.js CHANGED
@@ -1,31 +1,126 @@
1
1
  // exprforge/index.js
2
- const { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets } = require("./ast.js");
2
+ const { num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, field, collectLets, checkUnboundVars } = require("./ast.js");
3
3
  const { forComponents } = require("./util.js");
4
4
  const { expr } = require("./expr.js");
5
5
  const { fn } = require("./fn.js");
6
6
  const { evaluate } = require("./evaluate.js");
7
+ const { loadMacro, loadExtern, expandMacros, createRegistry } = require("./macros.js");
8
+ const { loadExpr, loadExprSource } = require("./load-expr.js");
7
9
  const emitters = require("./emitters/registry.js");
8
10
  const { catmullRomAst } = require("./samples/catmull-rom.js");
9
11
  const { fibonacciAst } = require("./samples/fibonacci.js");
10
12
  const { splineFrameAsts } = require("./samples/spline-frame.js");
11
13
  const { kitchenSinkAst } = require("./samples/kitchen-sink.js");
12
14
  const { mathDemoAst } = require("./samples/math-demo.js");
15
+ const { macroDemoAst } = require("./samples/macro-demo.js");
13
16
 
14
17
  /**
15
- * Run every registered emitter against one AST function definition.
16
- * Returns { [lang]: { ext, source } }.
18
+ * Run ONE emitter against one AST function definition. Returns
19
+ * { ext, source }. Throws if `lang` isn't a registered emitter name, or if
20
+ * that emitter itself throws for this fn (an unmapped Math function, a
21
+ * reserved-name collision, ...) -- the caller picked exactly this one
22
+ * target, so there's no "other languages" for a per-language error to be
23
+ * isolated from; let it propagate. Prefer this over emitAll (deprecated,
24
+ * below) when you only need one or a few targets -- it doesn't pay for
25
+ * every registered emitter to get one.
26
+ *
27
+ * `registry` (see macros.js's createRegistry()) defaults to the
28
+ * process-wide default when omitted -- pass a session's own (see
29
+ * createSession() below) to resolve macros/externs registered in that
30
+ * session instead; ordinary callers never need to pass it directly.
17
31
  */
18
- function emitAll(fnDef) {
32
+ function emit(fnDef, lang, registry = undefined) {
33
+ const emitter = emitters[lang];
34
+ if (!emitter) {
35
+ throw new Error(
36
+ `emit(): no emitter registered for language "${lang}" -- known languages: ${Object.keys(emitters).sort().join(", ")}`,
37
+ );
38
+ }
39
+ return { ext: emitter.ext, source: emitter.emitFunction(fnDef, registry) };
40
+ }
41
+
42
+ /**
43
+ * Run several emitters against one AST function definition, each in
44
+ * isolation from the others: one language's emitter throwing shows up as
45
+ * { source: null, error } for THAT language only, alongside every other
46
+ * requested language's real { source, error: null } result -- a single
47
+ * bad target (e.g. a parameter name COBOL's reserved-word check rejects)
48
+ * no longer takes the whole batch down with it. `langs` defaults to every
49
+ * registered language; pass an explicit array to avoid running (and
50
+ * paying for) emitters you don't need.
51
+ *
52
+ * `registry` -- see emit()'s own doc comment above -- same meaning here,
53
+ * applied to every language in `langs`.
54
+ */
55
+ function emitMany(fnDef, langs = Object.keys(emitters), registry = undefined) {
19
56
  const result = {};
20
- for (const [lang, emitter] of Object.entries(emitters)) {
21
- result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fnDef) };
57
+ for (const lang of langs) {
58
+ const emitter = emitters[lang];
59
+ if (!emitter) {
60
+ result[lang] = { ext: null, source: null, error: `no emitter registered for language "${lang}"` };
61
+ continue;
62
+ }
63
+ try {
64
+ result[lang] = { ext: emitter.ext, source: emitter.emitFunction(fnDef, registry), error: null };
65
+ } catch (e) {
66
+ result[lang] = { ext: emitter.ext, source: null, error: e instanceof Error ? e.message : String(e) };
67
+ }
22
68
  }
23
69
  return result;
24
70
  }
25
71
 
72
+ /**
73
+ * @deprecated Runs every registered emitter unconditionally, whether you
74
+ * need all of them or not -- prefer emit(fnDef, lang) for one target, or
75
+ * emitMany(fnDef, langs) for an explicit subset (or emitMany(fnDef) with
76
+ * no `langs` for the same "every language" behavior this has always had).
77
+ * Kept working, not scheduled for removal -- existing callers reading
78
+ * result[lang].ext/.source see no change; the only behavior change is the
79
+ * bug fix this shares with emitMany: a single emitter throwing used to
80
+ * abort the whole batch (nothing for ANY language came back), and now
81
+ * surfaces as { source: null, error } for that language alone.
82
+ */
83
+ function emitAll(fnDef) {
84
+ return emitMany(fnDef);
85
+ }
86
+
87
+ /**
88
+ * Creates an isolated "session": its own private macro/extern registry
89
+ * (see macros.js's createRegistry()), plus every macro/extern/evaluate/
90
+ * emit-shaped function bound to use it instead of the process-wide
91
+ * default registry loadMacro/loadExtern/evaluate/emit/emitMany use when
92
+ * called bare. Purely additive -- loadMacro/loadExtern/etc. above are
93
+ * completely unaffected by a session's existence, and a session's own
94
+ * registrations are invisible to them and to every OTHER session,
95
+ * garbage-collected normally once the session object itself is no
96
+ * longer referenced. No removal API: rebuild a fresh session (via a new
97
+ * createSession() call) instead of trying to unregister one macro/extern
98
+ * out of an existing one -- see the design discussion this came out of
99
+ * (github.com/theraccoonbear/exprforge/issues/21).
100
+ *
101
+ * Useful whenever a program legitimately needs more than one independent
102
+ * "namespace" of macros/externs at once -- e.g. a multi-tenant service
103
+ * evaluating math defined by different untrusted users, where one
104
+ * user's loadMacro("helper", ...) must never resolve inside another
105
+ * user's expression just because they picked the same name.
106
+ */
107
+ function createSession() {
108
+ const registry = createRegistry();
109
+ return {
110
+ loadMacro: (name, def) => loadMacro(name, def, registry),
111
+ loadExtern: (name, def) => loadExtern(name, def, registry),
112
+ expandMacros: (fnOrNode, extraRegistry = null) => expandMacros(fnOrNode, extraRegistry, registry),
113
+ evaluate: (fn, args) => evaluate(fn, args, registry),
114
+ emit: (fnDef, lang) => emit(fnDef, lang, registry),
115
+ emitMany: (fnDef, langs = Object.keys(emitters)) => emitMany(fnDef, langs, registry),
116
+ loadExpr: (path) => loadExpr(path, registry),
117
+ loadExprSource: (source, label = "loadExprSource()") => loadExprSource(source, label, registry),
118
+ };
119
+ }
120
+
26
121
  module.exports = {
27
122
  // AST builders — use these to define your own formulas.
28
- num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, collectLets,
123
+ num, v, bin, call, add, mul, sub, div, neg, letIn, letChain, cmp, select, outputs, field, collectLets, checkUnboundVars,
29
124
  // Authoring convenience — not an AST primitive, see util.js.
30
125
  forComponents,
31
126
  // Infix syntax sugar over the builders above — same Nodes, see expr.js.
@@ -37,6 +132,28 @@ module.exports = {
37
132
  // A native interpreter over the AST -- evaluate(fn, args) computes a
38
133
  // result directly in JS, no codegen/compile step. See evaluate.js.
39
134
  evaluate,
135
+ // Register a macro: a name usable inside fn`...`/expr`...` text
136
+ // beyond the built-in primitives, inline-expanded at build time,
137
+ // never emitted as a real call. See macros.js's own header comment.
138
+ loadMacro,
139
+ // Register an extern: same usable-by-name mechanism, but a real
140
+ // per-target native call instead -- caller-owned risk, ExprForge
141
+ // can't verify it. See macros.js's own header comment.
142
+ loadExtern,
143
+ // Runs the same macro/field-access expansion evaluate() and every
144
+ // emitter already run internally -- exposed for callers who want the
145
+ // expanded tree itself (e.g. to inspect or re-emit it without
146
+ // re-running expansion). Ordinary callers never need this.
147
+ expandMacros,
148
+ // Parses a .expr file (the exprsyntax emitter's own round-trip
149
+ // format) into its function definitions, letting later functions in
150
+ // the file reference earlier ones as inline macros. See load-expr.js.
151
+ loadExpr,
152
+ // Same parser, given source text directly instead of a file path --
153
+ // no filesystem involved, so this is the one usable from a browser
154
+ // (a text editor buffer, an HTTP response, ...). loadExpr(path) is
155
+ // now just this plus a readFileSync.
156
+ loadExprSource,
40
157
  // Built-in example formulas — see samples/ for the source.
41
158
  catmullRomAst,
42
159
  fibonacciAst,
@@ -47,15 +164,31 @@ module.exports = {
47
164
  // Also not a worked example -- a conformance-test fixture for
48
165
  // require("exprforge/math"). See samples/math-demo.js.
49
166
  mathDemoAst,
167
+ // Also not a worked example -- a conformance-test fixture for
168
+ // macros.js's AST-fn-def macro tier specifically (loadMacro(name,
169
+ // fn`...`), not the plain-JS-function tier mathDemoAst already
170
+ // covers). See samples/macro-demo.js.
171
+ macroDemoAst,
50
172
  samples: {
51
173
  catmullRom: catmullRomAst,
52
174
  fibonacci: fibonacciAst,
53
175
  splineFrame: splineFrameAsts,
54
176
  kitchenSink: kitchenSinkAst,
55
177
  mathDemo: mathDemoAst,
178
+ macroDemo: macroDemoAst,
56
179
  },
57
180
  // Per-language emitter instances, keyed by name (js, qb64, c, java, go, rust).
58
181
  emitters,
59
- // Convenience: run every emitter at once.
182
+ // One target, explicit -- see this function's own doc comment above.
183
+ emit,
184
+ // Several targets at once, each isolated from the others' failures.
185
+ emitMany,
186
+ // Deprecated: every target at once, no way to ask for fewer. Prefer
187
+ // emit()/emitMany() above -- kept working, not removed.
60
188
  emitAll,
189
+ // Creates an isolated session: its own private macro/extern registry,
190
+ // plus loadMacro/loadExtern/evaluate/emit/emitMany/loadExpr/
191
+ // loadExprSource bound to use it. See this function's own doc
192
+ // comment above.
193
+ createSession,
61
194
  };
package/load-expr.js ADDED
@@ -0,0 +1,112 @@
1
+ // exprforge/load-expr.js
2
+ //
3
+ // Parses the .expr round-trip text format (see emitters/exprsyntax.js and
4
+ // test/conformance.test.js's assertExprSyntaxRoundTrips) as zero or more
5
+ // function definitions, using the exact same grammar/engine fn`...`
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.
11
+ //
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.
22
+ //
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.
27
+ const fs = require("node:fs");
28
+ const { Parser, tokenizeSegment } = require("./expr.js");
29
+ const { parseProgram } = require("./fn.js");
30
+ const { expandMacros, toMacro } = require("./macros.js");
31
+
32
+ // Tokenizes the WHOLE file as one segment -- unlike fn()/expr() there's
33
+ // no tagged-template interpolation to splice HOLE tokens between (a .expr
34
+ // file is plain text, not JS source with ${...} holes), so this is
35
+ // simpler than fn.js's own tokenizeSegment loop, not a fork of it.
36
+ function tokenizeFile(source, label) {
37
+ const tokens = [];
38
+ tokenizeSegment(source, 0, tokens, { inComment: false }, label);
39
+ tokens.push({ type: "EOF", value: null, pos: source.length });
40
+ return tokens;
41
+ }
42
+
43
+ /**
44
+ * 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).
55
+ *
56
+ * Throws if any definition has no "name(params):" signature line, or if
57
+ * two definitions share a name.
58
+ *
59
+ * `registry` (see macros.js's createRegistry()) defaults to the
60
+ * process-wide default when omitted -- pass a session's own (see
61
+ * index.js's createSession()) to resolve macros/externs defined in that
62
+ * session, alongside whatever's defined earlier in this same source.
63
+ */
64
+ function loadExprSource(source, label = "loadExprSource()", registry = undefined) {
65
+ const parser = new Parser(tokenizeFile(source, label), source, label);
66
+
67
+ const fileRegistry = new Map(); // name -> {arity, fn, alreadyExpanded} -- see toMacro in macros.js
68
+ const defs = {};
69
+
70
+ 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]) {
78
+ throw new Error(`${label}: duplicate function name "${raw.name}" -- names must be unique in one file`);
79
+ }
80
+
81
+ // Expanded against whatever's already in fileRegistry (earlier
82
+ // definitions in this same source) PLUS every macro/extern
83
+ // registered in `registry` (expandMacros merges both -- see
84
+ // macros.js).
85
+ const expanded = expandMacros(raw, fileRegistry, registry);
86
+ defs[raw.name] = expanded;
87
+
88
+ // Available to whatever's defined AFTER this point in the source
89
+ // -- never to itself (expanded above, against fileRegistry
90
+ // BEFORE this line adds it) or to anything defined earlier.
91
+ // `expanded` has nothing left to resolve (macro calls/field
92
+ // access are already gone), so no extraRegistry/registry needs
93
+ // passing here.
94
+ fileRegistry.set(raw.name, toMacro(expanded));
95
+ }
96
+
97
+ return defs;
98
+ }
99
+
100
+ /**
101
+ * Reads `path` from disk and parses it via loadExprSource() above -- see
102
+ * that function's own doc comment for the actual grammar/semantics
103
+ * (including `registry`); this is purely the file-reading convenience
104
+ * wrapper around it. Node-only (fs.readFileSync); use loadExprSource(text)
105
+ * directly wherever the source text comes from somewhere else (e.g. a
106
+ * browser text buffer, an HTTP response) instead of a real file on disk.
107
+ */
108
+ function loadExpr(path, registry = undefined) {
109
+ return loadExprSource(fs.readFileSync(path, "utf8"), `loadExpr(${path})`, registry);
110
+ }
111
+
112
+ module.exports = { loadExpr, loadExprSource };