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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/tova.js +1530 -0
- package/package.json +38 -0
- package/src/analyzer/analyzer.js +2053 -0
- package/src/analyzer/scope.js +60 -0
- package/src/codegen/base-codegen.js +1351 -0
- package/src/codegen/client-codegen.js +876 -0
- package/src/codegen/codegen.js +148 -0
- package/src/codegen/server-codegen.js +2506 -0
- package/src/codegen/shared-codegen.js +29 -0
- package/src/diagnostics/formatter.js +139 -0
- package/src/formatter/formatter.js +559 -0
- package/src/index.js +6 -0
- package/src/lexer/lexer.js +886 -0
- package/src/lexer/tokens.js +214 -0
- package/src/lsp/server.js +738 -0
- package/src/parser/ast.js +1135 -0
- package/src/parser/parser.js +2803 -0
- package/src/runtime/db.js +106 -0
- package/src/runtime/embedded.js +8 -0
- package/src/runtime/reactivity.js +1366 -0
- package/src/runtime/router.js +200 -0
- package/src/runtime/rpc.js +46 -0
- package/src/runtime/ssr.js +134 -0
- package/src/runtime/string-proto.js +27 -0
- package/src/stdlib/collections.js +90 -0
- package/src/stdlib/core.js +98 -0
- package/src/stdlib/inline.js +172 -0
- package/src/stdlib/string.js +100 -0
|
@@ -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();
|