mt-lang 0.3.38 → 0.3.40

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/docs/lsp-performance.md +347 -0
  3. data/lib/milk_tea/base.rb +1 -1
  4. data/lib/milk_tea/core/ast.rb +2 -2
  5. data/lib/milk_tea/core/bindings/attribute_binding.rb +1 -6
  6. data/lib/milk_tea/core/bindings/module_binding.rb +1 -1
  7. data/lib/milk_tea/core/intrinsics.rb +23 -1
  8. data/lib/milk_tea/core/lowering/functions.rb +3 -0
  9. data/lib/milk_tea/core/lowering/resolve.rb +4 -65
  10. data/lib/milk_tea/core/lowering/utils.rb +0 -17
  11. data/lib/milk_tea/core/lowering.rb +1 -2
  12. data/lib/milk_tea/core/module_binder.rb +4 -15
  13. data/lib/milk_tea/core/module_loader.rb +0 -2
  14. data/lib/milk_tea/core/parser/declarations.rb +24 -17
  15. data/lib/milk_tea/core/semantic_analyzer/analysis_context.rb +2 -111
  16. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +1 -2
  17. data/lib/milk_tea/core/semantic_analyzer/function_binding.rb +26 -17
  18. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +26 -42
  19. data/lib/milk_tea/core/semantic_analyzer/type_compatibility.rb +0 -59
  20. data/lib/milk_tea/core/semantic_analyzer/type_declaration.rb +0 -2
  21. data/lib/milk_tea/core/types/predicates.rb +57 -0
  22. data/lib/milk_tea/core/types.rb +0 -4
  23. data/lib/milk_tea/lsp/diagnostics.rb +15 -5
  24. data/lib/milk_tea/lsp/server/code_actions.rb +0 -4
  25. data/lib/milk_tea/lsp/server/completion.rb +100 -77
  26. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +4 -6
  27. data/lib/milk_tea/lsp/server/formatting.rb +13 -3
  28. data/lib/milk_tea/lsp/server/hover.rb +321 -41
  29. data/lib/milk_tea/lsp/server/lifecycle.rb +12 -0
  30. data/lib/milk_tea/lsp/server/references.rb +3 -1
  31. data/lib/milk_tea/lsp/server/semantic_tokens.rb +55 -16
  32. data/lib/milk_tea/lsp/server/text_documents.rb +2 -2
  33. data/lib/milk_tea/lsp/server/type_hierarchy.rb +2 -2
  34. data/lib/milk_tea/lsp/server/utilities.rb +4 -0
  35. data/lib/milk_tea/lsp/server.rb +9 -0
  36. data/lib/milk_tea/lsp/workspace/analysis.rb +14 -3
  37. data/lib/milk_tea/lsp/workspace/caches.rb +17 -1
  38. data/lib/milk_tea/lsp/workspace/collection.rb +4 -0
  39. data/lib/milk_tea/lsp/workspace/module_index.rb +134 -0
  40. data/lib/milk_tea/lsp/workspace/store.rb +9 -4
  41. data/lib/milk_tea/lsp/workspace.rb +8 -0
  42. data/lib/milk_tea/tooling/formatter.rb +2 -3
  43. data/lib/milk_tea/tooling/linter/fix_engine.rb +46 -0
  44. data/lib/milk_tea/tooling/linter/rules.rb +32 -0
  45. data/lib/milk_tea/tooling/linter.rb +4 -0
  46. data/lib/milk_tea/tooling.rb +0 -1
  47. metadata +4 -3
  48. data/lib/milk_tea/tooling/cst_formatter.rb +0 -13
@@ -650,6 +650,63 @@ module MilkTea
650
650
  def array_element_type(type)
651
651
  type.arguments.first
652
652
  end
