mt-lang 0.3.39 → 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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/milk_tea/base.rb +1 -1
  3. data/lib/milk_tea/core/ast.rb +2 -2
  4. data/lib/milk_tea/core/bindings/attribute_binding.rb +1 -6
  5. data/lib/milk_tea/core/bindings/module_binding.rb +1 -1
  6. data/lib/milk_tea/core/intrinsics.rb +23 -1
  7. data/lib/milk_tea/core/lowering/functions.rb +3 -0
  8. data/lib/milk_tea/core/lowering/resolve.rb +4 -65
  9. data/lib/milk_tea/core/lowering/utils.rb +0 -17
  10. data/lib/milk_tea/core/lowering.rb +1 -2
  11. data/lib/milk_tea/core/module_binder.rb +4 -15
  12. data/lib/milk_tea/core/module_loader.rb +41 -8
  13. data/lib/milk_tea/core/parser/declarations.rb +24 -17
  14. data/lib/milk_tea/core/semantic_analyzer/analysis_context.rb +2 -111
  15. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +1 -2
  16. data/lib/milk_tea/core/semantic_analyzer/function_binding.rb +26 -17
  17. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +26 -42
  18. data/lib/milk_tea/core/semantic_analyzer/type_compatibility.rb +0 -59
  19. data/lib/milk_tea/core/semantic_analyzer/type_declaration.rb +0 -2
  20. data/lib/milk_tea/core/types/predicates.rb +57 -0
  21. data/lib/milk_tea/core/types/registry.rb +14 -2
  22. data/lib/milk_tea/core/types.rb +0 -4
  23. data/lib/milk_tea/lsp/server/code_actions.rb +13 -7
  24. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +56 -12
  25. data/lib/milk_tea/lsp/server/text_documents.rb +18 -2
  26. data/lib/milk_tea/lsp/workspace/caches.rb +28 -1
  27. data/lib/milk_tea/lsp/workspace/dependency_graph.rb +31 -0
  28. data/lib/milk_tea/lsp/workspace/store.rb +7 -1
  29. data/lib/milk_tea/lsp/workspace.rb +6 -0
  30. data/lib/milk_tea/tooling/formatter.rb +2 -3
  31. data/lib/milk_tea/tooling/linter/fix_engine.rb +46 -0
  32. data/lib/milk_tea/tooling/linter/rules.rb +32 -0
  33. data/lib/milk_tea/tooling/linter.rb +4 -0
  34. data/lib/milk_tea/tooling.rb +0 -1
  35. metadata +2 -3
  36. data/lib/milk_tea/tooling/cst_formatter.rb +0 -13
@@ -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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../core"
4
- require_relative "cst_formatter"
5
4
 
6
5
  module MilkTea
7
6
  class FormatterError < StandardError; end
@@ -49,12 +48,12 @@ module MilkTea
49
48
 
50
49
  def self.preserve_format(source, path:, profile: nil)
51
50
  cst = profile_phase(profile, "format.cst") { build_cst(source, path:) }
52
- profile_phase(profile, "format.cst_fmt") { CSTFormatter.format(cst) }
51
+ profile_phase(profile, "format.cst_fmt") { cst.reconstruct }
53
52
  end
54
53
 
55
54
  def self.tidy_format(source, path:, max_line_length: DEFAULT_MAX_LINE_LENGTH, profile: nil)
56
55
  cst = profile_phase(profile, "format.cst") { build_cst(source, path:) }
57
- normalized = profile_phase(profile, "format.normalize") { CSTFormatter.format_normalized(cst) }
56
+ normalized = profile_phase(profile, "format.normalize") { cst.reconstruct_normalized }
58
57
  wrapped = profile_phase(profile, "format.wrap") { wrap_long_argument_lists(normalized, max_line_length:, path:) }
59
58
  profile_phase(profile, "format.blank_lines") { normalize_blank_lines(wrapped, path:) }
60
59
  end
@@ -17,6 +17,7 @@ module MilkTea
17
17
  when "redundant-else" then redundant_else_edits(lines, warning)
18
18
  when "redundant-return" then redundant_return_edits(lines, warning)
