mt-lang 0.3.37 → 0.3.39

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 (41) 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/c_backend/reinterpret.rb +2 -2
  5. data/lib/milk_tea/core/c_backend.rb +12 -0
  6. data/lib/milk_tea/core/compile_time.rb +46 -17
  7. data/lib/milk_tea/core/lexer.rb +12 -0
  8. data/lib/milk_tea/core/lowering/resolve.rb +2 -3
  9. data/lib/milk_tea/core/lowering.rb +12 -0
  10. data/lib/milk_tea/core/module_loader.rb +59 -37
  11. data/lib/milk_tea/core/parser.rb +12 -0
  12. data/lib/milk_tea/core/semantic_analyzer/attributes.rb +2 -1
  13. data/lib/milk_tea/core/semantic_analyzer/function_binding.rb +8 -20
  14. data/lib/milk_tea/core/semantic_analyzer/interface_conformance.rb +2 -1
  15. data/lib/milk_tea/core/semantic_analyzer/statements.rb +10 -10
  16. data/lib/milk_tea/core/semantic_analyzer/top_level.rb +9 -11
  17. data/lib/milk_tea/core/semantic_analyzer/type_declaration.rb +14 -5
  18. data/lib/milk_tea/core/semantic_analyzer.rb +6 -31
  19. data/lib/milk_tea/lsp/diagnostics.rb +15 -5
  20. data/lib/milk_tea/lsp/server/code_actions.rb +12 -2
  21. data/lib/milk_tea/lsp/server/completion.rb +100 -77
  22. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +4 -6
  23. data/lib/milk_tea/lsp/server/formatting.rb +184 -60
  24. data/lib/milk_tea/lsp/server/hover.rb +321 -41
  25. data/lib/milk_tea/lsp/server/inlay_hints.rb +33 -11
  26. data/lib/milk_tea/lsp/server/lifecycle.rb +12 -0
  27. data/lib/milk_tea/lsp/server/references.rb +3 -1
  28. data/lib/milk_tea/lsp/server/selection_range.rb +4 -4
  29. data/lib/milk_tea/lsp/server/semantic_tokens.rb +63 -20
  30. data/lib/milk_tea/lsp/server/text_documents.rb +2 -2
  31. data/lib/milk_tea/lsp/server/type_hierarchy.rb +7 -7
  32. data/lib/milk_tea/lsp/server/utilities.rb +4 -0
  33. data/lib/milk_tea/lsp/server.rb +9 -0
  34. data/lib/milk_tea/lsp/workspace/analysis.rb +14 -3
  35. data/lib/milk_tea/lsp/workspace/caches.rb +17 -1
  36. data/lib/milk_tea/lsp/workspace/collection.rb +4 -0
  37. data/lib/milk_tea/lsp/workspace/module_index.rb +134 -0
  38. data/lib/milk_tea/lsp/workspace/store.rb +9 -4
  39. data/lib/milk_tea/lsp/workspace.rb +8 -0
  40. data/lib/milk_tea/packages/manifest.rb +9 -0
  41. metadata +5 -3
@@ -12,23 +12,21 @@ module MilkTea
12
12
 
13
13
  content = @workspace.get_content(uri)
14
14
  cache_key = content.hash
15
+ facts = @workspace.peek_facts(uri)
16
+ facts_id = facts&.object_id
15
17
  cached = @semantic_tokens_cache[uri]
16
- if cached && cached[:content_hash] == cache_key
18
+ if cached && cached[:content_hash] == cache_key && cached[:facts_id] == facts_id
17
19
  elapsed = elapsed_ms(total_start)
18
20
  short_uri = shorten_uri(uri) || uri
19
21
  log_perf_breakdown('textDocument/semanticTokens/full', elapsed,
20
22
  "uri=#{short_uri} bytes=#{content.bytesize} lines=#{content.count("\n") + 1} cache=hit data_len=#{cached[:data].length}")
21
- return { data: cached[:data] }
23
+ return { resultId: cached[:result_id], data: cached[:data] }
22
24
  end
23
25
 
24
26
  tokens_start = monotonic_time
