@elwood-lang/core 0.1.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/src/parser.ts ADDED
@@ -0,0 +1,626 @@
1
+ import type { Token, SourceSpan } from './token.js';
2
+ import { TokenKind } from './token.js';
3
+ import { tokenize, type Diagnostic } from './lexer.js';
4
+ import type {
5
+ ElwoodExpression, ScriptNode, LetBindingNode, PipeOperation, PathSegment,
6
+ ObjectProperty, MatchArm, InterpolationPart, JoinMode,
7
+ } from './ast.js';
8
+
9
+ export class ParseError extends Error {
10
+ constructor(public diagnostic: Diagnostic) { super(diagnostic.message); }
11
+ }
12
+
13
+ export function parseExpression(source: string): { ast: ElwoodExpression; diagnostics: Diagnostic[] } {
14
+ const { tokens, diagnostics: lexDiags } = tokenize(source);
15
+ const parser = new Parser(tokens);
16
+ const ast = parser.parseExpression();
17
+ return { ast, diagnostics: [...lexDiags, ...parser.diagnostics] };
18
+ }
19
+
20
+ export function parseScript(source: string): { ast: ScriptNode; diagnostics: Diagnostic[] } {
21
+ const { tokens, diagnostics: lexDiags } = tokenize(source);
22
+ const parser = new Parser(tokens);
23
+ const ast = parser.parseScript();
24
+ return { ast, diagnostics: [...lexDiags, ...parser.diagnostics] };
25
+ }
26
+
27
+ class Parser {
28
+ private pos = 0;
29
+ private pipeDepth = 0;
30
+ readonly diagnostics: Diagnostic[] = [];
31
+
32
+ constructor(private tokens: Token[]) {}
33
+
34
+ // ── Entry points ──
35
+
36
+ parseScript(): ScriptNode {
37
+ const bindings: LetBindingNode[] = [];
38
+ let returnExpr: ElwoodExpression | null = null;
39
+ const start = this.current().span;
40
+
41
+ while (!this.isAtEnd()) {
42
+ if (this.check(TokenKind.Let)) {
43
+ bindings.push(this.parseLetBinding());
44
+ } else if (this.check(TokenKind.Return)) {
45
+ this.advance();
46
+ returnExpr = this.parseExpression();
47
+ break;
48
+ } else {
49
+ returnExpr = this.parseExpression();
50
+ if (!this.isAtEnd() && !this.check(TokenKind.Eof)) {
51
+ this.error(`Unexpected token '${this.current().text}'. Did you mean 'let'?`);
52
+ break;
53
+ }
54
+ }
55
+ }
56
+
57
+ return { type: 'Script', bindings, returnExpression: returnExpr, span: this.span(start) };
58
+ }
59
+
60
+ parseExpression(): ElwoodExpression {
61
+ return this.parsePipeline();
62
+ }
63
+
64
+ // ── Pipeline ──
65
+
66
+ private parsePipeline(): ElwoodExpression {
67
+ const start = this.current().span;
68
+ let expr = this.parseTernary();
69
+ const ops: PipeOperation[] = [];
70
+
71
+ while (this.match(TokenKind.Pipe)) {
72
+ ops.push(this.parsePipeOperation());
73
+ }
74
+
75
+ return ops.length > 0 ? { type: 'Pipeline', source: expr, operations: ops, span: this.span(start) } : expr;
76
+ }
77
+
78
+ private parsePipeOperation(): PipeOperation {
79
+ const start = this.current().span;
80
+ const name = this.current().text;
81
+
82
+ if (this.check(TokenKind.Match)) {
83
+ this.advance();
84
+ return { type: 'MatchOp', arms: this.parseMatchArms(), span: this.span(start) };
85
+ }
86
+
87
+ if (!this.check(TokenKind.Identifier)) {
88
+ this.error(`Expected pipe operator name after '|', got '${this.current().text}'`);
89
+ return { type: 'Aggregate', name: 'error', span: this.span(start) };
90
+ }
91
+
92
+ this.advance();
93
+
94
+ switch (name) {
95
+ case 'where': return { type: 'Where', predicate: this.parsePipeArg(), span: this.span(start) };
96
+ case 'select': return { type: 'Select', projection: this.parsePipeArg(), span: this.span(start) };
97
+ case 'selectMany': return { type: 'SelectMany', projection: this.parsePipeArg(), span: this.span(start) };
98
+ case 'orderBy': return this.parseOrderBy(start);
99
+ case 'groupBy': return { type: 'GroupBy', keySelector: this.parsePipeArg(), span: this.span(start) };
100
+ case 'distinct': return { type: 'Distinct', span: this.span(start) };
101
+ case 'first': case 'last': return this.parseFirstLast(name, start);
102
+ case 'count': case 'sum': case 'min': case 'max': case 'index':
103
+ return { type: 'Aggregate', name, span: this.span(start) };
104
+ case 'take': case 'skip':
105
+ return { type: 'Slice', kind: name as 'take' | 'skip', count: this.parsePipeArg(), span: this.span(start) };
106
+ case 'batch': return { type: 'Batch', size: this.parsePipeArg(), span: this.span(start) };
107
+ case 'join': return this.parseJoin(start);
108
+ case 'concat': return this.parseConcat(start);
109
+ case 'reduce': return this.parseReduce(start);
110
+ case 'any': case 'all':
111
+ return { type: 'Quantifier', kind: name as 'any' | 'all', predicate: this.parsePipeArg(), span: this.span(start) };
112
+ default: throw this.error(`Unknown pipe operator '${name}'`);
113
+ }
114
+ }
115
+
116
+ private parsePipeArg(): ElwoodExpression {
117
+ this.pipeDepth++;
118
+ try { return this.parseTernary(); }
119
+ finally { this.pipeDepth--; }
120
+ }
121
+
122
+ private parseOrderBy(start: SourceSpan): PipeOperation {
123
+ const keys: { key: ElwoodExpression; ascending: boolean }[] = [];
124
+ do {
125
+ const key = this.parsePipeArg();
126
+ let ascending = true;
127
+ if (this.check(TokenKind.Asc)) { this.advance(); ascending = true; }
128
+ else if (this.check(TokenKind.Desc)) { this.advance(); ascending = false; }
129
+ keys.push({ key, ascending });
130
+ } while (this.match(TokenKind.Comma));
131
+ return { type: 'OrderBy', keys, span: this.span(start) };
132
+ }
133
+
134
+ private parseFirstLast(name: string, start: SourceSpan): PipeOperation {
135
+ if (this.isAtEnd() || this.check(TokenKind.Pipe) || this.check(TokenKind.RightBrace) ||
136
+ this.check(TokenKind.RightParen) || this.check(TokenKind.Eof) || this.check(TokenKind.Comma)) {
137
+ return { type: 'Aggregate', name, span: this.span(start) };
138
+ }
139
+ return { type: 'Aggregate', name, predicate: this.parsePipeArg(), span: this.span(start) };
140
+ }
141
+
142
+ private parseReduce(start: SourceSpan): PipeOperation {
143
+ const accumulator = this.parsePipeArg();
144
+ let initialValue: ElwoodExpression | undefined;
145
+ if (this.match(TokenKind.From)) initialValue = this.parsePipeArg();
146
+ return { type: 'Reduce', accumulator, initialValue, span: this.span(start) };
147
+ }
148
+
149
+ private parseConcat(start: SourceSpan): PipeOperation {
150
+ if (this.isAtEnd() || this.check(TokenKind.Pipe) || this.check(TokenKind.RightBrace) ||
151
+ this.check(TokenKind.RightParen) || this.check(TokenKind.Eof) || this.check(TokenKind.Comma)) {
152
+ return { type: 'Concat', span: this.span(start) };
153
+ }
154
+ return { type: 'Concat', separator: this.parsePipeArg(), span: this.span(start) };
155
+ }
156
+
157
+ private parseJoin(start: SourceSpan): PipeOperation {
158
+ const source = this.parsePipeArg();
159
+ this.expect(TokenKind.On, "Expected 'on' after join source");
160
+ const leftKey = this.parsePipeArg();
161
+ this.expect(TokenKind.Equals, "Expected 'equals' in join");
162
+ const rightKey = this.parsePipeArg();
163
+
164
+ let intoAlias: string | undefined;
165
+ if (this.match(TokenKind.Into)) {
166
+ intoAlias = this.expect(TokenKind.Identifier, "Expected alias after 'into'").text;
167
+ }
168
+
169
+ let mode: JoinMode = 'inner';
170
+ if (this.check(TokenKind.Identifier)) {
171
+ const m = this.current().text.toLowerCase();
172
+ if (m === 'inner' || m === 'left' || m === 'right' || m === 'full') {
173
+ mode = m; this.advance();
174
+ }
175
+ }
176
+
177
+ return { type: 'Join', source, leftKey, rightKey, intoAlias, mode, span: this.span(start) };
178
+ }
179
+
180
+ // ── Ternary ──
181
+
182
+ private parseTernary(): ElwoodExpression {
183
+ if (this.match(TokenKind.If)) {
184
+ const start = this.previous().span;
185
+ const condition = this.parseOr();
186
+ this.expect(TokenKind.Then, "Expected 'then'");
187
+ const thenBranch = this.parseTernary();
188
+ this.expect(TokenKind.Else, "Expected 'else'");
189
+ const elseBranch = this.parseTernary();
190
+ return { type: 'If', condition, thenBranch, elseBranch, span: this.span(start) };
191
+ }
192
+ return this.parseOr();
193
+ }
194
+
195
+ // ── Boolean ──
196
+
197
+ private parseOr(): ElwoodExpression {
198
+ const start = this.current().span;
199
+ let left = this.parseAnd();
200
+ while (this.match(TokenKind.PipePipe)) {
201
+ left = { type: 'Binary', left, operator: 'Or', right: this.parseAnd(), span: this.span(start) };
202
+ }
203
+ return left;
204
+ }
205
+
206
+ private parseAnd(): ElwoodExpression {
207
+ const start = this.current().span;
208
+ let left = this.parseEquality();
209
+ while (this.match(TokenKind.AmpersandAmpersand)) {
210
+ left = { type: 'Binary', left, operator: 'And', right: this.parseEquality(), span: this.span(start) };
211
+ }
212
+ return left;
213
+ }
214
+
215
+ // ── Comparison ──
216
+
217
+ private parseEquality(): ElwoodExpression {
218
+ const start = this.current().span;
219
+ let left = this.parseComparison();
220
+ while (this.check(TokenKind.EqualEqual) || this.check(TokenKind.BangEqual)) {
221
+ const op = this.advance().kind === TokenKind.EqualEqual ? 'Equal' as const : 'NotEqual' as const;
222
+ left = { type: 'Binary', left, operator: op, right: this.parseComparison(), span: this.span(start) };
223
+ }
224
+ return left;
225
+ }
226
+
227
+ private parseComparison(): ElwoodExpression {
228
+ const start = this.current().span;
229
+ let left = this.parseAdditive();
230
+ while (this.check(TokenKind.LessThan) || this.check(TokenKind.LessThanOrEqual) ||
231
+ this.check(TokenKind.GreaterThan) || this.check(TokenKind.GreaterThanOrEqual)) {
232
+ const tok = this.advance();
233
+ const op = tok.kind === TokenKind.LessThan ? 'LessThan' as const
234
+ : tok.kind === TokenKind.LessThanOrEqual ? 'LessThanOrEqual' as const
235
+ : tok.kind === TokenKind.GreaterThan ? 'GreaterThan' as const
236
+ : 'GreaterThanOrEqual' as const;
237
+ left = { type: 'Binary', left, operator: op, right: this.parseAdditive(), span: this.span(start) };
238
+ }
239
+ return left;
240
+ }
241
+
242
+ // ── Arithmetic ──
243
+
244
+ private parseAdditive(): ElwoodExpression {
245
+ const start = this.current().span;
246
+ let left = this.parseMultiplicative();
247
+ while (this.check(TokenKind.Plus) || this.check(TokenKind.Minus)) {
248
+ const op = this.advance().kind === TokenKind.Plus ? 'Add' as const : 'Subtract' as const;
249
+ left = { type: 'Binary', left, operator: op, right: this.parseMultiplicative(), span: this.span(start) };
250
+ }
251
+ return left;
252
+ }
253
+
254
+ private parseMultiplicative(): ElwoodExpression {
255
+ const start = this.current().span;
256
+ let left = this.parseUnary();
257
+ while (this.check(TokenKind.Star) || this.check(TokenKind.Slash)) {
258
+ const op = this.advance().kind === TokenKind.Star ? 'Multiply' as const : 'Divide' as const;
259
+ left = { type: 'Binary', left, operator: op, right: this.parseUnary(), span: this.span(start) };
260
+ }
261
+ return left;
262
+ }
263
+
264
+ private parseUnary(): ElwoodExpression {
265
+ if (this.match(TokenKind.Bang))
266
+ return { type: 'Unary', operator: 'Not', operand: this.parseUnary(), span: this.span(this.previous().span) };
267
+ if (this.match(TokenKind.Minus))
268
+ return { type: 'Unary', operator: 'Negate', operand: this.parseUnary(), span: this.span(this.previous().span) };
269
+ return this.parsePostfix();
270
+ }
271
+
272
+ // ── Postfix ──
273
+
274
+ private parsePostfix(): ElwoodExpression {
275
+ let expr = this.parsePrimary();
276
+
277
+ while (true) {
278
+ if (this.match(TokenKind.Dot)) {
279
+ const start = this.previous().span;
280
+ const name = this.expect(TokenKind.Identifier, "Expected property name after '.'").text;
281
+ if (this.match(TokenKind.LeftParen)) {
282
+ const args = this.parseArgList();
283
+ this.expect(TokenKind.RightParen, "Expected ')'");
284
+ expr = { type: 'MethodCall', target: expr, methodName: name, arguments: args, span: this.span(start) };
285
+ } else {
286
+ expr = { type: 'MemberAccess', target: expr, memberName: name, span: this.span(start) };
287
+ }
288
+ } else if (this.match(TokenKind.LeftBracket)) {
289
+ const start = this.previous().span;
290
+ if (this.match(TokenKind.Star)) {
291
+ this.expect(TokenKind.RightBracket, "Expected ']'");
292
+ expr = { type: 'Index', target: expr, index: null, span: this.span(start) };
293
+ } else {
294
+ const index = this.parseExpression();
295
+ this.expect(TokenKind.RightBracket, "Expected ']'");
296
+ expr = { type: 'Index', target: expr, index, span: this.span(start) };
297
+ }
298
+ } else {
299
+ break;
300
+ }
301
+ }
302
+
303
+ return expr;
304
+ }
305
+
306
+ // ── Primary ──
307
+
308
+ private parsePrimary(): ElwoodExpression {
309
+ const start = this.current().span;
310
+
311
+ // Dollar path
312
+ if (this.check(TokenKind.DollarDot) || this.check(TokenKind.Dollar))
313
+ return this.parsePath();
314
+
315
+ // Identifier / lambda / function call
316
+ if (this.check(TokenKind.Identifier))
317
+ return this.parseIdentifierOrLambda();
318
+
319
+ // Literals
320
+ if (this.match(TokenKind.StringLiteral))
321
+ return { type: 'Literal', value: this.previous().text, span: this.span(start) };
322
+ if (this.match(TokenKind.NumberLiteral))
323
+ return { type: 'Literal', value: parseFloat(this.previous().text), span: this.span(start) };
324
+ if (this.match(TokenKind.TrueLiteral))
325
+ return { type: 'Literal', value: true, span: this.span(start) };
326
+ if (this.match(TokenKind.FalseLiteral))
327
+ return { type: 'Literal', value: false, span: this.span(start) };
328
+ if (this.match(TokenKind.NullLiteral))
329
+ return { type: 'Literal', value: null, span: this.span(start) };
330
+
331
+ // Interpolated string
332
+ if (this.match(TokenKind.Backtick))
333
+ return this.parseInterpolatedContent(this.previous().text, start);
334
+
335
+ // Object literal
336
+ if (this.check(TokenKind.LeftBrace))
337
+ return this.parseObjectLiteral();
338
+
339
+ // Array literal
340
+ if (this.match(TokenKind.LeftBracket)) {
341
+ const items: ElwoodExpression[] = [];
342
+ if (!this.check(TokenKind.RightBracket)) {
343
+ do { items.push(this.parseExpression()); } while (this.match(TokenKind.Comma));
344
+ }
345
+ this.expect(TokenKind.RightBracket, "Expected ']'");
346
+ return { type: 'Array', items, span: this.span(start) };
347
+ }
348
+
349
+ // Parenthesized or multi-param lambda
350
+ if (this.match(TokenKind.LeftParen)) {
351
+ if (this.check(TokenKind.Identifier) && this.lookAheadMultiParamLambda()) {
352
+ const params: string[] = [];
353
+ do { params.push(this.expect(TokenKind.Identifier, "Expected parameter name").text); } while (this.match(TokenKind.Comma));
354
+ this.expect(TokenKind.RightParen, "Expected ')'");
355
+ this.expect(TokenKind.FatArrow, "Expected '=>'");
356
+ const body = this.pipeDepth > 0 ? this.parseTernary() : this.parseExpression();
357
+ return { type: 'Lambda', parameters: params, body, span: this.span(start) };
358
+ }
359
+ const expr = this.parseExpression();
360
+ this.expect(TokenKind.RightParen, "Expected ')'");
361
+ return expr;
362
+ }
363
+
364
+ // Memo
365
+ if (this.match(TokenKind.Memo)) {
366
+ const saved = this.pipeDepth;
367
+ this.pipeDepth = 0;
368
+ try {
369
+ const inner = this.parseTernary();
370
+ if (inner.type !== 'Lambda') throw this.error("Expected lambda after 'memo'");
371
+ return { type: 'Memo', lambda: inner, span: this.span(start) };
372
+ } finally {
373
+ this.pipeDepth = saved;
374
+ }
375
+ }
376
+
377
+ // Wildcard
378
+ if (this.match(TokenKind.Underscore))
379
+ return { type: 'Identifier', name: '_', span: this.span(start) };
380
+
381
+ throw this.error(`Unexpected token '${this.current().text}'`);
382
+ }
383
+
384
+ // ── Path ──
385
+
386
+ private parsePath(): ElwoodExpression {
387
+ const start = this.current().span;
388
+ const segments: PathSegment[] = [];
389
+
390
+ if (this.match(TokenKind.DollarDot)) {
391
+ segments.push(...this.parsePathSegments());
392
+ } else {
393
+ this.match(TokenKind.Dollar); // standalone $
394
+ }
395
+
396
+ return { type: 'Path', segments, isRooted: true, span: this.span(start) };
397
+ }
398
+
399
+ private parsePathSegments(): PathSegment[] {
400
+ const segments: PathSegment[] = [];
401
+
402
+ if (this.check(TokenKind.Identifier)) {
403
+ segments.push({ type: 'Property', name: this.advance().text, span: this.span(this.current().span) });
404
+ }
405
+
406
+ while (true) {
407
+ if (this.match(TokenKind.DotDot)) {
408
+ const name = this.expect(TokenKind.Identifier, "Expected name after '..'").text;
409
+ segments.push({ type: 'RecursiveDescent', name, span: this.span(this.current().span) });
410
+ } else if (this.check(TokenKind.Dot) && !this.check(TokenKind.DotDot)) {
411
+ // Stop if .identifier( — it's a method call
412
+ if (this.peekAt(1)?.kind === TokenKind.Identifier && this.peekAt(2)?.kind === TokenKind.LeftParen) break;
413
+ this.advance();
414
+ if (this.check(TokenKind.Identifier)) {
415
+ segments.push({ type: 'Property', name: this.advance().text, span: this.span(this.current().span) });
416
+ } else break;
417
+ } else if (this.match(TokenKind.LeftBracket)) {
418
+ if (this.match(TokenKind.Star)) {
419
+ this.expect(TokenKind.RightBracket, "Expected ']'");
420
+ segments.push({ type: 'Index', index: null, span: this.span(this.current().span) });
421
+ } else if (this.check(TokenKind.NumberLiteral) || this.check(TokenKind.Minus) || this.check(TokenKind.Colon)) {
422
+ const s = this.tryParseBracketInt();
423
+ if (this.match(TokenKind.Colon)) {
424
+ const e = this.tryParseBracketInt();
425
+ this.expect(TokenKind.RightBracket, "Expected ']'");
426
+ segments.push({ type: 'Slice', start: s, end: e, span: this.span(this.current().span) });
427
+ } else if (s !== null) {
428
+ this.expect(TokenKind.RightBracket, "Expected ']'");
429
+ segments.push({ type: 'Index', index: s, span: this.span(this.current().span) });
430
+ } else break;
431
+ } else break;
432
+ } else break;
433
+ }
434
+
435
+ return segments;
436
+ }
437
+
438
+ private tryParseBracketInt(): number | null {
439
+ const negate = this.match(TokenKind.Minus);
440
+ if (this.check(TokenKind.NumberLiteral)) {
441
+ const val = parseInt(this.advance().text, 10);
442
+ return negate ? -val : val;
443
+ }
444
+ return null;
445
+ }
446
+
447
+ // ── Identifier / Lambda / Function call ──
448
+
449
+ private parseIdentifierOrLambda(): ElwoodExpression {
450
+ const start = this.current().span;
451
+ const name = this.advance().text;
452
+
453
+ if (this.check(TokenKind.FatArrow)) {
454
+ this.advance();
455
+ const body = this.pipeDepth > 0 ? this.parseTernary() : this.parseExpression();
456
+ return { type: 'Lambda', parameters: [name], body, span: this.span(start) };
457
+ }
458
+
459
+ if (this.match(TokenKind.LeftParen)) {
460
+ const args = this.parseArgList();
461
+ this.expect(TokenKind.RightParen, "Expected ')'");
462
+ return { type: 'FunctionCall', functionName: name, arguments: args, span: this.span(start) };
463
+ }
464
+
465
+ return { type: 'Identifier', name, span: this.span(start) };
466
+ }
467
+
468
+ // ── Object literal ──
469
+
470
+ private parseObjectLiteral(): ElwoodExpression {
471
+ const start = this.current().span;
472
+ this.expect(TokenKind.LeftBrace, "Expected '{'");
473
+ const properties: ObjectProperty[] = [];
474
+
475
+ if (!this.check(TokenKind.RightBrace)) {
476
+ do {
477
+ const propStart = this.current().span;
478
+
479
+ if (this.match(TokenKind.Spread)) {
480
+ properties.push({ key: '', value: this.parseExpression(), span: this.span(propStart), isSpread: true });
481
+ continue;
482
+ }
483
+
484
+ if (this.match(TokenKind.LeftBracket)) {
485
+ const keyExpr = this.parseExpression();
486
+ this.expect(TokenKind.RightBracket, "Expected ']'");
487
+ this.expect(TokenKind.Colon, "Expected ':'");
488
+ properties.push({ key: '', value: this.parseExpression(), span: this.span(propStart), computedKey: keyExpr });
489
+ continue;
490
+ }
491
+
492
+ let key: string;
493
+ if (this.check(TokenKind.Identifier)) key = this.advance().text;
494
+ else if (this.check(TokenKind.StringLiteral)) key = this.advance().text;
495
+ else throw this.error(`Expected property name, got '${this.current().text}'`);
496
+
497
+ this.expect(TokenKind.Colon, "Expected ':'");
498
+ properties.push({ key, value: this.parseExpression(), span: this.span(propStart) });
499
+ } while (this.match(TokenKind.Comma));
500
+ }
501
+
502
+ this.expect(TokenKind.RightBrace, "Expected '}'");
503
+ return { type: 'Object', properties, span: this.span(start) };
504
+ }
505
+
506
+ // ── Interpolated string ──
507
+
508
+ private parseInterpolatedContent(raw: string, start: SourceSpan): ElwoodExpression {
509
+ const parts: InterpolationPart[] = [];
510
+ let text = '';
511
+ let i = 0;
512
+
513
+ while (i < raw.length) {
514
+ if (raw[i] === '{') {
515
+ if (text) { parts.push({ type: 'Text', text, span: start }); text = ''; }
516
+ i++;
517
+ let depth = 1;
518
+ let exprText = '';
519
+ while (i < raw.length && depth > 0) {
520
+ if (raw[i] === '{') depth++;
521
+ else if (raw[i] === '}') { depth--; if (depth === 0) { i++; break; } }
522
+ exprText += raw[i]; i++;
523
+ }
524
+ const { ast } = parseExpression(exprText);
525
+ parts.push({ type: 'Expression', expression: ast, span: start });
526
+ } else {
527
+ text += raw[i]; i++;
528
+ }
529
+ }
530
+
531
+ if (text) parts.push({ type: 'Text', text, span: start });
532
+ return { type: 'InterpolatedString', parts, span: start };
533
+ }
534
+
535
+ // ── Match arms ──
536
+
537
+ private parseMatchArms(): MatchArm[] {
538
+ const arms: MatchArm[] = [];
539
+ while (!this.isAtEnd() && !this.check(TokenKind.Pipe) && !this.check(TokenKind.Eof) &&
540
+ !this.check(TokenKind.RightBrace) && !this.check(TokenKind.RightParen)) {
541
+ const armStart = this.current().span;
542
+ let pattern: ElwoodExpression | null;
543
+ if (this.check(TokenKind.Underscore)) { this.advance(); pattern = null; }
544
+ else pattern = this.parsePrimary();
545
+ this.expect(TokenKind.FatArrow, "Expected '=>'");
546
+ const result = this.parseTernary();
547
+ arms.push({ pattern, result, span: this.span(armStart) });
548
+ this.match(TokenKind.Comma);
549
+ }
550
+ return arms;
551
+ }
552
+
553
+ // ── Let binding ──
554
+
555
+ private parseLetBinding(): LetBindingNode {
556
+ const start = this.current().span;
557
+ this.expect(TokenKind.Let, "Expected 'let'");
558
+ const name = this.expect(TokenKind.Identifier, "Expected variable name").text;
559
+ this.expect(TokenKind.Assign, "Expected '='");
560
+ const value = this.parseExpression();
561
+ return { type: 'LetBinding', name, value, span: this.span(start) };
562
+ }
563
+
564
+ // ── Helpers ──
565
+
566
+ private parseArgList(): ElwoodExpression[] {
567
+ const args: ElwoodExpression[] = [];
568
+ if (!this.check(TokenKind.RightParen)) {
569
+ do { args.push(this.parseExpression()); } while (this.match(TokenKind.Comma));
570
+ }
571
+ return args;
572
+ }
573
+
574
+ private lookAheadMultiParamLambda(): boolean {
575
+ const saved = this.pos;
576
+ try {
577
+ if (!this.check(TokenKind.Identifier)) return false;
578
+ this.advance();
579
+ while (this.check(TokenKind.Comma)) {
580
+ this.advance();
581
+ if (!this.check(TokenKind.Identifier)) return false;
582
+ this.advance();
583
+ }
584
+ if (!this.check(TokenKind.RightParen)) return false;
585
+ this.advance();
586
+ return this.check(TokenKind.FatArrow);
587
+ } finally { this.pos = saved; }
588
+ }
589
+
590
+ private current(): Token { return this.pos < this.tokens.length ? this.tokens[this.pos] : this.tokens[this.tokens.length - 1]; }
591
+ private previous(): Token { return this.tokens[this.pos - 1]; }
592
+ private isAtEnd(): boolean { return this.pos >= this.tokens.length || this.current().kind === TokenKind.Eof; }
593
+ private check(kind: TokenKind): boolean { return !this.isAtEnd() && this.current().kind === kind; }
594
+ private peekAt(offset: number): Token | undefined { return this.tokens[this.pos + offset]; }
595
+
596
+ private match(kind: TokenKind): boolean {
597
+ if (!this.check(kind)) return false;
598
+ this.pos++;
599
+ return true;
600
+ }
601
+
602
+ private advance(): Token {
603
+ const token = this.current();
604
+ this.pos++;
605
+ return token;
606
+ }
607
+
608
+ private expect(kind: TokenKind, message: string): Token {
609
+ if (this.check(kind)) return this.advance();
610
+ throw this.error(message);
611
+ }
612
+
613
+ private span(start: SourceSpan): SourceSpan {
614
+ return { start: start.start, end: this.current().span.end, line: start.line, column: start.column };
615
+ }
616
+
617
+ private error(message: string): ParseError {
618
+ const diag: Diagnostic = {
619
+ severity: 'error',
620
+ message,
621
+ span: this.current().span,
622
+ };
623
+ this.diagnostics.push(diag);
624
+ return new ParseError(diag);
625
+ }
626
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Variable scope for Elwood evaluation. Supports nested scopes (let bindings, lambdas).
3
+ */
4
+ export class Scope {
5
+ private vars = new Map<string, unknown>();
6
+
7
+ constructor(private parent?: Scope) {}
8
+
9
+ set(name: string, value: unknown): void {
10
+ this.vars.set(name, value);
11
+ }
12
+
13
+ get(name: string): unknown | undefined {
14
+ if (this.vars.has(name)) return this.vars.get(name);
15
+ return this.parent?.get(name);
16
+ }
17
+
18
+ has(name: string): boolean {
19
+ if (this.vars.has(name)) return true;
20
+ return this.parent?.has(name) ?? false;
21
+ }
22
+
23
+ child(): Scope {
24
+ return new Scope(this);
25
+ }
26
+ }