exprforge 0.2.0 → 0.3.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.
@@ -0,0 +1,126 @@
1
+ // exprforge/emitters/zig.js
2
+ const Emitter = require("./base.js");
3
+
4
+ // Zig's reserved words plus the small set of extra identifiers this
5
+ // emitter's own output introduces (Result, the struct-suite convention
6
+ // below) -- same role as QB64_RESERVED in emitters/qb64.js. Zig is
7
+ // case-sensitive, unlike QB64/Fortran, so this checks exact names.
8
+ const ZIG_RESERVED = new Set([
9
+ "addrspace", "align", "allowzero", "and", "anyframe", "anytype", "asm", "async",
10
+ "await", "break", "callconv", "catch", "comptime", "const", "continue", "defer",
11
+ "else", "enum", "errdefer", "error", "export", "extern", "fn", "for", "if",
12
+ "inline", "noalias", "noinline", "nosuspend", "opaque", "or", "orelse", "packed",
13
+ "pub", "resume", "return", "linksection", "struct", "suspend", "switch", "test",
14
+ "threadlocal", "try", "union", "unreachable", "usingnamespace", "var", "volatile",
15
+ "while", "true", "false", "null", "undefined", "void", "type", "anyerror",
16
+ "std", "result",
17
+ ]);
18
+
19
+ function checkReservedNames(names) {
20
+ for (const name of names) {
21
+ if (ZIG_RESERVED.has(name)) {
22
+ throw new Error(
23
+ `emitter for .zig: "${name}" is a reserved Zig keyword/identifier and can't be used as a ` +
24
+ `function/variable/parameter name -- rename it (see ZIG_RESERVED in emitters/zig.js)`,
25
+ );
26
+ }
27
+ }
28
+ }
29
+
30
+ function mathFn(name) {
31
+ return (args) => `std.math.${name}(${args.join(", ")})`;
32
+ }
33
+
34
+ function builtin(name) {
35
+ return ([x]) => `@${name}(${x})`;
36
+ }
37
+
38
+ function capitalize(s) {
39
+ return s.charAt(0).toUpperCase() + s.slice(1);
40
+ }
41
+
42
+ const emitter = new Emitter({
43
+ ext: "zig",
44
+ // Every literal is forced through @as(f64, ...), not left bare --
45
+ // confirmed against a real compiler that a bare literal reaching a
46
+ // comptime-eligible call (e.g. std.math.sqrt(2.0) with no runtime f64
47
+ // operand anywhere) gets evaluated at Zig's extended comptime_float
48
+ // precision instead of truncated to an actual IEEE 754 double, giving
49
+ // a DIFFERENT (more precise, not less) result than every other target
50
+ // here. @as(f64, ...) forces real double-precision truncation before
51
+ // any arithmetic happens, regardless of whether the surrounding
52
+ // expression also involves a runtime variable.
53
+ formatNumber: (v) => `@as(f64, ${String(v)})`,
54
+ calls: {
55
+ // Compiler builtins (map to LLVM intrinsics directly).
56
+ abs: builtin("abs"), exp: builtin("exp"), log: builtin("log"),
57
+ log2: builtin("log2"), log10: builtin("log10"),
58
+ floor: builtin("floor"), ceil: builtin("ceil"), round: builtin("round"), trunc: builtin("trunc"),
59
+ min: builtin2("min"), max: builtin2("max"),
60
+ // std.math functions -- not compiler builtins, but ordinary Zig
61
+ // stdlib functions with the expected 1/2-arg float signatures.
62
+ sqrt: mathFn("sqrt"), sin: mathFn("sin"), cos: mathFn("cos"), tan: mathFn("tan"),
63
+ asin: mathFn("asin"), acos: mathFn("acos"), atan: mathFn("atan"), atan2: mathFn("atan2"),
64
+ hypot: mathFn("hypot"),
65
+ // pow needs an explicit type argument (std.math.pow(T, x, y)) --
66
+ // unlike every other std.math function here, it's not purely
67
+ // inferred from its float arguments.
68
+ pow: ([x, y]) => `std.math.pow(f64, ${x}, ${y})`,
69
+ // std.math.sign is genuinely zero-aware (confirmed: sign(0.0) ==
70
+ // 0.0) -- unlike most other targets here, this doesn't need to be
71
+ // hand-built to get the zero case right (see Go's/Rust's sign()
72
+ // history in this project for what happens when a language's
73
+ // native sign function isn't).
74
+ sign: mathFn("sign"),
75
+ },
76
+ // Zig has no ?: ternary, but if/else as an EXPRESSION (not just a
77
+ // statement) works exactly like this project's select() -- including
78
+ // evaluating both branches, since there's no separate "lazy" form.
79
+ emitSelect: function (condNode, thenStr, elseStr) {
80
+ const L = this.emitExpr(condNode.left);
81
+ const R = this.emitExpr(condNode.right);
82
+ return `(if ((${L}) ${condNode.op} (${R})) ${thenStr} else ${elseStr})`;
83
+ },
84
+ formatFunction: (fn, body, letBindings = []) => {
85
+ checkReservedNames([fn.name, ...fn.params, ...letBindings.map((b) => b.name)]);
86
+ const params = fn.params.map((p) => `${p}: f64`).join(", ");
87
+ const lets = letBindings.map(({ name, valueStr }) => ` const ${name}: f64 = ${valueStr};`).join("\n");
88
+ const letsBlock = lets ? lets + "\n" : "";
89
+ return `// AUTO-GENERATED by ExprForge -- do not hand-edit.\n` +
90
+ `const std = @import("std");\n\n` +
91
+ `pub fn ${fn.name}(${params}) f64 {\n` +
92
+ letsBlock +
93
+ ` return ${body};\n` +
94
+ `}\n`;
95
+ },
96
+ // Multiple named outputs from one call: a small struct type (same idea
97
+ // as C's typedef struct, Rust's struct) returned by value -- Zig has
98
+ // no native tuple-with-names or multi-return.
99
+ formatSuite: (fn, outputStrs, letBindings = []) => {
100
+ const outputNames = Object.keys(outputStrs);
101
+ checkReservedNames([fn.name, ...fn.params, ...outputNames, ...letBindings.map((b) => b.name)]);
102
+ const resultName = `${capitalize(fn.name)}Result`;
103
+ const params = fn.params.map((p) => `${p}: f64`).join(", ");
104
+ const lets = letBindings.map(({ name, valueStr }) => ` const ${name}: f64 = ${valueStr};`).join("\n");
105
+ const letsBlock = lets ? lets + "\n" : "";
106
+ const structFields = outputNames.map((n) => ` ${n}: f64,`).join("\n");
107
+ const initFields = outputNames.map((n) => ` .${n} = ${outputStrs[n]},`).join("\n");
108
+ return `// AUTO-GENERATED by ExprForge -- do not hand-edit.\n` +
109
+ `const std = @import("std");\n\n` +
110
+ `pub const ${resultName} = struct {\n${structFields}\n};\n\n` +
111
+ `pub fn ${fn.name}(${params}) ${resultName} {\n` +
112
+ letsBlock +
113
+ ` return ${resultName}{\n${initFields}\n };\n` +
114
+ `}\n`;
115
+ },
116
+ });
117
+
118
+ // builtin2 is used by the calls table above but defined after it's
119
+ // referenced (function declarations hoist) -- kept next to builtin/mathFn
120
+ // rather than above them so the two "2-arg compiler builtin" and "1-arg
121
+ // compiler builtin" helpers read as a pair.
122
+ function builtin2(name) {
123
+ return ([a, b]) => `@${name}(${a}, ${b})`;
124
+ }
125
+
126
+ module.exports = emitter;
package/evaluate.js ADDED
@@ -0,0 +1,109 @@
1
+ // exprforge/evaluate.js
2
+ //
3
+ // A native tree-walking interpreter over the exact same AST every
4
+ // emitter compiles from -- evaluate(fn, args) computes a result (or a
5
+ // {name: value} object for a multi-output suite) directly in JS, no
6
+ // codegen/compile/subprocess step involved. Reuses collectLets (ast.js)
7
+ // for the same let-lifting every emitter already goes through, so this
8
+ // walks nodes in the identical dependency order every target does, and
9
+ // there's exactly one node-shape contract (ast.js's own header comment)
10
+ // for both this file and every emitters/<lang>.js to agree with.
11
+ //
12
+ // Every intrinsic name maps 1:1 onto emitters/js.js's own `calls` table
13
+ // keys (the simplest existing source of truth for "what the ~22
14
+ // intrinsics are called") straight to the real Math.* function -- this
15
+ // target has no codegen step to route an intermediate string through.
16
+ const { collectLets } = require("./ast.js");
17
+
18
+ const CMP_OPS = {
19
+ ">": (a, b) => a > b,
20
+ "<": (a, b) => a < b,
21
+ ">=": (a, b) => a >= b,
22
+ "<=": (a, b) => a <= b,
23
+ "==": (a, b) => a === b,
24
+ "!=": (a, b) => a !== b,
25
+ };
26
+
27
+ const BIN_OPS = {
28
+ "+": (a, b) => a + b,
29
+ "-": (a, b) => a - b,
30
+ "*": (a, b) => a * b,
31
+ "/": (a, b) => a / b,
32
+ };
33
+
34
+ const CALLS = {
35
+ sqrt: Math.sqrt, abs: Math.abs, sin: Math.sin, cos: Math.cos, tan: Math.tan,
36
+ asin: Math.asin, acos: Math.acos, atan: Math.atan, log: Math.log,
37
+ log2: Math.log2, log10: Math.log10, exp: Math.exp, floor: Math.floor,
38
+ ceil: Math.ceil, round: Math.round, trunc: Math.trunc, sign: Math.sign,
39
+ pow: Math.pow, atan2: Math.atan2, min: Math.min, max: Math.max, hypot: Math.hypot,
40
+ };
41
+
42
+ // Handles every node type EXCEPT "let"/"outputs" -- those are only ever
43
+ // valid pre-collectLets (a function's top-level let-chain/body shape),
44
+ // never nested inside a bin/call/select, same constraint every emitter
45
+ // already relies on (see ast.js's own comments on letIn/outputs).
46
+ function evalNode(node, env) {
47
+ switch (node.type) {
48
+ case "num":
49
+ return node.value;
50
+ case "var":
51
+ if (!(node.name in env)) {
52
+ throw new Error(`evaluate(): unbound variable "${node.name}"`);
53
+ }
54
+ return env[node.name];
55
+ case "bin": {
56
+ const op = BIN_OPS[node.op];
57
+ if (!op) throw new Error(`evaluate(): unknown bin op "${node.op}"`);
58
+ return op(evalNode(node.left, env), evalNode(node.right, env));
59
+ }
60
+ case "call": {
61
+ const impl = CALLS[node.name];
62
+ if (!impl) throw new Error(`evaluate(): no mapping for Math function "${node.name}"`);
63
+ return impl(...node.args.map((a) => evalNode(a, env)));
64
+ }
65
+ case "select": {
66
+ const cmpFn = CMP_OPS[node.cond.op];
67
+ 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);
70
+ }
71
+ default:
72
+ throw new Error(
73
+ `evaluate(): unexpected node type "${node.type}" -- "let"/"outputs" must already ` +
74
+ `be lifted out by collectLets before evalNode runs`,
75
+ );
76
+ }
77
+ }
78
+
79
+ // evaluate(fn, args) -- fn is a {name, params, body} definition (the
80
+ // same shape emitAll() consumes), args is a plain array positional to
81
+ // fn.params. Returns a number for a plain body, or a {name: value}
82
+ // object for a multi-output (outputs()) body -- matching the shape
83
+ // test/conformance.test.js's own parseSuiteOutput() already expects
84
+ // back from every other target.
85
+ function evaluate(fn, args) {
86
+ if (args.length !== fn.params.length) {
87
+ throw new Error(`evaluate(): ${fn.name} expects ${fn.params.length} argument(s), got ${args.length}`);
88
+ }
89
+ const env = {};
90
+ fn.params.forEach((name, i) => {
91
+ env[name] = args[i];
92
+ });
93
+
94
+ const { bindings, body } = collectLets(fn.body);
95
+ for (const { name, node } of bindings) {
96
+ env[name] = evalNode(node, env);
97
+ }
98
+
99
+ if (body.type === "outputs") {
100
+ const result = {};
101
+ for (const [name, node] of Object.entries(body.fields)) {
102
+ result[name] = evalNode(node, env);
103
+ }
104
+ return result;
105
+ }
106
+ return evalNode(body, env);
107
+ }
108
+
109
+ module.exports = { evaluate };
package/expr.js ADDED
@@ -0,0 +1,313 @@
1
+ // exprforge/expr.js
2
+ //
3
+ // Infix syntax sugar over ast.js's own builders -- NOT a new node type or
4
+ // a new capability. `` expr`a * b + 1` `` builds exactly the same tree
5
+ // `add(mul(v("a"), v("b")), num(1))` would, by calling num/v/add/sub/mul/
6
+ // div/neg/call/cmp/select directly (never constructing a raw {type: ...}
7
+ // object by hand), so every guarantee those builders already have
8
+ // (collectLets round-tripping, emitter compatibility) carries over for
9
+ // free. Unlike util.js's forComponents, this DOES return a Node -- it's
10
+ // closer in spirit to letChain (also in ast.js): a different way to
11
+ // spell the same tree, not a different tree.
12
+ //
13
+ // A tagged template literal, not a plain string function: `expr` is
14
+ // called by JS itself as expr(strings, ...values) -- see the grammar
15
+ // comment below for why that's the whole interpolation mechanism, with
16
+ // no `${` text syntax of its own to parse.
17
+ //
18
+ // Grammar:
19
+ //
20
+ // expression := ternary
21
+ // ternary := additive ( compOp additive "?" expression ":" expression )?
22
+ // compOp := ">" | "<" | ">=" | "<=" | "==" | "!="
23
+ // additive := multiplicative ( ("+"|"-") multiplicative )*
24
+ // multiplicative := unary ( ("*"|"/") unary )*
25
+ // unary := "-" unary | power
26
+ // power := primary ( "^" unary )?
27
+ // primary := NUMBER | IDENT ("(" args ")")? | "(" expression ")" | HOLE
28
+ // args := expression ("," expression)*
29
+ //
30
+ // Deliberately NOT supported (see the plan doc's "Scope decisions"):
31
+ // - No let/outputs blocks -- this is a pure expression grammar, same
32
+ // "expression AST, not a program AST" boundary as ast.js itself.
33
+ // Wrap the result in letIn/letChain/outputs instead.
34
+ // - No &&/|| -- the AST has no boolean-combinator node to lower them
35
+ // to. A comparison is ONLY ever valid as a ternary's condition
36
+ // (matching cmp()'s own documented constraint in ast.js), enforced
37
+ // here at PARSE time with a clear error, not deferred to the
38
+ // "cmp used outside select()" throw every emitter already has.
39
+ // - "^" lowers to call("pow", left, right), never a bin node -- bin.op
40
+ // is only ever "+"|"-"|"*"|"/" (see ast.js), and every emitter's
41
+ // `calls` table keys "pow" by name, even for targets whose own
42
+ // syntax has a native ^/** operator.
43
+ const { num, v, add, sub, mul, div, neg, call, cmp, select } = require("./ast.js");
44
+
45
+ const COMPARE_OPS = [">", "<", ">=", "<=", "==", "!="];
46
+
47
+ // Tokenizes one template-literal string segment, appending {type, value,
48
+ // pos} tokens to `tokens` (pos is an offset into the reconstructed full
49
+ // source string built in expr() below, used only for error messages).
50
+ // `label` is just which tag function's name shows up in error messages
51
+ // -- fn.js passes "fn()" here so a lex error inside `` fn`...` `` isn't
52
+ // misattributed to expr().
53
+ function tokenizeSegment(str, offset, tokens, label = "expr()") {
54
+ let i = 0;
55
+ while (i < str.length) {
56
+ const ch = str[i];
57
+ const start = i;
58
+ if (/\s/.test(ch)) {
59
+ i++;
60
+ continue;
61
+ }
62
+ // NUMBER: 123, 123.45, .5, 1e-9, 1.5E+10
63
+ if (/[0-9]/.test(ch) || (ch === "." && /[0-9]/.test(str[i + 1] || ""))) {
64
+ i++;
65
+ while (i < str.length && /[0-9]/.test(str[i])) i++;
66
+ if (str[i] === ".") {
67
+ i++;
68
+ while (i < str.length && /[0-9]/.test(str[i])) i++;
69
+ }
70
+ if (str[i] === "e" || str[i] === "E") {
71
+ let j = i + 1;
72
+ if (str[j] === "+" || str[j] === "-") j++;
73
+ if (/[0-9]/.test(str[j] || "")) {
74
+ i = j;
75
+ while (i < str.length && /[0-9]/.test(str[i])) i++;
76
+ }
77
+ }
78
+ tokens.push({ type: "NUMBER", value: Number(str.slice(start, i)), pos: offset + start });
79
+ continue;
80
+ }
81
+ // IDENT: variable names and function names, e.g. wy_wire, sqrt.
82
+ if (/[A-Za-z_]/.test(ch)) {
83
+ i++;
84
+ while (i < str.length && /[A-Za-z0-9_]/.test(str[i])) i++;
85
+ tokens.push({ type: "IDENT", value: str.slice(start, i), pos: offset + start });
86
+ continue;
87
+ }
88
+ // Two-character comparison operators before their one-character
89
+ // prefixes, so ">=" doesn't get lexed as ">" followed by "=".
90
+ if ((ch === ">" || ch === "<" || ch === "=" || ch === "!") && str[i + 1] === "=") {
91
+ tokens.push({ type: "OP", value: str.slice(i, i + 2), pos: offset + start });
92
+ i += 2;
93
+ continue;
94
+ }
95
+ // ";", "{", "}", "=" aren't used by expr()'s own grammar -- they're
96
+ // here for fn.js's statement syntax (let name = ...; / return
97
+ // {...};) to reuse this same tokenizer instead of forking it.
98
+ // Inert for expr(): nothing that parses successfully today could
99
+ // contain them anyway ("=" alone was always a lex error before,
100
+ // since only "==" was recognized).
101
+ if ("+-*/^(),?:><;{}=".includes(ch)) {
102
+ tokens.push({ type: "OP", value: ch, pos: offset + start });
103
+ i++;
104
+ continue;
105
+ }
106
+ throw new Error(`${label}: unexpected character "${ch}" at position ${offset + start}`);
107
+ }
108
+ }
109
+
110
+ // A HOLE's value is resolved to a Node right where it's produced (not
111
+ // deferred into the parser), so a bad interpolation fails immediately
112
+ // with a clear error rather than surfacing as a confusing parse error
113
+ // somewhere else in the tree. `label` -- see tokenizeSegment above.
114
+ function holeToNode(value, label = "expr()") {
115
+ if (typeof value === "number") return num(value);
116
+ if (value && typeof value === "object" && typeof value.type === "string") return value;
117
+ const shown = typeof value === "string" ? `"${value}"` : JSON.stringify(value);
118
+ throw new Error(
119
+ `${label}: interpolated value must be an AST node or a plain number, got ${shown} -- ` +
120
+ `a bare variable name doesn't need interpolation, just write it directly in the template text`,
121
+ );
122
+ }
123
+
124
+ class Parser {
125
+ // `label` -- see tokenizeSegment above; also threaded through to
126
+ // holeToNode so a bad interpolation inside `` fn`...` `` reports
127
+ // "fn():" too, not just lex/parse errors.
128
+ constructor(tokens, source, label = "expr()") {
129
+ this.tokens = tokens;
130
+ this.source = source;
131
+ this.i = 0;
132
+ this.label = label;
133
+ }
134
+
135
+ peek() {
136
+ return this.tokens[this.i];
137
+ }
138
+
139
+ next() {
140
+ return this.tokens[this.i++];
141
+ }
142
+
143
+ isOp(value) {
144
+ const t = this.peek();
145
+ return t.type === "OP" && t.value === value;
146
+ }
147
+
148
+ expectOp(value) {
149
+ if (!this.isOp(value)) this.error(`expected "${value}"`);
150
+ return this.next();
151
+ }
152
+
153
+ error(message) {
154
+ const t = this.peek();
155
+ const tokDesc = t.type === "EOF" ? "end of input" : `"${t.value}"`;
156
+ throw new Error(`${this.label}: ${message} -- found ${tokDesc} at position ${t.pos} in \`${this.source}\``);
157
+ }
158
+
159
+ parseExpression() {
160
+ return this.parseTernary();
161
+ }
162
+
163
+ // Only place a comparison is ever accepted -- matches cmp()'s own
164
+ // documented constraint in ast.js exactly (only valid as select()'s
165
+ // cond). A bare comparison with no trailing "?" is a parse error
166
+ // here, not deferred to the "cmp used outside select()" throw every
167
+ // emitter already has.
168
+ parseTernary() {
169
+ const left = this.parseAdditive();
170
+ const t = this.peek();
171
+ if (t.type === "OP" && COMPARE_OPS.includes(t.value)) {
172
+ const op = this.next().value;
173
+ const right = this.parseAdditive();
174
+ if (!this.isOp("?")) {
175
+ this.error(`comparison ("${op}") must be used as a ternary condition ("cond ${op} ... ? then : else")`);
176
+ }
177
+ this.next(); // consume "?"
178
+ const thenNode = this.parseExpression();
179
+ this.expectOp(":");
180
+ const elseNode = this.parseExpression();
181
+ return select(cmp(left, op, right), thenNode, elseNode);
182
+ }
183
+ if (this.isOp("?")) {
184
+ this.error(`"?" needs an explicit comparison as its condition (e.g. "a > 0 ? x : y") -- a bare value can't be a select() condition`);
185
+ }
186
+ return left;
187
+ }
188
+
189
+ parseAdditive() {
190
+ let node = this.parseMultiplicative();
191
+ while (this.isOp("+") || this.isOp("-")) {
192
+ const op = this.next().value;
193
+ const right = this.parseMultiplicative();
194
+ node = op === "+" ? add(node, right) : sub(node, right);
195
+ }
196
+ return node;
197
+ }
198
+
199
+ parseMultiplicative() {
200
+ let node = this.parseUnary();
201
+ while (this.isOp("*") || this.isOp("/")) {
202
+ const op = this.next().value;
203
+ const right = this.parseUnary();
204
+ node = op === "*" ? mul(node, right) : div(node, right);
205
+ }
206
+ return node;
207
+ }
208
+
209
+ // Unary minus binds LOOSER than "^" (-2^2 = -4, not 4) -- standard
210
+ // math convention, and the reason `power` sits below `unary` here
211
+ // rather than the other way around.
212
+ parseUnary() {
213
+ if (this.isOp("-")) {
214
+ this.next();
215
+ return neg(this.parseUnary());
216
+ }
217
+ if (this.isOp("+")) {
218
+ this.next(); // unary plus: no-op
219
+ return this.parseUnary();
220
+ }
221
+ return this.parsePower();
222
+ }
223
+
224
+ // Right-associative (2^3^2 = 2^(3^2) = 512): the exponent recurses
225
+ // into `unary`, not `power`, which is also what lets the exponent
226
+ // itself carry a leading unary minus (2^-1 = 0.5).
227
+ parsePower() {
228
+ const base = this.parsePrimary();
229
+ if (this.isOp("^")) {
230
+ this.next();
231
+ const exponent = this.parseUnary();
232
+ return call("pow", base, exponent);
233
+ }
234
+ return base;
235
+ }
236
+
237
+ parsePrimary() {
238
+ const t = this.peek();
239
+ if (t.type === "NUMBER") {
240
+ this.next();
241
+ return num(t.value);
242
+ }
243
+ if (t.type === "HOLE") {
244
+ this.next();
245
+ return holeToNode(t.value, this.label);
246
+ }
247
+ if (t.type === "IDENT") {
248
+ this.next();
249
+ if (this.isOp("(")) {
250
+ this.next();
251
+ const args = [];
252
+ if (!this.isOp(")")) {
253
+ args.push(this.parseExpression());
254
+ while (this.isOp(",")) {
255
+ this.next();
256
+ args.push(this.parseExpression());
257
+ }
258
+ }
259
+ this.expectOp(")");
260
+ // Not validated against the 22 known Math functions here
261
+ // -- deferred to the same "no mapping for Math function"
262
+ // check every hand-built call() node already goes
263
+ // through in emitters/base.js, so there's only one list
264
+ // of known function names to keep in sync, not two.
265
+ return call(t.value, ...args);
266
+ }
267
+ return v(t.value);
268
+ }
269
+ if (this.isOp("(")) {
270
+ this.next();
271
+ const node = this.parseExpression();
272
+ this.expectOp(")");
273
+ return node;
274
+ }
275
+ this.error("expected a number, identifier, function call, or parenthesized expression");
276
+ }
277
+ }
278
+
279
+ // The tagged-template tag function itself: `` expr`a + b` `` is called by
280
+ // JS as expr(["a + b"], ) -- with interpolations, `` expr`${x} + b` ``
281
+ // is called as expr(["", " + b"], x). strings.length is always
282
+ // values.length + 1. There is no "${" text syntax to lex: JS has already
283
+ // done that splitting before this function ever runs, so a HOLE token is
284
+ // just spliced into the token stream at each boundary, carrying the
285
+ // already-evaluated JS value through untouched.
286
+ function expr(strings, ...values) {
287
+ const tokens = [];
288
+ let source = "";
289
+ for (let i = 0; i < strings.length; i++) {
290
+ tokenizeSegment(strings[i], source.length, tokens);
291
+ source += strings[i];
292
+ if (i < values.length) {
293
+ tokens.push({ type: "HOLE", value: values[i], pos: source.length });
294
+ source += "${...}";
295
+ }
296
+ }
297
+ tokens.push({ type: "EOF", value: null, pos: source.length });
298
+
299
+ const parser = new Parser(tokens, source);
300
+ const node = parser.parseExpression();
301
+ if (parser.peek().type !== "EOF") {
302
+ parser.error("unexpected trailing input");
303
+ }
304
+ return node;
305
+ }
306
+
307
+ // Parser/tokenizeSegment/holeToNode are exported alongside expr itself so
308
+ // fn.js (full function-body syntax: let/return on top of this same
309
+ // expression grammar) can reuse this tokenizer and parsing engine
310
+ // directly instead of forking it -- "fn's contain expr's" literally, not
311
+ // just as a description. Nothing here is part of expr()'s own public
312
+ // contract; treat these as internal to the expr/fn syntax family.
313
+ module.exports = { expr, Parser, tokenizeSegment, holeToNode };
package/fn.js ADDED
@@ -0,0 +1,117 @@
1
+ // exprforge/fn.js
2
+ //
3
+ // Full function-body syntax on top of expr.js's expression grammar: adds
4
+ // `let` bindings and a `return` statement, so a whole function body
5
+ // (let-chain + a single or multi-output result) can be authored as text
6
+ // instead of nested letChain()/outputs() calls. Every individual
7
+ // expression inside a `fn` template -- each let's value, the returned
8
+ // expression(s) -- is parsed by the *same* Parser class expr.js uses,
9
+ // via its parseExpression() entry point. fn's own grammar is a thin
10
+ // statement-sequence wrapper around that, lowering to the real ast.js
11
+ // builders (letChain, outputs), never a new node shape:
12
+ //
13
+ // program := stmt* returnStmt
14
+ // stmt := "let" IDENT "=" expression ";"
15
+ // returnStmt := "return" expression ";"
16
+ // | "return" "{" IDENT ":" expression ("," IDENT ":" expression)* "}" ";"
17
+ //
18
+ // "let"/"return" are recognized contextually -- an IDENT token whose
19
+ // value happens to be "let"/"return" at statement-start position. They
20
+ // are NOT reserved words in expr.js's own grammar, so nothing about
21
+ // expr()'s behavior changes: `` expr`let * 2` `` still means
22
+ // v("let") * 2 today, same as before this file existed.
23
+ //
24
+ // Duplicate let-names are deliberately NOT checked here -- letChain()
25
+ // doesn't check either (ast.js); collectLets() already does, at
26
+ // emission time. Same "defer semantic validation to emission" precedent
27
+ // expr.js itself follows for function/call names.
28
+ const { letChain, outputs } = require("./ast.js");
29
+ const { Parser, tokenizeSegment } = require("./expr.js");
30
+
31
+ function isKeyword(parser, word) {
32
+ const t = parser.peek();
33
+ return t.type === "IDENT" && t.value === word;
34
+ }
35
+
36
+ function expectIdent(parser, context) {
37
+ const t = parser.peek();
38
+ if (t.type !== "IDENT") {
39
+ parser.error(`expected an identifier ${context}`);
40
+ }
41
+ parser.next();
42
+ return t.value;
43
+ }
44
+
45
+ function parseLetStatement(parser) {
46
+ parser.next(); // consume "let", already confirmed present by the caller
47
+ const name = expectIdent(parser, 'after "let"');
48
+ parser.expectOp("=");
49
+ const value = parser.parseExpression();
50
+ parser.expectOp(";");
51
+ return [name, value];
52
+ }
53
+
54
+ function parseReturnStatement(parser) {
55
+ parser.next(); // consume "return", already confirmed present by the caller
56
+ if (parser.isOp("{")) {
57
+ parser.next();
58
+ const fields = {};
59
+ const readField = () => {
60
+ const name = expectIdent(parser, 'as an output name inside "return { ... }"');
61
+ parser.expectOp(":");
62
+ fields[name] = parser.parseExpression();
63
+ };
64
+ if (!parser.isOp("}")) {
65
+ readField();
66
+ while (parser.isOp(",")) {
67
+ parser.next();
68
+ readField();
69
+ }
70
+ }
71
+ parser.expectOp("}");
72
+ parser.expectOp(";");
73
+ return outputs(fields);
74
+ }
75
+ const node = parser.parseExpression();
76
+ parser.expectOp(";");
77
+ return node;
78
+ }
79
+
80
+ function parseProgram(parser) {
81
+ const bindings = [];
82
+ while (isKeyword(parser, "let")) {
83
+ bindings.push(parseLetStatement(parser));
84
+ }
85
+ if (!isKeyword(parser, "return")) {
86
+ parser.error('expected "return" (a fn`...` body is zero or more "let" statements followed by a "return")');
87
+ }
88
+ const body = parseReturnStatement(parser);
89
+ return bindings.length > 0 ? letChain(bindings, body) : body;
90
+ }
91
+
92
+ // Same token-splicing loop expr() uses in expr.js -- see that file's
93
+ // header comment for why there's no "${" text syntax to lex separately;
94
+ // the only difference here is the entry point (parseProgram instead of
95
+ // parser.parseExpression()).
96
+ function fn(strings, ...values) {
97
+ const tokens = [];
98
+ let source = "";
99
+ for (let i = 0; i < strings.length; i++) {
100
+ tokenizeSegment(strings[i], source.length, tokens, "fn()");
101
+ source += strings[i];
102
+ if (i < values.length) {
103
+ tokens.push({ type: "HOLE", value: values[i], pos: source.length });
104
+ source += "${...}";
105
+ }
106
+ }
107
+ tokens.push({ type: "EOF", value: null, pos: source.length });
108
+
109
+ const parser = new Parser(tokens, source, "fn()");
110
+ const node = parseProgram(parser);
111
+ if (parser.peek().type !== "EOF") {
112
+ parser.error("unexpected trailing input");
113
+ }
114
+ return node;
115
+ }
116
+
117
+ module.exports = { fn };