19
19
  when "redundant-type-annotation" then redundant_type_annotation_edits(lines, warning)
20
+ when "prefer-inline-methods" then prefer_inline_methods_edits(lines, warning)
20
21
  when "unused-import" then unused_import_edits(lines, warning)
21
22
  when "trailing-list-comma" then trailing_list_comma_edits(lines, warning)
22
23
  else []
@@ -152,6 +153,51 @@ module MilkTea
152
153
  [FixEdit.new(start_line: line_idx, start_char: 0, end_line: line_idx + 1, end_char: 0, new_text: "")]
153
154
  end
154
155
 
156
+ # Moves the methods of an `extending X:` block inline into the matching
157
+ # `struct X:` declaration. Only rewrites when the struct immediately
158
+ # precedes the extending block (blank lines allowed between them); other
159
+ # layouts are left alone since the move would be non-local.
160
+ def self.prefer_inline_methods_edits(lines, warning)
161
+ name = warning.symbol_name
162
+ return [] unless name && warning.line
163
+
164
+ struct_idx = lines.index { |l| l.match?(/\A\s*struct\s+#{Regexp.escape(name)}\b/) }
165
+ return [] unless struct_idx
166
+
167
+ last_member_idx = nil
168
+ ((struct_idx + 1)...lines.length).each do |i|
169
+ l = lines[i]
170
+ break if !l.chomp.empty? && !l.start_with?(" ", "\t")
171
+
172
+ last_member_idx = i unless l.chomp.empty?
173
+ end
174
+ return [] unless last_member_idx
175
+
176
+ ext_start_idx = warning.line - 1
177
+ return [] unless last_member_idx < ext_start_idx
178
+
179
+ ext_end_idx = ext_start_idx
180
+ ((ext_start_idx + 1)...lines.length).each do |i|
181
+ l = lines[i]
182
+ break if !l.chomp.empty? && !l.start_with?(" ", "\t")
183
+
184
+ ext_end_idx = i unless l.chomp.empty?
185
+ end
186
+
187
+ method_lines = lines[(ext_start_idx + 1)..ext_end_idx].to_a
188
+ method_lines.shift while method_lines.first && method_lines.first.chomp.empty?
189
+ method_lines.pop while method_lines.last && method_lines.last.chomp.empty?
190
+ return [] if method_lines.empty?
191
+
192
+ between = lines[(last_member_idx + 1)...ext_start_idx]
193
+ return [] unless between.all? { |l| l.chomp.empty? }
194
+
195
+ method_text = method_lines.join
196
+ new_text = "#{lines[last_member_idx].chomp}\n\n#{method_text}"
197
+
198
+ [FixEdit.new(start_line: last_member_idx, start_char: 0, end_line: ext_end_idx + 1, end_char: 0, new_text: new_text)]
199
+ end
200
+
155
201
  def self.unused_import_edits(lines, warning)
156
202
  return [] unless warning.line
157
203
 
@@ -586,6 +586,38 @@ module MilkTea
586
586
  def lvalue_expression?(expression)
587
587
  expression.is_a?(AST::Identifier) || expression.is_a?(AST::MemberAccess) || expression.is_a?(AST::IndexAccess)
588
588
  end
589
+ # ── prefer-inline-methods ──────────────────────────────────────────────
590
+ # Flag `extending X:` blocks whose receiver struct is declared in the same
591
+ # file, suggesting the methods be written inline inside the struct body
592
+ # (which desugars to the identical `extending` block).
593
+
594
+ def emit_prefer_inline_methods_warnings(source_file)
595
+ struct_names = source_file.declarations.filter_map { |decl| decl.name if decl.is_a?(AST::StructDecl) }.to_set
596
+ return if struct_names.empty?
597
+
598
+ source_file.declarations.each do |declaration|
599
+ next unless declaration.is_a?(AST::ExtendingBlock)
600
+ next if declaration.inline
601
+
602
+ parts = declaration.type_name.name.parts
603
+ next unless parts.length == 1
604
+ next if declaration.type_name.arguments.any?
605
+
606
+ name = parts.first
607
+ next unless struct_names.include?(name)
608
+
609
+ @warnings << Warning.new(
610
+ path: @path,
611
+ line: declaration.line,
612
+ column: declaration.column,
613
+ length: name.length,
614
+ code: "prefer-inline-methods",
615
+ message: "methods on '#{name}' can be written inline inside the struct declaration",
616
+ severity: :hint,
617
+ symbol_name: name,
618
+ )
619
+ end
620
+ end
589
621
  end
590
622
  end
591
623
  end
@@ -35,6 +35,7 @@ module MilkTea
35
35
  owning-release-double
36
36
  prefer-conditional-expression
37
37
  prefer-inline-if
38
+ prefer-inline-methods
38
39
  prefer-is-variant
39
40
  prefer-let
40
41
  prefer-let-else
@@ -69,6 +70,7 @@ module MilkTea
69
70
  redundant-ignored-match-binding
70
71
  prefer-let-else
71
72
  prefer-var-else
73
+ prefer-inline-methods
72
74
  redundant-bool-compare
73
75
  redundant-cast
74
76
  redundant-else
@@ -95,6 +97,7 @@ module MilkTea
95
97
  "redundant-type-annotation" => "Remove redundant type annotation",
96
98
  "prefer-let-else" => "Rewrite as let-else",
97
99
  "prefer-var-else" => "Rewrite as var-else",
100
+ "prefer-inline-methods" => "Inline methods into struct",
98
101
  "trailing-list-comma" => "Remove trailing list comma",
99
102
  }.freeze
100
103
  EVENT_STACK_SNAPSHOT_WARNING_THRESHOLD = 128
@@ -771,6 +774,7 @@ module MilkTea
771
774
  profile_phase("rule.doc_tag") { emit_doc_tag_warnings(ast) } if full_tier?
772
775
  profile_phase("rule.event_capacity") { emit_event_capacity_warnings(ast) }
773
776
  profile_phase("rule.trailing_list_comma") { emit_trailing_list_comma_warnings(ast) }
777
+ profile_phase("rule.prefer_inline_methods") { emit_prefer_inline_methods_warnings(ast) }
774
778
  profile_phase("rule.line_too_long") { emit_line_too_long_warnings }
775
779
  @warnings
776
780
  end
@@ -11,7 +11,6 @@ require_relative "tooling/sexpr_dumper"
11
11
  require_relative "tooling/sexpr_parser"
12
12
  require_relative "tooling/build"
13
13
  require_relative "tooling/run"
14
- require_relative "tooling/cst_formatter"
15
14
  require_relative "tooling/error_formatter"
16
15
  require_relative "tooling/formatter"
17
16
  require_relative "tooling/linter"
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.39
4
+ version: 0.3.41
5
5
  platform: ruby
6
6
  authors:
7
7
  - Long (Teefan) Tran
@@ -385,7 +385,6 @@ files:
385
385
  - lib/milk_tea/tooling/cli/commands/snapshot.rb
386
386
  - lib/milk_tea/tooling/cli/commands/test.rb
387
387
  - lib/milk_tea/tooling/cli/commands/toolchain.rb
388
- - lib/milk_tea/tooling/cst_formatter.rb
389
388
  - lib/milk_tea/tooling/debug_info_formatter.rb
390
389
  - lib/milk_tea/tooling/debug_map.rb
391
390
  - lib/milk_tea/tooling/docs_app.rb
@@ -626,7 +625,7 @@ metadata:
626
625
  homepage_uri: https://teefan.github.io/mt-lang/
627
626
  source_code_uri: https://github.com/teefan/mt-lang
628
627
  post_install_message: |
629
- Milk Tea 0.3.39 installed!
628
+ Milk Tea 0.3.41 installed!
630
629
 
631
630
  System requirements:
632
631
  - A C compiler (gcc or clang) must be available on PATH
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module MilkTea
4
- class CSTFormatter
5
- def self.format(cst)
6
- cst.reconstruct
7
- end
8
-
9
- def self.format_normalized(cst)
10
- cst.reconstruct_normalized
11
- end
12
- end
13
- end