mt-lang 0.4.24 → 0.4.25

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +8 -8
  5. data/lib/milk_tea/base.rb +1 -1
  6. data/lib/milk_tea/core/control_flow/builder.rb +12 -0
  7. data/lib/milk_tea/tooling/cli/commands/std.rb +84 -0
  8. data/lib/milk_tea/tooling/cli.rb +25 -0
  9. data/lib/milk_tea/tooling/linter/fix_engine.rb +100 -0
  10. data/lib/milk_tea/tooling/linter/release_rules.rb +69 -11
  11. data/lib/milk_tea/tooling/linter/visitors.rb +58 -31
  12. data/lib/milk_tea/tooling/linter.rb +8 -1
  13. data/lib/milk_tea/tooling/std_catalog.rb +277 -0
  14. data/lib/milk_tea/tooling.rb +1 -0
  15. data/std/async/libuv_runtime.mt +20 -23
  16. data/std/async/mailbox.mt +2 -2
  17. data/std/base64.mt +3 -6
  18. data/std/behavior_tree.mt +1 -6
  19. data/std/binary.mt +2 -5
  20. data/std/color.mt +20 -20
  21. data/std/cookie.mt +1 -4
  22. data/std/deque.mt +2 -8
  23. data/std/encoding.mt +1 -4
  24. data/std/fmt.mt +1 -4
  25. data/std/graph.mt +5 -1
  26. data/std/htn.mt +9 -1
  27. data/std/http/server.mt +1 -4
  28. data/std/http.mt +14 -17
  29. data/std/jobs.mt +9 -10
  30. data/std/json.mt +17 -28
  31. data/std/linked_map.mt +3 -9
  32. data/std/map.mt +1 -4
  33. data/std/net/channel.mt +6 -6
  34. data/std/net/lobby.mt +3 -3
  35. data/std/net/manager.mt +0 -1
  36. data/std/net/mux.mt +1 -1
  37. data/std/net/packet.mt +4 -1
  38. data/std/net/punch.mt +3 -2
  39. data/std/net/rpc.mt +1 -3
  40. data/std/net/session.mt +12 -25
  41. data/std/net/sync.mt +3 -3
  42. data/std/net/turn.mt +7 -3
  43. data/std/net.mt +145 -159
  44. data/std/noise.mt +11 -7
  45. data/std/ordered_map.mt +2 -8
  46. data/std/ordered_set.mt +3 -5
  47. data/std/path.mt +1 -4
  48. data/std/process.mt +7 -18
  49. data/std/random.mt +2 -2
  50. data/std/sdl3/runtime.mt +1 -1
  51. data/std/steering.mt +27 -4
  52. data/std/string.mt +1 -4
  53. data/std/tar.mt +8 -10
  54. data/std/terminal.mt +21 -40
  55. data/std/thread.mt +3 -4
  56. data/std/tls.mt +22 -29
  57. data/std/toml.mt +23 -31
  58. data/std/uri.mt +2 -1
  59. data/std/utility.mt +2 -2
  60. data/std/vec.mt +7 -17
  61. metadata +5 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 95423784419c8060f10277cb7abbb1fe1cd955fe648008781d1184976968e2d8
4
- data.tar.gz: 6e1b33f4c2a17226dc34ba00618b89be6f329841c5badc7a53ccd022daa3c54d
3
+ metadata.gz: f4c65f190209c5486b52d39b2ac868b2e2c076ad3ec01206151df16675319cf9
4
+ data.tar.gz: 28bb0127501319fb64f0da085409bb8658b6fd9cd41d8c18c0ac2175608f93dd
5
5
  SHA512:
6
- metadata.gz: aec1e36ac602ad755b89252058932df48abd25c0e645e212fa404afd25b23679e86d6cf45a970a36d566b08c9be774231496cf59766f64efa366da382e559963
7
- data.tar.gz: 85af5d83647fd5cb635bec77a3ae8846be1b6f8f62638b5c82a2f99b9f76f2de6fb08455b6660e0cace75669e9281456a3d7336043ef3009988e50fc3efb4c0c
6
+ metadata.gz: '098dc1e2c0303bbf39dcd4402d7f21482c682f83b904799c1c85a9a73207b4c8443c0a98bdee192db9d17a0bf33ec4a96ad31c03a733f28c57880b7601c91d42'
7
+ data.tar.gz: ce0f5ba1ae8e8c6f958ab4cd5e2d0e89c522a9f814d8e5e31a1e8b8b849faafe437caaab098bd3a4d59755cdbf4f12c23d521810e9892c6e039acf27176b7d10
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 4.0.3
1
+ 4.0.6
data/Gemfile CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- ruby "4.0.3"
5
+ ruby "4.0.6"
6
6
 