25
27
  tokens = @workspace.get_tokens(uri) || []
26
28
  tokens_ms = elapsed_ms(tokens_start)
27
29
 
28
- facts_start = monotonic_time
29
- facts = @workspace.get_facts(uri, allow_last_good_fallback: semantic_tokens_allow_last_good_fallback?(uri))
30
- facts_ms = elapsed_ms(facts_start)
31
-
32
30
  build_start = monotonic_time
33
31
  semantic_entries = build_semantic_token_entries(tokens, facts)
34
32
  build_ms = elapsed_ms(build_start)
@@ -37,21 +35,21 @@ module MilkTea
37
35
  data = encode_semantic_tokens(semantic_entries)
38
36
  encode_ms = elapsed_ms(encode_start)
39
37
 
40
- @semantic_tokens_cache[uri] = { content_hash: cache_key, data: data }
41
-
42
38
  result_id = next_semantic_token_result_id(uri)
39
+ @semantic_tokens_cache[uri] = { content_hash: cache_key, data: data, result_id: result_id, facts_id: facts_id }
43
40
  @semantic_tokens_delta_cache[uri] = {
44
41
  result_id: result_id,
45
42
  content_hash: cache_key,
43
+ facts_id: facts_id,
46
44
  entries: semantic_entries,
47
45
  }
48
46
 
49
47
  elapsed = elapsed_ms(total_start)
50
48
  short_uri = shorten_uri(uri) || uri
51
49
  log_perf_breakdown('textDocument/semanticTokens/full', elapsed,
52
- "uri=#{short_uri} bytes=#{content.bytesize} lines=#{content.count("\n") + 1} cache=miss tokens=#{tokens.length} entries=#{semantic_entries.length} data_len=#{data.length} facts=on stages_ms=tokens:#{tokens_ms},facts:#{facts_ms},build:#{build_ms},encode:#{encode_ms}")
50
+ "uri=#{short_uri} bytes=#{content.bytesize} lines=#{content.count("\n") + 1} cache=miss tokens=#{tokens.length} entries=#{semantic_entries.length} data_len=#{data.length} facts=#{facts_id ? 'on' : 'off'} stages_ms=tokens:#{tokens_ms},build:#{build_ms},encode:#{encode_ms}")
53
51
 
54
- { data: data }
52
+ { resultId: result_id, data: data }
55
53
  rescue StandardError => e
56
54
  warn "Error in semanticTokens/full handler: #{e.message}"
57
55
  { data: [] }
@@ -69,18 +67,20 @@ module MilkTea
69
67
 
70
68
  content = @workspace.get_content(uri)
71
69
  cache_key = content.hash
72
- if cached[:content_hash] == cache_key
70
+ facts = @workspace.peek_facts(uri)
71
+ facts_id = facts&.object_id
72
+ if cached[:content_hash] == cache_key && cached[:facts_id] == facts_id
73
73
  return { resultId: cached[:result_id], edits: [] }
74
74
  end
75
75
 
76
76
  tokens = @workspace.get_tokens(uri) || []
77
- facts = @workspace.get_facts(uri, allow_last_good_fallback: semantic_tokens_allow_last_good_fallback?(uri))
78
77
  new_entries = build_semantic_token_entries(tokens, facts)
79
78
 
80
79
  new_result_id = next_semantic_token_result_id(uri)
81
80
  @semantic_tokens_delta_cache[uri] = {
82
81
  result_id: new_result_id,
83
82
  content_hash: cache_key,
83
+ facts_id: facts_id,
84
84
  entries: new_entries,
85
85
  }
86
86
 
@@ -110,7 +110,7 @@ module MilkTea
110
110
  ![:newline, :indent, :dedent, :eof].include?(t.type)
111
111
  end
112
112
 
113
- facts = @workspace.get_facts(uri, allow_last_good_fallback: semantic_tokens_allow_last_good_fallback?(uri))
113
+ facts = @workspace.peek_facts(uri)
114
114
  semantic_entries = build_semantic_token_entries(range_tokens, facts)
115
115
 
