mt-lang 0.3.40 → 0.3.41

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a57605301758f1ff1307cfb5aa7300bb73876be082b9736f853bf14dce18352
4
- data.tar.gz: 8d540b3326a74c0a8370d5799b19eadb78f8d579899ffdfed263847bbfa0c367
3
+ metadata.gz: 91b5015b218da9925b56895b551063fca825ca776fc336fe898ef17e332aae4e
4
+ data.tar.gz: 46e4a2da80edfb7c64fc847ee10844c9f5d36572df35105ab6ed51e2455d347a
5
5
  SHA512:
6
- metadata.gz: c7ff36f0b7a609a7c4dbd69264c70ee7dca22201d520753e254609a3009844c23572c578cbdb5802afdd04897559b578f1e539b5c9c6c2a97c64f16f5e286062
7
- data.tar.gz: 1987f20a6c919b3c8341a339c6473292bf31866a07ffe1805707153b077a282284686366dd299eeb02e91de23a83eec1b8aaa837fc901eadefe58acf54df07a9
6
+ metadata.gz: 0c1dcedaf98ef374c9dd21cded75f8512c755a63e4a3a155e494d9edf098b2d53da3b258edab5d63066a2a9eaa3c2a3883aa69241608509085caf398c1edecc1
7
+ data.tar.gz: a427c7ad0f2475b7dc5cccadcb6cc143ab195f906b7b9b13407a7e754043fd8550495c7dc750f5f27c92e2eca13477d9f6697650bddd515403526812b1dac06b
data/lib/milk_tea/base.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "pathname"
4
4
 
5
5
  module MilkTea
6
- VERSION = "0.3.40"
6
+ VERSION = "0.3.41"
7
7
 
8
8
  def self.root
9
9
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
@@ -158,7 +158,30 @@ module MilkTea
158
158
 
159
159
  def load_file(path)
160
160
  resolved_path = self.class.resolve_source_path(path, platform: @platform, error_class: ModuleLoadError)
161
- @ast_cache[resolved_path] ||= parse_file(resolved_path)
161
+ @ast_cache[resolved_path] ||= cached_or_parse_file(resolved_path)
162
+ end
163
+
164
+ # Parse +resolved_path+, reusing the shared-cache parsed AST when the source
165
+ # is disk-backed and unchanged. ASTs are immutable Data, so sharing them
166
+ # across loaders (and worker threads) is safe; node_ids are assigned at
167
+ # parse time and analyses keep their own per-node result hashes. Source
168
+ # files covered by source_overrides are always re-parsed so live editor
169
+ # buffers win.
170
+ def cached_or_parse_file(resolved_path)
171
+ if use_shared_cache_for?(resolved_path)
172
+ mtime = source_mtime(resolved_path)
173
+ if mtime
174
+ entry = @shared_cache[[:ast, resolved_path]]
175
+ return entry[:ast] if entry && entry[:mtime] == mtime
176
+ end
177
+ end
178
+
179
+ ast = parse_file(resolved_path)
180
+ if use_shared_cache_for?(resolved_path)
181
+ mtime = source_mtime(resolved_path)
182
+ @shared_cache[[:ast, resolved_path]] = { mtime:, ast: } if mtime
183
+ end
184
+ ast
162
185
  end
163
186
 
164
187
  def check_file(path)
@@ -166,7 +189,14 @@ module MilkTea
166
189
  end
167
190
 
168
191
  def with_check_context(path, &block)
169
- Types::Registry.reset!
192
+ # NOTE: Types::Registry.reset! is intentionally NOT called here. The LSP
193
+ # runs check_program_collecting concurrently on diagnostics workers and
194
+ # the request thread; a reset would clear the global intern pool while
195
+ # another thread is mid-analysis, causing threads to intern duplicate type
196
+ # instances. Interning is idempotent and the pool stays small (bounded by
197
+ # the type universe), so it is safe to let it grow for the process
198
+ # lifetime. Single-threaded entry points that want a clean slate (Build#build,
199
+ # server re-initialize) reset explicitly.
170
200
  requested_path = File.expand_path(path)
171
201
  previous_platform = @platform
172
202
  @platform ||= self.class.platform_suffix_for_path(requested_path)
@@ -541,7 +571,7 @@ module MilkTea
541
571
  return [resolved_path, nil, @analysis_cache[resolved_path]] if @analysis_cache.key?(resolved_path)
542
572
  return [resolved_path, nil, extra_cache[resolved_path]] if extra_cache&.key?(resolved_path)
543
573
 