7
7
  gem "rake", "~> 13.4"
8
8
  gem "rexml", "~> 3.4"
data/Gemfile.lock CHANGED
@@ -5,7 +5,7 @@ GEM
5
5
  drb (2.2.3)
6
6
  fiddle (1.1.8)
7
7
  logger (1.7.0)
8
- minitest (6.0.4)
8
+ minitest (6.0.6)
9
9
  drb (~> 2.0)
10
10
  prism (~> 1.5)
11
11
  minitest-coverage (1.0.0.b3)
@@ -15,7 +15,7 @@ GEM
15
15
  puma (6.6.1)
16
16
  nio4r (~> 2.0)
17
17
  racc (1.8.1)
18
- rack (3.2.6)
18
+ rack (3.2.7)
19
19
  rack-protection (4.2.1)
20
20
  base64 (>= 0.1.0)
21
21
  logger (>= 1.6.0)
@@ -34,7 +34,7 @@ GEM
34
34
  rack-protection (= 4.2.1)
35
35
  rack-session (>= 2.0.0, < 3)
36
36
  tilt (~> 2.0)
37
- tilt (2.7.0)
37
+ tilt (2.9.0)
38
38
  tomlrb (2.0.4)
39
39
 
40
40
  PLATFORMS
@@ -58,25 +58,25 @@ CHECKSUMS
58
58
  drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
59
59
  fiddle (1.1.8) sha256=7fa8ee3627271497f3add5503acdbc3f40b32f610fc1cf49634f083ef3f32eee
60
60
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
61
- minitest (6.0.4) sha256=df1304664589d40f46089247fdc451f866b0ce0d7cae1457a15fc1eb7d48dca1
61
+ minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
62
62
  minitest-coverage (1.0.0.b3) sha256=27c8b6b8df1259325dd206baa2b143c594f8a88b7dfd97fd8d3389a0e8a6ad26
63
63
  mustermann (3.1.1) sha256=4c6170c7234d5499c345562ba7c7dfe73e1754286dcc1abb053064d66a127198
64
64
  nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
65
65
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
66
66
  puma (6.6.1) sha256=b9b56e4a4ea75d1bfa6d9e1972ee2c9f43d0883f011826d914e8e37b3694ea1e
67
67
  racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
68
- rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2
68
+ rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7
69
69
  rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
70
70
  rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8
71
71
  rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
72
72
  rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
73
73
  rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
74
74
  sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
75
- tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
75
+ tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5
76
76
  tomlrb (2.0.4) sha256=262f77947ac3ac9b3366a0a5940ecd238300c553e2e14f22009e2afcd2181b99
77
77
 
78
78
  RUBY VERSION
79
- ruby 4.0.3
79
+ ruby 4.0.6
80
80
 
81
81
  BUNDLED WITH
82
- 4.0.3
82
+ 4.0.16
data/lib/milk_tea/base.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "pathname"
4
4
 
5
5
  module MilkTea
6
- VERSION = "0.4.24"
6
+ VERSION = "0.4.25"
7
7
 
8
8
  def self.root
9
9
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
@@ -352,6 +352,12 @@ module MilkTea
352
352
  read_identifiers(expression.expression, names, reads_info)
353
353
  when AST::UnsafeExpr
354
354
  read_identifiers(expression.expression, names, reads_info)
355
+ when AST::PrefixCast
356
+ read_identifiers(expression.expression, names, reads_info)
357
+ when AST::DetachExpr
358
+ expression.body.each do |stmt|
359
+ read_identifiers(stmt.expression, names, reads_info) if stmt.is_a?(AST::ExpressionStmt)
360
+ end
355
361
  when AST::FormatString
356
362
  expression.parts.each do |part|
357
363
  read_identifiers(part.expression, names, reads_info) if part.is_a?(AST::FormatExprPart)
@@ -433,6 +439,12 @@ module MilkTea
433
439
  end
434
440
  when AST::UnsafeExpr
435
441
  write_targets_from_expression(expression.expression, line:, writes:, writes_info:)
