tova 0.3.4 → 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
  }