tova 0.2.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lsp/server.js CHANGED
@@ -8,12 +8,13 @@ import { Analyzer } from '../analyzer/analyzer.js';
8
8
  import { TokenType } from '../lexer/tokens.js';
9
9
  import { Formatter } from '../formatter/formatter.js';
10
10
  import { TypeRegistry } from '../analyzer/type-registry.js';
11
+ import { BUILTIN_NAMES, BUILTIN_FUNCTIONS } from '../stdlib/inline.js';
11
12
 
12
13
  class TovaLanguageServer {
13
14
  static MAX_CACHE_SIZE = 100; // max cached diagnostics entries
14
15
 
15
16
  constructor() {
16
- this._buffer = '';
17
+ this._buffer = Buffer.alloc(0);
17
18
  this._documents = new Map(); // uri -> { text, version }
18
19
  this._diagnosticsCache = new Map(); // uri -> { ast, analyzer, errors, typeRegistry }
19
20
  this._initialized = false;
@@ -22,7 +23,7 @@ class TovaLanguageServer {
22
23
  }
23
24
 
24
25
  start() {
25
- process.stdin.setEncoding('utf8');
26
+ // Do NOT set encoding — use raw Buffers for correct byte-based Content-Length (LSP protocol)
26
27
  process.stdin.on('data', (chunk) => this._onData(chunk));
27
28
  process.stdin.on('end', () => process.exit(0));
28
29
 
@@ -46,12 +47,13 @@ class TovaLanguageServer {
46
47
  // ─── JSON-RPC Transport ────────────────────────────────────
47
48
 
48
49
  _onData(chunk) {
49
- this._buffer += chunk;
50
+ this._buffer = Buffer.concat([this._buffer, typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk]);
50
51
  while (true) {
51
- const headerEnd = this._buffer.indexOf('\r\n\r\n');
52
+ const sep = Buffer.from('\r\n\r\n');
53
+ const headerEnd = this._buffer.indexOf(sep);
52
54
  if (headerEnd === -1) break;
53
55
 
54
- const header = this._buffer.slice(0, headerEnd);
56
+ const header = this._buffer.slice(0, headerEnd).toString('utf8');
55
57
  const match = header.match(/Content-Length:\s*(\d+)/i);
56
58
  if (!match) {
57
59
  this._buffer = this._buffer.slice(headerEnd + 4);
@@ -62,7 +64,7 @@ class TovaLanguageServer {
62
64
  const start = headerEnd + 4;
63
65
  if (this._buffer.length < start + contentLength) break;
64
66
 
65
- const body = this._buffer.slice(start, start + contentLength);
67
+ const body = this._buffer.slice(start, start + contentLength).toString('utf8');
66
68
  this._buffer = this._buffer.slice(start + contentLength);
67
69
 
68
70
  try {
@@ -116,7 +118,9 @@ class TovaLanguageServer {
116
118
  case 'textDocument/signatureHelp': return this._onSignatureHelp(msg);
117
119
  case 'textDocument/formatting': return this._onFormatting(msg);
118
120
  case 'textDocument/rename': return this._onRename(msg);
121
+ case 'textDocument/codeAction': return this._onCodeAction(msg);
119
122
  case 'textDocument/references': return this._onReferences(msg);
123
+ case 'textDocument/inlayHint': return this._onInlayHint(msg);
120
124
  case 'workspace/symbol': return this._onWorkspaceSymbol(msg);
121
125
  default: return this._respondError(msg.id, -32601, `Method not found: ${method}`);
122
126
  }
@@ -153,9 +157,13 @@ class TovaLanguageServer {
153
157
  signatureHelpProvider: {
154
158
  triggerCharacters: ['(', ','],
155
159
  },
160
+ codeActionProvider: {
161
+ codeActionKinds: ['quickfix'],
162
+ },
156
163
  documentFormattingProvider: true,
157
164
  renameProvider: { prepareProvider: false },
158
165
  referencesProvider: true,
166
+ inlayHintProvider: true,
159
167
  workspaceSymbolProvider: true,
160
168
  },
161
169
  });
@@ -241,8 +249,9 @@ class TovaLanguageServer {
241
249
  },
242
250
  severity,
243
251
  source: 'tova',
244
- message: w.message,
252
+ message: w.hint ? `${w.message} (hint: ${w.hint})` : w.message,
245
253
  };
254
+ if (w.code) diag.code = w.code;
246
255
  // Add unnecessary tag for unused variables
247
256
  if (w.message.includes('declared but never used')) {
248
257
  diag.tags = [1]; // Unnecessary
@@ -305,7 +314,7 @@ class TovaLanguageServer {
305
314
  diagnostics.push({
306
315
  range: {
307
316
  start: { line: (e.line || 1) - 1, character: (e.column || 1) - 1 },
308
- end: { line: (e.line || 1) - 1, character: (e.column || 1) + 10 },
317
+ end: { line: (e.line || 1) - 1, character: (e.column || 1) - 1 + 10 },
309
318
  },
310
319
  severity: 1,
311
320
  source: 'tova',
@@ -332,6 +341,8 @@ class TovaLanguageServer {
332
341
  if (msg.includes('Non-exhaustive match')) return 2;
333
342
  // Type mismatches in strict mode → Error (1)
334
343
  if (msg.includes('Type mismatch')) return 1;
344
+ // Naming convention → Hint (4)
345
+ if (msg.includes('should use snake_case') || msg.includes('should use PascalCase')) return 4;
335
346
  // Default → Warning (2)
336
347
  return 2;
337
348
  }
@@ -414,7 +425,7 @@ class TovaLanguageServer {
414
425
 
415
426
  // Keywords
416
427
  const keywords = [
417
- 'fn', 'let', 'if', 'elif', 'else', 'for', 'while', 'in',
428
+ 'fn', 'let', 'if', 'elif', 'else', 'for', 'while', 'loop', 'when', 'in',
418
429
  'return', 'match', 'type', 'import', 'from', 'true', 'false',
419
430
  'nil', 'server', 'client', 'shared', 'pub', 'mut',
420
431
  'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
@@ -426,15 +437,17 @@ class TovaLanguageServer {
426
437
  }
427
438
  }
428
439
 
429
- // Built-in functions
430
- const builtins = [
431
- 'print', 'len', 'range', 'enumerate', 'sum', 'sorted',
432
- 'reversed', 'zip', 'min', 'max', 'type_of', 'filter', 'map',
433
- 'Ok', 'Err', 'Some', 'None',
434
- ];
435
- for (const fn of builtins) {
436
- if (fn.startsWith(prefix)) {
437
- items.push({ label: fn, kind: 3 /* Function */, detail: 'Tova built-in' });
440
+ // Built-in functions (dynamically from stdlib) — with parameter info
441
+ for (const fn of BUILTIN_NAMES) {
442
+ if (fn.startsWith(prefix) && !fn.startsWith('__')) {
443
+ const detail = this._getBuiltinDetail(fn);
444
+ items.push({ label: fn, kind: 3 /* Function */, detail });
445
+ }
446
+ }
447
+ // Runtime types
448
+ for (const rt of ['Ok', 'Err', 'Some', 'None']) {
449
+ if (rt.startsWith(prefix)) {
450
+ items.push({ label: rt, kind: 3 /* Function */, detail: 'Tova built-in' });
438
451
  }
439
452
  }
440
453
 
@@ -443,7 +456,7 @@ class TovaLanguageServer {
443
456
  if (cached?.analyzer) {
444
457
  const symbols = this._collectSymbols(cached.analyzer);
445
458
  for (const sym of symbols) {
446
- if (sym.name.startsWith(prefix) && !builtins.includes(sym.name)) {
459
+ if (sym.name.startsWith(prefix) && !BUILTIN_NAMES.has(sym.name)) {
447
460
  items.push({
448
461
  label: sym.name,
449
462
  kind: sym.kind === 'function' ? 3 : sym.kind === 'type' ? 22 : 6,
@@ -617,23 +630,291 @@ class TovaLanguageServer {
617
630
  const word = this._getWordAt(line, position.character);
618
631
  if (!word) return this._respond(msg.id, null);
619
632
 
620
- // Check builtins
633
+ // Check builtins — comprehensive hover docs for all stdlib functions
621
634
  const builtinDocs = {
635
+ // Core
622
636
  'print': '`fn print(...args)` — Print values to console',
623
637
  'len': '`fn len(v)` — Get length of string, array, or object',
624
- 'range': '`fn range(start, end, step?)` — Generate array of numbers',
638
+ 'range': '`fn range(start, end?, step?)` — Generate array of numbers',
625
639
  'enumerate': '`fn enumerate(arr)` — Returns [[index, value], ...]',
626
- 'sum': '`fn sum(arr)` — Sum all elements in array',
627
- 'sorted': '`fn sorted(arr, key?)` — Return sorted copy of array',
628
- 'reversed': '`fn reversed(arr)` — Return reversed copy of array',
629
- 'zip': '`fn zip(...arrays)` — Zip arrays together',
630
- 'min': '`fn min(arr)` — Minimum value in array',
631
- 'max': '`fn max(arr)` — Maximum value in array',
632
- 'type_of': '`fn type_of(v)` — Get Tova type name as string',
633
- 'Ok': '`Ok(value)` — Create a successful Result',
634
- 'Err': '`Err(error)` — Create an error Result',
635
- 'Some': '`Some(value)`Create an Option with a value',
636
- 'None': '`None`Empty Option value',
640
+ 'type_of': '`fn type_of(v) -> String` — Get Tova type name as string',
641
+ // Result/Option
642
+ 'Ok': '`Ok(value) -> Result` — Create a successful Result\n\nMethods: `.map(fn)`, `.flatMap(fn)`, `.andThen(fn)`, `.unwrap()`, `.unwrapOr(default)`, `.isOk()`, `.isErr()`, `.mapErr(fn)`',
643
+ 'Err': '`Err(error) -> Result` — Create an error Result\n\nMethods: `.unwrapOr(default)`, `.isOk()`, `.isErr()`, `.mapErr(fn)`, `.unwrapErr()`',
644
+ 'Some': '`Some(value) -> Option` — Create an Option with a value\n\nMethods: `.map(fn)`, `.flatMap(fn)`, `.andThen(fn)`, `.unwrap()`, `.unwrapOr(default)`, `.isSome()`, `.isNone()`, `.filter(fn)`',
645
+ 'None': '`None` — Empty Option value\n\nMethods: `.unwrapOr(default)`, `.isSome()`, `.isNone()`',
646
+ // Collections
647
+ 'filter': '`fn filter(arr, fn) -> [T]` — Filter array by predicate',
648
+ 'map': '`fn map(arr, fn) -> [U]` — Transform each element',
649
+ 'find': '`fn find(arr, fn) -> T?` Find first matching element (nil if none)',
650
+ 'find_index': '`fn find_index(arr, fn) -> Int?` Find index of first match (nil if none)',
651
+ 'any': '`fn any(arr, fn) -> Bool` — True if any element matches predicate',
652
+ 'all': '`fn all(arr, fn) -> Bool` — True if all elements match predicate',
653
+ 'flat_map': '`fn flat_map(arr, fn) -> [U]` — Map and flatten one level',
654
+ 'reduce': '`fn reduce(arr, fn, init?) -> T` — Reduce array to single value',
655
+ 'sum': '`fn sum(arr) -> Float` — Sum all elements in array',
656
+ 'min': '`fn min(arr) -> T?` — Minimum value in array',
657
+ 'max': '`fn max(arr) -> T?` — Maximum value in array',
658
+ 'sorted': '`fn sorted(arr, key?) -> [T]` — Return sorted copy of array',
659
+ 'reversed': '`fn reversed(arr) -> [T]` — Return reversed copy',
660
+ 'zip': '`fn zip(...arrays) -> [[T]]` — Zip arrays together',
661
+ 'unique': '`fn unique(arr) -> [T]` — Remove duplicates',
662
+ 'group_by': '`fn group_by(arr, fn) -> {String: [T]}` — Group elements by key function',
663
+ 'chunk': '`fn chunk(arr, n) -> [[T]]` — Split array into chunks of size n',
664
+ 'flatten': '`fn flatten(arr) -> [T]` — Flatten one level of nesting',
665
+ 'take': '`fn take(arr, n) -> [T]` — Take first n elements',
666
+ 'drop': '`fn drop(arr, n) -> [T]` — Drop first n elements',
667
+ 'first': '`fn first(arr) -> T?` — First element (nil if empty)',
668
+ 'last': '`fn last(arr) -> T?` — Last element (nil if empty)',
669
+ 'count': '`fn count(arr, fn) -> Int` — Count elements matching predicate',
670
+ 'partition': '`fn partition(arr, fn) -> [[T], [T]]` — Split into [matching, non-matching]',
671
+ 'includes': '`fn includes(arr, value) -> Bool` — Check if array contains value',
672
+ 'compact': '`fn compact(arr) -> [T]` — Remove nil values',
673
+ 'rotate': '`fn rotate(arr, n) -> [T]` — Rotate array by n positions',
674
+ 'insert_at': '`fn insert_at(arr, idx, val) -> [T]` — Insert value at index',
675
+ 'remove_at': '`fn remove_at(arr, idx) -> [T]` — Remove element at index',
676
+ 'update_at': '`fn update_at(arr, idx, val) -> [T]` — Replace element at index',
677
+ // Math
678
+ 'abs': '`fn abs(n) -> Float` — Absolute value',
679
+ 'floor': '`fn floor(n) -> Int` — Round down',
680
+ 'ceil': '`fn ceil(n) -> Int` — Round up',
681
+ 'round': '`fn round(n) -> Int` — Round to nearest integer',
682
+ 'clamp': '`fn clamp(n, lo, hi) -> Float` — Clamp value to range [lo, hi]',
683
+ 'sqrt': '`fn sqrt(n) -> Float` — Square root',
684
+ 'pow': '`fn pow(base, exp) -> Float` — Exponentiation',
685
+ 'random': '`fn random() -> Float` — Random number in [0, 1)',
686
+ 'random_int': '`fn random_int(lo, hi) -> Int` — Random integer in [lo, hi]',
687
+ 'random_float': '`fn random_float(lo, hi) -> Float` — Random float in [lo, hi)',
688
+ 'sign': '`fn sign(n) -> Int` — Sign of number (-1, 0, or 1)',
689
+ 'trunc': '`fn trunc(n) -> Int` — Truncate toward zero',
690
+ 'gcd': '`fn gcd(a, b) -> Int` — Greatest common divisor',
691
+ 'lcm': '`fn lcm(a, b) -> Int` — Least common multiple',
692
+ 'factorial': '`fn factorial(n) -> Int` — Factorial (nil for negative)',
693
+ 'hypot': '`fn hypot(a, b) -> Float` — Hypotenuse length',
694
+ 'lerp': '`fn lerp(a, b, t) -> Float` — Linear interpolation',
695
+ 'divmod': '`fn divmod(a, b) -> [Int, Int]` — Quotient and remainder',
696
+ 'avg': '`fn avg(arr) -> Float` — Average of array values',
697
+ 'is_nan': '`fn is_nan(n) -> Bool` — Check if value is NaN',
698
+ 'is_finite': '`fn is_finite(n) -> Bool` — Check if value is finite',
699
+ 'is_close': '`fn is_close(a, b, tol?) -> Bool` — Check if values are approximately equal',
700
+ 'PI': '`PI: Float` — Mathematical constant pi (3.14159...)',
701
+ 'E': '`E: Float` — Euler\'s number (2.71828...)',
702
+ 'INF': '`INF: Float` — Positive infinity',
703
+ // Trig
704
+ 'sin': '`fn sin(n) -> Float` — Sine (radians)',
705
+ 'cos': '`fn cos(n) -> Float` — Cosine (radians)',
706
+ 'tan': '`fn tan(n) -> Float` — Tangent (radians)',
707
+ 'asin': '`fn asin(n) -> Float` — Arcsine',
708
+ 'acos': '`fn acos(n) -> Float` — Arccosine',
709
+ 'atan': '`fn atan(n) -> Float` — Arctangent',
710
+ 'atan2': '`fn atan2(y, x) -> Float` — Two-argument arctangent',
711
+ 'to_radians': '`fn to_radians(deg) -> Float` — Convert degrees to radians',
712
+ 'to_degrees': '`fn to_degrees(rad) -> Float` — Convert radians to degrees',
713
+ // Logarithmic
714
+ 'log': '`fn log(n) -> Float` — Natural logarithm',
715
+ 'log2': '`fn log2(n) -> Float` — Base-2 logarithm',
716
+ 'log10': '`fn log10(n) -> Float` — Base-10 logarithm',
717
+ 'exp': '`fn exp(n) -> Float` — e raised to the power n',
718
+ // String
719
+ 'trim': '`fn trim(s) -> String` — Remove leading/trailing whitespace',
720
+ 'trim_start': '`fn trim_start(s) -> String` — Remove leading whitespace',
721
+ 'trim_end': '`fn trim_end(s) -> String` — Remove trailing whitespace',
722
+ 'split': '`fn split(s, sep) -> [String]` — Split string by separator',
723
+ 'join': '`fn join(arr, sep) -> String` — Join array elements with separator',
724
+ 'replace': '`fn replace(s, from, to) -> String` — Replace all occurrences',
725
+ 'replace_first': '`fn replace_first(s, from, to) -> String` — Replace first occurrence',
726
+ 'repeat': '`fn repeat(s, n) -> String` — Repeat string n times',
727
+ 'upper': '`fn upper(s) -> String` — Convert to uppercase',
728
+ 'lower': '`fn lower(s) -> String` — Convert to lowercase',
729
+ 'contains': '`fn contains(s, sub) -> Bool` — Check if string contains substring',
730
+ 'starts_with': '`fn starts_with(s, prefix) -> Bool` — Check if string starts with prefix',
731
+ 'ends_with': '`fn ends_with(s, suffix) -> Bool` — Check if string ends with suffix',
732
+ 'chars': '`fn chars(s) -> [String]` — Split string into individual characters',
733
+ 'words': '`fn words(s) -> [String]` — Split by whitespace',
734
+ 'lines': '`fn lines(s) -> [String]` — Split by newlines',
735
+ 'capitalize': '`fn capitalize(s) -> String` — Capitalize first letter',
736
+ 'title_case': '`fn title_case(s) -> String` — Capitalize each word',
737
+ 'snake_case': '`fn snake_case(s) -> String` — Convert to snake_case',
738
+ 'camel_case': '`fn camel_case(s) -> String` — Convert to camelCase',
739
+ 'kebab_case': '`fn kebab_case(s) -> String` — Convert to kebab-case',
740
+ 'pad_start': '`fn pad_start(s, n, fill?) -> String` — Pad start to length n',
741
+ 'pad_end': '`fn pad_end(s, n, fill?) -> String` — Pad end to length n',
742
+ 'char_at': '`fn char_at(s, i) -> String?` — Character at index (nil if out of bounds)',
743
+ 'index_of': '`fn index_of(s, sub) -> Int?` — Index of first occurrence (nil if not found)',
744
+ 'last_index_of': '`fn last_index_of(s, sub) -> Int?` — Index of last occurrence',
745
+ 'count_of': '`fn count_of(s, sub) -> Int` — Count occurrences of substring',
746
+ 'reverse_str': '`fn reverse_str(s) -> String` — Reverse a string',
747
+ 'substr': '`fn substr(s, start, end?) -> String` — Extract substring',
748
+ 'center': '`fn center(s, n, fill?) -> String` — Center string in field of width n',
749
+ 'slugify': '`fn slugify(s) -> String` — Convert to URL-safe slug',
750
+ 'truncate': '`fn truncate(s, n, suffix?) -> String` — Truncate with ellipsis',
751
+ 'escape_html': '`fn escape_html(s) -> String` — Escape HTML entities',
752
+ 'unescape_html': '`fn unescape_html(s) -> String` — Unescape HTML entities',
753
+ 'dedent': '`fn dedent(s) -> String` — Remove common leading whitespace',
754
+ 'indent_str': '`fn indent_str(s, n, ch?) -> String` — Indent each line',
755
+ 'word_wrap': '`fn word_wrap(s, width) -> String` — Wrap text at word boundaries',
756
+ 'fmt': '`fn fmt(template, ...args) -> String` — Format string with `{}` placeholders',
757
+ 'is_empty': '`fn is_empty(v) -> Bool` — Check if string, array, or object is empty',
758
+ // Object
759
+ 'keys': '`fn keys(obj) -> [String]` — Object keys',
760
+ 'values': '`fn values(obj) -> [T]` — Object values',
761
+ 'entries': '`fn entries(obj) -> [[String, T]]` — Object entries as [key, value] pairs',
762
+ 'merge': '`fn merge(...objs) -> Object` — Merge objects (later values win)',
763
+ 'freeze': '`fn freeze(obj) -> Object` — Make object immutable',
764
+ 'clone': '`fn clone(obj) -> Object` — Deep clone',
765
+ 'has_key': '`fn has_key(obj, key) -> Bool` — Check if object has key',
766
+ 'get': '`fn get(obj, path, default?) -> T` — Get nested value by dot path',
767
+ 'pick': '`fn pick(obj, keys) -> Object` — Select subset of keys',
768
+ 'omit': '`fn omit(obj, keys) -> Object` — Remove subset of keys',
769
+ 'map_values': '`fn map_values(obj, fn) -> Object` — Transform all values',
770
+ 'from_entries': '`fn from_entries(pairs) -> Object` — Create object from [key, value] pairs',
771
+ // Type Conversion
772
+ 'to_int': '`fn to_int(v) -> Int?` — Parse value to integer (nil on failure)',
773
+ 'to_float': '`fn to_float(v) -> Float?` — Parse value to float (nil on failure)',
774
+ 'to_string': '`fn to_string(v) -> String` — Convert any value to string',
775
+ 'to_bool': '`fn to_bool(v) -> Bool` — Convert value to boolean',
776
+ // Assertions
777
+ 'assert': '`fn assert(cond, msg?)` — Assert condition is true',
778
+ 'assert_eq': '`fn assert_eq(a, b, msg?)` — Assert values are equal',
779
+ 'assert_ne': '`fn assert_ne(a, b, msg?)` — Assert values are not equal',
780
+ // Async
781
+ 'sleep': '`fn sleep(ms) -> Promise` — Wait for ms milliseconds',
782
+ 'parallel': '`fn parallel(list) -> Promise` — Run promises concurrently (Promise.all)',
783
+ 'timeout': '`fn timeout(promise, ms) -> Promise` — Reject if promise exceeds timeout',
784
+ 'retry': '`fn retry(fn, {times?, delay?, backoff?}) -> Promise` — Retry async function',
785
+ // Functional
786
+ 'compose': '`fn compose(...fns) -> Function` — Right-to-left function composition',
787
+ 'pipe_fn': '`fn pipe_fn(...fns) -> Function` — Left-to-right function composition',
788
+ 'identity': '`fn identity(x) -> T` — Return value unchanged',
789
+ 'memoize': '`fn memoize(fn) -> Function` — Cache function results',
790
+ 'debounce': '`fn debounce(fn, ms) -> Function` — Debounce function calls',
791
+ 'throttle': '`fn throttle(fn, ms) -> Function` — Throttle function calls',
792
+ 'once': '`fn once(fn) -> Function` — Only call function once',
793
+ 'negate': '`fn negate(fn) -> Function` — Return a negated predicate',
794
+ 'partial': '`fn partial(fn, ...args) -> Function` — Partially apply arguments',
795
+ 'curry': '`fn curry(fn, arity?) -> Function` — Curry a function',
796
+ 'flip': '`fn flip(fn) -> Function` — Swap first two arguments',
797
+ // Error Handling
798
+ 'try_fn': '`fn try_fn(fn) -> Result` — Wrap function call in Result',
799
+ 'try_async': '`fn try_async(fn) -> Result` — Wrap async call in Result',
800
+ 'filter_ok': '`fn filter_ok(arr) -> [T]` — Extract Ok values from Result array',
801
+ 'filter_err': '`fn filter_err(arr) -> [E]` — Extract Err values from Result array',
802
+ // Randomness
803
+ 'choice': '`fn choice(arr) -> T?` — Random element from array',
804
+ 'sample': '`fn sample(arr, n) -> [T]` — Random n elements from array',
805
+ 'shuffle': '`fn shuffle(arr) -> [T]` — Randomly reorder array',
806
+ // JSON
807
+ 'json_parse': '`fn json_parse(s) -> Result` — Parse JSON string (returns Result)',
808
+ 'json_stringify': '`fn json_stringify(v) -> String` — Convert to JSON string',
809
+ 'json_pretty': '`fn json_pretty(v) -> String` — Convert to pretty-printed JSON',
810
+ // Encoding
811
+ 'base64_encode': '`fn base64_encode(s) -> String` — Encode string to base64',
812
+ 'base64_decode': '`fn base64_decode(s) -> String` — Decode base64 string',
813
+ 'url_encode': '`fn url_encode(s) -> String` — URL-encode string',
814
+ 'url_decode': '`fn url_decode(s) -> String` — URL-decode string',
815
+ 'hex_encode': '`fn hex_encode(s) -> String` — Encode string to hex',
816
+ 'hex_decode': '`fn hex_decode(s) -> String` — Decode hex string',
817
+ // Number Formatting
818
+ 'format_number': '`fn format_number(n, {separator?, decimals?}) -> String` — Format number with separators',
819
+ 'to_hex': '`fn to_hex(n) -> String` — Convert number to hex string',
820
+ 'to_binary': '`fn to_binary(n) -> String` — Convert number to binary string',
821
+ 'to_octal': '`fn to_octal(n) -> String` — Convert number to octal string',
822
+ 'to_fixed': '`fn to_fixed(n, decimals) -> Float` — Round to fixed decimal places',
823
+ // Itertools
824
+ 'pairwise': '`fn pairwise(arr) -> [[T, T]]` — Adjacent pairs',
825
+ 'combinations': '`fn combinations(arr, r) -> [[T]]` — All r-combinations',
826
+ 'permutations': '`fn permutations(arr, r?) -> [[T]]` — All permutations',
827
+ 'intersperse': '`fn intersperse(arr, sep) -> [T]` — Insert separator between elements',
828
+ 'interleave': '`fn interleave(...arrs) -> [T]` — Interleave multiple arrays',
829
+ 'repeat_value': '`fn repeat_value(val, n) -> [T]` — Create array of n copies',
830
+ 'sliding_window': '`fn sliding_window(arr, n) -> [[T]]` — Sliding window of size n',
831
+ 'zip_with': '`fn zip_with(a, b, fn) -> [U]` — Zip two arrays with combining function',
832
+ 'frequencies': '`fn frequencies(arr) -> {String: Int}` — Count occurrences of each element',
833
+ 'scan': '`fn scan(arr, fn, init) -> [T]` — Running accumulation',
834
+ 'min_by': '`fn min_by(arr, fn) -> T?` — Minimum by key function',
835
+ 'max_by': '`fn max_by(arr, fn) -> T?` — Maximum by key function',
836
+ 'sum_by': '`fn sum_by(arr, fn) -> Float` — Sum by key function',
837
+ 'product': '`fn product(arr) -> Float` — Product of all elements',
838
+ 'binary_search': '`fn binary_search(arr, target, key?) -> Int` — Binary search (-1 if not found)',
839
+ 'is_sorted': '`fn is_sorted(arr, key?) -> Bool` — Check if array is sorted',
840
+ // Set Operations
841
+ 'intersection': '`fn intersection(a, b) -> [T]` — Elements in both arrays',
842
+ 'difference': '`fn difference(a, b) -> [T]` — Elements in a but not b',
843
+ 'symmetric_difference': '`fn symmetric_difference(a, b) -> [T]` — Elements in either but not both',
844
+ 'is_subset': '`fn is_subset(a, b) -> Bool` — Check if a is subset of b',
845
+ 'is_superset': '`fn is_superset(a, b) -> Bool` — Check if a is superset of b',
846
+ // Statistics
847
+ 'mean': '`fn mean(arr) -> Float` — Arithmetic mean',
848
+ 'median': '`fn median(arr) -> Float?` — Median value',
849
+ 'mode': '`fn mode(arr) -> T?` — Most frequent value',
850
+ 'stdev': '`fn stdev(arr) -> Float` — Standard deviation',
851
+ 'variance': '`fn variance(arr) -> Float` — Variance',
852
+ 'percentile': '`fn percentile(arr, p) -> Float?` — p-th percentile',
853
+ // Validation
854
+ 'is_email': '`fn is_email(s) -> Bool` — Check if string is valid email',
855
+ 'is_url': '`fn is_url(s) -> Bool` — Check if string is valid URL',
856
+ 'is_numeric': '`fn is_numeric(s) -> Bool` — Check if string is numeric',
857
+ 'is_alpha': '`fn is_alpha(s) -> Bool` — Check if string is alphabetic',
858
+ 'is_alphanumeric': '`fn is_alphanumeric(s) -> Bool` — Check if string is alphanumeric',
859
+ 'is_uuid': '`fn is_uuid(s) -> Bool` — Check if string is valid UUID',
860
+ 'is_hex': '`fn is_hex(s) -> Bool` — Check if string is valid hex',
861
+ // URL
862
+ 'uuid': '`fn uuid() -> String` — Generate random UUID v4',
863
+ 'parse_url': '`fn parse_url(s) -> Result` — Parse URL into components',
864
+ 'build_url': '`fn build_url(parts) -> String` — Build URL from components',
865
+ 'parse_query': '`fn parse_query(s) -> Object` — Parse query string',
866
+ 'build_query': '`fn build_query(obj) -> String` — Build query string from object',
867
+ // Regex
868
+ 'regex_test': '`fn regex_test(s, pattern, flags?) -> Bool` — Test if string matches regex',
869
+ 'regex_match': '`fn regex_match(s, pattern, flags?) -> Result` — First regex match',
870
+ 'regex_find_all': '`fn regex_find_all(s, pattern, flags?) -> [Match]` — All regex matches',
871
+ 'regex_replace': '`fn regex_replace(s, pattern, replacement, flags?) -> String` — Replace by regex',
872
+ 'regex_split': '`fn regex_split(s, pattern, flags?) -> [String]` — Split by regex',
873
+ 'regex_capture': '`fn regex_capture(s, pattern, flags?) -> Result` — Named capture groups',
874
+ // Date/Time
875
+ 'now': '`fn now() -> Int` — Current timestamp in milliseconds',
876
+ 'now_iso': '`fn now_iso() -> String` — Current time as ISO 8601 string',
877
+ 'date_parse': '`fn date_parse(s) -> Result` — Parse date string',
878
+ 'date_format': '`fn date_format(d, fmt) -> String` — Format date (iso, date, time, datetime, or custom)',
879
+ 'date_add': '`fn date_add(d, amount, unit) -> Date` — Add time to date',
880
+ 'date_diff': '`fn date_diff(d1, d2, unit) -> Int` — Difference between dates',
881
+ 'date_from': '`fn date_from(parts) -> Date` — Create date from {year, month, day, ...}',
882
+ 'date_part': '`fn date_part(d, part) -> Int` — Extract part (year, month, day, ...)',
883
+ 'time_ago': '`fn time_ago(d) -> String` — Human-readable relative time',
884
+ // I/O
885
+ 'read': '`fn read(source, opts?) -> Table` — Read CSV, JSON, JSONL, or URL into Table',
886
+ 'write': '`fn write(data, dest, opts?)` — Write Table/array to CSV, JSON, JSONL',
887
+ // Scripting
888
+ 'env': '`fn env(key?, fallback?) -> String?` — Get environment variable',
889
+ 'args': '`fn args() -> [String]` — Get CLI arguments',
890
+ 'exit': '`fn exit(code?)` — Exit process',
891
+ 'exists': '`fn exists(path) -> Bool` — Check if file/directory exists',
892
+ 'read_text': '`fn read_text(path) -> Result` — Read file as string',
893
+ 'write_text': '`fn write_text(path, content, opts?) -> Result` — Write string to file',
894
+ 'mkdir': '`fn mkdir(dir) -> Result` — Create directory (recursive)',
895
+ 'ls': '`fn ls(dir?, opts?) -> [String]` — List directory contents',
896
+ 'cwd': '`fn cwd() -> String` — Current working directory',
897
+ 'read_stdin': '`fn read_stdin() -> String` — Read all of stdin (for piped input)',
898
+ 'read_lines': '`fn read_lines() -> [String]` — Read stdin as array of lines',
899
+ 'script_path': '`fn script_path() -> String?` — Absolute path of running .tova script',
900
+ 'script_dir': '`fn script_dir() -> String?` — Directory containing running .tova script',
901
+ 'parse_args': '`fn parse_args(argv) -> {flags, positional}` — Parse CLI args into flags and positional args',
902
+ 'color': '`fn color(text, name) -> String` — ANSI color (red/green/yellow/blue/magenta/cyan/white/gray)',
903
+ 'bold': '`fn bold(text) -> String` — Bold ANSI text',
904
+ 'dim': '`fn dim(text) -> String` — Dim ANSI text',
905
+ 'on_signal': '`fn on_signal(name, callback)` — Register signal handler (e.g. "SIGINT")',
906
+ 'file_stat': '`fn file_stat(path) -> Result<{size, mode, mtime, atime, isDir, isFile, isSymlink}>` — File metadata',
907
+ 'file_size': '`fn file_size(path) -> Result<Int>` — File size in bytes',
908
+ 'path_join': '`fn path_join(...parts) -> String` — Join path segments',
909
+ 'path_dirname': '`fn path_dirname(path) -> String` — Directory portion of path',
910
+ 'path_basename': '`fn path_basename(path, ext?) -> String` — File name portion of path',
911
+ 'path_resolve': '`fn path_resolve(path) -> String` — Resolve to absolute path',
912
+ 'path_ext': '`fn path_ext(path) -> String` — File extension (e.g. ".js")',
913
+ 'path_relative': '`fn path_relative(from, to) -> String` — Relative path between two paths',
914
+ 'symlink': '`fn symlink(target, path) -> Result` — Create symbolic link',
915
+ 'readlink': '`fn readlink(path) -> Result<String>` — Read symbolic link target',
916
+ 'is_symlink': '`fn is_symlink(path) -> Bool` — Check if path is a symbolic link',
917
+ 'spawn': '`fn spawn(cmd, args?, opts?) -> Promise<Result<{stdout, stderr, exitCode}>>` — Async shell command',
637
918
  };
638
919
 
639
920
  if (builtinDocs[word]) {
@@ -714,11 +995,32 @@ class TovaLanguageServer {
714
995
  const line = doc.text.split('\n')[position.line] || '';
715
996
  const before = line.slice(0, position.character);
716
997
 
717
- // Find function name before (
718
- const match = before.match(/(\w+)\s*\([^)]*$/);
719
- if (!match) return this._respond(msg.id, null);
998
+ // Walk backwards to find the immediately enclosing function call (handles nesting)
999
+ let depth = 0;
1000
+ let parenPos = -1;
1001
+ for (let i = before.length - 1; i >= 0; i--) {
1002
+ if (before[i] === ')') depth++;
1003
+ else if (before[i] === '(') {
1004
+ if (depth === 0) { parenPos = i; break; }
1005
+ depth--;
1006
+ }
1007
+ }
1008
+ if (parenPos === -1) return this._respond(msg.id, null);
720
1009
 
721
- const funcName = match[1];
1010
+ const funcMatch = before.slice(0, parenPos).match(/(\w+)\s*$/);
1011
+ if (!funcMatch) return this._respond(msg.id, null);
1012
+
1013
+ const funcName = funcMatch[1];
1014
+
1015
+ // Count commas at depth 0 after the enclosing paren (ignores nested call commas)
1016
+ const afterParen = before.slice(parenPos + 1);
1017
+ let activeParam = 0;
1018
+ let parenDepth = 0;
1019
+ for (const ch of afterParen) {
1020
+ if (ch === '(') parenDepth++;
1021
+ else if (ch === ')') parenDepth--;
1022
+ else if (ch === ',' && parenDepth === 0) activeParam++;
1023
+ }
722
1024
 
723
1025
  // Built-in signatures
724
1026
  const signatures = {
@@ -737,10 +1039,6 @@ class TovaLanguageServer {
737
1039
 
738
1040
  const sig = signatures[funcName];
739
1041
  if (sig) {
740
- // Count commas to determine active parameter
741
- const afterParen = before.slice(before.lastIndexOf('(') + 1);
742
- const activeParam = (afterParen.match(/,/g) || []).length;
743
-
744
1042
  return this._respond(msg.id, {
745
1043
  signatures: [{
746
1044
  label: sig.label,
@@ -755,17 +1053,14 @@ class TovaLanguageServer {
755
1053
  const cached = this._diagnosticsCache.get(textDocument.uri);
756
1054
  if (cached?.analyzer) {
757
1055
  const symbol = this._findSymbolInScopes(cached.analyzer, funcName);
758
- if (symbol?.params) {
759
- const afterParen = before.slice(before.lastIndexOf('(') + 1);
760
- const activeParam = (afterParen.match(/,/g) || []).length;
761
-
1056
+ if (symbol?._params) {
762
1057
  return this._respond(msg.id, {
763
1058
  signatures: [{
764
- label: `${funcName}(${symbol.params.join(', ')})`,
765
- parameters: symbol.params.map(p => ({ label: p })),
1059
+ label: `${funcName}(${symbol._params.join(', ')})`,
1060
+ parameters: symbol._params.map(p => ({ label: p })),
766
1061
  }],
767
1062
  activeSignature: 0,
768
- activeParameter: Math.min(activeParam, symbol.params.length - 1),
1063
+ activeParameter: Math.max(0, Math.min(activeParam, symbol._params.length - 1)),
769
1064
  });
770
1065
  }
771
1066
  }
@@ -803,7 +1098,352 @@ class TovaLanguageServer {
803
1098
  }
804
1099
  }
805
1100
 
806
- // ─── Rename ─────────────────────────────────────────────
1101
+ // ─── Code Actions ──────────────────────────────────────
1102
+
1103
+ _onCodeAction(msg) {
1104
+ const { textDocument, range, context } = msg.params;
1105
+ const doc = this._documents.get(textDocument.uri);
1106
+ if (!doc) return this._respond(msg.id, []);
1107
+
1108
+ const actions = [];
1109
+ const diagnostics = context.diagnostics || [];
1110
+
1111
+ for (const diag of diagnostics) {
1112
+ const message = diag.message || '';
1113
+
1114
+ // Unused variable: offer "prefix with _"
1115
+ if (message.includes('declared but never used')) {
1116
+ const match = message.match(/'([^']+)'/);
1117
+ if (match) {
1118
+ const varName = match[1];
1119
+ const line = doc.text.split('\n')[diag.range.start.line] || '';
1120
+ const wordRegex = new RegExp(`\\b${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
1121
+ const wordMatch = wordRegex.exec(line);
1122
+ if (wordMatch) {
1123
+ actions.push({
1124
+ title: `Prefix '${varName}' with _`,
1125
+ kind: 'quickfix',
1126
+ diagnostics: [diag],
1127
+ edit: {
1128
+ changes: {
1129
+ [textDocument.uri]: [{
1130
+ range: {
1131
+ start: { line: diag.range.start.line, character: wordMatch.index },
1132
+ end: { line: diag.range.start.line, character: wordMatch.index + varName.length },
1133
+ },
1134
+ newText: `_${varName}`,
1135
+ }],
1136
+ },
1137
+ },
1138
+ });
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ // "Did you mean?" suggestion: offer replacement
1144
+ if (message.includes('is not defined') && message.includes('hint: did you mean')) {
1145
+ const nameMatch = message.match(/'([^']+)' is not defined/);
1146
+ const suggMatch = message.match(/did you mean '([^']+)'/);
1147
+ if (nameMatch && suggMatch) {
1148
+ const oldName = nameMatch[1];
1149
+ const newName = suggMatch[1];
1150
+ const line = doc.text.split('\n')[diag.range.start.line] || '';
1151
+ const wordRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
1152
+ const wordMatch = wordRegex.exec(line);
1153
+ if (wordMatch) {
1154
+ actions.push({
1155
+ title: `Replace '${oldName}' with '${newName}'`,
1156
+ kind: 'quickfix',
1157
+ isPreferred: true,
1158
+ diagnostics: [diag],
1159
+ edit: {
1160
+ changes: {
1161
+ [textDocument.uri]: [{
1162
+ range: {
1163
+ start: { line: diag.range.start.line, character: wordMatch.index },
1164
+ end: { line: diag.range.start.line, character: wordMatch.index + oldName.length },
1165
+ },
1166
+ newText: newName,
1167
+ }],
1168
+ },
1169
+ },
1170
+ });
1171
+ }
1172
+ }
1173
+ }
1174
+
1175
+ // Naming convention: offer rename
1176
+ if (message.includes('should use snake_case') || message.includes('should use PascalCase')) {
1177
+ const nameMatch = message.match(/'([^']+)'/);
1178
+ const hintMatch = (diag.message || '').match(/Rename '([^']+)' to '([^']+)'/);
1179
+ if (nameMatch && hintMatch) {
1180
+ const oldName = hintMatch[1];
1181
+ const newName = hintMatch[2];
1182
+ const line = doc.text.split('\n')[diag.range.start.line] || '';
1183
+ const wordRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
1184
+ const wordMatch = wordRegex.exec(line);
1185
+ if (wordMatch) {
1186
+ actions.push({
1187
+ title: `Rename '${oldName}' to '${newName}'`,
1188
+ kind: 'quickfix',
1189
+ diagnostics: [diag],
1190
+ edit: {
1191
+ changes: {
1192
+ [textDocument.uri]: [{
1193
+ range: {
1194
+ start: { line: diag.range.start.line, character: wordMatch.index },
1195
+ end: { line: diag.range.start.line, character: wordMatch.index + oldName.length },
1196
+ },
1197
+ newText: newName,
1198
+ }],
1199
+ },
1200
+ },
1201
+ });
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ // Type mismatch with toString hint
1207
+ if (message.includes('Type mismatch') && message.includes('hint: try toString')) {
1208
+ actions.push({
1209
+ title: 'Wrap with toString()',
1210
+ kind: 'quickfix',
1211
+ diagnostics: [diag],
1212
+ });
1213
+ }
1214
+
1215
+ // Type mismatch with Ok() hint
1216
+ if (message.includes('Type mismatch') && message.includes('hint: try Ok(value)')) {
1217
+ actions.push({
1218
+ title: 'Wrap with Ok()',
1219
+ kind: 'quickfix',
1220
+ diagnostics: [diag],
1221
+ });
1222
+ }
1223
+
1224
+ // Type mismatch with Some() hint
1225
+ if (message.includes('Type mismatch') && message.includes('hint: try Some(value)')) {
1226
+ actions.push({
1227
+ title: 'Wrap with Some()',
1228
+ kind: 'quickfix',
1229
+ diagnostics: [diag],
1230
+ });
1231
+ }
1232
+
1233
+ // Type mismatch: try toInt/toFloat/floor/round
1234
+ if (message.includes('Type mismatch') && message.includes('hint: try toInt')) {
1235
+ actions.push({
1236
+ title: 'Convert with toInt()',
1237
+ kind: 'quickfix',
1238
+ diagnostics: [diag],
1239
+ });
1240
+ }
1241
+ if (message.includes('Type mismatch') && message.includes('hint: try toFloat')) {
1242
+ actions.push({
1243
+ title: 'Convert with toFloat()',
1244
+ kind: 'quickfix',
1245
+ diagnostics: [diag],
1246
+ });
1247
+ }
1248
+ if (message.includes('hint: try floor(value)')) {
1249
+ actions.push({
1250
+ title: 'Convert with floor()',
1251
+ kind: 'quickfix',
1252
+ diagnostics: [diag],
1253
+ });
1254
+ }
1255
+
1256
+ // Immutable variable: offer to change declaration to var
1257
+ if (message.includes('Cannot reassign immutable variable')) {
1258
+ const nameMatch = message.match(/variable '([^']+)'/);
1259
+ if (nameMatch) {
1260
+ const varName = nameMatch[1];
1261
+ // Find the line where the variable is first declared
1262
+ const docLines = doc.text.split('\n');
1263
+ for (let i = 0; i < docLines.length; i++) {
1264
+ const declMatch = docLines[i].match(new RegExp(`(?<![\\w])${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`));
1265
+ if (declMatch && i < diag.range.start.line) {
1266
+ const col = declMatch.index;
1267
+ actions.push({
1268
+ title: `Make '${varName}' mutable (add 'var')`,
1269
+ kind: 'quickfix',
1270
+ isPreferred: true,
1271
+ diagnostics: [diag],
1272
+ edit: {
1273
+ changes: {
1274
+ [textDocument.uri]: [{
1275
+ range: {
1276
+ start: { line: i, character: col },
1277
+ end: { line: i, character: col },
1278
+ },
1279
+ newText: 'var ',
1280
+ }],
1281
+ },
1282
+ },
1283
+ });
1284
+ break;
1285
+ }
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // Cannot use operator on immutable variable
1291
+ if (message.includes("Cannot use") && message.includes("on immutable variable")) {
1292
+ const nameMatch = message.match(/variable '([^']+)'/);
1293
+ if (nameMatch) {
1294
+ const varName = nameMatch[1];
1295
+ const docLines = doc.text.split('\n');
1296
+ for (let i = 0; i < docLines.length; i++) {
1297
+ const declMatch = docLines[i].match(new RegExp(`(?<![\\w])${varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`));
1298
+ if (declMatch && i < diag.range.start.line) {
1299
+ const col = declMatch.index;
1300
+ actions.push({
1301
+ title: `Make '${varName}' mutable (add 'var')`,
1302
+ kind: 'quickfix',
1303
+ isPreferred: true,
1304
+ diagnostics: [diag],
1305
+ edit: {
1306
+ changes: {
1307
+ [textDocument.uri]: [{
1308
+ range: {
1309
+ start: { line: i, character: col },
1310
+ end: { line: i, character: col },
1311
+ },
1312
+ newText: 'var ',
1313
+ }],
1314
+ },
1315
+ },
1316
+ });
1317
+ break;
1318
+ }
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ // await outside async: offer to add 'async' to the function
1324
+ if (message.includes("'await' can only be used inside an async function")) {
1325
+ const docLines = doc.text.split('\n');
1326
+ // Search backward for the enclosing 'fn' declaration
1327
+ for (let i = diag.range.start.line; i >= 0; i--) {
1328
+ const fnMatch = docLines[i].match(/(\s*)fn\s+/);
1329
+ if (fnMatch) {
1330
+ const col = fnMatch.index + fnMatch[1].length;
1331
+ actions.push({
1332
+ title: 'Add async to function',
1333
+ kind: 'quickfix',
1334
+ isPreferred: true,
1335
+ diagnostics: [diag],
1336
+ edit: {
1337
+ changes: {
1338
+ [textDocument.uri]: [{
1339
+ range: {
1340
+ start: { line: i, character: col },
1341
+ end: { line: i, character: col },
1342
+ },
1343
+ newText: 'async ',
1344
+ }],
1345
+ },
1346
+ },
1347
+ });
1348
+ break;
1349
+ }
1350
+ }
1351
+ }
1352
+
1353
+ // Non-exhaustive match: offer to add wildcard arm
1354
+ if (message.includes('Non-exhaustive match')) {
1355
+ const variantMatch = message.match(/missing '([^']+)'/);
1356
+ if (variantMatch) {
1357
+ actions.push({
1358
+ title: `Add '_ => ...' catch-all arm`,
1359
+ kind: 'quickfix',
1360
+ diagnostics: [diag],
1361
+ });
1362
+ }
1363
+ }
1364
+
1365
+ // 'throw' is not a Tova keyword
1366
+ if (message.includes("'throw' is not a Tova keyword")) {
1367
+ const line = doc.text.split('\n')[diag.range.start.line] || '';
1368
+ const throwMatch = line.match(/\bthrow\b/);
1369
+ if (throwMatch) {
1370
+ actions.push({
1371
+ title: "Replace 'throw' with 'Err()'",
1372
+ kind: 'quickfix',
1373
+ isPreferred: true,
1374
+ diagnostics: [diag],
1375
+ edit: {
1376
+ changes: {
1377
+ [textDocument.uri]: [{
1378
+ range: {
1379
+ start: { line: diag.range.start.line, character: throwMatch.index },
1380
+ end: { line: diag.range.start.line, character: throwMatch.index + 5 },
1381
+ },
1382
+ newText: 'return Err(',
1383
+ }],
1384
+ },
1385
+ },
1386
+ });
1387
+ }
1388
+ }
1389
+
1390
+ // 'mut' not supported: offer 'var'
1391
+ if (message.includes("'mut' is not supported") || message.includes("Use 'var' for mutable")) {
1392
+ const line = doc.text.split('\n')[diag.range.start.line] || '';
1393
+ const mutMatch = line.match(/\bmut\b/);
1394
+ if (mutMatch) {
1395
+ actions.push({
1396
+ title: "Replace 'mut' with 'var'",
1397
+ kind: 'quickfix',
1398
+ isPreferred: true,
1399
+ diagnostics: [diag],
1400
+ edit: {
1401
+ changes: {
1402
+ [textDocument.uri]: [{
1403
+ range: {
1404
+ start: { line: diag.range.start.line, character: mutMatch.index },
1405
+ end: { line: diag.range.start.line, character: mutMatch.index + 3 },
1406
+ },
1407
+ newText: 'var',
1408
+ }],
1409
+ },
1410
+ },
1411
+ });
1412
+ }
1413
+ }
1414
+
1415
+ // Suppress unused variable with tova-ignore comment
1416
+ if (message.includes('declared but never used')) {
1417
+ const nameMatch = message.match(/'([^']+)'/);
1418
+ if (nameMatch) {
1419
+ const lineNum = diag.range.start.line;
1420
+ const docLines = doc.text.split('\n');
1421
+ const lineContent = docLines[lineNum] || '';
1422
+ const indent = lineContent.match(/^(\s*)/)[1];
1423
+ actions.push({
1424
+ title: `Suppress with // tova-ignore W001`,
1425
+ kind: 'quickfix',
1426
+ diagnostics: [diag],
1427
+ edit: {
1428
+ changes: {
1429
+ [textDocument.uri]: [{
1430
+ range: {
1431
+ start: { line: lineNum, character: 0 },
1432
+ end: { line: lineNum, character: 0 },
1433
+ },
1434
+ newText: `${indent}// tova-ignore W001\n`,
1435
+ }],
1436
+ },
1437
+ },
1438
+ });
1439
+ }
1440
+ }
1441
+ }
1442
+
1443
+ this._respond(msg.id, actions);
1444
+ }
1445
+
1446
+ // ─── Rename (scope-aware) ────────────────────────────────
807
1447
 
808
1448
  _onRename(msg) {
809
1449
  const { position, textDocument, newName } = msg.params;
@@ -814,10 +1454,88 @@ class TovaLanguageServer {
814
1454
  const oldName = this._getWordAt(line, position.character);
815
1455
  if (!oldName) return this._respond(msg.id, null);
816
1456
 
817
- // Find all occurrences of the identifier in the document
1457
+ const cached = this._diagnosticsCache.get(textDocument.uri);
1458
+ if (!cached || !cached.analyzer || !cached.analyzer.globalScope) {
1459
+ // Fallback to naive rename if no scope info
1460
+ return this._naiveRename(msg.id, textDocument.uri, doc.text, oldName, newName);
1461
+ }
1462
+
1463
+ // Find which scope defines the binding the cursor is on
1464
+ const cursorLine = position.line + 1; // LSP 0-based to 1-based
1465
+ const cursorCol = position.character + 1;
1466
+ const cursorScope = cached.analyzer.globalScope.findScopeAtPosition(cursorLine, cursorCol);
1467
+ if (!cursorScope) {
1468
+ return this._naiveRename(msg.id, textDocument.uri, doc.text, oldName, newName);
1469
+ }
1470
+
1471
+ // Walk up from cursor scope to find where this name is defined
1472
+ let definingScope = null;
1473
+ let scope = cursorScope;
1474
+ while (scope) {
1475
+ if (scope.symbols && scope.symbols.has(oldName)) {
1476
+ definingScope = scope;
1477
+ break;
1478
+ }
1479
+ scope = scope.parent;
1480
+ }
1481
+
1482
+ if (!definingScope) {
1483
+ return this._naiveRename(msg.id, textDocument.uri, doc.text, oldName, newName);
1484
+ }
1485
+
1486
+ // Collect edits: for each occurrence, check if it resolves to the same defining scope
818
1487
  const edits = [];
819
1488
  const docLines = doc.text.split('\n');
820
- const wordRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
1489
+ const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1490
+ const wordRegex = new RegExp('\\b' + escaped + '\\b', 'g');
1491
+
1492
+ for (let i = 0; i < docLines.length; i++) {
1493
+ let match;
1494
+ while ((match = wordRegex.exec(docLines[i])) !== null) {
1495
+ const matchLine = i + 1; // 1-based
1496
+ const matchCol = match.index + 1;
1497
+ // Find the narrowest scope at this location
1498
+ const matchScope = cached.analyzer.globalScope.findScopeAtPosition(matchLine, matchCol);
1499
+ if (!matchScope) continue;
1500
+
1501
+ // Walk up from matchScope to see if the name resolves to definingScope
1502
+ let resolvedScope = null;
1503
+ let s = matchScope;
1504
+ while (s) {
1505
+ if (s.symbols && s.symbols.has(oldName)) {
1506
+ resolvedScope = s;
1507
+ break;
1508
+ }
1509
+ s = s.parent;
1510
+ }
1511
+
1512
+ if (resolvedScope === definingScope) {
1513
+ edits.push({
1514
+ range: {
1515
+ start: { line: i, character: match.index },
1516
+ end: { line: i, character: match.index + oldName.length },
1517
+ },
1518
+ newText: newName,
1519
+ });
1520
+ }
1521
+ }
1522
+ }
1523
+
1524
+ // If scope-aware rename found nothing (e.g. positional info gaps), fall back
1525
+ if (edits.length === 0) {
1526
+ return this._naiveRename(msg.id, textDocument.uri, doc.text, oldName, newName);
1527
+ }
1528
+
1529
+ this._respond(msg.id, {
1530
+ changes: { [textDocument.uri]: edits },
1531
+ });
1532
+ }
1533
+
1534
+ _naiveRename(id, uri, text, oldName, newName) {
1535
+ const edits = [];
1536
+ const docLines = text.split('\n');
1537
+ const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1538
+ const wordRegex = new RegExp('\\b' + escaped + '\\b', 'g');
821
1539
 
822
1540
  for (let i = 0; i < docLines.length; i++) {
823
1541
  let match;
@@ -832,8 +1550,8 @@ class TovaLanguageServer {
832
1550
  }
833
1551
  }
834
1552
 
835
- this._respond(msg.id, {
836
- changes: { [textDocument.uri]: edits },
1553
+ this._respond(id, {
1554
+ changes: { [uri]: edits },
837
1555
  });
838
1556
  }
839
1557
 
@@ -897,15 +1615,179 @@ class TovaLanguageServer {
897
1615
  this._respond(msg.id, results.slice(0, 100));
898
1616
  }
899
1617
 
1618
+ // ─── Inlay Hints ─────────────────────────────────────────
1619
+
1620
+ _onInlayHint(msg) {
1621
+ const { textDocument, range } = msg.params;
1622
+ const cached = this._diagnosticsCache.get(textDocument.uri);
1623
+ const doc = this._documents.get(textDocument.uri);
1624
+ if (!cached || !cached.analyzer || !doc) return this._respond(msg.id, []);
1625
+
1626
+ const hints = [];
1627
+ const docLines = doc.text.split('\n');
1628
+ const startLine = range.start.line;
1629
+ const endLine = range.end.line;
1630
+
1631
+ // ─── Type hints for variable bindings ─────────────
1632
+ // Match: `name = expr` and `var name = expr` (but NOT `name: Type = expr` which already has annotation)
1633
+ const bindingRegex = /^(\s*)(?:var\s+)?([a-zA-Z_]\w*)\s*=\s*(.+)/;
1634
+ const hasAnnotation = /^(\s*)(?:var\s+)?[a-zA-Z_]\w*\s*:\s*\w/;
1635
+
1636
+ for (let i = startLine; i <= endLine && i < docLines.length; i++) {
1637
+ const line = docLines[i];
1638
+
1639
+ // Skip lines that already have type annotations
1640
+ if (hasAnnotation.test(line)) continue;
1641
+
1642
+ const bindMatch = bindingRegex.exec(line);
1643
+ if (bindMatch) {
1644
+ const varName = bindMatch[2];
1645
+ // Skip private/special names
1646
+ if (varName.startsWith('_') || varName === '_') continue;
1647
+ // Skip keywords that look like assignments
1648
+ if (['fn', 'if', 'for', 'while', 'match', 'type', 'import', 'return', 'let'].includes(varName)) continue;
1649
+
1650
+ // Look up the symbol's inferred type
1651
+ const sym = this._findSymbolAtPosition(cached.analyzer, varName, { line: i, character: bindMatch[1].length + (line.includes('var ') ? 4 : 0) })
1652
+ || this._findSymbolInScopes(cached.analyzer, varName);
1653
+ if (sym) {
1654
+ let typeStr = null;
1655
+ if (sym.inferredType) {
1656
+ typeStr = sym.inferredType;
1657
+ } else if (sym.typeAnnotation) {
1658
+ typeStr = sym.typeAnnotation;
1659
+ } else if (sym.type && typeof sym.type === 'object' && sym.type.name) {
1660
+ typeStr = sym.type.name;
1661
+ } else if (sym.kind === 'function') {
1662
+ typeStr = 'Function';
1663
+ }
1664
+
1665
+ if (typeStr && typeStr !== 'Unknown' && typeStr !== 'Any') {
1666
+ // Position: right after the variable name
1667
+ const nameEnd = line.indexOf(varName) + varName.length;
1668
+ hints.push({
1669
+ position: { line: i, character: nameEnd },
1670
+ label: `: ${typeStr}`,
1671
+ kind: 1, // Type
1672
+ paddingLeft: false,
1673
+ paddingRight: true,
1674
+ });
1675
+ }
1676
+ }
1677
+ }
1678
+
1679
+ // ─── Parameter name hints at call sites ─────────
1680
+ // Match function calls: name(arg1, arg2, ...)
1681
+ const callRegex = /\b([a-zA-Z_]\w*)\s*\(/g;
1682
+ let callMatch;
1683
+ while ((callMatch = callRegex.exec(line)) !== null) {
1684
+ const funcName = callMatch[1];
1685
+ // Skip keywords that look like function calls
1686
+ if (['if', 'for', 'while', 'match', 'fn', 'catch', 'switch'].includes(funcName)) continue;
1687
+
1688
+ // Look up function signature
1689
+ const funcSym = this._findSymbolInScopes(cached.analyzer, funcName);
1690
+ const params = funcSym?._params;
1691
+ if (!params || params.length === 0) continue;
1692
+
1693
+ // Parse the arguments (simplified — handles nested parens but not all edge cases)
1694
+ const argsStart = callMatch.index + callMatch[0].length;
1695
+ const argPositions = this._parseCallArgPositions(line, argsStart);
1696
+
1697
+ for (let ai = 0; ai < argPositions.length && ai < params.length; ai++) {
1698
+ const argPos = argPositions[ai];
1699
+ const argText = line.slice(argPos.start, argPos.end).trim();
1700
+ // Don't show hint if the argument is already the parameter name
1701
+ if (argText === params[ai]) continue;
1702
+ // Don't show hints for single-argument calls with obvious context
1703
+ if (params.length === 1 && argText.length <= 3) continue;
1704
+ // Don't show for self parameter
1705
+ if (params[ai] === 'self') continue;
1706
+
1707
+ hints.push({
1708
+ position: { line: i, character: argPos.start },
1709
+ label: `${params[ai]}:`,
1710
+ kind: 2, // Parameter
1711
+ paddingLeft: false,
1712
+ paddingRight: true,
1713
+ });
1714
+ }
1715
+ }
1716
+ }
1717
+
1718
+ this._respond(msg.id, hints);
1719
+ }
1720
+
1721
+ /**
1722
+ * Parse positions of arguments in a function call, handling nested parens.
1723
+ * Starts just after the opening '(' character.
1724
+ */
1725
+ _parseCallArgPositions(line, startIdx) {
1726
+ const positions = [];
1727
+ let depth = 0;
1728
+ let argStart = startIdx;
1729
+ let inStr = null;
1730
+
1731
+ for (let i = startIdx; i < line.length; i++) {
1732
+ const ch = line[i];
1733
+ if (inStr) {
1734
+ if (ch === '\\') { i++; continue; }
1735
+ if (ch === inStr) inStr = null;
1736
+ continue;
1737
+ }
1738
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
1739
+ if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
1740
+ if (ch === ')' || ch === ']' || ch === '}') {
1741
+ if (depth === 0) {
1742
+ // End of argument list
1743
+ if (i > argStart) {
1744
+ positions.push({ start: argStart, end: i });
1745
+ }
1746
+ break;
1747
+ }
1748
+ depth--;
1749
+ continue;
1750
+ }
1751
+ if (ch === ',' && depth === 0) {
1752
+ positions.push({ start: argStart, end: i });
1753
+ argStart = i + 1;
1754
+ // Skip whitespace after comma
1755
+ while (argStart < line.length && line[argStart] === ' ') argStart++;
1756
+ }
1757
+ }
1758
+
1759
+ return positions;
1760
+ }
1761
+
900
1762
  // ─── Utilities ────────────────────────────────────────────
901
1763
 
902
1764
  _uriToPath(uri) {
903
1765
  if (uri.startsWith('file://')) {
904
- return decodeURIComponent(uri.slice(7));
1766
+ let path = decodeURIComponent(uri.slice(7));
1767
+ // On Windows, file:///C:/path becomes /C:/path — strip leading slash
1768
+ if (/^\/[a-zA-Z]:/.test(path)) {
1769
+ path = path.slice(1);
1770
+ }
1771
+ return path;
905
1772
  }
906
1773
  return uri;
907
1774
  }
908
1775
 
1776
+ _getBuiltinDetail(name) {
1777
+ const src = BUILTIN_FUNCTIONS[name];
1778
+ if (!src) return 'Tova built-in';
1779
+ // Constants (const PI = ...)
1780
+ const constMatch = src.match(/^const\s+\w+\s*=/);
1781
+ if (constMatch) return 'const';
1782
+ // Functions — extract params from source
1783
+ const fnMatch = src.match(/^(?:async\s+)?function\s+\w+\s*\(([^)]*)\)/);
1784
+ if (fnMatch) {
1785
+ const params = fnMatch[1].trim();
1786
+ return params ? `fn(${params})` : 'fn()';
1787
+ }
1788
+ return 'Tova built-in';
1789
+ }
1790
+
909
1791
  _getWordAt(line, character) {
910
1792
  let start = character;
911
1793
  let end = character;