442
+ when AST::PrefixCast
443
+ write_targets_from_expression(expression.expression, line:, writes:, writes_info:)
444
+ when AST::DetachExpr
445
+ expression.body.each do |stmt|
446
+ write_targets_from_expression(stmt.expression, line:, writes:, writes_info:) if stmt.is_a?(AST::ExpressionStmt)
447
+ end
436
448
  when AST::FormatString
437
449
  expression.parts.each do |part|
438
450
  write_targets_from_expression(part.expression, line:, writes:, writes_info:) if part.is_a?(AST::FormatExprPart)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ class CLI
5
+ module CommandStd
6
+ def std_command
7
+ subcommand = @argv.shift
8
+
9
+ case subcommand
10
+ when "list"
11
+ std_list_command
12
+ when "show"
13
+ std_show_command
14
+ when nil
15
+ @err.puts("missing std subcommand (expected list or show)")
16
+ print_command_help("std", @err)
17
+ 1
18
+ else
19
+ @err.puts("unknown std subcommand #{subcommand}")
20
+ print_command_help("std", @err)
21
+ 1
22
+ end
23
+ end
24
+
25
+ def std_list_command
26
+ as_json = false
27
+ until @argv.empty?
28
+ option = @argv.shift
29
+ case option
30
+ when "--json"
31
+ as_json = true
32
+ else
33
+ @err.puts("unknown std list option #{option}")
34
+ return 1
35
+ end
36
+ end
37
+
38
+ entries = StdCatalog.entries
39
+ if as_json
40
+ @out.puts(JSON.generate(entries.map(&:to_h)))
41
+ return 0
42
+ end
43
+
44
+ print_std_list(entries)
45
+ 0
46
+ end
47
+
48
+ def print_std_list(entries)
49
+ name_width = entries.map { |entry| entry.name.length }.max
50
+ description_width = entries.map { |entry| entry.description&.length.to_i }.max
51
+
52
+ StdCatalog::CATEGORY_ORDER.each do |category|
53
+ category_entries = entries.select { |entry| entry.category == category }
54
+ next if category_entries.empty?
55
+
56
+ @out.puts("#{category}:")
57
+ category_entries.each do |entry|
58
+ description = entry.description || "(no description)"
59
+ @out.puts(" #{entry.name.ljust(name_width)} #{description.ljust(description_width)} #{entry.path}")
60
+ end
61
+ @out.puts
62
+ end
63
+ end
64
+
65
+ def std_show_command
66
+ name = @argv.shift
67
+ unless name
68
+ @err.puts("missing module name")
69
+ print_command_help("std", @err)
70
+ return 1
71
+ end
72
+
73
+ path = StdCatalog.resolve(name)
74
+ unless path
75
+ @err.puts("std module not found: #{name}")
76
+ return 1
77
+ end
78
+
79
+ @out.write(File.read(path))
80
+ 0
81
+ end
82
+ end
83
+ end
84
+ end
@@ -33,6 +33,7 @@ module MilkTea
33
33
  ["bindgen", "Generate a binding module from a C header"],
34
34
  ["cache", "Inspect and manage the build cache"],
35
35
  ["docs", "Serve the local documentation site"],
36
+ ["std", "List standard library modules and print their source"],
36
37
  ["snapshot", "Render a highlighted HTML snapshot"],
37
38
  ["lsp", "Start the Language Server Protocol server"],
38
39
  ["dap", "Start the Debug Adapter Protocol server"],
@@ -127,6 +128,8 @@ module MilkTea
127
128
  cache_command
128
129
  when "docs"
129
130
  docs_command
131
+ when "std"
132
+ std_command
130
133
  when "snapshot"
131
134
  snapshot_command
132
135
  when "lsp"
@@ -164,6 +167,7 @@ module MilkTea
164
167
  require_relative "cli/commands/bindgen"
165
168
  require_relative "cli/commands/cache"
166
169
  require_relative "cli/commands/docs"
170
+ require_relative "cli/commands/std"
167
171
  require_relative "cli/commands/snapshot"
168
172
  require_relative "cli/commands/lsp"
169
173
  require_relative "cli/commands/dap"
@@ -187,6 +191,7 @@ module MilkTea
187
191
  include CommandBindgen
188
192
  include CommandCache
189
193
  include CommandDocs
194
+ include CommandStd
190
195
  include CommandSnapshot
191
196
  include CommandLsp
192
197
  include CommandDap
@@ -796,6 +801,24 @@ module MilkTea
796
801
  --open, -o Open the docs in your default browser.
