tova 0.3.0 → 0.3.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/src/lsp/server.js CHANGED
@@ -8,6 +8,7 @@ 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
@@ -117,7 +118,9 @@ class TovaLanguageServer {
117
118
  case 'textDocument/signatureHelp': return this._onSignatureHelp(msg);
118
119
  case 'textDocument/formatting': return this._onFormatting(msg);
119
120
  case 'textDocument/rename': return this._onRename(msg);
121
+ case 'textDocument/codeAction': return this._onCodeAction(msg);
120
122
  case 'textDocument/references': return this._onReferences(msg);
123
+ case 'textDocument/inlayHint': return this._onInlayHint(msg);
121
124
  case 'workspace/symbol': return this._onWorkspaceSymbol(msg);
122
125
  default: return this._respondError(msg.id, -32601, `Method not found: ${method}`);
123
126
  }
@@ -154,9 +157,13 @@ class TovaLanguageServer {
154
157
  signatureHelpProvider: {
155
158
  triggerCharacters: ['(', ','],
156
159
  },
160
+ codeActionProvider: {
161
+ codeActionKinds: ['quickfix'],
162
+ },
157
163
  documentFormattingProvider: true,
158
164
  renameProvider: { prepareProvider: false },
159
165
  referencesProvider: true,
166
+ inlayHintProvider: true,
160
167
  workspaceSymbolProvider: true,
161
168
  },
162
169
  });
@@ -242,8 +249,9 @@ class TovaLanguageServer {
242
249
  },
243
250
  severity,
244
251
  source: 'tova',
245
- message: w.message,
252
+ message: w.hint ? `${w.message} (hint: ${w.hint})` : w.message,
246
253
  };
254
+ if (w.code) diag.code = w.code;
247
255
  // Add unnecessary tag for unused variables
248
256
  if (w.message.includes('declared but never used')) {
249
257
  diag.tags = [1]; // Unnecessary
@@ -333,6 +341,8 @@ class TovaLanguageServer {
333
341
  if (msg.includes('Non-exhaustive match')) return 2;
334
342
  // Type mismatches in strict mode → Error (1)
335
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;
336
346
  // Default → Warning (2)
337
347
  return 2;
338
348
  }
@@ -415,7 +425,7 @@ class TovaLanguageServer {
415
425
 
416
426
  // Keywords
417
427
  const keywords = [
418
- 'fn', 'let', 'if', 'elif', 'else', 'for', 'while', 'in',
428
+ 'fn', 'let', 'if', 'elif', 'else', 'for', 'while', 'loop', 'when', 'in',
419
429
  'return', 'match', 'type', 'import', 'from', 'true', 'false',
420
430
  'nil', 'server', 'client', 'shared', 'pub', 'mut',
421
431
  'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
@@ -427,15 +437,17 @@ class TovaLanguageServer {
427
437
  }
428
438
  }
429
439
 
430
- // Built-in functions
431
- const builtins = [
432
- 'print', 'len', 'range', 'enumerate', 'sum', 'sorted',
433
- 'reversed', 'zip', 'min', 'max', 'type_of', 'filter', 'map',
434
- 'Ok', 'Err', 'Some', 'None',
435
- ];
436
- for (const fn of builtins) {
437
- if (fn.startsWith(prefix)) {
438
- 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' });
439
451
  }
440
452
  }
441
453
 
@@ -444,7 +456,7 @@ class TovaLanguageServer {
444
456
  if (cached?.analyzer) {
445
457
  const symbols = this._collectSymbols(cached.analyzer);
446
458
  for (const sym of symbols) {
447
- if (sym.name.startsWith(prefix) && !builtins.includes(sym.name)) {
459
+ if (sym.name.startsWith(prefix) && !BUILTIN_NAMES.has(sym.name)) {
448
460
  items.push({
449
461
  label: sym.name,
450
462
  kind: sym.kind === 'function' ? 3 : sym.kind === 'type' ? 22 : 6,
@@ -618,23 +630,291 @@ class TovaLanguageServer {
618
630
  const word = this._getWordAt(line, position.character);
619
631
  if (!word) return this._respond(msg.id, null);
620
632
 
621
- // Check builtins
633
+ // Check builtins — comprehensive hover docs for all stdlib functions
622
634
  const builtinDocs = {
635
+ // Core
623
636
  'print': '`fn print(...args)` — Print values to console',
624
637
  'len': '`fn len(v)` — Get length of string, array, or object',
625
- 'range': '`fn range(start, end, step?)` — Generate array of numbers',
638
+ 'range': '`fn range(start, end?, step?)` — Generate array of numbers',
626
639
  'enumerate': '`fn enumerate(arr)` — Returns [[index, value], ...]',
627
- 'sum': '`fn sum(arr)` — Sum all elements in array',
628
- 'sorted': '`fn sorted(arr, key?)` — Return sorted copy of array',
629
- 'reversed': '`fn reversed(arr)` — Return reversed copy of array',
630
- 'zip': '`fn zip(...arrays)` — Zip arrays together',
631
- 'min': '`fn min(arr)` — Minimum value in array',
632
- 'max': '`fn max(arr)` — Maximum value in array',
633
- 'type_of': '`fn type_of(v)` — Get Tova type name as string',
634
- 'Ok': '`Ok(value)` — Create a successful Result',
635
- 'Err': '`Err(error)` — Create an error Result',
636
- 'Some': '`Some(value)`Create an Option with a value',
637
- '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',
638
918
  };
639
919
 
640
920
  if (builtinDocs[word]) {
@@ -818,7 +1098,352 @@ class TovaLanguageServer {
818
1098
  }
819
1099
  }
820
1100
 
821
- // ─── 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) ────────────────────────────────
822
1447
 
823
1448
  _onRename(msg) {
824
1449
  const { position, textDocument, newName } = msg.params;
@@ -829,10 +1454,88 @@ class TovaLanguageServer {
829
1454
  const oldName = this._getWordAt(line, position.character);
830
1455
  if (!oldName) return this._respond(msg.id, null);
831
1456
 
832
- // 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
833
1487
  const edits = [];
834
1488
  const docLines = doc.text.split('\n');
835
- 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');
836
1539
 
837
1540
  for (let i = 0; i < docLines.length; i++) {
838
1541
  let match;
@@ -847,8 +1550,8 @@ class TovaLanguageServer {
847
1550
  }
848
1551
  }
849
1552
 
850
- this._respond(msg.id, {
851
- changes: { [textDocument.uri]: edits },
1553
+ this._respond(id, {
1554
+ changes: { [uri]: edits },
852
1555
  });
853
1556
  }
854
1557
 
@@ -912,6 +1615,150 @@ class TovaLanguageServer {
912
1615
  this._respond(msg.id, results.slice(0, 100));
913
1616
  }
914
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
+
915
1762
  // ─── Utilities ────────────────────────────────────────────
916
1763
 
917
1764
  _uriToPath(uri) {
@@ -926,6 +1773,21 @@ class TovaLanguageServer {
926
1773
  return uri;
927
1774
  }
928
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
+
929
1791
  _getWordAt(line, character) {
930
1792
  let start = character;
931
1793
  let end = character;