116
116
  entries_in_range = semantic_entries.select do |entry|
@@ -129,6 +129,7 @@ module MilkTea
129
129
  # is O(1) per call instead of O(n), turning the overall build from O(n²) to O(n).
130
130
  trivia_types = Set[:newline, :indent, :dedent, :eof]
131
131
  @tokens_by_line_cache = Hash.new { |h, k| h[k] = [] }
132
+ @semantic_token_build_cache = {}
132
133
  tokens.each { |t| @tokens_by_line_cache[t.line] << t unless trivia_types.include?(t.type) }
133
134
  @attribute_name_semantic_overrides = build_attribute_name_semantic_overrides(tokens, facts)
134
135
 
@@ -153,6 +154,24 @@ module MilkTea
153
154
  ensure
154
155
  @tokens_by_line_cache = nil
155
156
  @attribute_name_semantic_overrides = nil
157
+ @semantic_token_build_cache = nil
158
+ end
159
+
160
+ # Memoize facts-derived lookups for the duration of one semantic-token
161
+ # build. The build cache is scoped per build (set up and torn down in
162
+ # build_semantic_token_entries) so repeated per-token classifications do
163
+ # not re-scan frames/type scopes/types for every identifier token.
164
+ def semantic_build_memo?(key)
165
+ !@semantic_token_build_cache.nil? && @semantic_token_build_cache.key?(key)
166
+ end
167
+
168
+ def semantic_build_memo(key)
169
+ @semantic_token_build_cache[key][1] if @semantic_token_build_cache&.key?(key)
170
+ end
171
+
172
+ def store_semantic_build_memo(key, value)
173
+ @semantic_token_build_cache[key] = [:ok, value] if @semantic_token_build_cache
174
+ value
156
175
  end
157
176
 
158
177
  def fstring_interpolation_entries(fstring_tok, facts)
@@ -1053,11 +1072,23 @@ module MilkTea
1053
1072
 
1054
1073
  def known_type_name?(facts, name)
1055
1074
  return false unless facts
1056
- return true if facts.types.key?(name)
1057
- facts.types.each_value do |type|
1058
- return true if type.respond_to?(:nested_types) && type.nested_types.key?(name)
1075
+ memo_key = [:known_type, facts.object_id, name]
1076
+ return semantic_build_memo(memo_key) if semantic_build_memo?(memo_key)
1077
+
1078
+ result = if facts.types.key?(name)
1079
+ true
1080
+ else
1081
+ found = false
1082
+ facts.types.each_value do |type|
1083
+ if type.respond_to?(:nested_types) && type.nested_types.key?(name)
1084
+ found = true
1085
+ break
1086
+ end
1087
+ end
1088
+ found
1059
1089
  end
1060
- false
1090
+
1091
+ store_semantic_build_memo(memo_key, result)
1061
1092
  end
1062
1093
 
1063
1094
  def dot_nested_type_member?(tokens, index, facts)
@@ -1318,6 +1349,9 @@ module MilkTea
1318
1349
  end
1319
1350
 
1320
1351
  def type_parameter_names_in_scope(facts, line)
1352
+ memo_key = [:type_param_names, facts.object_id, line]
1353
+ return semantic_build_memo(memo_key) if semantic_build_memo?(memo_key)
1354
+
1321
1355
  scopes = @type_parameter_scope_cache ||= {}
1322
1356
  cached = scopes[facts.object_id]
1323
1357
  unless cached
@@ -1331,11 +1365,15 @@ module MilkTea
1331
1365
  scopes[facts.object_id] = cached
1332
1366
  end
1333
1367
 
1368
+ result = []
1334
1369
  cached.reverse_each do |scope|
1335
- return scope[:names] if line >= scope[:start_line] && line <= scope[:end_line]
1370
+ if line >= scope[:start_line] && line <= scope[:end_line]
1371
+ result = scope[:names]
1372
+ break
1373
+ end
1336
1374
  end
1337
1375
 
1338
- []
1376
+ store_semantic_build_memo(memo_key, result)
1339
1377
  end
1340
1378
 