797
802
  --port, -p PORT Listen on a specific port (default: random).
798
803
  HELP
804
+ "std" => <<~HELP,
805
+ Usage: mtc std list [--json]
806
+ mtc std show MODULE
807
+
808
+ Inspect the Milk Tea standard library shipped in std/.
809
+
810
+ list Print every hand-written standard library module with a short
811
+ description and its path on disk, grouped by category.
812
+ Generated binding modules (std/c/* and imported-binding
813
+ wrappers such as raylib or zstd) are not listed.
814
+ show Print the source of a module. Accepts dotted or slashed names
815
+ (mem.arena, mem/arena) and resolves platform variants such as
816
+ fs to the active platform file. Generated modules stay
817
+ viewable here (for example c.cjson or raylib).
818
+
819
+ Options:
820
+ --json Emit list output as JSON.
821
+ HELP
799
822
  "snapshot" => <<~HELP,
800
823
  Usage: mtc snapshot INPUT.mt [OPTIONS]
801
824
 
@@ -891,6 +914,8 @@ module MilkTea
891
914
  io.puts(" mtc bindgen MODULE HEADER [-o OUTPUT] [--nullable-report PATH] [--link LIB] [--include HEADER] [--clang PATH] [--clang-arg ARG]")
892
915
  io.puts(" mtc cache purge|status")
893
916
  io.puts(" mtc docs [--open] [--port PORT]")
917
+ io.puts(" mtc std list [--json]")
918
+ io.puts(" mtc std show MODULE")
894
919
  io.puts(" mtc snapshot INPUT.mt [--theme PATH] [-o OUTPUT]")
895
920
  io.puts(" mtc lsp [--log-level LEVEL] [--stdio]")
896
921
  io.puts(" mtc dap [--backend KIND] [--adapter-path PATH]")
@@ -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 "redundant-unsafe" then redundant_unsafe_edits(lines, warning)
20
21
  when "prefer-inline-methods" then prefer_inline_methods_edits(lines, warning)
21
22
  when "unused-import" then unused_import_edits(lines, warning)
22
23
  when "trailing-list-comma" then trailing_list_comma_edits(lines, warning)
@@ -302,6 +303,105 @@ module MilkTea
302
303
  edits
303
304
  end
304
305
 
306
+ # ── redundant-unsafe ───────────────────────────────────────────────────
307
+ # Removes an `unsafe:` wrapper that contains no unsafe operations.
308
+ #
309
+ # Supported shapes:
310
+ # <indent>unsafe:\n<deeper body> (block statement; body is dedented)
311
+ # ... unsafe: expr ... (inline expression on a single line)
312
+ def self.redundant_unsafe_edits(lines, warning)
313
+ return [] unless warning.line
314
+
315
+ line_idx = warning.line - 1
316
+ line = lines[line_idx]
317
+ return [] unless line
318
+
319
+ unsafe_idx = line.index("unsafe:")
320
+ return [] unless unsafe_idx
321
+
322
+ keyword_end = unsafe_idx + "unsafe:".length
323
+ rest = line[keyword_end..]
324
+
325
+ if rest && !rest.strip.empty?
326
+ # Inline expression form: drop the keyword and the whitespace after
327
+ # it, but only when the remainder is balanced on this line.
328
+ remainder = rest.sub(/\A\s+/, "")
329
+ return [] if remainder.empty? || !balanced_line?(remainder)
330
+
331
+ return [FixEdit.new(
332
+ start_line: line_idx,
333
+ start_char: unsafe_idx,
334
+ end_line: line_idx,
335
+ end_char: keyword_end + (rest.length - remainder.length),
336
+ new_text: "",
337
+ )]
338
+ end
339
+
340
+ # Block statement form: `unsafe:` alone, body indented deeper.
341
+ indent = line[0...unsafe_idx]
342
+ return [] unless indent.match?(/\A *\z/)
343
+
344
+ body_start = line_idx + 1
345
+ body_indent = nil
346
+ last_body = nil
347
+ (body_start...lines.length).each do |i|
348
+ candidate = lines[i]
349
+ if candidate.nil? || candidate.strip.empty?
350
+ last_body = i
351
+ next
352
+ end
353
+
354
+ break unless candidate.start_with?(indent + " ")
355
+
356
+ body_indent ||= candidate[/\A */].length - indent.length
357
+ last_body = i
358
+ end
359
+
360
+ return [] if last_body.nil? || last_body < body_start
361
+ return [] if body_indent.nil? || body_indent <= 0
362
+ return [] unless (body_start..last_body).all? do |i|
363
+ l = lines[i]
364
+ l.nil? || l.strip.empty? || l.start_with?(indent + " " * body_indent)
365
+ end
366
+
367
+ replacement = (body_start..last_body).map do |i|
368
+ l = lines[i]
369
+ l.nil? || l.strip.empty? ? "" : l[body_indent..].to_s.chomp
370
+ end
371
+
372
+ if last_body + 1 <= lines.length - 1
373
+ # Consume through the first following line; apply_fix_edits re-emits
374
+ # it as the suffix of a multi-line replacement.
375
+ end_line = last_body + 1
376
+ end_char = 0
377
+ else
378
+ end_line = last_body
379
+ end_char = lines[last_body].to_s.length
380
+ end
381
+
382
+ [FixEdit.new(
383
+ start_line: line_idx,
384
+ start_char: 0,
385
+ end_line:,
386
+ end_char:,
387
+ new_text: replacement.join("\n") + "\n",
388
+ )]
389
+ end
390
+
391
+ def self.balanced_line?(text)
392
+ depth = 0
393
+ text.each_char do |char|
394
+ case char
395
+ when "(", "["
396
+ depth += 1
397
+ when ")", "]"
398
+ depth -= 1
399
+ return false if depth.negative?
400
+ end
401
+ end
402
+ depth.zero?
403
+ end
404
+
305
405
  def self.match_paren(line, open_pos)
