tova 0.1.1 → 0.2.2
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 +1 -1
- package/README.md +2 -0
- package/bin/tova.js +811 -154
- package/package.json +8 -2
- package/src/analyzer/analyzer.js +297 -58
- package/src/analyzer/scope.js +38 -1
- package/src/analyzer/type-registry.js +72 -0
- package/src/analyzer/types.js +478 -0
- package/src/codegen/base-codegen.js +371 -0
- package/src/codegen/client-codegen.js +62 -10
- package/src/codegen/codegen.js +111 -2
- package/src/codegen/server-codegen.js +175 -3
- package/src/config/edit-toml.js +100 -0
- package/src/config/package-json.js +52 -0
- package/src/config/resolve.js +100 -0
- package/src/config/toml.js +209 -0
- package/src/lexer/lexer.js +2 -2
- package/src/lsp/server.js +284 -30
- package/src/parser/ast.js +105 -0
- package/src/parser/parser.js +202 -2
- package/src/runtime/ai.js +305 -0
- package/src/runtime/devtools.js +228 -0
- package/src/runtime/embedded.js +3 -1
- package/src/runtime/io.js +240 -0
- package/src/runtime/reactivity.js +264 -19
- package/src/runtime/ssr.js +196 -24
- package/src/runtime/table.js +522 -0
- package/src/stdlib/collections.js +245 -0
- package/src/stdlib/core.js +87 -0
- package/src/stdlib/datetime.js +88 -0
- package/src/stdlib/encoding.js +35 -0
- package/src/stdlib/functional.js +82 -0
- package/src/stdlib/inline.js +334 -67
- package/src/stdlib/math.js +93 -0
- package/src/stdlib/string.js +95 -0
- package/src/stdlib/url.js +33 -0
- package/src/stdlib/validation.js +29 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Minimal TOML parser for tova.toml project manifests.
|
|
2
|
+
// Handles: sections ([name], [a.b]), strings, numbers, booleans, simple arrays.
|
|
3
|
+
|
|
4
|
+
export function parseTOML(input) {
|
|
5
|
+
const result = {};
|
|
6
|
+
let current = result;
|
|
7
|
+
const lines = input.split('\n');
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < lines.length; i++) {
|
|
10
|
+
const raw = lines[i];
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
|
|
13
|
+
// Skip empty lines and comments
|
|
14
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
15
|
+
|
|
16
|
+
// Section header: [section] or [section.subsection]
|
|
17
|
+
if (line.startsWith('[') && !line.startsWith('[[')) {
|
|
18
|
+
const close = line.indexOf(']');
|
|
19
|
+
if (close === -1) {
|
|
20
|
+
throw new Error(`TOML parse error on line ${i + 1}: unclosed section header`);
|
|
21
|
+
}
|
|
22
|
+
const sectionPath = line.slice(1, close).trim();
|
|
23
|
+
if (!sectionPath) {
|
|
24
|
+
throw new Error(`TOML parse error on line ${i + 1}: empty section name`);
|
|
25
|
+
}
|
|
26
|
+
current = result;
|
|
27
|
+
for (const part of sectionPath.split('.')) {
|
|
28
|
+
const key = part.trim();
|
|
29
|
+
if (!current[key]) current[key] = {};
|
|
30
|
+
current = current[key];
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Key = value
|
|
36
|
+
const eqIdx = line.indexOf('=');
|
|
37
|
+
if (eqIdx === -1) continue; // skip lines without =
|
|
38
|
+
|
|
39
|
+
const key = line.slice(0, eqIdx).trim();
|
|
40
|
+
const rawValue = line.slice(eqIdx + 1).trim();
|
|
41
|
+
|
|
42
|
+
current[key] = parseValue(rawValue, i + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseValue(raw, lineNum) {
|
|
49
|
+
if (raw === '') {
|
|
50
|
+
throw new Error(`TOML parse error on line ${lineNum}: missing value`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Strip inline comment (not inside quotes)
|
|
54
|
+
const stripped = stripInlineComment(raw);
|
|
55
|
+
|
|
56
|
+
// Boolean
|
|
57
|
+
if (stripped === 'true') return true;
|
|
58
|
+
if (stripped === 'false') return false;
|
|
59
|
+
|
|
60
|
+
// Quoted string (double or single)
|
|
61
|
+
if ((stripped.startsWith('"') && stripped.endsWith('"')) ||
|
|
62
|
+
(stripped.startsWith("'") && stripped.endsWith("'"))) {
|
|
63
|
+
return parseString(stripped);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Array
|
|
67
|
+
if (stripped.startsWith('[')) {
|
|
68
|
+
return parseArray(stripped, lineNum);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Number (integer or float)
|
|
72
|
+
if (/^-?\d+(\.\d+)?$/.test(stripped)) {
|
|
73
|
+
return stripped.includes('.') ? parseFloat(stripped) : parseInt(stripped, 10);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Bare value (treat as string for compat with version ranges like ^2.0.0)
|
|
77
|
+
return stripped;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stripInlineComment(raw) {
|
|
81
|
+
// Find # that's not inside a quoted string
|
|
82
|
+
let inStr = null;
|
|
83
|
+
for (let i = 0; i < raw.length; i++) {
|
|
84
|
+
const ch = raw[i];
|
|
85
|
+
if (inStr) {
|
|
86
|
+
if (ch === '\\') { i++; continue; }
|
|
87
|
+
if (ch === inStr) inStr = null;
|
|
88
|
+
} else {
|
|
89
|
+
if (ch === '"' || ch === "'") { inStr = ch; continue; }
|
|
90
|
+
if (ch === '#') return raw.slice(0, i).trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return raw;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseString(raw) {
|
|
97
|
+
const quote = raw[0];
|
|
98
|
+
const inner = raw.slice(1, -1);
|
|
99
|
+
if (quote === '"') {
|
|
100
|
+
// Handle escape sequences
|
|
101
|
+
return inner
|
|
102
|
+
.replace(/\\n/g, '\n')
|
|
103
|
+
.replace(/\\t/g, '\t')
|
|
104
|
+
.replace(/\\r/g, '\r')
|
|
105
|
+
.replace(/\\\\/g, '\\')
|
|
106
|
+
.replace(/\\"/g, '"');
|
|
107
|
+
}
|
|
108
|
+
// Single-quoted: literal string, no escapes
|
|
109
|
+
return inner;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseArray(raw, lineNum) {
|
|
113
|
+
// Simple single-line array: [val1, val2, ...]
|
|
114
|
+
if (!raw.endsWith(']')) {
|
|
115
|
+
throw new Error(`TOML parse error on line ${lineNum}: unclosed array`);
|
|
116
|
+
}
|
|
117
|
+
const inner = raw.slice(1, -1).trim();
|
|
118
|
+
if (inner === '') return [];
|
|
119
|
+
|
|
120
|
+
const items = [];
|
|
121
|
+
let current = '';
|
|
122
|
+
let depth = 0;
|
|
123
|
+
let inStr = null;
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < inner.length; i++) {
|
|
126
|
+
const ch = inner[i];
|
|
127
|
+
if (inStr) {
|
|
128
|
+
current += ch;
|
|
129
|
+
if (ch === '\\') { current += inner[++i] || ''; continue; }
|
|
130
|
+
if (ch === inStr) inStr = null;
|
|
131
|
+
} else {
|
|
132
|
+
if (ch === '"' || ch === "'") { inStr = ch; current += ch; continue; }
|
|
133
|
+
if (ch === '[') { depth++; current += ch; continue; }
|
|
134
|
+
if (ch === ']') { depth--; current += ch; continue; }
|
|
135
|
+
if (ch === ',' && depth === 0) {
|
|
136
|
+
const val = current.trim();
|
|
137
|
+
if (val !== '') items.push(parseValue(val, lineNum));
|
|
138
|
+
current = '';
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
current += ch;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const last = current.trim();
|
|
145
|
+
if (last !== '') items.push(parseValue(last, lineNum));
|
|
146
|
+
|
|
147
|
+
return items;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function stringifyTOML(obj, _prefix = '') {
|
|
151
|
+
const lines = [];
|
|
152
|
+
const sections = [];
|
|
153
|
+
|
|
154
|
+
// Write top-level key-value pairs first
|
|
155
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
156
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
157
|
+
sections.push([key, value]);
|
|
158
|
+
} else {
|
|
159
|
+
lines.push(`${key} = ${formatValue(value)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Write sections
|
|
164
|
+
for (const [key, value] of sections) {
|
|
165
|
+
const sectionKey = _prefix ? `${_prefix}.${key}` : key;
|
|
166
|
+
const { topLevel, nested } = splitObject(value);
|
|
167
|
+
|
|
168
|
+
if (lines.length > 0 || sections.indexOf([key, value]) > 0) {
|
|
169
|
+
lines.push('');
|
|
170
|
+
}
|
|
171
|
+
lines.push(`[${sectionKey}]`);
|
|
172
|
+
|
|
173
|
+
for (const [k, v] of Object.entries(topLevel)) {
|
|
174
|
+
lines.push(`${k} = ${formatValue(v)}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [k, v] of Object.entries(nested)) {
|
|
178
|
+
const nestedKey = `${sectionKey}.${k}`;
|
|
179
|
+
lines.push('');
|
|
180
|
+
lines.push(`[${nestedKey}]`);
|
|
181
|
+
for (const [nk, nv] of Object.entries(v)) {
|
|
182
|
+
lines.push(`${nk} = ${formatValue(nv)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return lines.join('\n') + '\n';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function splitObject(obj) {
|
|
191
|
+
const topLevel = {};
|
|
192
|
+
const nested = {};
|
|
193
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
194
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
195
|
+
nested[key] = value;
|
|
196
|
+
} else {
|
|
197
|
+
topLevel[key] = value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { topLevel, nested };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatValue(value) {
|
|
204
|
+
if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
205
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
206
|
+
if (typeof value === 'number') return String(value);
|
|
207
|
+
if (Array.isArray(value)) return `[${value.map(formatValue).join(', ')}]`;
|
|
208
|
+
return String(value);
|
|
209
|
+
}
|
package/src/lexer/lexer.js
CHANGED
|
@@ -380,8 +380,8 @@ export class Lexer {
|
|
|
380
380
|
if (ch !== '_') value += ch;
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
-
// Decimal point
|
|
384
|
-
if (this.peek() === '.' && this.peek(1) !== '.') {
|
|
383
|
+
// Decimal point — only consume if followed by a digit or underscore (not e.g. 15.minutes)
|
|
384
|
+
if (this.peek() === '.' && this.peek(1) !== '.' && (this.isDigit(this.peek(1)) || this.peek(1) === '_')) {
|
|
385
385
|
value += this.advance(); // .
|
|
386
386
|
while (this.pos < this.length && (this.isDigit(this.peek()) || this.peek() === '_')) {
|
|
387
387
|
const ch = this.advance();
|
package/src/lsp/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Parser } from '../parser/parser.js';
|
|
|
7
7
|
import { Analyzer } from '../analyzer/analyzer.js';
|
|
8
8
|
import { TokenType } from '../lexer/tokens.js';
|
|
9
9
|
import { Formatter } from '../formatter/formatter.js';
|
|
10
|
+
import { TypeRegistry } from '../analyzer/type-registry.js';
|
|
10
11
|
|
|
11
12
|
class TovaLanguageServer {
|
|
12
13
|
static MAX_CACHE_SIZE = 100; // max cached diagnostics entries
|
|
@@ -14,7 +15,7 @@ class TovaLanguageServer {
|
|
|
14
15
|
constructor() {
|
|
15
16
|
this._buffer = '';
|
|
16
17
|
this._documents = new Map(); // uri -> { text, version }
|
|
17
|
-
this._diagnosticsCache = new Map(); // uri -> { ast, analyzer, errors }
|
|
18
|
+
this._diagnosticsCache = new Map(); // uri -> { ast, analyzer, errors, typeRegistry }
|
|
18
19
|
this._initialized = false;
|
|
19
20
|
this._shutdownReceived = false;
|
|
20
21
|
this._capabilities = {};
|
|
@@ -144,7 +145,7 @@ class TovaLanguageServer {
|
|
|
144
145
|
save: { includeText: true },
|
|
145
146
|
},
|
|
146
147
|
completionProvider: {
|
|
147
|
-
triggerCharacters: ['.', '"', "'", '/', '<'],
|
|
148
|
+
triggerCharacters: ['.', '"', "'", '/', '<', ':'],
|
|
148
149
|
resolveProvider: false,
|
|
149
150
|
},
|
|
150
151
|
definitionProvider: true,
|
|
@@ -210,35 +211,43 @@ class TovaLanguageServer {
|
|
|
210
211
|
const parser = new Parser(tokens, filename);
|
|
211
212
|
const ast = parser.parse();
|
|
212
213
|
|
|
213
|
-
|
|
214
|
-
const {
|
|
214
|
+
// LSP always runs with strict: true for better diagnostics
|
|
215
|
+
const analyzer = new Analyzer(ast, filename, { strict: true });
|
|
216
|
+
const result = analyzer.analyze();
|
|
217
|
+
const { warnings } = result;
|
|
218
|
+
const typeRegistry = TypeRegistry.fromAnalyzer(analyzer);
|
|
215
219
|
|
|
216
220
|
// Cache for go-to-definition (with LRU eviction)
|
|
217
|
-
this._diagnosticsCache.set(uri, { ast, analyzer, text });
|
|
221
|
+
this._diagnosticsCache.set(uri, { ast, analyzer, text, typeRegistry });
|
|
218
222
|
if (this._diagnosticsCache.size > TovaLanguageServer.MAX_CACHE_SIZE) {
|
|
219
|
-
// Evict oldest entries (first inserted in Map iteration order)
|
|
220
223
|
const toEvict = this._diagnosticsCache.size - TovaLanguageServer.MAX_CACHE_SIZE;
|
|
221
224
|
let evicted = 0;
|
|
222
225
|
for (const key of this._diagnosticsCache.keys()) {
|
|
223
226
|
if (evicted >= toEvict) break;
|
|
224
|
-
if (!this._documents.has(key)) {
|
|
227
|
+
if (!this._documents.has(key)) {
|
|
225
228
|
this._diagnosticsCache.delete(key);
|
|
226
229
|
evicted++;
|
|
227
230
|
}
|
|
228
231
|
}
|
|
229
232
|
}
|
|
230
233
|
|
|
231
|
-
// Convert warnings to diagnostics
|
|
234
|
+
// Convert warnings to diagnostics with refined severity
|
|
232
235
|
for (const w of warnings) {
|
|
233
|
-
|
|
236
|
+
const severity = this._getDiagnosticSeverity(w);
|
|
237
|
+
const diag = {
|
|
234
238
|
range: {
|
|
235
239
|
start: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 },
|
|
236
240
|
end: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 + (w.length || 10) },
|
|
237
241
|
},
|
|
238
|
-
severity
|
|
242
|
+
severity,
|
|
239
243
|
source: 'tova',
|
|
240
244
|
message: w.message,
|
|
241
|
-
}
|
|
245
|
+
};
|
|
246
|
+
// Add unnecessary tag for unused variables
|
|
247
|
+
if (w.message.includes('declared but never used')) {
|
|
248
|
+
diag.tags = [1]; // Unnecessary
|
|
249
|
+
}
|
|
250
|
+
diagnostics.push(diag);
|
|
242
251
|
}
|
|
243
252
|
} catch (err) {
|
|
244
253
|
// Multi-error support from parser recovery
|
|
@@ -248,7 +257,7 @@ class TovaLanguageServer {
|
|
|
248
257
|
diagnostics.push({
|
|
249
258
|
range: {
|
|
250
259
|
start: { line: (loc?.line || 1) - 1, character: (loc?.column || 1) - 1 },
|
|
251
|
-
end: { line: (loc?.line || 1) - 1, character:
|
|
260
|
+
end: { line: (loc?.line || 1) - 1, character: (loc?.column || 1) - 1 + (loc?.length || 20) },
|
|
252
261
|
},
|
|
253
262
|
severity: 1, // Error
|
|
254
263
|
source: 'tova',
|
|
@@ -259,12 +268,13 @@ class TovaLanguageServer {
|
|
|
259
268
|
// Convert analyzer warnings to diagnostics (from analyzer errors that carry warnings)
|
|
260
269
|
if (err.warnings) {
|
|
261
270
|
for (const w of err.warnings) {
|
|
271
|
+
const severity = this._getDiagnosticSeverity(w);
|
|
262
272
|
diagnostics.push({
|
|
263
273
|
range: {
|
|
264
274
|
start: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 },
|
|
265
275
|
end: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 + (w.length || 10) },
|
|
266
276
|
},
|
|
267
|
-
severity
|
|
277
|
+
severity,
|
|
268
278
|
source: 'tova',
|
|
269
279
|
message: w.message,
|
|
270
280
|
});
|
|
@@ -274,24 +284,22 @@ class TovaLanguageServer {
|
|
|
274
284
|
// Use partial AST for go-to-definition even when there are errors
|
|
275
285
|
const partialAST = err.partialAST;
|
|
276
286
|
if (partialAST) {
|
|
277
|
-
// Try running analyzer in tolerant mode on partial AST for completions/hover
|
|
278
287
|
try {
|
|
279
|
-
const analyzer = new Analyzer(partialAST, filename, { tolerant: true });
|
|
288
|
+
const analyzer = new Analyzer(partialAST, filename, { tolerant: true, strict: true });
|
|
280
289
|
const result = analyzer.analyze();
|
|
281
|
-
|
|
282
|
-
|
|
290
|
+
const typeRegistry = TypeRegistry.fromAnalyzer(analyzer);
|
|
291
|
+
this._diagnosticsCache.set(uri, { ast: partialAST, analyzer, text, typeRegistry });
|
|
283
292
|
for (const w of result.warnings) {
|
|
284
293
|
diagnostics.push({
|
|
285
294
|
range: {
|
|
286
295
|
start: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 },
|
|
287
296
|
end: { line: (w.line || 1) - 1, character: (w.column || 1) - 1 + (w.length || 10) },
|
|
288
297
|
},
|
|
289
|
-
severity: 2,
|
|
298
|
+
severity: 2,
|
|
290
299
|
source: 'tova',
|
|
291
300
|
message: w.message,
|
|
292
301
|
});
|
|
293
302
|
}
|
|
294
|
-
// Add analyzer errors (type errors) as diagnostics
|
|
295
303
|
if (result.errors) {
|
|
296
304
|
for (const e of result.errors) {
|
|
297
305
|
diagnostics.push({
|
|
@@ -299,14 +307,13 @@ class TovaLanguageServer {
|
|
|
299
307
|
start: { line: (e.line || 1) - 1, character: (e.column || 1) - 1 },
|
|
300
308
|
end: { line: (e.line || 1) - 1, character: (e.column || 1) + 10 },
|
|
301
309
|
},
|
|
302
|
-
severity: 1,
|
|
310
|
+
severity: 1,
|
|
303
311
|
source: 'tova',
|
|
304
312
|
message: e.message,
|
|
305
313
|
});
|
|
306
314
|
}
|
|
307
315
|
}
|
|
308
316
|
} catch (_) {
|
|
309
|
-
// Analyzer failed on partial AST — just cache the AST without analyzer
|
|
310
317
|
this._diagnosticsCache.set(uri, { ast: partialAST, text });
|
|
311
318
|
}
|
|
312
319
|
}
|
|
@@ -315,6 +322,20 @@ class TovaLanguageServer {
|
|
|
315
322
|
this._notify('textDocument/publishDiagnostics', { uri, diagnostics });
|
|
316
323
|
}
|
|
317
324
|
|
|
325
|
+
_getDiagnosticSeverity(diagnostic) {
|
|
326
|
+
const msg = diagnostic.message || '';
|
|
327
|
+
// Unused variables → Hint (4)
|
|
328
|
+
if (msg.includes('declared but never used')) return 4;
|
|
329
|
+
// Variable shadowing → Information (3)
|
|
330
|
+
if (msg.includes('shadows a binding')) return 3;
|
|
331
|
+
// Non-exhaustive match → Warning (2)
|
|
332
|
+
if (msg.includes('Non-exhaustive match')) return 2;
|
|
333
|
+
// Type mismatches in strict mode → Error (1)
|
|
334
|
+
if (msg.includes('Type mismatch')) return 1;
|
|
335
|
+
// Default → Warning (2)
|
|
336
|
+
return 2;
|
|
337
|
+
}
|
|
338
|
+
|
|
318
339
|
_extractErrorLocation(message, filename) {
|
|
319
340
|
// Try "file:line:column — message" format
|
|
320
341
|
const match = message.match(/^(.+?):(\d+):(\d+)\s*[—-]\s*(.+)/);
|
|
@@ -341,7 +362,55 @@ class TovaLanguageServer {
|
|
|
341
362
|
|
|
342
363
|
const items = [];
|
|
343
364
|
const line = doc.text.split('\n')[position.line] || '';
|
|
344
|
-
const
|
|
365
|
+
const before = line.slice(0, position.character);
|
|
366
|
+
|
|
367
|
+
// CASE 1: Dot completion — "expr."
|
|
368
|
+
const dotMatch = before.match(/(\w+)\.\s*(\w*)$/);
|
|
369
|
+
if (dotMatch) {
|
|
370
|
+
const objectName = dotMatch[1];
|
|
371
|
+
const partial = dotMatch[2] || '';
|
|
372
|
+
const dotItems = this._getDotCompletions(textDocument.uri, objectName, partial);
|
|
373
|
+
if (dotItems.length > 0) {
|
|
374
|
+
return this._respond(msg.id, dotItems.slice(0, 50));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// CASE 2: Type annotation — after ":"
|
|
379
|
+
const typeMatch = before.match(/:\s*(\w*)$/);
|
|
380
|
+
if (typeMatch) {
|
|
381
|
+
const partial = typeMatch[1] || '';
|
|
382
|
+
const typeNames = ['Int', 'Float', 'String', 'Bool', 'Nil', 'Any',
|
|
383
|
+
'Result', 'Option', 'Function'];
|
|
384
|
+
// Add user-defined types
|
|
385
|
+
const cached = this._diagnosticsCache.get(textDocument.uri);
|
|
386
|
+
if (cached?.analyzer) {
|
|
387
|
+
const symbols = this._collectSymbols(cached.analyzer);
|
|
388
|
+
for (const sym of symbols) {
|
|
389
|
+
if (sym.kind === 'type' && !typeNames.includes(sym.name)) {
|
|
390
|
+
typeNames.push(sym.name);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const name of typeNames) {
|
|
395
|
+
if (name.toLowerCase().startsWith(partial.toLowerCase())) {
|
|
396
|
+
items.push({
|
|
397
|
+
label: name,
|
|
398
|
+
kind: 22, // Struct
|
|
399
|
+
detail: 'type',
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return this._respond(msg.id, items.slice(0, 50));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// CASE 3: Match arm — detect if we're inside a match block
|
|
407
|
+
const matchItems = this._getMatchCompletions(textDocument.uri, doc.text, position);
|
|
408
|
+
if (matchItems.length > 0) {
|
|
409
|
+
return this._respond(msg.id, matchItems.slice(0, 50));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// CASE 4: Default — keywords + builtins + symbols
|
|
413
|
+
const prefix = before.split(/[^a-zA-Z0-9_]/).pop() || '';
|
|
345
414
|
|
|
346
415
|
// Keywords
|
|
347
416
|
const keywords = [
|
|
@@ -387,6 +456,122 @@ class TovaLanguageServer {
|
|
|
387
456
|
this._respond(msg.id, items.slice(0, 50)); // Limit results
|
|
388
457
|
}
|
|
389
458
|
|
|
459
|
+
_getDotCompletions(uri, objectName, partial) {
|
|
460
|
+
const items = [];
|
|
461
|
+
const cached = this._diagnosticsCache.get(uri);
|
|
462
|
+
if (!cached?.analyzer) return items;
|
|
463
|
+
|
|
464
|
+
// Look up the object's type
|
|
465
|
+
const sym = this._findSymbolInScopes(cached.analyzer, objectName);
|
|
466
|
+
if (!sym) return items;
|
|
467
|
+
|
|
468
|
+
// Determine the type name
|
|
469
|
+
let typeName = null;
|
|
470
|
+
if (sym.inferredType) {
|
|
471
|
+
typeName = sym.inferredType;
|
|
472
|
+
} else if (sym._variantOf) {
|
|
473
|
+
typeName = sym._variantOf;
|
|
474
|
+
} else if (sym.kind === 'type' && sym._typeStructure) {
|
|
475
|
+
typeName = sym.name;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!typeName) return items;
|
|
479
|
+
|
|
480
|
+
// Get members from type registry
|
|
481
|
+
const typeRegistry = cached.typeRegistry;
|
|
482
|
+
if (typeRegistry) {
|
|
483
|
+
const members = typeRegistry.getMembers(typeName);
|
|
484
|
+
|
|
485
|
+
// Add fields
|
|
486
|
+
for (const [fieldName, fieldType] of members.fields) {
|
|
487
|
+
if (!partial || fieldName.startsWith(partial)) {
|
|
488
|
+
items.push({
|
|
489
|
+
label: fieldName,
|
|
490
|
+
kind: 5, // Field
|
|
491
|
+
detail: fieldType ? fieldType.toString() : 'field',
|
|
492
|
+
sortText: `0${fieldName}`, // Fields first
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Add impl methods
|
|
498
|
+
for (const method of members.methods) {
|
|
499
|
+
if (!partial || method.name.startsWith(partial)) {
|
|
500
|
+
const paramStr = (method.params || []).filter(p => p !== 'self').join(', ');
|
|
501
|
+
const retStr = method.returnType ? ` -> ${method.returnType}` : '';
|
|
502
|
+
items.push({
|
|
503
|
+
label: method.name,
|
|
504
|
+
kind: 2, // Method
|
|
505
|
+
detail: `fn(${paramStr})${retStr}`,
|
|
506
|
+
sortText: `1${method.name}`, // Methods after fields
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return items;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
_getMatchCompletions(uri, text, position) {
|
|
516
|
+
const items = [];
|
|
517
|
+
const lines = text.split('\n');
|
|
518
|
+
|
|
519
|
+
// Walk backwards from cursor to find if we're inside a match block
|
|
520
|
+
let matchSubject = null;
|
|
521
|
+
let braceDepth = 0;
|
|
522
|
+
for (let i = position.line; i >= 0; i--) {
|
|
523
|
+
const lineText = lines[i] || '';
|
|
524
|
+
for (let j = (i === position.line ? position.character : lineText.length) - 1; j >= 0; j--) {
|
|
525
|
+
if (lineText[j] === '}') braceDepth++;
|
|
526
|
+
if (lineText[j] === '{') {
|
|
527
|
+
braceDepth--;
|
|
528
|
+
if (braceDepth < 0) {
|
|
529
|
+
// Found the opening brace — check if preceding text is a match expression
|
|
530
|
+
const beforeBrace = lineText.slice(0, j).trim();
|
|
531
|
+
const matchExpr = beforeBrace.match(/match\s+(\w+)\s*$/);
|
|
532
|
+
if (matchExpr) {
|
|
533
|
+
matchSubject = matchExpr[1];
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (matchSubject || braceDepth < 0) break;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!matchSubject) return items;
|
|
543
|
+
|
|
544
|
+
const cached = this._diagnosticsCache.get(uri);
|
|
545
|
+
if (!cached?.analyzer) return items;
|
|
546
|
+
|
|
547
|
+
// Look up subject type
|
|
548
|
+
const sym = this._findSymbolInScopes(cached.analyzer, matchSubject);
|
|
549
|
+
let typeName = sym?.inferredType || sym?._variantOf;
|
|
550
|
+
|
|
551
|
+
if (typeName && cached.typeRegistry) {
|
|
552
|
+
const variants = cached.typeRegistry.getVariantNames(typeName);
|
|
553
|
+
for (const variant of variants) {
|
|
554
|
+
items.push({
|
|
555
|
+
label: variant,
|
|
556
|
+
kind: 20, // EnumMember
|
|
557
|
+
detail: `variant of ${typeName}`,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Also suggest built-in variants if subject type is Result or Option
|
|
563
|
+
if (typeName === 'Result' || (typeName && typeName.startsWith('Result<'))) {
|
|
564
|
+
if (!items.some(i => i.label === 'Ok')) items.push({ label: 'Ok', kind: 20, detail: 'Result variant' });
|
|
565
|
+
if (!items.some(i => i.label === 'Err')) items.push({ label: 'Err', kind: 20, detail: 'Result variant' });
|
|
566
|
+
}
|
|
567
|
+
if (typeName === 'Option' || (typeName && typeName.startsWith('Option<'))) {
|
|
568
|
+
if (!items.some(i => i.label === 'Some')) items.push({ label: 'Some', kind: 20, detail: 'Option variant' });
|
|
569
|
+
if (!items.some(i => i.label === 'None')) items.push({ label: 'None', kind: 20, detail: 'Option variant' });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return items;
|
|
573
|
+
}
|
|
574
|
+
|
|
390
575
|
// ─── Go to Definition ────────────────────────────────────
|
|
391
576
|
|
|
392
577
|
_onDefinition(msg) {
|
|
@@ -402,8 +587,9 @@ class TovaLanguageServer {
|
|
|
402
587
|
const word = this._getWordAt(line, position.character);
|
|
403
588
|
if (!word) return this._respond(msg.id, null);
|
|
404
589
|
|
|
405
|
-
// Look up in symbol table
|
|
406
|
-
const symbol = this.
|
|
590
|
+
// Look up in symbol table — try scope-aware lookup first
|
|
591
|
+
const symbol = this._findSymbolAtPosition(cached.analyzer, word, position) ||
|
|
592
|
+
this._findSymbolInScopes(cached.analyzer, word);
|
|
407
593
|
if (symbol?.loc) {
|
|
408
594
|
this._respond(msg.id, {
|
|
409
595
|
uri: textDocument.uri,
|
|
@@ -457,14 +643,61 @@ class TovaLanguageServer {
|
|
|
457
643
|
}
|
|
458
644
|
|
|
459
645
|
// Check user-defined symbols
|
|
460
|
-
const symbol = this.
|
|
646
|
+
const symbol = this._findSymbolAtPosition(cached.analyzer, word, position) ||
|
|
647
|
+
this._findSymbolInScopes(cached.analyzer, word);
|
|
461
648
|
if (symbol) {
|
|
462
|
-
let
|
|
463
|
-
if (symbol.kind)
|
|
464
|
-
|
|
465
|
-
|
|
649
|
+
let hoverText = `**${word}**`;
|
|
650
|
+
if (symbol.kind) hoverText += ` *(${symbol.kind})*`;
|
|
651
|
+
|
|
652
|
+
// Show inferred type for variables
|
|
653
|
+
if (symbol.inferredType) {
|
|
654
|
+
hoverText += `\n\nType: \`${symbol.inferredType}\``;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Show declared type annotation
|
|
658
|
+
if (symbol.typeAnnotation) {
|
|
659
|
+
hoverText += `\n\nType: \`${symbol.typeAnnotation}\``;
|
|
660
|
+
} else if (symbol.type && typeof symbol.type === 'object' && symbol.type.type === 'TypeAnnotation') {
|
|
661
|
+
hoverText += `\n\nReturn type: \`${symbol.type.name}\``;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Show full function signature
|
|
665
|
+
if (symbol._params) {
|
|
666
|
+
const params = symbol._params.map((p, i) => {
|
|
667
|
+
const paramType = symbol._paramTypes && symbol._paramTypes[i];
|
|
668
|
+
if (paramType) {
|
|
669
|
+
const typeStr = typeof paramType === 'string' ? paramType :
|
|
670
|
+
(paramType.name || paramType.type || '');
|
|
671
|
+
return typeStr ? `${p}: ${typeStr}` : p;
|
|
672
|
+
}
|
|
673
|
+
return p;
|
|
674
|
+
});
|
|
675
|
+
const retType = symbol.type ? ` -> ${symbol.type.name || symbol.type}` : '';
|
|
676
|
+
hoverText += `\n\nSignature: \`fn ${word}(${params.join(', ')})${retType}\``;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Show type structure for type symbols
|
|
680
|
+
if (symbol.kind === 'type' && symbol._typeStructure) {
|
|
681
|
+
const structure = symbol._typeStructure;
|
|
682
|
+
if (structure.variants && structure.variants.size > 0) {
|
|
683
|
+
const variantStrs = [];
|
|
684
|
+
for (const [vName, fields] of structure.variants) {
|
|
685
|
+
if (fields.size > 0) {
|
|
686
|
+
const fieldStrs = [];
|
|
687
|
+
for (const [fName, fType] of fields) {
|
|
688
|
+
fieldStrs.push(`${fName}: ${fType}`);
|
|
689
|
+
}
|
|
690
|
+
variantStrs.push(` ${vName}(${fieldStrs.join(', ')})`);
|
|
691
|
+
} else {
|
|
692
|
+
variantStrs.push(` ${vName}`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
hoverText += `\n\n\`\`\`\ntype ${word} {\n${variantStrs.join('\n')}\n}\n\`\`\``;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
466
699
|
return this._respond(msg.id, {
|
|
467
|
-
contents: { kind: 'markdown', value:
|
|
700
|
+
contents: { kind: 'markdown', value: hoverText },
|
|
468
701
|
});
|
|
469
702
|
}
|
|
470
703
|
|
|
@@ -697,6 +930,11 @@ class TovaLanguageServer {
|
|
|
697
930
|
loc: sym.loc,
|
|
698
931
|
typeAnnotation: sym.typeAnnotation,
|
|
699
932
|
params: sym._params,
|
|
933
|
+
inferredType: sym.inferredType,
|
|
934
|
+
_paramTypes: sym._paramTypes,
|
|
935
|
+
_typeStructure: sym._typeStructure,
|
|
936
|
+
_variantOf: sym._variantOf,
|
|
937
|
+
type: sym.type,
|
|
700
938
|
});
|
|
701
939
|
}
|
|
702
940
|
}
|
|
@@ -731,6 +969,22 @@ class TovaLanguageServer {
|
|
|
731
969
|
if (analyzer.currentScope) return walkScope(analyzer.currentScope);
|
|
732
970
|
return null;
|
|
733
971
|
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Scope-aware symbol lookup using positional information.
|
|
975
|
+
* Finds the narrowest scope containing the cursor position, then walks up.
|
|
976
|
+
*/
|
|
977
|
+
_findSymbolAtPosition(analyzer, name, position) {
|
|
978
|
+
if (!analyzer.globalScope) return null;
|
|
979
|
+
const line = position.line + 1; // LSP is 0-based, our scopes are 1-based
|
|
980
|
+
const column = position.character + 1;
|
|
981
|
+
|
|
982
|
+
const scope = analyzer.globalScope.findScopeAtPosition(line, column);
|
|
983
|
+
if (scope) {
|
|
984
|
+
return scope.lookup(name);
|
|
985
|
+
}
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
734
988
|
}
|
|
735
989
|
|
|
736
990
|
// Start the server
|