1341
1379
  def type_parameter_scopes_for_declaration(decl, end_line: Float::INFINITY)
@@ -1745,7 +1783,12 @@ module MilkTea
1745
1783
 
1746
1784
  start_offset = prefix * 5
1747
1785
  delete_count = old_mid * 5
1748
- insert_tokens = encode_semantic_tokens(new_entries[prefix...(new_entries.length - suffix)])
1786
+
1787
+ # The inserted segment must keep the relative encoding against the
1788
+ # last unchanged token preceding the edit, so encode the full token
1789
+ # stream from the document origin and slice out the middle segment.
1790
+ full_encoded = encode_semantic_tokens(new_entries)
1791
+ insert_tokens = full_encoded[start_offset...(new_entries.length - suffix) * 5]
1749
1792
 
1750
1793
  [{ start: start_offset, deleteCount: delete_count, data: insert_tokens }]
1751
1794
  end
@@ -10,7 +10,7 @@ module MilkTea
10
10
  content = params['textDocument']['text']
11
11
  source = @workspace.document_source(uri) || 'unknown'
12
12
  open_start = monotonic_time
13
- open_stats = @workspace.open_document(uri, content)
13
+ open_stats = @workspace.open_document(uri, content, warm_facts: false)
14
14
  open_ms = elapsed_ms(open_start)
15
15
  @semantic_tokens_cache.delete(uri)
16
16
  @semantic_tokens_delta_cache.delete(uri)
@@ -38,7 +38,7 @@ module MilkTea
38
38
  changes = params['contentChanges'] || []
39
39
  previous_content = @workspace.get_content(uri)
40
40
 
41
- @workspace.apply_incremental_changes(uri, changes)
41
+ @workspace.apply_incremental_changes(uri, changes, warm_facts: false)
42
42
 
43
43
  invalidate_document_caches(uri)
44
44
  current_content = @workspace.get_content(uri)
@@ -5,11 +5,11 @@ module MilkTea
5
5
  class Server
6
6
  module ServerTypeHierarchy
7
7
  TYPE_KIND_MAP = {
8
- struct: 22,
9
- enum: 13,
10
- flags: 13,
11
- variant: 13,
12
- union: 22,
8
+ struct: 23,
9
+ enum: 10,
10
+ flags: 10,
11
+ variant: 23,
12
+ union: 23,
13
13
  interface: 11,
14
14
  }.freeze
15
15
 
@@ -115,12 +115,12 @@ module MilkTea
115
115
  def find_type_info(facts, name)
116
116
  if (t = facts.types[name])
117
117
  ast = t.respond_to?(:ast_declaration) ? t.ast_declaration : nil
118
- return { kind: type_kind_from_object(t), line: ast&.line, column: ast.column || 0 }
118
+ return { kind: type_kind_from_object(t), line: ast&.line, column: ast&.column || 0 }
119
119
  end
120
120
 
121
121
  if (t = facts.interfaces[name])
122
122
  ast = t.respond_to?(:ast_declaration) ? t.ast_declaration : nil
123
- return { kind: :interface, line: ast&.line, column: ast.column || 0 }
123
+ return { kind: :interface, line: ast&.line, column: ast&.column || 0 }
124
124
  end
125
125
 
126
126
  nil
@@ -177,6 +177,7 @@ module MilkTea
177
177
 
178
178
  def handle_did_change_watched_files(params)
179
179
  changes = params['changes'] || []
180
+ @workspace.apply_module_index_events(changes)
180
181
  affected_uris = Set.new
181
182
  changes.each do |change|
182
183
  uri = change['uri']
@@ -198,6 +199,9 @@ module MilkTea
198
199
  @semantic_tokens_cache.delete(uri)
199
200
  @semantic_tokens_delta_cache.delete(uri)
200
201
  @fixall_cache.delete(uri)
202
+ @document_symbol_cache.delete(uri)
203
+ @completion_docs_cache.clear
204
+ @completion_resolve_cache.clear
201
205
  path = uri_to_path(uri)
202
206
  if path
203
207
  prefix = "#{path}:"