653
+
654
+ def common_numeric_type(left_type, right_type)
655
+ left_type = left_type.backing_type if left_type.is_a?(Types::EnumBase)
656
+ right_type = right_type.backing_type if right_type.is_a?(Types::EnumBase)
657
+ return unless left_type.is_a?(Types::Primitive) && right_type.is_a?(Types::Primitive)
658
+ return unless left_type.numeric? && right_type.numeric?
659
+ return left_type if left_type == right_type
660
+
661
+ return common_integer_type(left_type, right_type) if left_type.integer? && right_type.integer?
662
+ return wider_float_type(left_type, right_type) if left_type.float? && right_type.float?
663
+
664
+ float_type, integer_type = left_type.float? ? [left_type, right_type] : [right_type, left_type]
665
+ return unless integer_type.integer? && integer_type.fixed_width_integer?
666
+
667
+ float_type
668
+ end
669
+
670
+ def common_integer_type(left_type, right_type)
671
+ left_type = left_type.backing_type if left_type.is_a?(Types::EnumBase)
672
+ right_type = right_type.backing_type if right_type.is_a?(Types::EnumBase)
673
+ return unless left_type.is_a?(Types::Primitive) && right_type.is_a?(Types::Primitive)
674
+ return unless left_type.integer? && right_type.integer?
675
+ return left_type if left_type == right_type
676
+ return unless left_type.fixed_width_integer? && right_type.fixed_width_integer?
677
+
678
+ # Same signedness: the wider type wins. Mixed signed/unsigned: promote to
679
+ # the narrowest signed type that holds both operands' full ranges. A
680
+ # strictly-wider signed type covers an unsigned operand; equal-width or
681
+ # wider unsigned operands widen to the next signed width. Mixing with a
682
+ # 64-bit unsigned type has no safe signed common type, so callers fall
683
+ # back to requiring an explicit cast.
684
+ if left_type.signed_integer? == right_type.signed_integer?
685
+ return left_type.integer_width >= right_type.integer_width ? left_type : right_type
686
+ end
687
+
688
+ signed_type, unsigned_type = if left_type.signed_integer?
689
+ [left_type, right_type]
690
+ else
691
+ [right_type, left_type]
692
+ end
693
+
694
+ return signed_type if signed_type.integer_width > unsigned_type.integer_width
695
+
696
+ signed_type_above_width(unsigned_type.integer_width)
697
+ end
698
+
699
+ def signed_type_above_width(width)
700
+ case width
701
+ when 8 then Types::Registry.primitive("short")
702
+ when 16 then Types::Registry.primitive("int")
703
+ when 32 then Types::Registry.primitive("long")
704
+ end
705
+ end
706
+
707
+ def wider_float_type(left_type, right_type)
708
+ left_type.float_width >= right_type.float_width ? left_type : right_type
709
+ end
653
710
  end
654
711
  end
655
712
  end
@@ -1712,10 +1712,6 @@ module MilkTea
1712
1712
  GenericInstance.new("ptr", [type])
1713
1713
  end
1714
1714
 
1715
- def self.integer_type?(type)
1716
- type.is_a?(Primitive) && %w[int ptr_uint i8 i16 i32 i64 u8 u16 u32 u64].include?(type.name)
1717
- end
1718
-
1719
1715
  def self.array_type?(type)
1720
1716
  type.is_a?(GenericInstance) && type.name == "array" && type.arguments.length == 2
1721
1717
  end
@@ -31,8 +31,10 @@ module MilkTea
31
31
  # Parse
32
32
  begin
33
33
  parse_start = total_start ? monotonic_time : nil
34
+ parse_errors = []
34
35
  ast = if path && File.file?(path)
35
36
  parse_result = Parser.parse_collecting_errors(content, path: uri)
37
+ parse_errors = parse_result.errors
36
38
  parse_result.errors.each { |error| diagnostics << format_error(error) }
37
39
  parse_result.ast
38
40
  else
@@ -63,6 +65,10 @@ module MilkTea
63
65
  source_overrides: source_overrides,
64
66
  workspace_root_path: workspace_root_path,
65
67
  content: content,
68
+ # A file whose own parse recovered with errors can poison the loader's
69
+ # program-check cache and yield no facts on the standard path; skip the
70
+ # program check and resolve imports directly for those files.
71
+ skip_program_check: !parse_errors.empty?,
66
72
  )
67
73
  imports_ms = elapsed_ms(imports_start) if imports_start
68
74
  unresolved_import_paths = imported_modules.fetch(:unresolved_import_paths)
@@ -167,7 +173,7 @@ module MilkTea
167
173
 
168
174
  private
169
175
 