544
- if use_shared_cache?
574
+ if use_shared_cache_for?(resolved_path)
545
575
  entry = @shared_cache[resolved_path]
546
576
  if entry
547
577
  mtime = source_mtime(resolved_path)
@@ -566,7 +596,7 @@ module MilkTea
566
596
  end
567
597
 
568
598
  def update_shared_cache(resolved_path, analysis)
569
- return unless use_shared_cache?
599
+ return unless use_shared_cache_for?(resolved_path)
570
600
 
571
601
  mtime = source_mtime(resolved_path)
572
602
  @shared_cache[resolved_path] = { mtime:, analysis: } if mtime
@@ -730,8 +760,13 @@ module MilkTea
730
760
  )
731
761
  end
732
762
 
733
- def use_shared_cache?
734
- @shared_cache && @source_overrides.empty?
763
+ # The shared cache stores per-module analyses keyed by resolved path. It is
764
+ # consulted only for paths NOT covered by source_overrides: source-overridden
765
+ # files reflect live editor buffers (or in-memory sources) that never match
766
+ # disk, so their entries would be stale by construction. This lets a single
767
+ # open document invalidate only itself while imported modules stay cached.
768
+ def use_shared_cache_for?(resolved_path)
769
+ @shared_cache && !@source_overrides.key?(resolved_path)
735
770
  end
736
771
  end
737
772
  end
@@ -41,16 +41,28 @@ module MilkTea
41
41
  _intern([:string_view]) { StringView.new }
42
42
  end
43
43
 
44
+ # Param signature for intern keys. Parameter#eql? is name-insensitive
45
+ # (assignability must not depend on parameter names), so arrays of
46
+ # Parameter objects would conflate fn(value: int) with fn(arg0: int) in
47
+ # the intern pool. Embedding names here keeps distinct signatures
48
+ # distinct across independent programs sharing a long-lived pool (the LSP
49
+ # never resets the registry between checks).
50
+ def self.param_signature(params)
51
+ params.map { |p| [p.name, p.type, p.mutable, p.passing_mode, p.boundary_type] }
52
+ end
53
+
44
54
  def self.function(name, params:, return_type:, receiver_type: nil, receiver_editable: false, variadic: false, external: false)
45
55
  params_frozen = params.freeze
