exprforge 0.3.1 → 0.5.0
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 +562 -99
- package/ast.js +190 -2
- package/emitters/base.js +36 -4
- package/emitters/cobol.js +191 -54
- package/emitters/exprsyntax.js +39 -8
- package/emitters/registry.js +12 -1
- package/evaluate.js +47 -14
- package/expr.js +107 -9
- package/fn.js +31 -6
- package/index.js +141 -8
- package/load-expr.js +112 -0
- package/macros.js +707 -0
- package/math/index.js +21 -0
- package/package.json +4 -1
- package/primitives.js +24 -0
- package/samples/macro-demo.js +56 -0
package/emitters/exprsyntax.js
CHANGED
|
@@ -63,17 +63,48 @@ const emitter = new ExprSyntaxEmitter({
|
|
|
63
63
|
// That's both the most direct thing to paste into a real
|
|
64
64
|
// `` fn`...` `` call, and exactly what the round-trip test reparses
|
|
65
65
|
// with zero unwrapping first.
|
|
66
|
+
//
|
|
67
|
+
// ALWAYS includes the "name(params):" signature line -- every other
|
|
68
|
+
// emitter's formatFunction includes the full declaration per
|
|
69
|
+
// base.js's own documented contract ("Full source text for one
|
|
70
|
+
// function, including any language-specific signature/type/wrapper
|
|
71
|
+
// syntax"); this was the one target that didn't, dropping fn.name/
|
|
72
|
+
// fn.params on the floor entirely. That wasn't a deliberate
|
|
73
|
+
// minimalism choice, it was a leftover from before fn`...`'s
|
|
74
|
+
// optional signature line (see fn.js) existed at all -- without it,
|
|
75
|
+
// reparsing this emitter's own output via fn() could only ever
|
|
76
|
+
// recover a bare Node, never a runnable {name, params, body}, unlike
|
|
77
|
+
// literally every other target's output being immediately usable.
|
|
78
|
+
// Body lines (every let, the return) are indented 2 spaces deeper
|
|
79
|
+
// than the signature line itself -- a Python-esque pretty-print
|
|
80
|
+
// convention, not something the parser requires (whitespace is
|
|
81
|
+
// insignificant to fn's grammar; see expr.js's tokenizer). Applied
|
|
82
|
+
// here, not just in hand-written docs/examples, so every printed
|
|
83
|
+
// AST comes out reading the same way automatically.
|
|
66
84
|
formatFunction: (fn, bodyStr, letBindings = []) => {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
return lines.join("\n") + "\n";
|
|
85
|
+
const body = [...letLines(letBindings), `return ${bodyStr};`].map((line) => ` ${line}`);
|
|
86
|
+
return [`${fn.name}(${fn.params.join(", ")}):`, ...body].join("\n") + "\n";
|
|
70
87
|
},
|
|
88
|
+
// Each output field gets its own line (4 spaces -- one level deeper
|
|
89
|
+
// than "return {" itself, which sits at the usual 2), rather than
|
|
90
|
+
// cramming every field onto one line -- found the gap by comparing
|
|
91
|
+
// this against a hand-formatted multi-output example and noticing
|
|
92
|
+
// the printer didn't follow its own convention once a suite had
|
|
93
|
+
// more than a couple of fields (a real, wide, 5-output formula made
|
|
94
|
+
// this one very long line instead of something readable).
|
|
71
95
|
formatSuite: (fn, outputStrs, letBindings = []) => {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
const entries = Object.entries(outputStrs);
|
|
97
|
+
const fieldLines = entries.map(([name, valueStr], i) => {
|
|
98
|
+
const comma = i < entries.length - 1 ? "," : "";
|
|
99
|
+
return ` ${name}: ${valueStr}${comma}`;
|
|
100
|
+
});
|
|
101
|
+
const lines = [
|
|
102
|
+
`${fn.name}(${fn.params.join(", ")}):`,
|
|
103
|
+
...letLines(letBindings).map((line) => ` ${line}`),
|
|
104
|
+
" return {",
|
|
105
|
+
...fieldLines,
|
|
106
|
+
" };",
|
|
107
|
+
];
|
|
77
108
|
return lines.join("\n") + "\n";
|
|
78
109
|
},
|
|
79
110
|
});
|
package/emitters/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// exprforge/emitters/registry.js
|
|
2
2
|
// Add a new language: write emitters/<lang>.js exporting an Emitter instance,
|
|
3
3
|
// then add one line here. Nothing else in the project needs to change.
|
|
4
|
-
|
|
4
|
+
const emitters = {
|
|
5
5
|
js: require("./js.js"),
|
|
6
6
|
ts: require("./typescript.js"),
|
|
7
7
|
qb64: require("./qb64.js"),
|
|
@@ -21,3 +21,14 @@ module.exports = {
|
|
|
21
21
|
cobol: require("./cobol.js"),
|
|
22
22
|
expr: require("./exprsyntax.js"),
|
|
23
23
|
};
|
|
24
|
+
|
|
25
|
+
// Each emitter learns its own registered key here, once -- base.js's
|
|
26
|
+
// emitExpr uses this.lang to resolve an extern (see loadExtern() in
|
|
27
|
+
// macros.js), and this is the one place that already knows the
|
|
28
|
+
// name<->instance mapping, so it's the only file that needs to change
|
|
29
|
+
// for this instead of every emitters/<lang>.js.
|
|
30
|
+
for (const [lang, emitter] of Object.entries(emitters)) {
|
|
31
|
+
emitter.lang = lang;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = emitters;
|
package/evaluate.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
// there's exactly one node-shape contract (ast.js's own header comment)
|
|
10
10
|
// for both this file and every emitters/<lang>.js to agree with.
|
|
11
11
|
//
|
|
12
|
-
// Every
|
|
12
|
+
// Every primitive name maps 1:1 onto emitters/js.js's own `calls` table
|
|
13
13
|
// keys (the simplest existing source of truth for "what the ~22
|
|
14
|
-
//
|
|
14
|
+
// primitives are called") straight to the real Math.* function -- this
|
|
15
15
|
// target has no codegen step to route an intermediate string through.
|
|
16
|
-
const { collectLets } = require("./ast.js");
|
|
16
|
+
const { collectLets, checkUnboundVars } = require("./ast.js");
|
|
17
|
+
const { expandMacros, resolveExternForEvaluate, withContext } = require("./macros.js");
|
|
17
18
|
|
|
18
19
|
const CMP_OPS = {
|
|
19
20
|
">": (a, b) => a > b,
|
|
@@ -43,7 +44,12 @@ const CALLS = {
|
|
|
43
44
|
// valid pre-collectLets (a function's top-level let-chain/body shape),
|
|
44
45
|
// never nested inside a bin/call/select, same constraint every emitter
|
|
45
46
|
// already relies on (see ast.js's own comments on letIn/outputs).
|
|
46
|
-
|
|
47
|
+
// `registry` -- see macros.js's createRegistry()/index.js's
|
|
48
|
+
// createSession() -- resolves an extern against the right session's own
|
|
49
|
+
// registrations, defaulting to the process-wide one when not passed
|
|
50
|
+
// (evaluate() below never passes `undefined` on purpose either way, see
|
|
51
|
+
// there).
|
|
52
|
+
function evalNode(node, env, registry) {
|
|
47
53
|
switch (node.type) {
|
|
48
54
|
case "num":
|
|
49
55
|
return node.value;
|
|
@@ -55,18 +61,25 @@ function evalNode(node, env) {
|
|
|
55
61
|
case "bin": {
|
|
56
62
|
const op = BIN_OPS[node.op];
|
|
57
63
|
if (!op) throw new Error(`evaluate(): unknown bin op "${node.op}"`);
|
|
58
|
-
return op(evalNode(node.left, env), evalNode(node.right, env));
|
|
64
|
+
return op(evalNode(node.left, env, registry), evalNode(node.right, env, registry));
|
|
59
65
|
}
|
|
60
66
|
case "call": {
|
|
61
|
-
const impl = CALLS[node.name];
|
|
67
|
+
const impl = CALLS[node.name] || resolveExternForEvaluate(node.name, registry);
|
|
62
68
|
if (!impl) throw new Error(`evaluate(): no mapping for Math function "${node.name}"`);
|
|
63
|
-
|
|
69
|
+
const args = node.args.map((a) => evalNode(a, env, registry));
|
|
70
|
+
// Only an extern's own `evaluate` entry is caller-supplied
|
|
71
|
+
// code that could have a bug worth attributing -- a built-in
|
|
72
|
+
// primitive (impl === CALLS[node.name]) is exprforge's own
|
|
73
|
+
// Math.* wrapper, not worth wrapping.
|
|
74
|
+
return CALLS[node.name]
|
|
75
|
+
? impl(...args)
|
|
76
|
+
: withContext(`evaluate(): while running extern "${node.name}"`, () => impl(...args));
|
|
64
77
|
}
|
|
65
78
|
case "select": {
|
|
66
79
|
const cmpFn = CMP_OPS[node.cond.op];
|
|
67
80
|
if (!cmpFn) throw new Error(`evaluate(): unknown cmp op "${node.cond.op}"`);
|
|
68
|
-
const cond = cmpFn(evalNode(node.cond.left, env), evalNode(node.cond.right, env));
|
|
69
|
-
return evalNode(cond ? node.then : node.else, env);
|
|
81
|
+
const cond = cmpFn(evalNode(node.cond.left, env, registry), evalNode(node.cond.right, env, registry));
|
|
82
|
+
return evalNode(cond ? node.then : node.else, env, registry);
|
|
70
83
|
}
|
|
71
84
|
default:
|
|
72
85
|
throw new Error(
|
|
@@ -81,8 +94,28 @@ function evalNode(node, env) {
|
|
|
81
94
|
// fn.params. Returns a number for a plain body, or a {name: value}
|
|
82
95
|
// object for a multi-output (outputs()) body -- matching the shape
|
|
83
96
|
// test/conformance.test.js's own parseSuiteOutput() already expects
|
|
84
|
-
// back from every other target.
|
|
85
|
-
|
|
97
|
+
// back from every other target. `registry` (see macros.js's
|
|
98
|
+
// createRegistry()) defaults to the process-wide default when omitted --
|
|
99
|
+
// pass a session's own (see index.js's createSession()) to resolve
|
|
100
|
+
// macros/externs against that session instead; ordinary callers never
|
|
101
|
+
// need to pass it.
|
|
102
|
+
function evaluate(fn, args, registry = undefined) {
|
|
103
|
+
// Resolves every macro call and field() access into plain arithmetic
|
|
104
|
+
// FIRST -- checkUnboundVars/collectLets/evalNode below know nothing
|
|
105
|
+
// about either (see macros.js's own header comment); an extern's
|
|
106
|
+
// call node is left alone here, and resolved above in evalNode's own
|
|
107
|
+
// "call" case instead.
|
|
108
|
+
fn = expandMacros(fn, null, registry);
|
|
109
|
+
// Checked once, up front, exhaustively -- NOT relying on evalNode's
|
|
110
|
+
// own runtime "unbound variable" throw below to happen to hit it,
|
|
111
|
+
// which it might never do for a given call: a bad reference inside
|
|
112
|
+
// a select() branch these particular args don't take would silently
|
|
113
|
+
// never surface that way. See checkUnboundVars's own comment in
|
|
114
|
+
// ast.js. evalNode's runtime check stays in place too, as a cheap
|
|
115
|
+
// internal backstop -- it should be unreachable now that this runs
|
|
116
|
+
// first, same "check early, keep the deeper check anyway" precedent
|
|
117
|
+
// emitters/cobol.js already follows for its own reserved-name checks.
|
|
118
|
+
checkUnboundVars(fn);
|
|
86
119
|
if (args.length !== fn.params.length) {
|
|
87
120
|
throw new Error(`evaluate(): ${fn.name} expects ${fn.params.length} argument(s), got ${args.length}`);
|
|
88
121
|
}
|
|
@@ -93,17 +126,17 @@ function evaluate(fn, args) {
|
|
|
93
126
|
|
|
94
127
|
const { bindings, body } = collectLets(fn.body);
|
|
95
128
|
for (const { name, node } of bindings) {
|
|
96
|
-
env[name] = evalNode(node, env);
|
|
129
|
+
env[name] = evalNode(node, env, registry);
|
|
97
130
|
}
|
|
98
131
|
|
|
99
132
|
if (body.type === "outputs") {
|
|
100
133
|
const result = {};
|
|
101
134
|
for (const [name, node] of Object.entries(body.fields)) {
|
|
102
|
-
result[name] = evalNode(node, env);
|
|
135
|
+
result[name] = evalNode(node, env, registry);
|
|
103
136
|
}
|
|
104
137
|
return result;
|
|
105
138
|
}
|
|
106
|
-
return evalNode(body, env);
|
|
139
|
+
return evalNode(body, env, registry);
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
module.exports = { evaluate };
|
package/expr.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
// additive := multiplicative ( ("+"|"-") multiplicative )*
|
|
24
24
|
// multiplicative := unary ( ("*"|"/") unary )*
|
|
25
25
|
// unary := "-" unary | power
|
|
26
|
-
// power :=
|
|
26
|
+
// power := postfix ( "^" unary )?
|
|
27
|
+
// postfix := primary ( "." IDENT )*
|
|
27
28
|
// primary := NUMBER | IDENT ("(" args ")")? | "(" expression ")" | HOLE
|
|
28
29
|
// args := expression ("," expression)*
|
|
29
30
|
//
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
// is only ever "+"|"-"|"*"|"/" (see ast.js), and every emitter's
|
|
41
42
|
// `calls` table keys "pow" by name, even for targets whose own
|
|
42
43
|
// syntax has a native ^/** operator.
|
|
43
|
-
const { num, v, add, sub, mul, div, neg, call, cmp, select } = require("./ast.js");
|
|
44
|
+
const { num, v, add, sub, mul, div, neg, call, cmp, select, field } = require("./ast.js");
|
|
44
45
|
|
|
45
46
|
const COMPARE_OPS = [">", "<", ">=", "<=", "==", "!="];
|
|
46
47
|
|
|
@@ -50,8 +51,28 @@ const COMPARE_OPS = [">", "<", ">=", "<=", "==", "!="];
|
|
|
50
51
|
// `label` is just which tag function's name shows up in error messages
|
|
51
52
|
// -- fn.js passes "fn()" here so a lex error inside `` fn`...` `` isn't
|
|
52
53
|
// misattributed to expr().
|
|
53
|
-
|
|
54
|
+
//
|
|
55
|
+
// `state.inComment` carries "# comment" status ACROSS segments -- these
|
|
56
|
+
// are tagged template literals, so a source like
|
|
57
|
+
// `` expr`a + b # comment ${x} more` `` tokenizes segment "a + b #
|
|
58
|
+
// comment " and segment " more" separately, with a HOLE for `x` spliced
|
|
59
|
+
// between them by expr()/fn() below. A comment open at the end of one
|
|
60
|
+
// segment has to stay open into the next, or "more" would wrongly
|
|
61
|
+
// become real tokens again. One `state` object is created once per
|
|
62
|
+
// top-level expr()/fn() call and threaded through every call here --
|
|
63
|
+
// never reset per segment.
|
|
64
|
+
function tokenizeSegment(str, offset, tokens, state = { inComment: false }, label = "expr()") {
|
|
54
65
|
let i = 0;
|
|
66
|
+
if (state.inComment) {
|
|
67
|
+
const nl = str.indexOf("\n");
|
|
68
|
+
if (nl === -1) {
|
|
69
|
+
// The whole segment is still inside the comment -- nothing
|
|
70
|
+
// to tokenize, and still in-comment for whatever's next.
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
i = nl + 1;
|
|
74
|
+
state.inComment = false;
|
|
75
|
+
}
|
|
55
76
|
while (i < str.length) {
|
|
56
77
|
const ch = str[i];
|
|
57
78
|
const start = i;
|
|
@@ -59,6 +80,20 @@ function tokenizeSegment(str, offset, tokens, label = "expr()") {
|
|
|
59
80
|
i++;
|
|
60
81
|
continue;
|
|
61
82
|
}
|
|
83
|
+
// "#" comments run to the next newline (or off the end of this
|
|
84
|
+
// segment, in which case state.inComment stays set for the next
|
|
85
|
+
// one -- see above). Not part of the OP set below: this
|
|
86
|
+
// produces no token at all, the same category as whitespace,
|
|
87
|
+
// not an operator.
|
|
88
|
+
if (ch === "#") {
|
|
89
|
+
const nl = str.indexOf("\n", i);
|
|
90
|
+
if (nl === -1) {
|
|
91
|
+
state.inComment = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
i = nl + 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
62
97
|
// NUMBER: 123, 123.45, .5, 1e-9, 1.5E+10
|
|
63
98
|
if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(str[i + 1] || ""))) {
|
|
64
99
|
i++;
|
|
@@ -97,8 +132,12 @@ function tokenizeSegment(str, offset, tokens, label = "expr()") {
|
|
|
97
132
|
// {...};) to reuse this same tokenizer instead of forking it.
|
|
98
133
|
// Inert for expr(): nothing that parses successfully today could
|
|
99
134
|
// contain them anyway ("=" alone was always a lex error before,
|
|
100
|
-
// since only "==" was recognized).
|
|
101
|
-
|
|
135
|
+
// since only "==" was recognized). "." is new too -- postfix field
|
|
136
|
+
// access (b.rx), see parsePostfix() below; harmless to add here
|
|
137
|
+
// since a bare "." was always a lex error before (the NUMBER
|
|
138
|
+
// branch above already claims every "." that's followed by a
|
|
139
|
+
// digit, e.g. ".5", so there's no ambiguity with number literals).
|
|
140
|
+
if ("+-*/^(),?:><;{}=.".includes(ch)) {
|
|
102
141
|
tokens.push({ type: "OP", value: ch, pos: offset + start });
|
|
103
142
|
i++;
|
|
104
143
|
continue;
|
|
@@ -121,6 +160,21 @@ function holeToNode(value, label = "expr()") {
|
|
|
121
160
|
);
|
|
122
161
|
}
|
|
123
162
|
|
|
163
|
+
// How deep parseExpression() can recurse into itself -- nested
|
|
164
|
+
// parens/function-call args/ternary branches, the only places genuine
|
|
165
|
+
// nesting comes from (the additive/multiplicative/unary/power/postfix
|
|
166
|
+
// precedence chain always runs once per primary regardless of how deep
|
|
167
|
+
// the tree ends up, so it isn't what this is bounding). 100 is
|
|
168
|
+
// deliberately generous, not tight: the deepest real formula in this
|
|
169
|
+
// project (Cardano's cubic, samples/ via the playground's own example)
|
|
170
|
+
// doesn't come close to double digits. It exists so a pathologically
|
|
171
|
+
// nested input -- "((((((...))))))" or "f(f(f(f(...))))" thousands
|
|
172
|
+
// deep, plausible if this ever parses genuinely untrusted, unbounded-
|
|
173
|
+
// size text -- fails with one clear, controlled error instead of a raw
|
|
174
|
+
// "Maximum call stack size exceeded" RangeError once the real JS stack
|
|
175
|
+
// (which this sits comfortably below at 100) actually gives out.
|
|
176
|
+
const MAX_EXPRESSION_DEPTH = 100;
|
|
177
|
+
|
|
124
178
|
class Parser {
|
|
125
179
|
// `label` -- see tokenizeSegment above; also threaded through to
|
|
126
180
|
// holeToNode so a bad interpolation inside `` fn`...` `` reports
|
|
@@ -130,6 +184,7 @@ class Parser {
|
|
|
130
184
|
this.source = source;
|
|
131
185
|
this.i = 0;
|
|
132
186
|
this.label = label;
|
|
187
|
+
this.depth = 0;
|
|
133
188
|
}
|
|
134
189
|
|
|
135
190
|
peek() {
|
|
@@ -167,8 +222,22 @@ class Parser {
|
|
|
167
222
|
throw new Error(`${this.label}: ${message} -- found ${tokDesc} at position ${t.pos} in \`${this.source}\``);
|
|
168
223
|
}
|
|
169
224
|
|
|
225
|
+
// Every recursive re-entry (a parenthesized group, a function-call
|
|
226
|
+
// argument, a ternary's then/else branch) goes through here, so
|
|
227
|
+
// tracking depth at this ONE point -- not at every precedence-level
|
|
228
|
+
// method, which would overcount a wide-but-shallow expression like
|
|
229
|
+
// "a + b + c + ..." as if it were deeply nested -- correctly bounds
|
|
230
|
+
// genuine nesting without rejecting a merely long one.
|
|
170
231
|
parseExpression() {
|
|
171
|
-
|
|
232
|
+
this.depth++;
|
|
233
|
+
if (this.depth > MAX_EXPRESSION_DEPTH) {
|
|
234
|
+
this.error(`expression nested too deeply (max ${MAX_EXPRESSION_DEPTH} levels of parens/calls/ternaries)`);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return this.parseTernary();
|
|
238
|
+
} finally {
|
|
239
|
+
this.depth--;
|
|
240
|
+
}
|
|
172
241
|
}
|
|
173
242
|
|
|
174
243
|
// Only place a comparison is ever accepted -- matches cmp()'s own
|
|
@@ -236,7 +305,7 @@ class Parser {
|
|
|
236
305
|
// into `unary`, not `power`, which is also what lets the exponent
|
|
237
306
|
// itself carry a leading unary minus (2^-1 = 0.5).
|
|
238
307
|
parsePower() {
|
|
239
|
-
const base = this.
|
|
308
|
+
const base = this.parsePostfix();
|
|
240
309
|
if (this.isOp("^")) {
|
|
241
310
|
this.next();
|
|
242
311
|
const exponent = this.parseUnary();
|
|
@@ -245,6 +314,26 @@ class Parser {
|
|
|
245
314
|
return base;
|
|
246
315
|
}
|
|
247
316
|
|
|
317
|
+
// Postfix "." field access (b.rx, chainable: a.b.c) -- binds tighter
|
|
318
|
+
// than "^", same as function-call parens already do inside
|
|
319
|
+
// parsePrimary. Accepted generally here, for any primary, same "defer
|
|
320
|
+
// semantic validation" precedent call() already follows for unknown
|
|
321
|
+
// function names -- whether `target` actually resolves to something
|
|
322
|
+
// with that field is checked later, by macros.js's expandMacros(),
|
|
323
|
+
// which is the only thing that ever consumes a "field" node (see its
|
|
324
|
+
// and ast.js's own comments).
|
|
325
|
+
parsePostfix() {
|
|
326
|
+
let node = this.parsePrimary();
|
|
327
|
+
while (this.isOp(".")) {
|
|
328
|
+
this.next();
|
|
329
|
+
const t = this.peek();
|
|
330
|
+
if (t.type !== "IDENT") this.error('expected a field name after "."');
|
|
331
|
+
this.next();
|
|
332
|
+
node = field(node, t.value);
|
|
333
|
+
}
|
|
334
|
+
return node;
|
|
335
|
+
}
|
|
336
|
+
|
|
248
337
|
parsePrimary() {
|
|
249
338
|
const t = this.peek();
|
|
250
339
|
if (t.type === "NUMBER") {
|
|
@@ -296,13 +385,22 @@ class Parser {
|
|
|
296
385
|
// already-evaluated JS value through untouched.
|
|
297
386
|
function expr(strings, ...values) {
|
|
298
387
|
const tokens = [];
|
|
388
|
+
const state = { inComment: false };
|
|
299
389
|
let source = "";
|
|
300
390
|
for (let i = 0; i < strings.length; i++) {
|
|
301
|
-
tokenizeSegment(strings[i], source.length, tokens);
|
|
391
|
+
tokenizeSegment(strings[i], source.length, tokens, state);
|
|
302
392
|
source += strings[i];
|
|
303
393
|
if (i < values.length) {
|
|
304
|
-
tokens.push({ type: "HOLE", value: values[i], pos: source.length });
|
|
305
394
|
source += "${...}";
|
|
395
|
+
// A value interpolated inside an open "#" comment is
|
|
396
|
+
// silently dropped -- never reaches holeToNode, so it's
|
|
397
|
+
// never validated, even if it would otherwise be an
|
|
398
|
+
// invalid interpolation (a string, undefined, ...). This is
|
|
399
|
+
// deliberate: the whole point of a comment is that its
|
|
400
|
+
// contents don't matter.
|
|
401
|
+
if (!state.inComment) {
|
|
402
|
+
tokens.push({ type: "HOLE", value: values[i], pos: source.length });
|
|
403
|
+
}
|
|
306
404
|
}
|
|
307
405
|
}
|
|
308
406
|
tokens.push({ type: "EOF", value: null, pos: source.length });
|
package/fn.js
CHANGED
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// signature := IDENT "(" (IDENT ("," IDENT)*)? ")" ":"
|
|
15
15
|
// stmt := "let" IDENT "=" expression ";"
|
|
16
16
|
// returnStmt := "return" expression ";"
|
|
17
|
-
// | "return" "{"
|
|
17
|
+
// | "return" "{" field ("," field)* "}" ";"
|
|
18
|
+
// field := IDENT (":" expression)?
|
|
19
|
+
//
|
|
20
|
+
// A field with no ":" is shorthand for "name: name" (return { rx, ry, rz };
|
|
21
|
+
// means return { rx: rx, ry: ry, rz: rz };) -- same convention JS object
|
|
22
|
+
// literals use for a property whose value is a same-named variable, and
|
|
23
|
+
// fields can freely mix shorthand and explicit form in one return.
|
|
18
24
|
//
|
|
19
25
|
// "let"/"return" are recognized contextually -- an IDENT token whose
|
|
20
26
|
// value happens to be "let"/"return" at statement-start position. They
|
|
@@ -40,7 +46,7 @@
|
|
|
40
46
|
// was a deliberate API choice, not an oversight -- see the GitHub issue
|
|
41
47
|
// this shipped from for the alternative considered (a separate,
|
|
42
48
|
// always-full-definition tag) and why this was preferred.
|
|
43
|
-
const { letChain, outputs } = require("./ast.js");
|
|
49
|
+
const { letChain, outputs, v } = require("./ast.js");
|
|
44
50
|
const { Parser, tokenizeSegment } = require("./expr.js");
|
|
45
51
|
|
|
46
52
|
function isKeyword(parser, word) {
|
|
@@ -73,7 +79,15 @@ function parseReturnStatement(parser) {
|
|
|
73
79
|
const fields = {};
|
|
74
80
|
const readField = () => {
|
|
75
81
|
const name = expectIdent(parser, 'as an output name inside "return { ... }"');
|
|
76
|
-
|
|
82
|
+
// No ":" -- shorthand for "name: name" (return { rx, ry, rz };
|
|
83
|
+
// means return { rx: rx, ry: ry, rz: rz };), matching JS object
|
|
84
|
+
// literal shorthand. Explicit ":" still works, and either form
|
|
85
|
+
// can appear anywhere in the same field list.
|
|
86
|
+
if (!parser.isOp(":")) {
|
|
87
|
+
fields[name] = v(name);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
parser.next(); // consume ":"
|
|
77
91
|
fields[name] = parser.parseExpression();
|
|
78
92
|
};
|
|
79
93
|
if (!parser.isOp("}")) {
|
|
@@ -147,13 +161,19 @@ function parseProgram(parser) {
|
|
|
147
161
|
// parser.parseExpression()).
|
|
148
162
|
function fn(strings, ...values) {
|
|
149
163
|
const tokens = [];
|
|
164
|
+
const state = { inComment: false };
|
|
150
165
|
let source = "";
|
|
151
166
|
for (let i = 0; i < strings.length; i++) {
|
|
152
|
-
tokenizeSegment(strings[i], source.length, tokens, "fn()");
|
|
167
|
+
tokenizeSegment(strings[i], source.length, tokens, state, "fn()");
|
|
153
168
|
source += strings[i];
|
|
154
169
|
if (i < values.length) {
|
|
155
|
-
tokens.push({ type: "HOLE", value: values[i], pos: source.length });
|
|
156
170
|
source += "${...}";
|
|
171
|
+
// See expr.js's tokenizeSegment/expr() for why this is
|
|
172
|
+
// silently dropped rather than pushed -- same rule, same
|
|
173
|
+
// reasoning, shared state object.
|
|
174
|
+
if (!state.inComment) {
|
|
175
|
+
tokens.push({ type: "HOLE", value: values[i], pos: source.length });
|
|
176
|
+
}
|
|
157
177
|
}
|
|
158
178
|
}
|
|
159
179
|
tokens.push({ type: "EOF", value: null, pos: source.length });
|
|
@@ -166,4 +186,9 @@ function fn(strings, ...values) {
|
|
|
166
186
|
return node;
|
|
167
187
|
}
|
|
168
188
|
|
|
169
|
-
|
|
189
|
+
// parseProgram is also exported for load-expr.js: loading a .expr file
|
|
190
|
+
// means parsing zero or more of these back-to-back over one shared token
|
|
191
|
+
// stream (see load-expr.js's own header comment), which needs the same
|
|
192
|
+
// "signature? stmt* returnStmt" grammar this file already implements --
|
|
193
|
+
// reused directly, not forked.
|
|
194
|
+
module.exports = { fn, parseProgram };
|
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
|
|
16
|
-
*
|
|
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
|
|
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
|
|
21
|
-
|
|
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
|
-
//
|
|
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
|
};
|