306
406
  depth = 0
307
407
  (open_pos...line.length).each do |i|
@@ -258,10 +258,28 @@ module MilkTea
258
258
  case expr
259
259
  when AST::Call
260
260
  expr.arguments.any? do |arg|
261
- next unless arg.is_a?(AST::Argument)
261
+ next false unless arg.is_a?(AST::Argument)
262
262
 
263
- arg.value.is_a?(AST::Identifier) && arg.value.name == name ||
264
- own_struct_field_transfer?(arg.value, name)
263
+ own_transfer_identifier?(arg.value, name)
264
+ end
265
+ when AST::PrefixCast
266
+ own_transfer_identifier?(expr.expression, name)
267
+ else
268
+ false
269
+ end
270
+ end
271
+
272
+ def own_transfer_identifier?(value, name)
273
+ case value
274
+ when AST::Identifier
275
+ value.name == name
276
+ when AST::PrefixCast
277
+ own_transfer_identifier?(value.expression, name)
278
+ when AST::Call
279
+ value.arguments.any? do |arg|
280
+ next false unless arg.is_a?(AST::Argument)
281
+
282
+ own_transfer_identifier?(arg.value, name)
265
283
  end
266
284
  else
267
285
  false
@@ -426,15 +444,29 @@ module MilkTea
426
444
  return false unless expr.is_a?(AST::Call)
427
445
 
428
446
  expr.arguments.any? do |arg|
429
- next false unless arg.is_a?(AST::Argument) && arg.name
430
-
431
- if arg.value.is_a?(AST::Identifier) && arg.value.name == name
432
- true
433
- elsif arg.value.is_a?(AST::Call)
434
- _expr_contains_transfer?(arg.value, name)
435
- else
436
- false
447
+ named = arg.is_a?(AST::Argument) && !arg.name.nil?
448
+ argument_transfers?(arg.value, name, named)
449
+ end
450
+ end
451
+
452
+ # An identifier passed as a named argument transfers ownership (struct
453
+ # field initialization), even when wrapped in a pointer cast
454
+ # (e.g. handle_set_data(h, ptr[void]<-state)). Nested calls are walked
455
+ # so `queue.push(Foo(field = x))` recognizes the inner transfer.
456
+ def argument_transfers?(value, name, named)
457
+ case value
458
+ when AST::Identifier
459
+ named && value.name == name
460
+ when AST::PrefixCast
461
+ inner = value.expression
462
+ inner.is_a?(AST::Identifier) && inner.name == name
463
+ when AST::Call
464
+ value.arguments.any? do |arg|
465
+ nested_named = arg.is_a?(AST::Argument) && !arg.name.nil?
466
+ argument_transfers?(arg.value, name, nested_named)
437
467
  end
468
+ else
469
+ false
438
470
  end
439
471
  end
440
472
 
