mt-lang 0.3.40 → 0.3.42

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/milk_tea/base.rb +1 -1
  3. data/lib/milk_tea/bindings/bindgen/declaration.rb +17 -0
  4. data/lib/milk_tea/bindings/bindgen/emitter.rb +44 -5
  5. data/lib/milk_tea/bindings/imported_bindings/defaults.rb +7 -0
  6. data/lib/milk_tea/bindings/raw_bindings/defaults.rb +22 -0
  7. data/lib/milk_tea/bindings/upstream_sources.rb +9 -0
  8. data/lib/milk_tea/bindings/vendored_box3d.rb +77 -0
  9. data/lib/milk_tea/bindings.rb +1 -0
  10. data/lib/milk_tea/core/c_backend/expressions.rb +21 -5
  11. data/lib/milk_tea/core/c_backend/feature_detection.rb +1 -1
  12. data/lib/milk_tea/core/c_backend/runtime_helpers.rb +8 -4
  13. data/lib/milk_tea/core/c_backend/statements.rb +1 -1
  14. data/lib/milk_tea/core/c_backend/type_collectors.rb +23 -4
  15. data/lib/milk_tea/core/lowering/async/async_lowering.rb +2 -6
  16. data/lib/milk_tea/core/lowering/async/frame_builder.rb +12 -5
  17. data/lib/milk_tea/core/lowering/async/normalization.rb +26 -5
  18. data/lib/milk_tea/core/module_loader.rb +41 -6
  19. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +8 -2
  20. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +3 -1
  21. data/lib/milk_tea/core/types/registry.rb +14 -2
  22. data/lib/milk_tea/lsp/server/code_actions.rb +13 -3
  23. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +56 -12
  24. data/lib/milk_tea/lsp/server/text_documents.rb +18 -2
  25. data/lib/milk_tea/lsp/workspace/caches.rb +28 -1
  26. data/lib/milk_tea/lsp/workspace/dependency_graph.rb +31 -0
  27. data/lib/milk_tea/lsp/workspace/store.rb +7 -1
  28. data/lib/milk_tea/lsp/workspace.rb +6 -0
  29. data/lib/milk_tea/tooling/docs_app.rb +1 -1
  30. data/std/box3d.mt +784 -0
  31. data/std/c/box3d.mt +1871 -0
  32. data/std/c/miniaudio.mt +19 -28
  33. data/std/c/steamworks.mt +2 -5
  34. data/std/miniaudio.mt +9 -12
  35. data/std/steamworks.mt +0 -1
  36. metadata +5 -2
@@ -248,7 +248,13 @@ module MilkTea
248
248
  raise_sema_error("unsupported expression #{expression.class.name}")
249
249
  end
250
250
 
251
- @resolved_expr_types[@ctx.ast.node_ids[expression.object_id]] = type
251
+ # Specialized generic-instance bodies share the same AST nodes across
252
+ # substitutions, so a node-id keyed cache cannot be populated there:
253
+ # the last-checked instance would overwrite every other instance's
254
+ # types and leak wrong types to lowering, which reads this cache.
255
+ # Regular functions carry an empty (but non-nil) substitution hash, so
256
+ # only a non-empty hash identifies an instance body.
257
+ @resolved_expr_types[@ctx.ast.node_ids[expression.object_id]] = type unless @current_type_substitutions&.any?
252
258
  type
253
259
  end
254
260
  end
@@ -961,7 +967,7 @@ module MilkTea
961
967
  callable_kind = resolution.kind
962
968
  callable = resolution.value
963
969
  receiver = resolution.receiver
964
- @resolved_call_kinds[@ctx.ast.node_ids[expression.callee.object_id]] = callable_kind
970
+ @resolved_call_kinds[@ctx.ast.node_ids[expression.callee.object_id]] = callable_kind unless @current_type_substitutions&.any?
965
971
 
966
972
  case callable_kind
967
973
  when :function
@@ -695,7 +695,9 @@ module MilkTea
695
695
  offset = CompileTime::Layout.offset_of(type, binding.const_value.field_name)
696
696
  return unless offset
697
697
 
698
- @const_values[@ctx.ast.node_ids[expression.object_id]] = offset
698
+ # Same node-id key collision as resolved_expr_types: instance bodies
699
+ # share AST nodes across substitutions, so never cache their values.
700
+ @const_values[@ctx.ast.node_ids[expression.object_id]] = offset unless @current_type_substitutions&.any?
699
701
  end
700
702
 
701
703
  def infer_offsetof_type(type_ref, field_name, scopes: nil)
@@ -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
@@ -18,7 +18,7 @@ module MilkTea
18
18
  "Files & I/O" => %w[fs path stdio],
19
19
  "System" => %w[ctype errno process time c],
20
20
  "Network & HTTP" => %w[net http uri cookie curl],
21
- "Game & Graphics" => %w[raylib box2d flecs enet cgltf cjson],
21
+ "Game & Graphics" => %w[raylib box2d box3d flecs enet cgltf cjson],
22
22
  "Algorithms & AI" => %w[fsm behavior_tree goap],
23
23
  "Database & Matching" => %w[sqlite3 pcre2],
24
24
  "Utilities" => %w[cli terminal span spatial asset_pack],