46
- _intern([:function, name, params_frozen, return_type, receiver_type, receiver_editable, variadic, external]) {
56
+ param_key = param_signature(params_frozen)
57
+ _intern([:function, name, param_key, return_type, receiver_type, receiver_editable, variadic, external]) {
47
58
  Function.new(name, params: params_frozen, return_type: return_type, receiver_type: receiver_type, receiver_editable: receiver_editable, variadic: variadic, external: external)
48
59
  }
49
60
  end
50
61
 
51
62
  def self.proc(params:, return_type:)
52
63
  params_frozen = params.freeze
53
- _intern([:proc, params_frozen, return_type]) { Proc.new(params: params_frozen, return_type: return_type) }
64
+ param_key = param_signature(params_frozen)
65
+ _intern([:proc, param_key, return_type]) { Proc.new(params: params_frozen, return_type: return_type) }
54
66
  end
55
67
 
56
68
  def self.parameter(name, type, mutable: false, passing_mode: :plain, boundary_type: nil)
@@ -360,6 +360,8 @@ module MilkTea
360
360
  end
361
361
 
362
362
  def handle_document_diagnostic(params)
363
+ return { kind: 'full', items: [] } if request_cancelled?(@current_request_id)
364
+
363
365
  uri = params.dig('textDocument', 'uri')
364
366
  return { kind: 'full', items: [] } unless uri
365
367
 
@@ -404,6 +406,8 @@ module MilkTea
404
406
  end
405
407
 
406
408
  def handle_workspace_diagnostic(params)
409
+ return { items: [] } if request_cancelled?(@current_request_id)
410
+
407
411
  progress = nil
408
412
  if (work_done_token = params['workDoneToken'])
409
413
  progress = create_progress_handle(@protocol, work_done_token)
@@ -416,7 +420,13 @@ module MilkTea
416
420
  end
417
421
 
418
422
  all_uris = @workspace.open_document_uris
419
- items = all_uris.filter_map do |uri|
423
+ items = []
424
+ all_uris.each do |uri|
425
+ # Coarse cancellation: workspace/diagnostic can iterate many cold
426
+ # documents (~1s each); bail early when the client cancels so we do
427
+ # not sink CPU into results that will be discarded.
428
+ break if request_cancelled?(@current_request_id)
429
+
420
430
  content = @workspace.get_content(uri)
421
431
  next if content.empty?
422
432
 
@@ -426,10 +436,10 @@ module MilkTea
426
436
 
427
437
  cached = @workspace_diagnostic_cache[uri]
428
438
  if cached && cached[:result_id] == prev_map[uri] && cached[:fingerprint] == fingerprint
429
- { uri: uri, kind: 'unchanged', resultId: result_id, items: [], version: nil }
439
+ items << { uri: uri, kind: 'unchanged', resultId: result_id, items: [], version: nil }
430
440
  else
431
441
  @workspace_diagnostic_cache[uri] = { result_id: result_id, fingerprint: fingerprint }
432
- { uri: uri, kind: 'full', resultId: result_id, items: diagnostics, version: nil }
442
+ items << { uri: uri, kind: 'full', resultId: result_id, items: diagnostics, version: nil }
433
443
  end
434
444
  end
435
445
 
@@ -202,28 +202,72 @@ module MilkTea
202
202
  end.join("\n")
203
203
  end
204
204
 
205
+ # Declaration-prefix regex. Unlike the old form, it also covers the
206
+ # `const function` compound, async/foreign/external/editable/static
207
+ # modifiers, and `attribute` declarations. `static_assert` is not
208
+ # matched: `static` requires whitespace before the keyword, and a bare
209
+ # module-level statement cannot be a declaration.
210
+ SURFACE_DECL_LINE = %r{\A(?:(?:public|foreign|external|async|editable|static|const)\s+)*(?:function|struct|union|enum|flags|variant|interface|event|type|const|var|extending|opaque|attribute)\b}
211
+
212
+ # A bare `name: Type` line. Only struct/union fields (and continuation
213
+ # parameter lines) take this shape; local declarations use `let`/`var`,
214
+ # named arguments use `=`, and match-arm labels start with a keyword or
215
+ # pattern. Requires a type-like token after the colon so `_:` arm labels
216
+ # are not treated as surface.
217
+ SURFACE_FIELD_LINE = /\A[A-Za-z_][A-Za-z0-9_]*\s*:\s*[A-Za-z_\[\]]/
218
+
219
+ # Over-approximation of the module's externally-observable surface. It
220
+ # must never MISS a surface change (a miss leaves shared-cache analyses
221
+ # of dependents stale); false positives only trigger a harmless
222
+ # re-analysis. In addition to declaration lines it captures:
223
+ # - `@[...]` attributes (packed/align/deprecated change layout/docs)
224
+ # - multi-line declaration headers, so parameter/signature edits on
225
+ # continuation lines are detected
226
+ # - struct/union field lines (`name: Type`)
205
227
  def dependency_export_surface_fingerprint(content)
206
- content.to_s.each_line.filter_map do |line|
207
- stripped = line.strip
208
- next if stripped.empty? || stripped.start_with?('#')
228
+ lines = content.to_s.lines.map(&:strip)
229
+ surface = []
230
+ i = 0
231
+ while i < lines.length
232
+ line = lines[i]
233
+ if line.empty? || line.start_with?('#')
234
+ i += 1
235
+ next
236
+ end
209
237
 
210
- if stripped.match?(/\A(?:public\s+)?(?:type|struct|union|enum|flags|variant|interface|event|function|const|var)\b/)
211
- stripped
212
- elsif stripped.match?(/\Aextending\b/)
213
- stripped
214
- elsif stripped.match?(/\Apublic\s+(?:function|const|var|type)\b/)
215
- stripped
238
+ if line.start_with?('@[') || line.match?(SURFACE_DECL_LINE) || line.match?(SURFACE_FIELD_LINE)
239
+ surface << line
240
+ # Multi-line header: absorb continuation lines until the
241
+ # terminating ':' so edits inside the header are surfaced too.
242
+ unless line.end_with?(':')
243
+ i += 1
244
+ while i < lines.length
245
+ cont = lines[i]
246
+ break if cont.empty?
247
+ surface << cont
248
+ i += 1
249
+ break if cont.end_with?(':')
250
+ end
251
+ next
252
+ end
216
253
  end
217
- end.join("\n")
254
+ i += 1
255
+ end
256
+ surface.join("\n")
218
257
  end
219
258
 
220
259
  def dependency_refresh_required_for_edit?(changed_uri, previous_content, current_content)
221
260
  return false if previous_content == current_content
222
261
  return true if dependency_import_fingerprint(previous_content) != dependency_import_fingerprint(current_content)
223
262
 
224
- related_uris = @workspace.related_open_document_uris(changed_uri)
225
- return false unless related_uris.length > 1
263
+ # Keep the open-document dependency index fresh (related_open_document_uris
264
+ # updates it as a side effect) so dependent tracking stays accurate.
265
+ @workspace.related_open_document_uris(changed_uri)
226
266
 
267
+ # A surface edit can invalidate shared-cache analyses of NON-open
268
+ # dependents (their cached entries are recomputed against this module
269
+ # on their next pull), so clearing must not be gated on there being
270
+ # open dependents to refresh.
227
271
  dependency_export_surface_fingerprint(previous_content) != dependency_export_surface_fingerprint(current_content)
228
272
  end
229
273
 
@@ -43,7 +43,12 @@ module MilkTea
43
43
  invalidate_document_caches(uri)
44
44
  current_content = @workspace.get_content(uri)
45
45
  refresh_open_document_dependency_state(uri, previous_content: previous_content, current_content: current_content)
46
- schedule_diagnostics(uri, lint_tier: :fast) unless @workspace.background_document?(uri)
46
+ # This server is always pull-based (diagnosticProvider), so the
47
+ # client's textDocument/diagnostic request asks for the full tier.
48
+ # Scheduling a lighter tier here computes in the worker and then
49
+ # duplicates the full lint on the request thread; keep the tiers
50
+ # aligned so the pull can serve the worker's result.
51
+ schedule_diagnostics(uri, lint_tier: :full) unless @workspace.background_document?(uri)
47
52
  nil
48
53
  end
49
54
 
@@ -65,12 +70,23 @@ module MilkTea
65
70
 
66
71
  def handle_did_close(params)
67
72
  uri = params['textDocument']['uri']
73
+ previous_content = @workspace.get_content(uri)
68
74
  cancel_diagnostics(uri)
69
75
  @workspace.close_document(uri)
70
76
  invalidate_document_caches(uri)
71
77
  @diagnostic_report_cache.delete(uri)
72
78
  @workspace_diagnostic_cache.delete(uri)
73
- refresh_open_document_dependency_state(uri)
79
+ # Once closed, the buffer is no longer authoritative. If it differed
80
+ # from disk, module analyses computed against it (for dependents and
81
+ # the file itself) are stale; drop the whole shared cache. Closing is
82
+ # a rare per-file event, so an unconditional clear is cheap and safe.
83
+ disk_content = begin
84
+ path = uri_to_path(uri)
85
+ path && File.file?(path) ? File.read(path) : nil
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ clear_shared_module_cache if previous_content != disk_content
74
90
  unless defined?(@pull_diagnostics_active) && @pull_diagnostics_active
75
91
  @protocol.write_notification('textDocument/publishDiagnostics', {
76
92
  uri: uri,
@@ -99,14 +99,41 @@ module MilkTea
99
99
 
100
100
  lock_wait_start = total_start ? monotonic_time : nil
101
101
  if @facts_state_mutex.try_lock
102
+ # No analysis is in flight; computing here is fine (cold paths,
103
+ # tests). It does not block on other work.
102
104
  begin
103
105
  compute_snapshot.call
104
106
  ensure
105
107
  @facts_state_mutex.unlock
106
108
  end
107
109
  elsif allow_last_good_fallback && last_good_snapshot
108
- cache_state = 'last_good'
110
+ # Facts exist from a prior pass; never block on the in-flight one.
109
111
  snapshot = last_good_snapshot
112
+ cache_state = 'last_good'
113
+ elsif allow_last_good_fallback
114
+ # No facts yet and a background analysis is computing this document.
115
+ # Wait briefly (bounded) for it so the request usually returns fresh
116
+ # facts instead of a lexical fallback — without duplicating the work
117
+ # (we never run analysis here) or blocking indefinitely. Beyond the
118
+ # budget, serve nil and let the handler fall back.
119
+ deadline = monotonic_time + (IN_FLIGHT_FACTS_WAIT_MS / 1000.0)
120
+ acquired = false
121
+ while monotonic_time < deadline
122
+ if @facts_state_mutex.try_lock
123
+ acquired = true
124
+ break
125
+ end
126
+ sleep 0.02
127
+ end
128
+ if acquired
129
+ begin
130
+ compute_snapshot.call
131
+ ensure
132
+ @facts_state_mutex.unlock
133
+ end
134
+ else
135
+ cache_state = 'nil'
136
+ end
110
137
  else
111
138
  @facts_state_mutex.synchronize do
112
139
  lock_wait_ms = elapsed_ms(lock_wait_start) if lock_wait_start
@@ -107,6 +107,37 @@ module MilkTea
107
107
  @full_reverse_index_built = false
108
108
  end
109
109
 
110
+ # Drop all cached module analyses (and the per-uri snapshots derived from
111
+ # them) when an analysis input that was captured in those analyses is no
112
+ # longer authoritative — e.g. a closed document whose buffer differed from
113
+ # disk. Kept rare: closing a file is infrequent, so a wholesale clear is
114
+ # safer than reasoning about which dependents could have observed the
115
+ # closed buffer. Open documents keep their last-known-good snapshots so
116
+ # they keep serving rich features while re-analysis lands.
117
+ def clear_shared_module_cache
118
+ @facts_state_mutex.synchronize do
119
+ @facts_cache_mutex.synchronize do
120
+ all_open = @document_state_mutex.synchronize { @open_documents.keys }
121
+ preserved_facts = all_open.each_with_object({}) do |open_uri, preserved|
122
+ facts = @last_good_facts_cache[open_uri]
123
+ preserved[open_uri] = facts if facts
124
+ end
125
+ preserved_snapshots = all_open.each_with_object({}) do |open_uri, preserved|
126
+ snapshot = @last_good_tooling_snapshot_cache[open_uri]
127
+ preserved[open_uri] = snapshot if snapshot
128
+ end
129
+ @shared_module_cache.clear
130
+ @facts_cache.clear
131
+ @tooling_snapshot_cache.clear
132
+ @diagnostics_cache.clear
133
+ @last_good_facts_cache.clear
134
+ @last_good_tooling_snapshot_cache.clear
135
+ preserved_snapshots.each { |open_uri, snapshot| @last_good_tooling_snapshot_cache[open_uri] = snapshot }
136
+ preserved_facts.each { |open_uri, facts| @last_good_facts_cache[open_uri] = facts }
137
+ end
138
+ end
139
+ end
140
+
110
141
  def update_dependency_index(uri, facts)
111
142
  imported_module_names = if facts
112
143
  facts.imports.each_value.filter_map(&:name).to_set
@@ -72,7 +72,13 @@ module MilkTea
72
72
  invalidate_cache(uri)
73
73
  enqueue_definition_warmup(uri) unless background_document?(uri)
74
74
 
75
- @shared_module_cache.clear
75
+ # The shared module cache is intentionally NOT cleared here. Imported
76
+ # module analyses only become stale when this file's dependency surface
77
+ # (imports or exported declarations) changes; a body-only edit leaves
78
+ # them valid. handle_did_change detects surface changes via
79
+ # dependency_refresh_required_for_edit? and clears the cache through
80
+ # refresh_import_dependent_caches. Clearing it on every keystroke forced
81
+ # a full re-analysis of every transitive module per edit.
76
82
  dependent_uris = @facts_cache_mutex.synchronize do
77
83
  all_open = @document_state_mutex.synchronize { @open_documents.keys }
78
84
  dependent_open_document_uris_for(uri, all_open)
@@ -22,6 +22,12 @@ module MilkTea
22
22
  DOCUMENT_SOURCES = %w[active-editor visible-editor background-document].freeze
23
23
  PERF_LOG_THRESHOLD_MS = 1000
24
24
 
25
+ # How long a request thread waits (when no facts are available yet) for an
26
+ # in-flight background analysis to finish before falling back to lexical/
27
+ # nil results. Bounds the main-loop stall while usually returning fresh
28
+ # facts; request threads never run the analysis themselves in this window.
29
+ IN_FLIGHT_FACTS_WAIT_MS = Integer(ENV.fetch('MILK_TEA_LSP_FACTS_WAIT_MS', '500'))
30
+
25
31
  # Token types that introduce a named definition, in order of precedence.
26
32
  #
27
33
  # NOTE: this list is intentionally minimal. Multi-keyword prefixes such as
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.40
4
+ version: 0.3.41
5
5
  platform: ruby
6
6
  authors:
7
7
  - Long (Teefan) Tran
@@ -625,7 +625,7 @@ metadata:
625
625
  homepage_uri: https://teefan.github.io/mt-lang/
626
626
  source_code_uri: https://github.com/teefan/mt-lang
627
627
  post_install_message: |
628
- Milk Tea 0.3.40 installed!
628
+ Milk Tea 0.3.41 installed!
629
629
 
630
630
  System requirements:
631
631
  - A C compiler (gcc or clang) must be available on PATH