170
- def self.resolve_imported_modules(uri, ast, diagnostics, resolution:, effective_platform:, shared_module_cache: nil, source_overrides: nil, workspace_root_path: nil, content: nil)
176
+ def self.resolve_imported_modules(uri, ast, diagnostics, resolution:, effective_platform:, shared_module_cache: nil, source_overrides: nil, workspace_root_path: nil, content: nil, skip_program_check: false)
171
177
  path = uri_to_path(uri)
172
178
  return { modules: {}, unresolved_import_paths: [], module_name: nil } unless path && File.file?(path)
173
179
 
@@ -186,10 +192,14 @@ module MilkTea
186
192
  # cached analysis and resolves correctly. If the program check fails
187
193
  # (e.g. missing module), fall through to the standard resolution
188
194
  # path so that prelude modules and regular errors are still reported.
189
- begin
190
- loader.check_program_collecting(path)
191
- rescue ModuleLoadError, PackageLockError
192
- # best-effort — standard path below handles diagnostics
195
+ # Skipped when the file's own parse recovered with errors: the program
196
+ # check poisons the loader cache and yields no facts on that path.
197
+ unless skip_program_check
198
+ begin
199
+ loader.check_program_collecting(path)
200
+ rescue ModuleLoadError, PackageLockError
201
+ # best-effort — standard path below handles diagnostics
202
+ end
193
203
  end
194
204
 
195
205
  resolution_result = loader.imported_modules_for_ast_collecting_errors(ast, importer_path: path)
@@ -443,10 +443,6 @@ module MilkTea
443
443
  { items: [] }
444
444
  end
445
445
 
446
- def refresh_workspace_diagnostics
447
- @protocol.write_notification('workspace/diagnostic/refresh', nil)
448
- end
449
-
450
446
  def find_match_end_line(lines, match_start_idx)
451
447
  return nil if match_start_idx >= lines.length
452
448
 
@@ -37,11 +37,15 @@ module MilkTea
37
37
  result.empty? ? nil : result
38
38
  end
39
39
 
40
+ TRIGGER_KIND_INCOMPLETE = 2
41
+ MAX_COMPLETION_SESSION_ENTRIES = 64
42
+
40
43
  def handle_completion(params)
41
44
  stages = new_perf_stages
42
45
  total_start = stages ? monotonic_time : nil
43
46
  uri = params['textDocument']['uri']
44
47
  @current_completion_uri = uri
48
+ @current_completion_trigger_kind = params.dig('context', 'triggerKind') || 1
45
49
  lsp_line = params['position']['line']
46
50
  lsp_char = params['position']['character']
47
51
  branch = 'none'
@@ -49,50 +53,61 @@ module MilkTea
49
53
 
50
54
  prefix = measure_perf_stage(stages, 'prefix') { current_word_prefix(uri, lsp_line, lsp_char) }
51
55
 
56
+ # Completion session re-filtering: when the editor keeps requesting
57
+ # while the prefix grows (triggerFromIncompleteCompletions), re-filter
58
+ # the previously computed candidate pool instead of rebuilding it.
59
+ response = measure_perf_stage(stages, 'session') { completion_session_response(uri, lsp_line, lsp_char, prefix) }
60
+ if response
61
+ branch = 'session'
62
+ item_count = response[:items].length
63
+ return response
64
+ end
65
+
66
+ branch, item_count, response = measure_perf_stage(stages, 'compute') { compute_completion_items(uri, lsp_line, lsp_char, prefix, stages) }
67
+ store_completion_session(uri, lsp_line, lsp_char, prefix, response)
68
+ response
69
+ rescue StandardError => e
70
+ branch = 'error'
71
+ warn "Error in completion handler: #{e.message}"
72
+ { isIncomplete: false, items: [] }
73
+ ensure
74
+ log_request_stage_breakdown('textDocument/completion', total_start, uri: uri, stages: stages, summary: "branch=#{branch} items=#{item_count}")
75
+ end
76
+
77
+ def compute_completion_items(uri, lsp_line, lsp_char, prefix, stages)
52
78
  import_items = measure_perf_stage(stages, 'import_context') { import_completions(uri, lsp_line, lsp_char) }
53
79
  if import_items
54
- branch = 'import'
55
- item_count = import_items.length
56
- return { isIncomplete: false, items: import_items }
80
+ return ['import', import_items.length, { isIncomplete: false, items: import_items }]
57
81
  end
58
82
 
59
83
  facts = measure_perf_stage(stages, 'facts') { @workspace.get_facts(uri) }
