tova 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.
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env bun
2
+ // Tova Language Server Protocol implementation
3
+ // Communicates via JSON-RPC over stdio
4
+
5
+ import { Lexer } from '../lexer/lexer.js';
6
+ import { Parser } from '../parser/parser.js';
7
+ import { Analyzer } from '../analyzer/analyzer.js';
8
+ import { TokenType } from '../lexer/tokens.js';
9
+ import { Formatter } from '../formatter/formatter.js';
10
+
11
+ class TovaLanguageServer {
12
+ static MAX_CACHE_SIZE = 100; // max cached diagnostics entries
13
+
14
+ constructor() {
15
+ this._buffer = '';
16
+ this._documents = new Map(); // uri -> { text, version }
17
+ this._diagnosticsCache = new Map(); // uri -> { ast, analyzer, errors }
18
+ this._initialized = false;
19
+ this._shutdownReceived = false;
20
+ this._capabilities = {};
21
+ }
22
+
23
+ start() {
24
+ process.stdin.setEncoding('utf8');
25
+ process.stdin.on('data', (chunk) => this._onData(chunk));
26
+ process.stdin.on('end', () => process.exit(0));
27
+
28
+ // Crash recovery — prevent the LSP from dying on unexpected errors
29
+ process.on('uncaughtException', (err) => {
30
+ try {
31
+ this._logError(`Uncaught exception (recovered): ${err.message}`);
32
+ } catch {
33
+ process.stderr.write(`[tova-lsp] Uncaught exception: ${err.message}\n`);
34
+ }
35
+ });
36
+ process.on('unhandledRejection', (err) => {
37
+ try {
38
+ this._logError(`Unhandled rejection (recovered): ${err && err.message || err}`);
39
+ } catch {
40
+ process.stderr.write(`[tova-lsp] Unhandled rejection: ${err}\n`);
41
+ }
42
+ });
43
+ }
44
+
45
+ // ─── JSON-RPC Transport ────────────────────────────────────
46
+
47
+ _onData(chunk) {
48
+ this._buffer += chunk;
49
+ while (true) {
50
+ const headerEnd = this._buffer.indexOf('\r\n\r\n');
51
+ if (headerEnd === -1) break;
52
+
53
+ const header = this._buffer.slice(0, headerEnd);
54
+ const match = header.match(/Content-Length:\s*(\d+)/i);
55
+ if (!match) {
56
+ this._buffer = this._buffer.slice(headerEnd + 4);
57
+ continue;
58
+ }
59
+
60
+ const contentLength = parseInt(match[1]);
61
+ const start = headerEnd + 4;
62
+ if (this._buffer.length < start + contentLength) break;
63
+
64
+ const body = this._buffer.slice(start, start + contentLength);
65
+ this._buffer = this._buffer.slice(start + contentLength);
66
+
67
+ try {
68
+ const message = JSON.parse(body);
69
+ this._handleMessage(message);
70
+ } catch (e) {
71
+ this._logError(`Parse error: ${e.message}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ _send(message) {
77
+ const json = JSON.stringify(message);
78
+ const header = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n`;
79
+ process.stdout.write(header + json);
80
+ }
81
+
82
+ _respond(id, result) {
83
+ this._send({ jsonrpc: '2.0', id, result });
84
+ }
85
+
86
+ _respondError(id, code, message) {
87
+ this._send({ jsonrpc: '2.0', id, error: { code, message } });
88
+ }
89
+
90
+ _notify(method, params) {
91
+ this._send({ jsonrpc: '2.0', method, params });
92
+ }
93
+
94
+ _logError(msg) {
95
+ this._notify('window/logMessage', { type: 1, message: `[tova-lsp] ${msg}` });
96
+ }
97
+
98
+ _logInfo(msg) {
99
+ this._notify('window/logMessage', { type: 3, message: `[tova-lsp] ${msg}` });
100
+ }
101
+
102
+ // ─── Message Routing ──────────────────────────────────────
103
+
104
+ _handleMessage(msg) {
105
+ const method = msg.method;
106
+
107
+ if (msg.id !== undefined && method) {
108
+ // Request
109
+ switch (method) {
110
+ case 'initialize': return this._onInitialize(msg);
111
+ case 'shutdown': this._shutdownReceived = true; return this._respond(msg.id, null);
112
+ case 'textDocument/completion': return this._onCompletion(msg);
113
+ case 'textDocument/definition': return this._onDefinition(msg);
114
+ case 'textDocument/hover': return this._onHover(msg);
115
+ case 'textDocument/signatureHelp': return this._onSignatureHelp(msg);
116
+ case 'textDocument/formatting': return this._onFormatting(msg);
117
+ case 'textDocument/rename': return this._onRename(msg);
118
+ case 'textDocument/references': return this._onReferences(msg);
119
+ case 'workspace/symbol': return this._onWorkspaceSymbol(msg);
120
+ default: return this._respondError(msg.id, -32601, `Method not found: ${method}`);
121
+ }
122
+ } else if (method) {
123
+ // Notification
124
+ switch (method) {
125
+ case 'initialized': return this._onInitialized();
126
+ case 'exit': return process.exit(this._shutdownReceived ? 0 : 1);
127
+ case 'textDocument/didOpen': return this._onDidOpen(msg.params);
128
+ case 'textDocument/didChange': return this._onDidChange(msg.params);
129
+ case 'textDocument/didClose': return this._onDidClose(msg.params);
130
+ case 'textDocument/didSave': return this._onDidSave(msg.params);
131
+ }
132
+ }
133
+ }
134
+
135
+ // ─── Lifecycle ────────────────────────────────────────────
136
+
137
+ _onInitialize(msg) {
138
+ this._initialized = true;
139
+ this._respond(msg.id, {
140
+ capabilities: {
141
+ textDocumentSync: {
142
+ openClose: true,
143
+ change: 1, // Full content sync
144
+ save: { includeText: true },
145
+ },
146
+ completionProvider: {
147
+ triggerCharacters: ['.', '"', "'", '/', '<'],
148
+ resolveProvider: false,
149
+ },
150
+ definitionProvider: true,
151
+ hoverProvider: true,
152
+ signatureHelpProvider: {
153
+ triggerCharacters: ['(', ','],
154
+ },
155
+ documentFormattingProvider: true,
156
+ renameProvider: { prepareProvider: false },
157
+ referencesProvider: true,
158
+ workspaceSymbolProvider: true,
159
+ },
160
+ });
161
+ }
162
+
163
+ _onInitialized() {
164
+ this._logInfo('Tova Language Server initialized');
165
+ }
166
+
167
+ // ─── Document Management ──────────────────────────────────
168
+
169
+ _onDidOpen(params) {
170
+ const { uri, text, version } = params.textDocument;
171
+ this._documents.set(uri, { text, version });
172
+ this._validateDocument(uri, text);
173
+ }
174
+
175
+ _onDidChange(params) {
176
+ const { uri, version } = params.textDocument;
177
+ const text = params.contentChanges[0]?.text;
178
+ if (text !== undefined) {
179
+ this._documents.set(uri, { text, version });
180
+ this._validateDocument(uri, text);
181
+ }
182
+ }
183
+
184
+ _onDidClose(params) {
185
+ const uri = params.textDocument.uri;
186
+ this._documents.delete(uri);
187
+ this._diagnosticsCache.delete(uri);
188
+ // Clear diagnostics
189
+ this._notify('textDocument/publishDiagnostics', { uri, diagnostics: [] });
190
+ }
191
+
192
+ _onDidSave(params) {
193
+ const uri = params.textDocument.uri;
194
+ const text = params.text || this._documents.get(uri)?.text;
195
+ if (text) {
196
+ this._validateDocument(uri, text);
197
+ }
198
+ }
199
+
200
+ // ─── Diagnostics ──────────────────────────────────────────
201
+
202
+ _validateDocument(uri, text) {
203
+ const diagnostics = [];
204
+ const filename = this._uriToPath(uri);
205
+
206
+ try {
207
+ const lexer = new Lexer(text, filename);
208
+ const tokens = lexer.tokenize();
209
+
210
+ const parser = new Parser(tokens, filename);
211
+ const ast = parser.parse();
212
+
213
+ const analyzer = new Analyzer(ast, filename);
214
+ const { warnings } = analyzer.analyze();
215
+
216
+ // Cache for go-to-definition (with LRU eviction)
217
+ this._diagnosticsCache.set(uri, { ast, analyzer, text });
218
+ if (this._diagnosticsCache.size > TovaLanguageServer.MAX_CACHE_SIZE) {
219
+ // Evict oldest entries (first inserted in Map iteration order)
220
+ const toEvict = this._diagnosticsCache.size - TovaLanguageServer.MAX_CACHE_SIZE;
221
+ let evicted = 0;
222
+ for (const key of this._diagnosticsCache.keys()) {
223
+ if (evicted >= toEvict) break;
224
+ if (!this._documents.has(key)) { // only evict closed documents
225
+ this._diagnosticsCache.delete(key);
226
+ evicted++;
227
+ }
228
+ }
229
+ }
230
+
231
+ // Convert warnings to diagnostics
232
+ for (const w of warnings) {
233
+ diagnostics.push({
234
+ range: {
235
+ start: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 },
236
+ end: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 + (w.length || 10) },
237
+ },
238
+ severity: 2, // Warning
239
+ source: 'tova',
240
+ message: w.message,
241
+ });
242
+ }
243
+ } catch (err) {
244
+ // Multi-error support from parser recovery
245
+ const errors = err.errors || [err];
246
+ for (const e of errors) {
247
+ const loc = e.loc || this._extractErrorLocation(e.message, filename);
248
+ diagnostics.push({
249
+ range: {
250
+ start: { line: (loc?.line || 1) - 1, character: (loc?.column || 1) - 1 },
251
+ end: { line: (loc?.line || 1) - 1, character: 1000 },
252
+ },
253
+ severity: 1, // Error
254
+ source: 'tova',
255
+ message: loc?.message || e.message,
256
+ });
257
+ }
258
+
259
+ // Convert analyzer warnings to diagnostics (from analyzer errors that carry warnings)
260
+ if (err.warnings) {
261
+ for (const w of err.warnings) {
262
+ diagnostics.push({
263
+ range: {
264
+ start: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 },
265
+ end: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 + (w.length || 10) },
266
+ },
267
+ severity: 2, // Warning
268
+ source: 'tova',
269
+ message: w.message,
270
+ });
271
+ }
272
+ }
273
+
274
+ // Use partial AST for go-to-definition even when there are errors
275
+ const partialAST = err.partialAST;
276
+ if (partialAST) {
277
+ // Try running analyzer in tolerant mode on partial AST for completions/hover
278
+ try {
279
+ const analyzer = new Analyzer(partialAST, filename, { tolerant: true });
280
+ const result = analyzer.analyze();
281
+ this._diagnosticsCache.set(uri, { ast: partialAST, analyzer, text });
282
+ // Add analyzer warnings as diagnostics
283
+ for (const w of result.warnings) {
284
+ diagnostics.push({
285
+ range: {
286
+ start: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 },
287
+ end: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 + (w.length || 10) },
288
+ },
289
+ severity: 2, // Warning
290
+ source: 'tova',
291
+ message: w.message,
292
+ });
293
+ }
294
+ // Add analyzer errors (type errors) as diagnostics
295
+ if (result.errors) {
296
+ for (const e of result.errors) {
297
+ diagnostics.push({
298
+ range: {
299
+ start: { line: (e.line || 1) - 1, character: (e.column || 1) - 1 },
300
+ end: { line: (e.line || 1) - 1, character: (e.column || 1) + 10 },
301
+ },
302
+ severity: 1, // Error
303
+ source: 'tova',
304
+ message: e.message,
305
+ });
306
+ }
307
+ }
308
+ } catch (_) {
309
+ // Analyzer failed on partial AST — just cache the AST without analyzer
310
+ this._diagnosticsCache.set(uri, { ast: partialAST, text });
311
+ }
312
+ }
313
+ }
314
+
315
+ this._notify('textDocument/publishDiagnostics', { uri, diagnostics });
316
+ }
317
+
318
+ _extractErrorLocation(message, filename) {
319
+ // Try "file:line:column — message" format
320
+ const match = message.match(/^(.+?):(\d+):(\d+)\s*[—-]\s*(.+)/);
321
+ if (match) {
322
+ return { file: match[1], line: parseInt(match[2]), column: parseInt(match[3]), message: match[4] };
323
+ }
324
+ // Try "Analysis errors:" format
325
+ if (message.startsWith('Analysis errors:')) {
326
+ const lines = message.split('\n');
327
+ for (const line of lines) {
328
+ const m = line.trim().match(/^(.+?):(\d+):(\d+)\s*[—-]\s*(.+)/);
329
+ if (m) return { file: m[1], line: parseInt(m[2]), column: parseInt(m[3]), message: m[4] };
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
335
+ // ─── Completion ───────────────────────────────────────────
336
+
337
+ _onCompletion(msg) {
338
+ const { position, textDocument } = msg.params;
339
+ const doc = this._documents.get(textDocument.uri);
340
+ if (!doc) return this._respond(msg.id, []);
341
+
342
+ const items = [];
343
+ const line = doc.text.split('\n')[position.line] || '';
344
+ const prefix = line.slice(0, position.character).split(/[^a-zA-Z0-9_]/).pop() || '';
345
+
346
+ // Keywords
347
+ const keywords = [
348
+ 'fn', 'let', 'if', 'elif', 'else', 'for', 'while', 'in',
349
+ 'return', 'match', 'type', 'import', 'from', 'true', 'false',
350
+ 'nil', 'server', 'client', 'shared', 'pub', 'mut',
351
+ 'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
352
+ 'guard', 'interface', 'derive', 'route', 'model', 'db',
353
+ ];
354
+ for (const kw of keywords) {
355
+ if (kw.startsWith(prefix)) {
356
+ items.push({ label: kw, kind: 14 /* Keyword */ });
357
+ }
358
+ }
359
+
360
+ // Built-in functions
361
+ const builtins = [
362
+ 'print', 'len', 'range', 'enumerate', 'sum', 'sorted',
363
+ 'reversed', 'zip', 'min', 'max', 'type_of', 'filter', 'map',
364
+ 'Ok', 'Err', 'Some', 'None',
365
+ ];
366
+ for (const fn of builtins) {
367
+ if (fn.startsWith(prefix)) {
368
+ items.push({ label: fn, kind: 3 /* Function */, detail: 'Tova built-in' });
369
+ }
370
+ }
371
+
372
+ // Identifiers from current document's scope
373
+ const cached = this._diagnosticsCache.get(textDocument.uri);
374
+ if (cached?.analyzer) {
375
+ const symbols = this._collectSymbols(cached.analyzer);
376
+ for (const sym of symbols) {
377
+ if (sym.name.startsWith(prefix) && !builtins.includes(sym.name)) {
378
+ items.push({
379
+ label: sym.name,
380
+ kind: sym.kind === 'function' ? 3 : sym.kind === 'type' ? 22 : 6,
381
+ detail: sym.kind,
382
+ });
383
+ }
384
+ }
385
+ }
386
+
387
+ this._respond(msg.id, items.slice(0, 50)); // Limit results
388
+ }
389
+
390
+ // ─── Go to Definition ────────────────────────────────────
391
+
392
+ _onDefinition(msg) {
393
+ const { position, textDocument } = msg.params;
394
+ const cached = this._diagnosticsCache.get(textDocument.uri);
395
+ if (!cached?.analyzer) return this._respond(msg.id, null);
396
+
397
+ const doc = this._documents.get(textDocument.uri);
398
+ if (!doc) return this._respond(msg.id, null);
399
+
400
+ // Get word at cursor position
401
+ const line = doc.text.split('\n')[position.line] || '';
402
+ const word = this._getWordAt(line, position.character);
403
+ if (!word) return this._respond(msg.id, null);
404
+
405
+ // Look up in symbol table
406
+ const symbol = this._findSymbolInScopes(cached.analyzer, word);
407
+ if (symbol?.loc) {
408
+ this._respond(msg.id, {
409
+ uri: textDocument.uri,
410
+ range: {
411
+ start: { line: (symbol.loc.line || 1) - 1, character: (symbol.loc.column || 1) - 1 },
412
+ end: { line: (symbol.loc.line || 1) - 1, character: (symbol.loc.column || 1) - 1 + word.length },
413
+ },
414
+ });
415
+ } else {
416
+ this._respond(msg.id, null);
417
+ }
418
+ }
419
+
420
+ // ─── Hover ────────────────────────────────────────────────
421
+
422
+ _onHover(msg) {
423
+ const { position, textDocument } = msg.params;
424
+ const cached = this._diagnosticsCache.get(textDocument.uri);
425
+ if (!cached?.analyzer) return this._respond(msg.id, null);
426
+
427
+ const doc = this._documents.get(textDocument.uri);
428
+ if (!doc) return this._respond(msg.id, null);
429
+
430
+ const line = doc.text.split('\n')[position.line] || '';
431
+ const word = this._getWordAt(line, position.character);
432
+ if (!word) return this._respond(msg.id, null);
433
+
434
+ // Check builtins
435
+ const builtinDocs = {
436
+ 'print': '`fn print(...args)` — Print values to console',
437
+ 'len': '`fn len(v)` — Get length of string, array, or object',
438
+ 'range': '`fn range(start, end, step?)` — Generate array of numbers',
439
+ 'enumerate': '`fn enumerate(arr)` — Returns [[index, value], ...]',
440
+ 'sum': '`fn sum(arr)` — Sum all elements in array',
441
+ 'sorted': '`fn sorted(arr, key?)` — Return sorted copy of array',
442
+ 'reversed': '`fn reversed(arr)` — Return reversed copy of array',
443
+ 'zip': '`fn zip(...arrays)` — Zip arrays together',
444
+ 'min': '`fn min(arr)` — Minimum value in array',
445
+ 'max': '`fn max(arr)` — Maximum value in array',
446
+ 'type_of': '`fn type_of(v)` — Get Tova type name as string',
447
+ 'Ok': '`Ok(value)` — Create a successful Result',
448
+ 'Err': '`Err(error)` — Create an error Result',
449
+ 'Some': '`Some(value)` — Create an Option with a value',
450
+ 'None': '`None` — Empty Option value',
451
+ };
452
+
453
+ if (builtinDocs[word]) {
454
+ return this._respond(msg.id, {
455
+ contents: { kind: 'markdown', value: builtinDocs[word] },
456
+ });
457
+ }
458
+
459
+ // Check user-defined symbols
460
+ const symbol = this._findSymbolInScopes(cached.analyzer, word);
461
+ if (symbol) {
462
+ let doc = `**${word}**`;
463
+ if (symbol.kind) doc += ` *(${symbol.kind})*`;
464
+ if (symbol.typeAnnotation) doc += `\n\nType: \`${symbol.typeAnnotation}\``;
465
+ if (symbol.params) doc += `\n\nParameters: ${symbol.params.join(', ')}`;
466
+ return this._respond(msg.id, {
467
+ contents: { kind: 'markdown', value: doc },
468
+ });
469
+ }
470
+
471
+ this._respond(msg.id, null);
472
+ }
473
+
474
+ // ─── Signature Help ───────────────────────────────────────
475
+
476
+ _onSignatureHelp(msg) {
477
+ const { position, textDocument } = msg.params;
478
+ const doc = this._documents.get(textDocument.uri);
479
+ if (!doc) return this._respond(msg.id, null);
480
+
481
+ const line = doc.text.split('\n')[position.line] || '';
482
+ const before = line.slice(0, position.character);
483
+
484
+ // Find function name before (
485
+ const match = before.match(/(\w+)\s*\([^)]*$/);
486
+ if (!match) return this._respond(msg.id, null);
487
+
488
+ const funcName = match[1];
489
+
490
+ // Built-in signatures
491
+ const signatures = {
492
+ 'print': { label: 'print(...args)', params: [{ label: '...args' }] },
493
+ 'len': { label: 'len(value)', params: [{ label: 'value' }] },
494
+ 'range': { label: 'range(start, end, step?)', params: [{ label: 'start' }, { label: 'end' }, { label: 'step?' }] },
495
+ 'enumerate': { label: 'enumerate(array)', params: [{ label: 'array' }] },
496
+ 'sum': { label: 'sum(array)', params: [{ label: 'array' }] },
497
+ 'sorted': { label: 'sorted(array, key?)', params: [{ label: 'array' }, { label: 'key?' }] },
498
+ 'reversed': { label: 'reversed(array)', params: [{ label: 'array' }] },
499
+ 'zip': { label: 'zip(...arrays)', params: [{ label: '...arrays' }] },
500
+ 'Ok': { label: 'Ok(value)', params: [{ label: 'value' }] },
501
+ 'Err': { label: 'Err(error)', params: [{ label: 'error' }] },
502
+ 'Some': { label: 'Some(value)', params: [{ label: 'value' }] },
503
+ };
504
+
505
+ const sig = signatures[funcName];
506
+ if (sig) {
507
+ // Count commas to determine active parameter
508
+ const afterParen = before.slice(before.lastIndexOf('(') + 1);
509
+ const activeParam = (afterParen.match(/,/g) || []).length;
510
+
511
+ return this._respond(msg.id, {
512
+ signatures: [{
513
+ label: sig.label,
514
+ parameters: sig.params.map(p => ({ label: p.label })),
515
+ }],
516
+ activeSignature: 0,
517
+ activeParameter: Math.min(activeParam, sig.params.length - 1),
518
+ });
519
+ }
520
+
521
+ // Check user-defined functions
522
+ const cached = this._diagnosticsCache.get(textDocument.uri);
523
+ if (cached?.analyzer) {
524
+ const symbol = this._findSymbolInScopes(cached.analyzer, funcName);
525
+ if (symbol?.params) {
526
+ const afterParen = before.slice(before.lastIndexOf('(') + 1);
527
+ const activeParam = (afterParen.match(/,/g) || []).length;
528
+
529
+ return this._respond(msg.id, {
530
+ signatures: [{
531
+ label: `${funcName}(${symbol.params.join(', ')})`,
532
+ parameters: symbol.params.map(p => ({ label: p })),
533
+ }],
534
+ activeSignature: 0,
535
+ activeParameter: Math.min(activeParam, symbol.params.length - 1),
536
+ });
537
+ }
538
+ }
539
+
540
+ this._respond(msg.id, null);
541
+ }
542
+
543
+ // ─── Formatting ──────────────────────────────────────────
544
+
545
+ _onFormatting(msg) {
546
+ const { textDocument } = msg.params;
547
+ const doc = this._documents.get(textDocument.uri);
548
+ if (!doc) return this._respond(msg.id, []);
549
+
550
+ try {
551
+ const lexer = new Lexer(doc.text, this._uriToPath(textDocument.uri));
552
+ const tokens = lexer.tokenize();
553
+ const parser = new Parser(tokens, this._uriToPath(textDocument.uri));
554
+ const ast = parser.parse();
555
+ const formatter = new Formatter();
556
+ const formatted = formatter.format(ast);
557
+
558
+ if (formatted === doc.text) return this._respond(msg.id, []);
559
+
560
+ const lines = doc.text.split('\n');
561
+ this._respond(msg.id, [{
562
+ range: {
563
+ start: { line: 0, character: 0 },
564
+ end: { line: lines.length, character: 0 },
565
+ },
566
+ newText: formatted,
567
+ }]);
568
+ } catch (e) {
569
+ this._respond(msg.id, []);
570
+ }
571
+ }
572
+
573
+ // ─── Rename ─────────────────────────────────────────────
574
+
575
+ _onRename(msg) {
576
+ const { position, textDocument, newName } = msg.params;
577
+ const doc = this._documents.get(textDocument.uri);
578
+ if (!doc) return this._respond(msg.id, null);
579
+
580
+ const line = doc.text.split('\n')[position.line] || '';
581
+ const oldName = this._getWordAt(line, position.character);
582
+ if (!oldName) return this._respond(msg.id, null);
583
+
584
+ // Find all occurrences of the identifier in the document
585
+ const edits = [];
586
+ const docLines = doc.text.split('\n');
587
+ const wordRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
588
+
589
+ for (let i = 0; i < docLines.length; i++) {
590
+ let match;
591
+ while ((match = wordRegex.exec(docLines[i])) !== null) {
592
+ edits.push({
593
+ range: {
594
+ start: { line: i, character: match.index },
595
+ end: { line: i, character: match.index + oldName.length },
596
+ },
597
+ newText: newName,
598
+ });
599
+ }
600
+ }
601
+
602
+ this._respond(msg.id, {
603
+ changes: { [textDocument.uri]: edits },
604
+ });
605
+ }
606
+
607
+ // ─── References ─────────────────────────────────────────
608
+
609
+ _onReferences(msg) {
610
+ const { position, textDocument } = msg.params;
611
+ const doc = this._documents.get(textDocument.uri);
612
+ if (!doc) return this._respond(msg.id, []);
613
+
614
+ const line = doc.text.split('\n')[position.line] || '';
615
+ const word = this._getWordAt(line, position.character);
616
+ if (!word) return this._respond(msg.id, []);
617
+
618
+ const locations = [];
619
+ const docLines = doc.text.split('\n');
620
+ const wordRegex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
621
+
622
+ for (let i = 0; i < docLines.length; i++) {
623
+ let match;
624
+ while ((match = wordRegex.exec(docLines[i])) !== null) {
625
+ locations.push({
626
+ uri: textDocument.uri,
627
+ range: {
628
+ start: { line: i, character: match.index },
629
+ end: { line: i, character: match.index + word.length },
630
+ },
631
+ });
632
+ }
633
+ }
634
+
635
+ this._respond(msg.id, locations);
636
+ }
637
+
638
+ // ─── Workspace Symbol ──────────────────────────────────
639
+
640
+ _onWorkspaceSymbol(msg) {
641
+ const query = (msg.params.query || '').toLowerCase();
642
+ const results = [];
643
+
644
+ for (const [uri, cached] of this._diagnosticsCache) {
645
+ if (!cached?.analyzer) continue;
646
+ const symbols = this._collectSymbols(cached.analyzer);
647
+ for (const sym of symbols) {
648
+ if (query && !sym.name.toLowerCase().includes(query)) continue;
649
+ const kindMap = { 'function': 12, 'type': 5, 'variable': 13 };
650
+ results.push({
651
+ name: sym.name,
652
+ kind: kindMap[sym.kind] || 13,
653
+ location: {
654
+ uri,
655
+ range: {
656
+ start: { line: (sym.loc?.line || 1) - 1, character: (sym.loc?.column || 1) - 1 },
657
+ end: { line: (sym.loc?.line || 1) - 1, character: (sym.loc?.column || 1) - 1 + sym.name.length },
658
+ },
659
+ },
660
+ });
661
+ }
662
+ }
663
+
664
+ this._respond(msg.id, results.slice(0, 100));
665
+ }
666
+
667
+ // ─── Utilities ────────────────────────────────────────────
668
+
669
+ _uriToPath(uri) {
670
+ if (uri.startsWith('file://')) {
671
+ return decodeURIComponent(uri.slice(7));
672
+ }
673
+ return uri;
674
+ }
675
+
676
+ _getWordAt(line, character) {
677
+ let start = character;
678
+ let end = character;
679
+ while (start > 0 && /[a-zA-Z0-9_]/.test(line[start - 1])) start--;
680
+ while (end < line.length && /[a-zA-Z0-9_]/.test(line[end])) end++;
681
+ return line.slice(start, end) || null;
682
+ }
683
+
684
+ _collectSymbols(analyzer) {
685
+ const symbols = [];
686
+ const visited = new Set();
687
+
688
+ const walkScope = (scope) => {
689
+ if (!scope || visited.has(scope)) return;
690
+ visited.add(scope);
691
+
692
+ if (scope.symbols) {
693
+ for (const [name, sym] of scope.symbols) {
694
+ symbols.push({
695
+ name,
696
+ kind: sym.kind || 'variable',
697
+ loc: sym.loc,
698
+ typeAnnotation: sym.typeAnnotation,
699
+ params: sym._params,
700
+ });
701
+ }
702
+ }
703
+ if (scope.children) {
704
+ for (const child of scope.children) {
705
+ walkScope(child);
706
+ }
707
+ }
708
+ };
709
+
710
+ if (analyzer.globalScope) walkScope(analyzer.globalScope);
711
+ else if (analyzer.currentScope) walkScope(analyzer.currentScope);
712
+ return symbols;
713
+ }
714
+
715
+ _findSymbolInScopes(analyzer, name) {
716
+ const walkScope = (scope) => {
717
+ if (!scope) return null;
718
+ if (scope.symbols?.has(name)) {
719
+ return scope.symbols.get(name);
720
+ }
721
+ if (scope.children) {
722
+ for (const child of scope.children) {
723
+ const found = walkScope(child);
724
+ if (found) return found;
725
+ }
726
+ }
727
+ return null;
728
+ };
729
+
730
+ if (analyzer.globalScope) return walkScope(analyzer.globalScope);
731
+ if (analyzer.currentScope) return walkScope(analyzer.currentScope);
732
+ return null;
733
+ }
734
+ }
735
+
736
+ // Start the server
737
+ const server = new TovaLanguageServer();
738
+ server.start();