@@ -481,6 +513,9 @@ module MilkTea
481
513
  return if expr.nil?
482
514
 
483
515
  result << expr.callee.receiver.name if release_call_on_binding?(expr)
516
+ if expr.is_a?(AST::Call) && expr.callee.is_a?(AST::Identifier) && expr.callee.name.start_with?("release_")
517
+ result.merge(release_helper_binding_names(expr))
518
+ end
484
519
 
485
520
  case expr
486
521
  when AST::Call
@@ -496,6 +531,26 @@ module MilkTea
496
531
  end
497
532
  end
498
533
 
534
+ # std convention: `release_*` helper functions take ownership of the
535
+ # bindings handed to them via ref_of/ptr_of, e.g.
536
+ # `release_string_values(ref_of(values))`. Treat those bindings as
537
+ # released so the leak rule understands the cleanup-helper idiom.
538
+ def release_helper_binding_names(expr)
539
+ names = Set.new
540
+ expr.arguments.each do |arg|
541
+ value = arg.respond_to?(:value) ? arg.value : nil
542
+ next unless value.is_a?(AST::Call)
543
+ next unless value.callee.is_a?(AST::Identifier)
544
+ next unless %w[ref_of ptr_of].include?(value.callee.name)
545
+
546
+ value.arguments.each do |inner|
547
+ binding_name = inner.value
548
+ names << binding_name.name if binding_name.is_a?(AST::Identifier)
549
+ end
550
+ end
551
+ names
552
+ end
553
+
499
554
  def _any_stmt?(stmts, &pred)
500
555
  return false if stmts.nil?
501
556
 
@@ -620,6 +675,9 @@ module MilkTea
620
675
  return false unless expr
621
676
 
622
677
  return true if release_call_on_binding?(expr) && expr.callee.receiver.name == name
678
+ return true if expr.is_a?(AST::Call) && expr.callee.is_a?(AST::Identifier) &&
679
+ expr.callee.name.start_with?("release_") &&
680
+ release_helper_binding_names(expr).include?(name)
623
681
 
624
682
  case expr
625
683
  when AST::Call
@@ -366,6 +366,10 @@ module MilkTea
366
366
  end
367
367
  def check_redundant_unsafe(node)
368
368
  return unless @sema_facts
369
+ # Generic bodies are only fully checked once instantiated elsewhere,
370
+ # so required-unsafe facts are incomplete for them: an unsafe block in
371
+ # a generic body may be required by concrete instances.
372
+ return if generic_function_context?
369
373
  return if @sema_facts.required_unsafe_lines.include?(node.line)
370
374
 
371
375
  @warnings << Warning.new(
@@ -830,43 +834,64 @@ module MilkTea
830
834
  )
831
835
  end
832
836
 
837
+ # Simulates the actual one-line join: every branch body must be a single
838
+ # physical line with balanced delimiters, and the composed line must fit
839
+ # the column budget. Anything that cannot be joined cleanly is reported
840
+ # as "too wide" so the hint is suppressed.
833
841
  def inline_if_too_wide?(statement, stmts)
834
842
  return false if statement.branches.empty?
835
843
 
836
- text = +""
837
-
838
- statement.branches.each_with_index do |b, i|
839
- cond_src = source_line_from(b.line, b.column)
840
- return false unless cond_src
841
-
842
- stmt_col = stmts[i]&.column
843
- stmt_line = stmts[i]&.line
844
- body_src = source_line_from(stmt_line, stmt_col)
845
- return false unless body_src
846
-
847
- text << cond_src
848
- text << " "
849
- text << body_src
850
- text << " "
851
- end
844
+ if_line_idx = Integer(statement.line) - 1
845
+ return true if if_line_idx.negative? || if_line_idx >= @source_lines.length
846
+
847
+ header = @source_lines[if_line_idx].to_s
848
+ return true unless header.strip.start_with?("if ")
849
+
850
+ indent = header[/\A[ \t]*/].to_s
851
+ segments = []
852
+ cur_header = header.strip
853
+ cur_body = nil
854
+ idx = if_line_idx + 1
855
+ while idx < @source_lines.length
856
+ line = @source_lines[idx].to_s.rstrip
857
+ break if line.empty?
858
+
859
+ line_indent = line[/\A[ \t]*/].to_s
860
+ if line_indent == indent && (line.strip == "else:" || line.strip.start_with?("else if "))
861
+ return true if cur_body.nil?
862
+
863
+ segments << [cur_header, cur_body]
864
+ cur_header = line.strip
865
+ cur_body = nil
866
+ idx += 1
867
+ next
868
+ end
869
+ break if line_indent.length <= indent.length
870
+ return true unless cur_body.nil?
852
871
 