60
84
 
61
85
  unless facts
62
- branch = 'no-facts'
63
- return { isIncomplete: false, items: [] }
86
+ return ['no-facts', 0, { isIncomplete: false, items: [] }]
64
87
  end
65
88
 
66
89
  # Attribute context: complete inside @[...]
67
90
  attr_items = attribute_completions(facts, uri, lsp_line, lsp_char)
68
91
  if attr_items
69
- branch = 'attribute'
70
- item_count = attr_items.length
71
- return { isIncomplete: false, items: attr_items }
92
+ return ['attribute', attr_items.length, { isIncomplete: false, items: attr_items }]
72
93
  end
73
94
 
74
95
  # Format string interpolation: complete inside f"... #{ }
75
96
  fmt_items = format_string_completions(facts, uri, lsp_line, lsp_char)
76
97
  if fmt_items
77
- branch = 'format-string'
78
- item_count = fmt_items.length
79
- return { isIncomplete: false, items: fmt_items }
98
+ return ['format-string', fmt_items.length, { isIncomplete: false, items: fmt_items }]
80
99
  end
81
100
 
82
101
  # Named argument completions: inside function/struct call e.g. Point(x: 1, |)
83
102
  named_items = named_argument_completions(facts, uri, lsp_line, lsp_char)
84
103
  if named_items
85
- branch = 'named-arg'
86
- item_count = named_items.length
87
- return { isIncomplete: false, items: named_items }
104
+ return ['named-arg', named_items.length, { isIncomplete: false, items: named_items }]
88
105
  end
89
106
 
90
107
  # Specialization context: inside name[...]
91
108
  spec_items = specialization_completions(facts, uri, lsp_line, lsp_char)
92
109
  if spec_items
93
- branch = 'specialization'
94
- item_count = spec_items.length
95
- return { isIncomplete: false, items: spec_items }
110
+ return ['specialization', spec_items.length, { isIncomplete: false, items: spec_items }]
96
111
  end
97
112
 
98
113
  # When user is typing after '.', return module members or method completions.
@@ -105,7 +120,6 @@ module MilkTea
105
120
  if dot_recv
106
121
  # Module member access: rl.init_window, rl.RAYWHITE, etc.
107
122
  if (module_binding = facts.imports[dot_recv])
108
- branch = 'module'
109
123
  items = measure_perf_stage(stages, 'build') do
110
124
  result = []
111
125
  module_binding.functions.each do |fname, binding|
@@ -152,8 +166,7 @@ module MilkTea
152
166
  end
153
167
  result
154
168
  end
155
- item_count = items.length
156
- return { isIncomplete: false, items: items }
169
+ return ['module', items.length, { isIncomplete: false, items: items }]
157
170
  end
158
171
  if (type_receiver = measure_perf_stage(stages, 'type_receiver') { resolve_type_receiver_info(facts, dot_recv, dot_recv_path) })
159
172
  receiver_label = type_receiver[:label]
@@ -162,7 +175,6 @@ module MilkTea
162
175
 
163
176
  # Enum/Flags member access: Color.RED, KeyboardKey.A, etc.
164
177
  if type.is_a?(Types::EnumBase)
165
- branch = 'enum-members'
166
178
  items = measure_perf_stage(stages, 'build') do
167
179
  type.members.filter_map do |mname|
168
180
  next if !prefix.empty? && !mname.start_with?(prefix)
@@ -176,13 +188,11 @@ module MilkTea
176
188
  }
177
189
  end
178
190
  end
179
- item_count = items.length
180
- return { isIncomplete: false, items: items }
191
+ return ['enum-members', items.length, { isIncomplete: false, items: items }]
181
192
  end
182
193
 
183
194
  # Variant arm access: Option.none, Result.success, etc.
184
195
  if type.is_a?(Types::Variant)
185
- branch = 'variant-arms'
186
196
  items = measure_perf_stage(stages, 'build') do
187
197
  type.arm_names.filter_map do |aname|
188
198
  next if !prefix.empty? && !aname.start_with?(prefix)
@@ -196,13 +206,11 @@ module MilkTea
196
206
  }
197
207
  end
198
208
  end
199
- item_count = items.length
200
- return { isIncomplete: false, items: items }
209
+ return ['variant-arms', items.length, { isIncomplete: false, items: items }]
201
210
  end