@@ -209,6 +209,15 @@ module MilkTea
209
209
  @fixall_cache = {}
210
210
  @definition_file_token_cache = {}
211
211
  @definition_file_ast_cache = {}
212
+ # Completion session re-filtering pool keyed by [uri, line].
213
+ @completion_session_cache = {}
214
+ @completion_session_order = []
215
+ @completion_session_order_set = Set.new
216
+ # Server-scoped documentation / resolve memoization keyed by [uri, name].
217
+ @completion_docs_cache = {}
218
+ @completion_resolve_cache = {}
219
+ # Enriched documentSymbol outline keyed by uri -> content_hash.
220
+ @document_symbol_cache = {}
212
221
  @diagnostics_perf = {
213
222
  scheduled: 0,
214
223
  skipped_unchanged: 0,
@@ -62,8 +62,11 @@ module MilkTea
62
62
  nil
63
63
  end
64
64
 
65
- def warm_document_facts(uri, content)
66
- ast = ast_for_content(uri, content)
65
+ # Eagerly compute and cache facts for +uri+. The keystroke path
66
+ # (didChange) passes warm: false so the sema/import-resolution cost runs
67
+ # on the debounced diagnostics worker instead of the request thread;
68
+ # requests then serve last-good facts until the background pass lands.
69
+ def warm_document_facts(uri, content, warm: true)
67
70
  stats = {
68
71
  bytes: content.bytesize,
69
72
  lines: content.count("\n") + 1,
@@ -71,7 +74,7 @@ module MilkTea
71
74
  facts_mode: nil,
72
75
  facts_ms: nil,
73
76
  skip_reason: nil,
74
- import_count: ast.respond_to?(:imports) ? ast.imports.length : 0,
77
+ import_count: 0,
75
78
  shared_module_cache_size: @shared_module_cache.length,
76
79
  }
77
80
 
@@ -80,6 +83,14 @@ module MilkTea
80
83
  return stats
81
84
  end
82
85
 
86
+ unless warm
87
+ stats[:skip_reason] = :deferred
88
+ return stats
89
+ end
90
+
91
+ ast = ast_for_content(uri, content)
92
+ stats[:import_count] = ast.respond_to?(:imports) ? ast.imports.length : 0
93
+
83
94
  facts_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
95
  facts_path = uri_to_path(uri)
85
96
  get_facts(uri)
@@ -23,7 +23,12 @@ module MilkTea
23
23
  end
24
24
 
25
25
  def get_ast(uri)
26
- @ast_cache[uri] ||= parse_document(uri)
26
+ @ast_cache[uri] ||= begin
27
+ # Prefer the facts' own AST so consumers that iterate the AST and
28
+ # resolve bindings via facts.binding_resolution operate on the same
29
+ # node instances (binding IDs are keyed by AST node object_id).
30
+ peek_facts(uri)&.ast || parse_document(uri)
31
+ end
27
32
  end
28
33
 
29
34
  def get_facts(uri, allow_last_good_fallback: true)
@@ -31,6 +36,17 @@ module MilkTea
31
36
  snapshot&.facts
32
37
  end
33
38
 
39
+ # Return the most recent facts already computed for +uri+ without ever
40
+ # running analysis or blocking on the analysis mutex. Used by hot request
41
+ # paths (semantic tokens) that must not stall the request thread on sema;
42
+ # they serve the latest cached/last-good facts and are told to re-fetch
43
+ # once fresh facts land in the background.
44
+ def peek_facts(uri)
45
+ @facts_cache_mutex.synchronize do
46
+ @facts_cache[uri] || @last_good_facts_cache[uri]
47
+ end
48
+ end
49
+
34
50
  def get_tooling_snapshot(uri, allow_last_good_fallback: true)
35
51
  total_start = perf_logging? ? monotonic_time : nil
36
52
  cache_state = 'miss'
@@ -67,6 +67,10 @@ module MilkTea
67
67
  @facts_cache[uri] = facts if facts
68
68
  @last_good_facts_cache[uri] = facts if facts
69
69
  @document_module_names[uri] = facts.module_name if facts&.module_name
70
+ # Consumers resolve binding_resolution against get_ast(uri) node
71
+ # object_ids; keep the AST cache aligned with the facts that
72
+ # reference it so binding lookups line up across the worker path.
73
+ @ast_cache[uri] = snapshot.facts.ast if snapshot&.facts&.ast
70
74
  update_dependency_index(uri, facts)
71
75
  @diagnostics_cache[uri] = {
72
76
  content_hash: hash,
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ module LSP
5
+ class Workspace
6
+ # Persistent module-name index for import-line completion.
7
+ #
8
+ # `import_completions` previously walked the entire module-root tree on
9
+ # every keystroke (stat'ing every directory entry). This index records, per
10
+ # module root, the importable module names reachable from each directory:
11
+ # `.mt` file basenames directly in a directory, plus subdirectory names that
12
+ # transitively contain a `.mt` file. It is built once per root and kept in
13
+ # sync with workspace/didChangeWatchedFiles events, so per-keystroke import
14
+ # completion becomes an in-memory hash lookup.
15
+ module WorkspaceModuleIndex
16
+ def reset_module_index
17
+ @module_index_mutex.synchronize { @module_index.clear }
18
+ end
19
+
20
+ # Rebuild the index for every module root reachable from the workspace
21
+ # root. Called after initialization and after workspace-folder changes.
22
+ def refresh_module_index_for_workspace
23
+ root_path = @workspace_root_path
24
+ return unless root_path
25
+
26
+ module_roots_for_path(root_path).each { |root| ensure_module_index(root) }
27
+ end
28
+
29
+ # Ensure +root+ has an index entry, building it on first use. A race
30
+ # between two threads simply builds twice and the later write wins.
31
+ def ensure_module_index(root)
32
+ present = @module_index_mutex.synchronize { @module_index.key?(root) }
33
+ return if present
34
+
35
+ build_module_index(root)
36
+ end
37
+
38
+ # Return importable module names for +fs_dir+ under +root+, excluding the
39
+ # currently-open +current_path+ file. Returns nil when +root+ is unknown.
40
+ def module_importable_names(root, fs_dir, current_path: nil)
41
+ ensure_module_index(root)
42
+
43
+ entry = @module_index_mutex.synchronize { @module_index[root] }
44
+ return nil unless entry
45
+
46
+ result = {}
47
+ files = entry[:mt_files][fs_dir]
48
+ if files
49
+ files.each do |mod_name, file_path|
50
+ next if current_path && file_path == current_path
51
+
52
+ result[mod_name] = true
53
+ end
54
+ end
55
+ entry[:mt_dirs][fs_dir]&.each { |subdir| result[subdir] = true }
56
+ result
57
+ end
58
+
59
+ # Apply workspace/didChangeWatchedFiles events to the index. Content
60
+ # changes (type 2) do not affect the name index; creates (1) and deletes
61
+ # (3) of `.mt` files rebuild their root once per event batch so a build
62
+ # producing many events triggers a single rescan. Open documents are
63
+ # source-of-truth and are ignored (skip_open: true), matching
64
+ # apply_watched_file_change; rename handling passes skip_open: false so
65
+ # the index never keeps a stale name for a renamed module.
66
+ def apply_module_index_events(changes, skip_open: true)
67
+ open_uris = @document_state_mutex.synchronize { @open_documents.keys }
68
+ roots_to_rebuild = Set.new
69
+ changes.each do |change|
70
+ uri = change.is_a?(Hash) ? change['uri'] : nil
71
+ change_type = change.is_a?(Hash) ? change['type'] : nil
72
+ next unless uri && [1, 3].include?(change_type&.to_i)
73
+ next if skip_open && open_uris.include?(uri)
74
+
75
+ path = uri_to_path(uri)
76
+ next unless path && path.end_with?('.mt')
77
+
78
+ root = module_index_root_for_path(path)
79
+ roots_to_rebuild << root if root
80
+ end
81
+
82
+ roots_to_rebuild.each { |root| build_module_index(root) }
83
+ roots_to_rebuild.length
84
+ end
85
+
86
+ def module_index_roots
87
+ @module_index_mutex.synchronize { @module_index.keys }
88
+ end
89
+
90
+ private
91
+
92
+ def module_index_root_for_path(path)
93
+ expanded = File.expand_path(path)
94
+ @module_index_mutex.synchronize do
95
+ @module_index.each_key do |root|
96
+ return root if expanded == root || expanded.start_with?(root + File::SEPARATOR)
97
+ end
98
+ end
99
+ nil
100
+ end
101
+
102
+ # Scan +root+ once and record, for every directory, the importable module
103
+ # names: `.mt` file basenames in that directory and subdirectory names
104
+ # that transitively contain a `.mt` file.
105
+ def build_module_index(root)
106
+ mt_files = Hash.new { |hash, key| hash[key] = {} }
107
+ mt_dirs = Hash.new { |hash, key| hash[key] = Set.new }
108
+
109
+ files = Dir.glob(File.join(root, '**', '*.mt')).sort
110
+ prefix = root + File::SEPARATOR
111
+ files.each do |file_path|
112
+ rel = file_path.delete_prefix(prefix)
113
+ dir = File.dirname(rel)
114
+ dir = '' if dir == '.'
115
+ mod_name = File.basename(rel, '.mt')
116
+
117
+ mt_files[dir][mod_name] = File.expand_path(file_path)
118
+
119
+ acc = ''
120
+ dir.split(File::SEPARATOR).each do |segment|
121
+ mt_dirs[acc] << segment
122
+ acc = acc.empty? ? segment : "#{acc}#{File::SEPARATOR}#{segment}"
123
+ end
124
+ end
125
+
126
+ @module_index_mutex.synchronize do
127
+ @module_index[root] = { mt_files: mt_files, mt_dirs: mt_dirs }
128
+ end
129
+ nil
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -29,13 +29,13 @@ module MilkTea
29
29
 
30
30
  # ── Document lifecycle ──────────────────────────────────────────────────
31
31
 
32
- def open_document(uri, content)
32
+ def open_document(uri, content, warm_facts: true)
33
33
  @document_state_mutex.synchronize do
34
34
  @open_documents[uri] = content
35
35
  end
36
36
  invalidate_cache(uri)
37
37
  enqueue_definition_warmup(uri) unless background_document?(uri)
38
- warm_document_facts(uri, content)
38
+ warm_document_facts(uri, content, warm: warm_facts)
39
39
  end
40
40
 
41
41
  def close_document(uri)
@@ -56,7 +56,7 @@ module MilkTea
56
56
  enqueue_definition_warmup(uri) unless background_document?(uri)
57
57
  end
58
58
 
59
- def apply_incremental_changes(uri, changes)
59
+ def apply_incremental_changes(uri, changes, warm_facts: true)
60
60
  content = get_content(uri)
61
61
  edits = Array(changes)
62
62
 
@@ -79,7 +79,7 @@ module MilkTea
79
79
  end
80
80
  dependent_uris.each { |dep_uri| invalidate_cache(dep_uri) }
81
81
 
82
- warm_document_facts(uri, new_content)
82
+ warm_document_facts(uri, new_content, warm: warm_facts)
83
83
  end
84
84
 
85
85
  def apply_incremental_change_to_content(content, change)
@@ -107,11 +107,16 @@ module MilkTea
107
107
  end
108
108
 
109
109
  # Index all .mt files under root_uri so they are available for workspace-wide queries.
110
+ # Skips the per-file sweep when the on-disk file set is unchanged since the last
111
+ # index, so repeated workspace/symbol calls do not re-walk and re-read the tree.
110
112
  def index_workspace(root_uri, &progress)
111
113
  root_path = uri_to_path(root_uri)
112
114
  return unless root_path && File.directory?(root_path)
113
115
 
114
116
  paths = Dir.glob(File.join(root_path, '**', '*.mt')).sort
117
+ return if @indexed_workspace_paths == paths
118
+
119
+ @indexed_workspace_paths = paths
115
120
  total = paths.length
116
121
  paths.each_with_index do |path, idx|
117
122
  file_uri = path_to_uri(path)
@@ -11,6 +11,7 @@ require_relative 'workspace/analysis'
11
11
  require_relative 'workspace/dependency_graph'
12
12
  require_relative 'workspace/definition_index'
13
13
  require_relative 'workspace/collection'
14
+ require_relative 'workspace/module_index'
14
15
  require_relative 'workspace/utilities'
15
16
 
16
17
  module MilkTea
@@ -81,6 +82,10 @@ module MilkTea
81
82
  @identifier_index = {}
82
83
  @identifier_index_mutex = Mutex.new
83
84
  @indexed_uris = Set.new
85
+ # Module index: root path -> { mt_files: {dir => {name => path}}, mt_dirs: {dir => Set<subdir>} }
86
+ @module_index = {}
87
+ @module_index_mutex = Mutex.new
88
+ @indexed_workspace_paths = nil
84
89
  end
85
90
 
86
91
  def reset
@@ -113,6 +118,8 @@ module MilkTea
113
118
  @reverse_import_dependents.clear
114
119
  @diagnostics_cache.clear
115
120
  @facts_generation.clear
121
+ @module_index_mutex.synchronize { @module_index.clear }
122
+ @indexed_workspace_paths = nil
116
123
  end
117
124
 
118
125
  include WorkspaceStore
@@ -121,6 +128,7 @@ module MilkTea
121
128
  include WorkspaceDependencyGraph
122
129
  include WorkspaceDefinitionIndex
123
130
  include WorkspaceCollection
131
+ include WorkspaceModuleIndex
124
132
  include WorkspaceUtilities
125
133
  end
126
134
  end
@@ -42,6 +42,15 @@ module MilkTea
42
42
  new(path).load
43
43
  end
44
44
 
45
+ # Best-effort manifest load for optional lookups: returns nil when no
46
+ # manifest applies to the path or when the manifest is invalid, instead
47
+ # of raising PackageManifestError.
48
+ def self.load_option(path)
49
+ load(path)
50
+ rescue PackageManifestError
51
+ nil
52
+ end
53
+
45
54
  def self.manifest_exists_for?(path)
46
55
  path = File.expand_path(path)
47
56
  current = File.directory?(path) ? path : File.dirname(path)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mt-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.37
4
+ version: 0.3.39
5
5
  platform: ruby
6
6
  authors:
7
7
  - Long (Teefan) Tran
@@ -149,6 +149,7 @@ files:
149
149
  - docs/index.html
150
150
  - docs/language-design.md
151
151
  - docs/language-manual.md
152
+ - docs/lsp-performance.md
152
153
  - lib/milk_tea.rb
153
154
  - lib/milk_tea/base.rb
154
155
  - lib/milk_tea/bindings.rb
@@ -338,6 +339,7 @@ files:
338
339
  - lib/milk_tea/lsp/workspace/collection.rb
339
340
  - lib/milk_tea/lsp/workspace/definition_index.rb
340
341
  - lib/milk_tea/lsp/workspace/dependency_graph.rb
342
+ - lib/milk_tea/lsp/workspace/module_index.rb
341
343
  - lib/milk_tea/lsp/workspace/store.rb
342
344
  - lib/milk_tea/lsp/workspace/utilities.rb
343
345
  - lib/milk_tea/packages.rb
@@ -624,7 +626,7 @@ metadata:
624
626
  homepage_uri: https://teefan.github.io/mt-lang/
625
627
  source_code_uri: https://github.com/teefan/mt-lang
626
628
  post_install_message: |
627
- Milk Tea 0.3.37 installed!
629
+ Milk Tea 0.3.39 installed!
628
630
 
629
631
  System requirements:
630
632
  - A C compiler (gcc or clang) must be available on PATH
@@ -646,7 +648,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
646
648
  - !ruby/object:Gem::Version
647
649
  version: '0'
648
650
  requirements: []
649
- rubygems_version: 4.0.6
651
+ rubygems_version: 4.0.18
650
652
  specification_version: 4
651
653
  summary: The Milk Tea programming language compiler toolchain
652
654
  test_files: []