853
- if statement.else_line
854
- else_src = source_line_from(statement.else_line, statement.else_column)
855
- return false unless else_src
856
- text << else_src
857
- text << " "
872
+ cur_body = line.strip
873
+ idx += 1
858
874
  end
875
+ return true if cur_body.nil?
876
+ return true unless cur_header.strip == "else:"
859
877
 
860
- stmt_col = stmts.last&.column
861
- stmt_line = stmts.last&.line
862
- else_body = source_line_from(stmt_line, stmt_col)
863
- return false unless else_body
864
- text << else_body
878
+ segments << [cur_header, cur_body]
879
+ joined = indent + segments.map { |h, b| "#{h} #{b}" }.join(" ")
880
+ joined.length > 120 || !balanced_inline_line?(joined)
881
+ end
865
882
 
866
- # The inline form would start at the `if` keyword's column, so the real
867
- # line width includes the surrounding block's indentation.
868
- indent = statement.branches.first.column.to_i - 1
869
- (indent + text.strip.length) > 120
883
+ def balanced_inline_line?(text)
884
+ depth = 0
885
+ text.each_char do |ch|
886
+ case ch
887
+ when "(", "["
888
+ depth += 1
889
+ when ")", "]"
890
+ depth -= 1
891
+ return false if depth.negative?
892
+ end
893
+ end
894
+ depth.zero?
870
895
  end
871
896
 
872
897
  def source_line_from(line, column)
@@ -966,7 +991,9 @@ module MilkTea
966
991
  struct_type = resolve_expr_type(call)
967
992
  return unless struct_type.is_a?(Types::Struct)
968
993
  source_type = resolve_expr_type(copied.first.value.receiver)
969
- return unless source_type.equal?(struct_type) || (source_type.respond_to?(:name) && source_type.name == struct_type.name)
994
+ # Compare qualified names: bare names collide across modules
995
+ # (e.g. net::Config vs chan::Config).
996
+ return unless source_type.respond_to?(:to_s) && source_type.to_s == struct_type.to_s
970
997
 
971
998
  field_names = struct_type.fields.keys.map(&:to_s).to_set
972
999
  copied_names = copied.map { |arg| arg.name.to_s }.to_set
@@ -77,6 +77,7 @@ module MilkTea
77
77
  redundant-else
78
78
  redundant-return
79
79
  redundant-type-annotation
80
+ redundant-unsafe
80
81
  reserved-primitive-name
81
82
  trailing-list-comma
82
83
  ].freeze
@@ -96,6 +97,7 @@ module MilkTea
96
97
  "redundant-else" => "Remove redundant else",
97
98
  "redundant-return" => "Remove redundant return",
98
99
  "redundant-type-annotation" => "Remove redundant type annotation",
100
+ "redundant-unsafe" => "Remove redundant unsafe",
99
101
  "prefer-let-else" => "Rewrite as let-else",
100
102
  "prefer-var-else" => "Rewrite as var-else",
101
103
  "prefer-inline-methods" => "Inline methods into struct",
@@ -358,7 +360,7 @@ module MilkTea
358
360
  # redundant-else,
359
361
  # redundant-return, reserved-primitive-name,
360
362
  # trailing-list-comma.
361
- # Returns the fixed source (may be identical if nothing was fixable).
363
+ # Returns the fixed source (may be identical to nothing-was-fixable input).
362
364
  def self.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil)
363
365
  pass_limit = [max_passes.to_i, 1].max
364
366
  current_source = source
@@ -446,6 +448,11 @@ module MilkTea
446
448
  working_context = sema_facts ? nil : best_effort_lint_context(source, path:)
447
449
  working_sema_facts = sema_facts || working_context[:facts]
448
450
  unresolved_import_paths = sema_facts ? Set.new : working_context[:unresolved_import_paths]
451
+ # With unresolved imports the analysis facts are incomplete (receiver
452
+ # editability, casts, ownership), so hint rules can misfire and fixes
453
+ # would land on false positives. Leave such sources to the author.
454
+ return source if unresolved_import_paths.any?
455
+
449
456
  warnings = lint_source(
450
457
  source,
451
458
  path:,