202
211
 
203
212
  # Nested struct type members: ShapeGroup.CircleData, etc.
204
213
  if type.is_a?(Types::Struct) && type.respond_to?(:nested_types) && type.nested_types.any?
205
- branch = 'nested-types'
206
214
  items = measure_perf_stage(stages, 'build') do
207
215
  type.nested_types.filter_map do |nt_name, _nt_type|
208
216
  next if !prefix.empty? && !nt_name.start_with?(prefix)
@@ -216,15 +224,12 @@ module MilkTea
216
224
  }
217
225
  end
218
226
  end
219
- item_count = items.length
220
- return { isIncomplete: false, items: items } unless items.empty?
227
+ return ['nested-types', items.length, { isIncomplete: false, items: items }] unless items.empty?
221
228
  end
222
229
 
223
230
  items = measure_perf_stage(stages, 'build') { completion_items_for_type_receiver(facts, type, prefix) }
224
231
  unless items.empty?
225
- branch = 'type-receiver'
226
- item_count = items.length
227
- return { isIncomplete: false, items: items }
232
+ return ['type-receiver', items.length, { isIncomplete: false, items: items }]
228
233
  end
229
234
  end
230
235
 
@@ -237,17 +242,13 @@ module MilkTea
237
242
  receiver_type = measure_perf_stage(stages, 'imported_value_receiver') { val_binding.type }
238
243
  items = measure_perf_stage(stages, 'build') { completion_items_for_value_receiver(facts, receiver_type, prefix) }
239
244
  unless items.empty?
240
- branch = 'imported-value-receiver'
241
- item_count = items.length
242
- return { isIncomplete: false, items: items }
245
+ return ['imported-value-receiver', items.length, { isIncomplete: false, items: items }]
243
246
  end
244
247
  end
245
248
  else
246
249
  chain_items = measure_perf_stage(stages, 'value_chain') { value_chain_completions(facts, dot_recv_path, lsp_line, lsp_char, prefix) }
247
250
  if chain_items
248
- branch = 'value-chain'
249
- item_count = chain_items.length
250
- return { isIncomplete: false, items: chain_items }
251
+ return ['value-chain', chain_items.length, { isIncomplete: false, items: chain_items }]
251
252
  end
252
253
  end
253
254
  end
@@ -255,14 +256,11 @@ module MilkTea
255
256
  if (receiver_type = measure_perf_stage(stages, 'value_receiver') { resolve_dot_receiver_value_type(facts, dot_recv, lsp_line + 1, lsp_char + 1) })
256
257
  items = measure_perf_stage(stages, 'build') { completion_items_for_value_receiver(facts, receiver_type, prefix) }
257
258
  unless items.empty?
258
- branch = 'value-receiver'
259
- item_count = items.length
260
- return { isIncomplete: false, items: items }
259
+ return ['value-receiver', items.length, { isIncomplete: false, items: items }]
261
260
  end
262
261
  end
263
262
 
264
263
  # Method completions on a non-module receiver.
265
- branch = 'method-fallback'
266
264
  method_items = measure_perf_stage(stages, 'build') do
267
265
  result = []
268
266
  facts.methods.each do |_recv_type, methods|
@@ -296,10 +294,9 @@ module MilkTea
296
294
  method_items = method_items.first(MAX_COMPLETION_ITEMS)
297
295
  item_count = MAX_COMPLETION_ITEMS
298
296
  end
299
- return { isIncomplete: truncated, items: method_items }
297
+ return ['method-fallback', item_count, { isIncomplete: truncated, items: method_items }]
300
298
  end
301
299
 
302
- branch = 'global'
303
300
  items = measure_perf_stage(stages, 'build') do
304
301
  result = []
305
302
  function_docs_cache = {}
@@ -450,13 +447,51 @@ module MilkTea
450
447
  items = items.first(MAX_COMPLETION_ITEMS)
451
448
  item_count = MAX_COMPLETION_ITEMS
452
449
  end
