tova 0.3.5 → 0.3.6

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/lsp/server.js CHANGED
@@ -145,7 +145,7 @@ class TovaLanguageServer {
145
145
  capabilities: {
146
146
  textDocumentSync: {
147
147
  openClose: true,
148
- change: 1, // Full content sync
148
+ change: 2, // Incremental sync — receive only changed ranges
149
149
  save: { includeText: true },
150
150
  },
151
151
  completionProvider: {
@@ -183,11 +183,56 @@ class TovaLanguageServer {
183
183
 
184
184
  _onDidChange(params) {
185
185
  const { uri, version } = params.textDocument;
186
- const text = params.contentChanges[0]?.text;
187
- if (text !== undefined) {
188
- this._documents.set(uri, { text, version });
189
- this._validateDocument(uri, text);
186
+ const changes = params.contentChanges;
187
+ if (!changes || changes.length === 0) return;
188
+
189
+ const doc = this._documents.get(uri);
190
+ if (!doc) return;
191
+
192
+ let text = doc.text;
193
+
194
+ for (const change of changes) {
195
+ if (change.range) {
196
+ // Incremental sync: apply range edit
197
+ text = this._applyEdit(text, change.range, change.text);
198
+ } else {
199
+ // Full sync fallback (e.g., if client sends full text)
200
+ text = change.text;
201
+ }
190
202
  }
203
+
204
+ this._documents.set(uri, { text, version });
205
+ this._debouncedValidate(uri, text);
206
+ }
207
+
208
+ // Apply a single incremental text edit to the document
209
+ _applyEdit(text, range, newText) {
210
+ const startOffset = this._positionToOffset(text, range.start);
211
+ const endOffset = this._positionToOffset(text, range.end);
212
+ return text.slice(0, startOffset) + newText + text.slice(endOffset);
213
+ }
214
+
215
+ // Convert { line, character } position to byte offset in text
216
+ _positionToOffset(text, pos) {
217
+ let line = 0;
218
+ let offset = 0;
219
+ while (line < pos.line && offset < text.length) {
220
+ if (text[offset] === '\n') line++;
221
+ offset++;
222
+ }
223
+ return offset + pos.character;
224
+ }
225
+
226
+ _debouncedValidate(uri, text) {
227
+ if (!this._validateTimers) this._validateTimers = new Map();
228
+ const existing = this._validateTimers.get(uri);
229
+ if (existing) clearTimeout(existing);
230
+ this._validateTimers.set(uri, setTimeout(() => {
231
+ this._validateTimers.delete(uri);
232
+ // Re-read latest text in case more changes arrived
233
+ const doc = this._documents.get(uri);
234
+ if (doc) this._validateDocument(uri, doc.text);
235
+ }, 200));
191
236
  }
192
237
 
193
238
  _onDidClose(params) {
@@ -222,6 +267,8 @@ class TovaLanguageServer {
222
267
  // LSP always runs with strict: true for better diagnostics
223
268
  const analyzer = new Analyzer(ast, filename, { strict: true });
224
269
  const result = analyzer.analyze();
270
+ // Build sorted scope index for O(log n) position lookups
271
+ analyzer.globalScope.buildIndex();
225
272
  const { warnings } = result;
226
273
  const typeRegistry = TypeRegistry.fromAnalyzer(analyzer);
227
274
 
@@ -296,6 +343,7 @@ class TovaLanguageServer {
296
343
  try {
297
344
  const analyzer = new Analyzer(partialAST, filename, { tolerant: true, strict: true });
298
345
  const result = analyzer.analyze();
346
+ analyzer.globalScope.buildIndex();
299
347
  const typeRegistry = TypeRegistry.fromAnalyzer(analyzer);
300
348
  this._diagnosticsCache.set(uri, { ast: partialAST, analyzer, text, typeRegistry });
301
349
  for (const w of result.warnings) {
@@ -1555,7 +1603,7 @@ class TovaLanguageServer {
1555
1603
  });
1556
1604
  }
1557
1605
 
1558
- // ─── References ─────────────────────────────────────────
1606
+ // ─── References (scope-aware) ───────────────────────────
1559
1607
 
1560
1608
  _onReferences(msg) {
1561
1609
  const { position, textDocument } = msg.params;
@@ -1566,15 +1614,90 @@ class TovaLanguageServer {
1566
1614
  const word = this._getWordAt(line, position.character);
1567
1615
  if (!word) return this._respond(msg.id, []);
1568
1616
 
1617
+ const cached = this._diagnosticsCache.get(textDocument.uri);
1618
+ if (!cached || !cached.analyzer || !cached.analyzer.globalScope) {
1619
+ // Fallback to naive text search if no scope info
1620
+ return this._naiveReferences(msg.id, textDocument.uri, doc.text, word);
1621
+ }
1622
+
1623
+ // Find the scope at cursor and walk up to find the defining scope
1624
+ const cursorLine = position.line + 1;
1625
+ const cursorCol = position.character + 1;
1626
+ const cursorScope = cached.analyzer.globalScope.findScopeAtPosition(cursorLine, cursorCol);
1627
+ if (!cursorScope) {
1628
+ return this._naiveReferences(msg.id, textDocument.uri, doc.text, word);
1629
+ }
1630
+
1631
+ let definingScope = null;
1632
+ let scope = cursorScope;
1633
+ while (scope) {
1634
+ if (scope.symbols && scope.symbols.has(word)) {
1635
+ definingScope = scope;
1636
+ break;
1637
+ }
1638
+ scope = scope.parent;
1639
+ }
1640
+
1641
+ if (!definingScope) {
1642
+ return this._naiveReferences(msg.id, textDocument.uri, doc.text, word);
1643
+ }
1644
+
1645
+ // Collect locations: for each text occurrence, check if it resolves to the same scope
1569
1646
  const locations = [];
1570
1647
  const docLines = doc.text.split('\n');
1571
- const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
1648
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1649
+ const wordRegex = new RegExp('\\b' + escaped + '\\b', 'g');
1650
+
1651
+ for (let i = 0; i < docLines.length; i++) {
1652
+ let match;
1653
+ while ((match = wordRegex.exec(docLines[i])) !== null) {
1654
+ const matchLine = i + 1;
1655
+ const matchCol = match.index + 1;
1656
+ const matchScope = cached.analyzer.globalScope.findScopeAtPosition(matchLine, matchCol);
1657
+ if (!matchScope) continue;
1658
+
1659
+ // Walk up from matchScope to see if the name resolves to definingScope
1660
+ let resolvedScope = null;
1661
+ let s = matchScope;
1662
+ while (s) {
1663
+ if (s.symbols && s.symbols.has(word)) {
1664
+ resolvedScope = s;
1665
+ break;
1666
+ }
1667
+ s = s.parent;
1668
+ }
1669
+
1670
+ if (resolvedScope === definingScope) {
1671
+ locations.push({
1672
+ uri: textDocument.uri,
1673
+ range: {
1674
+ start: { line: i, character: match.index },
1675
+ end: { line: i, character: match.index + word.length },
1676
+ },
1677
+ });
1678
+ }
1679
+ }
1680
+ }
1681
+
1682
+ // Fallback if scope-aware search found nothing (e.g. positional info gaps)
1683
+ if (locations.length === 0) {
1684
+ return this._naiveReferences(msg.id, textDocument.uri, doc.text, word);
1685
+ }
1686
+
1687
+ this._respond(msg.id, locations);
1688
+ }
1689
+
1690
+ _naiveReferences(id, uri, text, word) {
1691
+ const locations = [];
1692
+ const docLines = text.split('\n');
1693
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1694
+ const wordRegex = new RegExp('\\b' + escaped + '\\b', 'g');
1572
1695
 
1573
1696
  for (let i = 0; i < docLines.length; i++) {
1574
1697
  let match;
1575
1698
  while ((match = wordRegex.exec(docLines[i])) !== null) {
1576
1699
  locations.push({
1577
- uri: textDocument.uri,
1700
+ uri: uri,
1578
1701
  range: {
1579
1702
  start: { line: i, character: match.index },
1580
1703
  end: { line: i, character: match.index + word.length },
@@ -1583,7 +1706,7 @@ class TovaLanguageServer {
1583
1706
  }
1584
1707
  }
1585
1708
 
1586
- this._respond(msg.id, locations);
1709
+ this._respond(id, locations);
1587
1710
  }
1588
1711
 
1589
1712
  // ─── Workspace Symbol ──────────────────────────────────
@@ -1869,6 +1992,19 @@ class TovaLanguageServer {
1869
1992
  }
1870
1993
  }
1871
1994
 
1872
- // Start the server
1873
- const server = new TovaLanguageServer();
1874
- server.start();
1995
+ export { TovaLanguageServer };
1996
+
1997
+ // Auto-start when imported (for `tova lsp` command).
1998
+ // Use startServer() export for explicit control.
1999
+ export function startServer() {
2000
+ const server = new TovaLanguageServer();
2001
+ server.start();
2002
+ return server;
2003
+ }
2004
+
2005
+ // Start automatically — callers that only want the class import should
2006
+ // use dynamic import with test-aware filtering or call startServer() explicitly
2007
+ const _autoStart = globalThis.__TOVA_LSP_NO_AUTOSTART !== true;
2008
+ if (_autoStart) {
2009
+ startServer();
2010
+ }
package/src/parser/ast.js CHANGED
@@ -77,7 +77,7 @@ export class LetDestructure {
77
77
  }
78
78
 
79
79
  export class FunctionDeclaration {
80
- constructor(name, params, body, returnType, loc, isAsync = false, typeParams = []) {
80
+ constructor(name, params, body, returnType, loc, isAsync = false, typeParams = [], decorators = []) {
81
81
  this.type = 'FunctionDeclaration';
82
82
  this.name = name;
83
83
  this.typeParams = typeParams; // Array of type parameter names (generics)
@@ -85,6 +85,7 @@ export class FunctionDeclaration {
85
85
  this.body = body; // BlockStatement or Expression (implicit return)
86
86
  this.returnType = returnType; // optional type annotation
87
87
  this.isAsync = isAsync;
88
+ this.decorators = decorators; // Array of { name, args } for @decorator syntax
88
89
  this.loc = loc;
89
90
  }
90
91
  }
@@ -293,6 +293,13 @@ export function installClientParser(ParserClass) {
293
293
  suffix = this.expect(TokenType.IDENTIFIER, "Expected name after ':'").value;
294
294
  }
295
295
  name = `${name}:${suffix}`;
296
+ // Consume event modifiers: on:click.stop.prevent
297
+ if (name.startsWith('on:') && this.check(TokenType.DOT)) {
298
+ while (this.match(TokenType.DOT)) {
299
+ const mod = this.expect(TokenType.IDENTIFIER, "Expected modifier name after '.'").value;
300
+ name += `.${mod}`;
301
+ }
302
+ }
296
303
  }
297
304
 
298
305
  if (!this.match(TokenType.ASSIGN)) {
@@ -402,17 +409,17 @@ export function installClientParser(ParserClass) {
402
409
  if (!this.match(TokenType.COMMA)) break;
403
410
  }
404
411
  this.expect(TokenType.RBRACKET, "Expected ']' in destructuring pattern");
405
- variable = `[${elements.join(', ')}]`;
412
+ variable = new AST.ArrayPattern(elements, l);
406
413
  } else if (this.check(TokenType.LBRACE)) {
407
414
  // Object destructuring: {name, age}
408
415
  this.advance(); // consume {
409
416
  const props = [];
410
417
  while (!this.check(TokenType.RBRACE) && !this.isAtEnd()) {
411
- props.push(this.expect(TokenType.IDENTIFIER, "Expected property name in object pattern").value);
418
+ props.push({ key: this.expect(TokenType.IDENTIFIER, "Expected property name in object pattern").value });
412
419
  if (!this.match(TokenType.COMMA)) break;
413
420
  }
414
421
  this.expect(TokenType.RBRACE, "Expected '}' in destructuring pattern");
415
- variable = `{${props.join(', ')}}`;
422
+ variable = new AST.ObjectPattern(props, l);
416
423
  } else {
417
424
  variable = this.expect(TokenType.IDENTIFIER, "Expected loop variable").value;
418
425
  }
@@ -5,35 +5,25 @@ import { installClientParser } from './client-parser.js';
5
5
 
6
6
  export class Parser {
7
7
  static MAX_EXPRESSION_DEPTH = 200;
8
+ static COMPARISON_OPS = null; // initialized after class definition
8
9
 
9
10
  constructor(tokens, filename = '<stdin>') {
10
- this.tokens = tokens;
11
+ // Pre-filter: build array of significant tokens for O(1) peek
12
+ const significant = [];
13
+ const docs = [];
14
+ for (const t of tokens) {
15
+ const type = t.type;
16
+ if (type === TokenType.NEWLINE || type === TokenType.SEMICOLON) continue;
17
+ if (type === TokenType.DOCSTRING) { docs.push(t); continue; }
18
+ significant.push(t);
19
+ }
20
+ this.tokens = significant;
21
+ this._eof = significant[significant.length - 1]; // cache EOF for hot-path methods
11
22
  this.filename = filename;
12
23
  this.pos = 0;
13
24
  this.errors = [];
14
25
  this._expressionDepth = 0;
15
- this.docstrings = this.extractDocstrings(tokens);
16
- this._skipInsignificant();
17
- }
18
-
19
- _isInsignificant(type) {
20
- return type === TokenType.NEWLINE || type === TokenType.DOCSTRING || type === TokenType.SEMICOLON;
21
- }
22
-
23
- _skipInsignificant() {
24
- while (this.pos < this.tokens.length && this._isInsignificant(this.tokens[this.pos].type)) {
25
- this.pos++;
26
- }
27
- }
28
-
29
- extractDocstrings(tokens) {
30
- const docs = [];
31
- for (const t of tokens) {
32
- if (t.type === TokenType.DOCSTRING) {
33
- docs.push(t);
34
- }
35
- }
36
- return docs;
26
+ this.docstrings = docs;
37
27
  }
38
28
 
39
29
  // ─── Helpers ───────────────────────────────────────────────
@@ -49,28 +39,16 @@ export class Parser {
49
39
  }
50
40
 
51
41
  current() {
52
- return this.tokens[this.pos] || this.tokens[this.tokens.length - 1];
42
+ return this.tokens[this.pos] || this._eof;
53
43
  }
54
44
 
55
45
  peek(offset = 0) {
56
- // Fast path: offset 0 is just the current position (always significant after skip)
57
- if (offset === 0) return this.tokens[this.pos] || this.tokens[this.tokens.length - 1];
58
- // General path: skip over insignificant tokens
59
- let count = 0;
60
- for (let idx = this.pos + 1; idx < this.tokens.length; idx++) {
61
- if (!this._isInsignificant(this.tokens[idx].type)) {
62
- count++;
63
- if (count === offset) return this.tokens[idx];
64
- }
65
- }
66
- return this.tokens[this.tokens.length - 1];
46
+ const idx = this.pos + offset;
47
+ return idx < this.tokens.length ? this.tokens[idx] : this._eof;
67
48
  }
68
49
 
69
50
  advance() {
70
- const tok = this.current();
71
- this.pos++;
72
- this._skipInsignificant();
73
- return tok;
51
+ return this.tokens[this.pos++] || this._eof;
74
52
  }
75
53
 
76
54
  check(type) {
@@ -250,8 +228,8 @@ export class Parser {
250
228
  }
251
229
 
252
230
  _attachDocstrings(program) {
253
- // Build a map of docstring line ranges from raw tokens
254
- const docTokens = this.tokens.filter(t => t.type === TokenType.DOCSTRING);
231
+ // Use pre-extracted docstring tokens
232
+ const docTokens = this.docstrings;
255
233
  if (docTokens.length === 0) return;
256
234
 
257
235
  // Group consecutive docstring lines
@@ -563,6 +541,7 @@ export class Parser {
563
541
  this.advance(); // consume async
564
542
  return this.parseForStatement(null, true);
565
543
  }
544
+ if (this.check(TokenType.AT)) return this.parseDecoratedDeclaration();
566
545
  if (this.check(TokenType.ASYNC) && this.peek(1).type === TokenType.FN) return this.parseAsyncFunctionDeclaration();
567
546
  if (this.check(TokenType.FN) && (this.peek(1).type === TokenType.IDENTIFIER || this._isContextualKeywordToken(this.peek(1)))) return this.parseFunctionDeclaration();
568
547
  if (this.check(TokenType.TYPE)) return this.parseTypeDeclaration();
@@ -723,7 +702,35 @@ export class Parser {
723
702
  return new AST.ExternDeclaration(name, params, returnType, l, isAsync);
724
703
  }
725
704
 
726
- parseFunctionDeclaration() {
705
+ parseDecoratedDeclaration() {
706
+ const decorators = [];
707
+ while (this.check(TokenType.AT)) {
708
+ this.advance(); // consume @
709
+ const decName = this.expect(TokenType.IDENTIFIER, "Expected decorator name after '@'").value;
710
+ let decArgs = [];
711
+ if (this.check(TokenType.LPAREN)) {
712
+ this.advance(); // consume (
713
+ while (!this.check(TokenType.RPAREN) && !this.isAtEnd()) {
714
+ decArgs.push(this.parseExpression());
715
+ if (!this.match(TokenType.COMMA)) break;
716
+ }
717
+ this.expect(TokenType.RPAREN, "Expected ')' after decorator arguments");
718
+ }
719
+ decorators.push({ name: decName, args: decArgs });
720
+ }
721
+ // After decorators, expect fn or async fn
722
+ if (this.check(TokenType.ASYNC) && this.peek(1).type === TokenType.FN) {
723
+ const node = this.parseAsyncFunctionDeclaration(decorators);
724
+ return node;
725
+ }
726
+ if (this.check(TokenType.FN)) {
727
+ const node = this.parseFunctionDeclaration(decorators);
728
+ return node;
729
+ }
730
+ this.error("Expected 'fn' or 'async fn' after decorator");
731
+ }
732
+
733
+ parseFunctionDeclaration(decorators = []) {
727
734
  const l = this.loc();
728
735
  this.expect(TokenType.FN);
729
736
  let name;
@@ -754,10 +761,10 @@ export class Parser {
754
761
  }
755
762
 
756
763
  const body = this.parseBlock();
757
- return new AST.FunctionDeclaration(name, params, body, returnType, l, false, typeParams);
764
+ return new AST.FunctionDeclaration(name, params, body, returnType, l, false, typeParams, decorators);
758
765
  }
759
766
 
760
- parseAsyncFunctionDeclaration() {
767
+ parseAsyncFunctionDeclaration(decorators = []) {
761
768
  const l = this.loc();
762
769
  this.expect(TokenType.ASYNC);
763
770
  this.expect(TokenType.FN);
@@ -789,7 +796,7 @@ export class Parser {
789
796
  }
790
797
 
791
798
  const body = this.parseBlock();
792
- return new AST.FunctionDeclaration(name, params, body, returnType, l, true, typeParams);
799
+ return new AST.FunctionDeclaration(name, params, body, returnType, l, true, typeParams, decorators);
793
800
  }
794
801
 
795
802
  parseBreakStatement() {
@@ -1495,10 +1502,10 @@ export class Parser {
1495
1502
  // ─── Expressions (precedence climbing) ────────────────────
1496
1503
 
1497
1504
  parseExpression() {
1498
- if (++this._expressionDepth > Parser.MAX_EXPRESSION_DEPTH) {
1499
- this._expressionDepth--;
1505
+ if (this._expressionDepth >= Parser.MAX_EXPRESSION_DEPTH) {
1500
1506
  this.error('Expression nested too deeply (max ' + Parser.MAX_EXPRESSION_DEPTH + ' levels)');
1501
1507
  }
1508
+ this._expressionDepth++;
1502
1509
  try {
1503
1510
  return this.parsePipe();
1504
1511
  } finally {
@@ -1578,9 +1585,7 @@ export class Parser {
1578
1585
  let left = this.parseMembership();
1579
1586
 
1580
1587
  // Check for chained comparisons: a < b < c
1581
- const compOps = [TokenType.LESS, TokenType.LESS_EQUAL, TokenType.GREATER, TokenType.GREATER_EQUAL, TokenType.EQUAL, TokenType.NOT_EQUAL];
1582
-
1583
- if (compOps.some(op => this.check(op))) {
1588
+ if (Parser.COMPARISON_OPS.has(this.current().type)) {
1584
1589
  // Don't parse < as comparison if it looks like JSX
1585
1590
  if (this.check(TokenType.LESS) && this._looksLikeJSX()) {
1586
1591
  return left;
@@ -1589,9 +1594,8 @@ export class Parser {
1589
1594
  const operands = [left];
1590
1595
  const operators = [];
1591
1596
 
1592
- while (true) {
1593
- const op = this.match(...compOps);
1594
- if (!op) break;
1597
+ while (Parser.COMPARISON_OPS.has(this.current().type)) {
1598
+ const op = this.advance();
1595
1599
  operators.push(op.value);
1596
1600
  operands.push(this.parseMembership());
1597
1601
  }
@@ -1885,117 +1889,100 @@ export class Parser {
1885
1889
 
1886
1890
  parsePrimary() {
1887
1891
  const l = this.loc();
1892
+ const tokenType = this.current().type;
1888
1893
 
1889
- // Number
1890
- if (this.check(TokenType.NUMBER)) {
1891
- return new AST.NumberLiteral(this.advance().value, l);
1892
- }
1894
+ switch (tokenType) {
1895
+ case TokenType.NUMBER:
1896
+ return new AST.NumberLiteral(this.advance().value, l);
1893
1897
 
1894
- // String
1895
- if (this.check(TokenType.STRING) || this.check(TokenType.STRING_TEMPLATE)) {
1896
- return this.parseStringLiteral();
1897
- }
1898
-
1899
- // Regex literal
1900
- if (this.check(TokenType.REGEX)) {
1901
- const token = this.advance();
1902
- return new AST.RegexLiteral(token.value.pattern, token.value.flags, l);
1903
- }
1898
+ case TokenType.STRING:
1899
+ case TokenType.STRING_TEMPLATE:
1900
+ return this.parseStringLiteral();
1904
1901
 
1905
- // Boolean
1906
- if (this.check(TokenType.TRUE)) {
1907
- this.advance();
1908
- return new AST.BooleanLiteral(true, l);
1909
- }
1910
- if (this.check(TokenType.FALSE)) {
1911
- this.advance();
1912
- return new AST.BooleanLiteral(false, l);
1913
- }
1902
+ case TokenType.REGEX: {
1903
+ const token = this.advance();
1904
+ return new AST.RegexLiteral(token.value.pattern, token.value.flags, l);
1905
+ }
1914
1906
 
1915
- // Nil
1916
- if (this.check(TokenType.NIL)) {
1917
- this.advance();
1918
- return new AST.NilLiteral(l);
1919
- }
1907
+ case TokenType.TRUE:
1908
+ this.advance();
1909
+ return new AST.BooleanLiteral(true, l);
1920
1910
 
1921
- // Match expression
1922
- if (this.check(TokenType.MATCH)) {
1923
- return this.parseMatchExpression();
1924
- }
1911
+ case TokenType.FALSE:
1912
+ this.advance();
1913
+ return new AST.BooleanLiteral(false, l);
1925
1914
 
1926
- // If expression (in expression position): if cond { a } else { b }
1927
- if (this.check(TokenType.IF)) {
1928
- return this.parseIfExpression();
1929
- }
1915
+ case TokenType.NIL:
1916
+ this.advance();
1917
+ return new AST.NilLiteral(l);
1930
1918
 
1931
- // Async lambda: async fn(params) body
1932
- if (this.check(TokenType.ASYNC) && this.peek(1).type === TokenType.FN) {
1933
- return this.parseAsyncLambda();
1934
- }
1919
+ case TokenType.MATCH:
1920
+ return this.parseMatchExpression();
1935
1921
 
1936
- // Lambda: fn(params) body or params => body
1937
- if (this.check(TokenType.FN) && this.peek(1).type === TokenType.LPAREN) {
1938
- return this.parseLambda();
1939
- }
1922
+ case TokenType.IF:
1923
+ return this.parseIfExpression();
1940
1924
 
1941
- // Arrow lambda: x => expr or (x, y) => expr
1942
- // We'll handle this in the identifier/paren case
1925
+ case TokenType.ASYNC:
1926
+ if (this.peek(1).type === TokenType.FN) {
1927
+ return this.parseAsyncLambda();
1928
+ }
1929
+ break;
1943
1930
 
1944
- // Array literal or list comprehension
1945
- if (this.check(TokenType.LBRACKET)) {
1946
- return this.parseArrayOrComprehension();
1947
- }
1931
+ case TokenType.FN:
1932
+ if (this.peek(1).type === TokenType.LPAREN) {
1933
+ return this.parseLambda();
1934
+ }
1935
+ break;
1936
+
1937
+ case TokenType.LBRACKET:
1938
+ return this.parseArrayOrComprehension();
1939
+
1940
+ case TokenType.LBRACE:
1941
+ return this.parseObjectOrDictComprehension();
1942
+
1943
+ case TokenType.DOT:
1944
+ // Column expression: .column (for table operations)
1945
+ if (this.peek(1).type === TokenType.IDENTIFIER) {
1946
+ this.advance(); // consume .
1947
+ const name = this.advance().value; // consume identifier
1948
+ // Check for column assignment: .col = expr (used in derive)
1949
+ if (this.check(TokenType.ASSIGN)) {
1950
+ this.advance(); // consume =
1951
+ const expr = this.parseExpression();
1952
+ return new AST.ColumnAssignment(name, expr, l);
1953
+ }
1954
+ return new AST.ColumnExpression(name, l);
1955
+ }
1956
+ break;
1948
1957
 
1949
- // Object literal or dict comprehension
1950
- if (this.check(TokenType.LBRACE)) {
1951
- return this.parseObjectOrDictComprehension();
1952
- }
1958
+ case TokenType.LPAREN:
1959
+ return this.parseParenOrArrowLambda();
1953
1960
 
1954
- // Column expression: .column (for table operations)
1955
- // Appears at expression-start when DOT followed by IDENTIFIER
1956
- // but NOT when preceded by an expression (which would be member access)
1957
- if (this.check(TokenType.DOT) && this.peek(1).type === TokenType.IDENTIFIER) {
1958
- // Check this isn't inside a method pipe (|> .method) — that's handled in parsePipe
1959
- // Column expressions appear in function arguments and assignments
1960
- this.advance(); // consume .
1961
- const name = this.advance().value; // consume identifier
1961
+ case TokenType.SERVER:
1962
+ case TokenType.CLIENT:
1963
+ case TokenType.SHARED:
1964
+ case TokenType.DERIVE:
1965
+ return new AST.Identifier(this.advance().value, l);
1962
1966
 
1963
- // Check for column assignment: .col = expr (used in derive)
1964
- if (this.check(TokenType.ASSIGN)) {
1965
- this.advance(); // consume =
1966
- const expr = this.parseExpression();
1967
- return new AST.ColumnAssignment(name, expr, l);
1967
+ case TokenType.IDENTIFIER: {
1968
+ const name = this.advance().value;
1969
+ // Check for arrow lambda: x => expr or x -> expr
1970
+ if (this.check(TokenType.ARROW) || this.check(TokenType.THIN_ARROW)) {
1971
+ this.advance();
1972
+ const body = this.parseExpression();
1973
+ return new AST.LambdaExpression(
1974
+ [new AST.Parameter(name, null, null, l)],
1975
+ body,
1976
+ l
1977
+ );
1978
+ }
1979
+ return new AST.Identifier(name, l);
1968
1980
  }
1969
-
1970
- return new AST.ColumnExpression(name, l);
1971
- }
1972
-
1973
- // Parenthesized expression or arrow lambda
1974
- if (this.check(TokenType.LPAREN)) {
1975
- return this.parseParenOrArrowLambda();
1976
- }
1977
-
1978
- // Keywords that can appear as identifiers in expression position
1979
- if (this.check(TokenType.SERVER) || this.check(TokenType.CLIENT) || this.check(TokenType.SHARED) || this.check(TokenType.DERIVE) ||
1980
- this._isContextualKeyword()) {
1981
- const name = this.advance().value;
1982
- return new AST.Identifier(name, l);
1983
1981
  }
1984
1982
 
1985
- // Identifier (or arrow lambda: x => expr)
1986
- if (this.check(TokenType.IDENTIFIER)) {
1987
- const name = this.advance().value;
1988
- // Check for arrow lambda: x => expr or x -> expr
1989
- if (this.check(TokenType.ARROW) || this.check(TokenType.THIN_ARROW)) {
1990
- this.advance();
1991
- const body = this.parseExpression();
1992
- return new AST.LambdaExpression(
1993
- [new AST.Parameter(name, null, null, l)],
1994
- body,
1995
- l
1996
- );
1997
- }
1998
- return new AST.Identifier(name, l);
1983
+ // Contextual keywords that can appear as identifiers in expression position
1984
+ if (this._isContextualKeyword()) {
1985
+ return new AST.Identifier(this.advance().value, l);
1999
1986
  }
2000
1987
 
2001
1988
  this.error(`Unexpected token: ${this.current().type}`);
@@ -2486,3 +2473,10 @@ export class Parser {
2486
2473
  return node;
2487
2474
  }
2488
2475
  }
2476
+
2477
+ // Initialize static Set after class definition (depends on TokenType)
2478
+ Parser.COMPARISON_OPS = new Set([
2479
+ TokenType.LESS, TokenType.LESS_EQUAL,
2480
+ TokenType.GREATER, TokenType.GREATER_EQUAL,
2481
+ TokenType.EQUAL, TokenType.NOT_EQUAL
2482
+ ]);