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.
@@ -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
+ }
@@ -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
- const analyzer = new Analyzer(ast, filename);
214
- const { warnings } = analyzer.analyze();
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)) { // only evict closed documents
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
- diagnostics.push({
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: 2, // Warning
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: 1000 },
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: 2, // Warning
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
- this._diagnosticsCache.set(uri, { ast: partialAST, analyzer, text });
282
- // Add analyzer warnings as diagnostics
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, // Warning
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, // Error
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 prefix = line.slice(0, position.character).split(/[^a-zA-Z0-9_]/).pop() || '';
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._findSymbolInScopes(cached.analyzer, word);
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._findSymbolInScopes(cached.analyzer, word);
646
+ const symbol = this._findSymbolAtPosition(cached.analyzer, word, position) ||
647
+ this._findSymbolInScopes(cached.analyzer, word);
461
648
  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(', ')}`;
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: doc },
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