453
- { isIncomplete: truncated, items: items }
454
- rescue StandardError => e
455
- branch = 'error'
456
- warn "Error in completion handler: #{e.message}"
457
- { isIncomplete: false, items: [] }
458
- ensure
459
- log_request_stage_breakdown('textDocument/completion', total_start, uri: uri, stages: stages, summary: "branch=#{branch} items=#{item_count}")
450
+ ['global', item_count, { isIncomplete: truncated, items: items }]
451
+ end
452
+
453
+ # Serve a previously computed candidate pool when the editor is
454
+ # re-triggering with a longer prefix (triggerFromIncompleteCompletions)
455
+ # on the same line. Safe only when the text before the cursor is a strict
456
+ # extension of the cached context and the word prefix has grown; re-filter
457
+ # the (already prefix-filtered) pool with the longer prefix.
458
+ def completion_session_response(uri, lsp_line, lsp_char, prefix)
459
+ return nil if prefix.empty?
460
+ return nil unless @current_completion_trigger_kind == TRIGGER_KIND_INCOMPLETE
461
+
462
+ content = @workspace.get_content(uri)
463
+ line_prefix = (content.split("\n", -1)[lsp_line] || '')[0...lsp_char]
464
+ cached = @completion_session_cache[[uri, lsp_line]]
465
+ return nil unless cached
466
+ return nil unless line_prefix.start_with?(cached[:line_prefix])
467
+ return nil unless prefix.start_with?(cached[:prefix])
468
+
469
+ items = cached[:items].select { |item| item[:label].to_s.start_with?(prefix) }
470
+ { isIncomplete: cached[:isIncomplete], items: items }
471
+ end
472
+
473
+ def store_completion_session(uri, lsp_line, lsp_char, prefix, response)
474
+ return if prefix.empty?
475
+
476
+ content = @workspace.get_content(uri)
477
+ line_prefix = (content.split("\n", -1)[lsp_line] || '')[0...lsp_char]
478
+ key = [uri, lsp_line]
479
+ @completion_session_cache[key] = {
480
+ line_prefix: line_prefix,
481
+ prefix: prefix,
482
+ isIncomplete: response[:isIncomplete],
483
+ items: response[:items],
484
+ }
485
+ if @completion_session_order_set.add?(key)
486
+ @completion_session_order << key
487
+ evict_completion_sessions while @completion_session_order.length > MAX_COMPLETION_SESSION_ENTRIES
488
+ end
489
+ end
490
+
491
+ def evict_completion_sessions
492
+ key = @completion_session_order.shift
493
+ @completion_session_cache.delete(key)
494
+ @completion_session_order_set.delete(key)
460
495
  end
461
496
 
462
497
  def value_chain_completions(facts, dot_recv_path, lsp_line, lsp_char, prefix)
@@ -821,6 +856,11 @@ module MilkTea
821
856
  return params if name.to_s.empty?
822
857
 
823
858
  uri = data['uri'] || ''
859
+ key = [uri, name]
860
+ if @completion_resolve_cache.key?(key)
861
+ return @completion_resolve_cache[key]
862
+ end
863
+
824
864
  definition_entry = @workspace.find_definition_token_global(name, preferred_uri: uri)
825
865
  return params unless definition_entry
826
866
 
@@ -828,7 +868,7 @@ module MilkTea
828
868
  docs = signature_help_markdown_for_doc_comment(doc_comment)
829
869
  return params if docs.empty?
830
870
 
831
- params.merge('documentation' => { 'kind' => 'markdown', 'value' => docs })
871
+ @completion_resolve_cache[key] = params.merge('documentation' => { 'kind' => 'markdown', 'value' => docs })
832
872
  rescue StandardError => e
833
873
  warn "Error in completion resolve handler: #{e.message}"
834
874
  params
@@ -862,6 +902,7 @@ module MilkTea
862
902
 
863
903
  current_path = uri_to_path(uri)
864
904
  return nil unless current_path
905
+ current_path = File.expand_path(current_path)
865
906
 
866
907
  roots = MilkTea::ModuleRoots.roots_for_path(current_path)
867
908
  fs_dir = dir_segments.join(File::SEPARATOR)
@@ -870,23 +911,13 @@ module MilkTea
870
911
  filter_lower = filter.downcase
871
912
 
872
913
  roots.each do |root|
873
- search_dir = fs_dir.empty? ? root : File.join(root, fs_dir)
874
- next unless File.directory?(search_dir)
875
-
876
- Dir.children(search_dir).sort.each do |name|
877
- next if name.start_with?('.')
878
- full_path = File.join(search_dir, name)
879
-
880
- if name.end_with?('.mt')
881
- mod_name = name.delete_suffix('.mt')
882
- next if mod_name.start_with?('.')
883
- next if full_path == current_path
884
- next unless filter.empty? || mod_name.downcase.start_with?(filter_lower)
885
- modules[mod_name] = mod_name
886
- elsif File.directory?(full_path) && module_dir_contains_mt?(full_path)
887
- next unless filter.empty? || name.downcase.start_with?(filter_lower)
888
- modules[name] = name
889
- end
914
+ names = @workspace.module_importable_names(root, fs_dir, current_path: current_path)
915
+ next unless names
916
+
917
+ names.each_key do |mod_name|
918
+ next unless filter.empty? || mod_name.downcase.start_with?(filter_lower)
919
+
920
+ modules[mod_name] = mod_name
890
921
  end
891
922
  end
892
923
 
@@ -904,14 +935,6 @@ module MilkTea
904
935
  end
905
936
  end
906
937
 
907
- def module_dir_contains_mt?(dir)
908
- Dir.children(dir).any? do |name|
909
- next false if name.start_with?('.')
910
- full = File.join(dir, name)
911
- name.end_with?('.mt') || (File.directory?(full) && module_dir_contains_mt?(full))
912
- end
913
- end
914
-
915
938
  def attribute_completions(facts, uri, line, char)
916
939
  content = @workspace.get_content(uri)
917
940
  return nil unless content
@@ -142,6 +142,10 @@ module MilkTea
142
142
  end
143
143
  end
144
144
  end
145
+ # Fresh facts just landed for this document; let the editor
146
+ # re-fetch semantic tokens so analyzed highlighting replaces the
147
+ # lexical fallback that was served while facts were unavailable.
148
+ refresh_client_semantic_tokens
145
149
  elsif perf_logging?
146
150
  @diagnostics_perf[:dropped_stale] += 1
147
151
  end
@@ -223,12 +227,6 @@ module MilkTea
223
227
  dependency_export_surface_fingerprint(previous_content) != dependency_export_surface_fingerprint(current_content)
224
228
  end
225
229
 
226
- def semantic_tokens_allow_last_good_fallback?(uri)
227
- @diagnostics_mutex.synchronize do
228
- @diagnostics_pending.key?(uri) || @diagnostics_enqueued.include?(uri)
229
- end
230
- end
231
-
232
230
  def notify_diagnostic_errors(uri, diagnostics)
233
231
  errors = diagnostics.select { |d| d.is_a?(Hash) && (d["severity"] || d[:severity]) == 1 }
234
232
  return if errors.empty?
@@ -10,19 +10,29 @@ module MilkTea
10
10
  stages = new_perf_stages
11
11
  total_start = stages ? monotonic_time : nil
12
12
  uri = params['textDocument']['uri']
13
+
14
+ content_hash = @workspace.get_content(uri).hash
15
+ facts = @workspace.get_facts(uri)
16
+ facts_id = facts&.object_id
17
+ cached = @document_symbol_cache[uri]
18
+ if cached && cached[:content_hash] == content_hash && cached[:facts_id] == facts_id
19
+ return cached[:result]
20
+ end
21
+
13
22
  symbols = measure_perf_stage(stages, 'symbols') { @workspace.get_symbols(uri) }
14
23
  result = measure_perf_stage(stages, 'format') { symbols.map { |sym| format_document_symbol(sym) } }
15
24
 
16
- # Enrich with hierarchical children from AST
17
- ast = @workspace.get_ast(uri)
25
+ # Enrich with hierarchical children from AST. Use the facts' own AST so
26
+ # binding_resolution node object_ids line up with the outline locals.
27
+ ast = facts&.ast || @workspace.get_ast(uri)
18
28
  if ast && result
19
- facts = @workspace.get_facts(uri)
20
29
  enrich_with_children(result, ast, facts)
21
30
  end
22
31
 
23
32
  module_name = resolve_outline_module_name(uri)
24
33
  result = wrap_in_module_hierarchy(result, module_name, uri) if module_name && result&.any?
25
34
 
35
+ @document_symbol_cache[uri] = { content_hash: content_hash, facts_id: facts_id, result: result }
26
36
  result
27
37
  rescue StandardError => e
28
38
  warn "Error in documentSymbol handler: #{e.message}"