mt-lang 0.4.24 → 0.4.26

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 (73) 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/README.md +39 -7
  6. data/docs/build-guide.md +5 -0
  7. data/docs/index.html +61 -29
  8. data/docs/language-design.md +2 -2
  9. data/docs/language-manual.md +15 -3
  10. data/lib/milk_tea/base.rb +1 -1
  11. data/lib/milk_tea/bindings/raw_bindings/defaults.rb +22 -0
  12. data/lib/milk_tea/core/c_backend/statements.rb +118 -1
  13. data/lib/milk_tea/core/control_flow/builder.rb +12 -0
  14. data/lib/milk_tea/core/lowering/utils.rb +8 -1
  15. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +43 -2
  16. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +7 -1
  17. data/lib/milk_tea/tooling/bindgen_cli.rb +56 -0
  18. data/lib/milk_tea/tooling/cli/commands/std.rb +84 -0
  19. data/lib/milk_tea/tooling/cli.rb +27 -1
  20. data/lib/milk_tea/tooling/linter/fix_engine.rb +100 -0
  21. data/lib/milk_tea/tooling/linter/release_rules.rb +69 -11
  22. data/lib/milk_tea/tooling/linter/visitors.rb +58 -31
  23. data/lib/milk_tea/tooling/linter.rb +8 -1
  24. data/lib/milk_tea/tooling/std_catalog.rb +277 -0
  25. data/lib/milk_tea/tooling.rb +1 -0
  26. data/std/async/libuv_runtime.mt +20 -23
  27. data/std/async/mailbox.mt +2 -2
  28. data/std/base64.mt +3 -6
  29. data/std/behavior_tree.mt +1 -6
  30. data/std/binary.mt +2 -5
  31. data/std/c/stb_image.mt +15 -15
  32. data/std/color.mt +20 -20
  33. data/std/cookie.mt +1 -4
  34. data/std/deque.mt +2 -8
  35. data/std/encoding.mt +1 -4
  36. data/std/fmt.mt +1 -4
  37. data/std/graph.mt +5 -1
  38. data/std/htn.mt +9 -1
  39. data/std/http/server.mt +1 -4
  40. data/std/http.mt +14 -17
  41. data/std/jobs.mt +9 -10
  42. data/std/json.mt +17 -28
  43. data/std/linked_map.mt +3 -9
  44. data/std/map.mt +1 -4
  45. data/std/net/channel.mt +6 -6
  46. data/std/net/lobby.mt +3 -3
  47. data/std/net/manager.mt +0 -1
  48. data/std/net/mux.mt +1 -1
  49. data/std/net/packet.mt +4 -1
  50. data/std/net/punch.mt +3 -2
  51. data/std/net/rpc.mt +1 -3
  52. data/std/net/session.mt +12 -25
  53. data/std/net/sync.mt +3 -3
  54. data/std/net/turn.mt +7 -3
  55. data/std/net.mt +145 -159
  56. data/std/noise.mt +11 -7
  57. data/std/ordered_map.mt +2 -8
  58. data/std/ordered_set.mt +3 -5
  59. data/std/path.mt +1 -4
  60. data/std/process.mt +7 -18
  61. data/std/random.mt +2 -2
  62. data/std/sdl3/runtime.mt +1 -1
  63. data/std/steering.mt +27 -4
  64. data/std/string.mt +1 -4
  65. data/std/tar.mt +8 -10
  66. data/std/terminal.mt +21 -40
  67. data/std/thread.mt +3 -4
  68. data/std/tls.mt +22 -29
  69. data/std/toml.mt +23 -31
  70. data/std/uri.mt +2 -1
  71. data/std/utility.mt +2 -2
  72. data/std/vec.mt +7 -17
  73. metadata +5 -3
@@ -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)
@@ -127,6 +127,13 @@ module MilkTea
127
127
 
128
128
  def infer_value_type(handle_expression, env:)
129
129
  handle_type = infer_expression_type(handle_expression, env:)
130
+ # BUG FIX: nullable pointer handles (ptr[T]?) read through as their
131
+ # base pointer type; std/net relies on this after flow-unguarded
132
+ # null checks on storage fields.
133
+ if handle_type.is_a?(Types::Nullable) &&
134
+ (ref_type?(handle_type.base) || pointer_type?(handle_type.base))
135
+ handle_type = handle_type.base
136
+ end
130
137
  return referenced_type(handle_type) if ref_type?(handle_type)
131
138
  return pointee_type(handle_type) if pointer_type?(handle_type)
132
139
 
@@ -371,7 +378,7 @@ module MilkTea
371
378
  elements = value.map { |element| lower_compile_time_literal(element, element_type) }
372
379
  return nil if elements.any?(&:nil?)
373
380
 
374
- IR::ArrayLiteral.new(type:, elements:)
381
+ return IR::ArrayLiteral.new(type:, elements:)
375
382
  when Hash
376
383
  return nil unless type.is_a?(Types::Struct)
377
384
  fields = value.map do |name, field_value|
@@ -721,8 +721,10 @@ module MilkTea
721
721
  raise_sema_error("operator #{expression.operator} is not supported for type #{bad_type}")
722
722
  end
723
723
  end
724
- unless common_numeric_type(left_type, right_type) || types_compatible?(left_type, right_type) || types_compatible?(right_type, left_type)
725
- raise_sema_error("operator #{expression.operator} requires comparable types, got #{left_type} and #{right_type}")
724
+ unless common_numeric_type(left_type, right_type) || types_compatible?(left_type, right_type) || types_compatible?(right_type, left_type) || refined_null_comparison?(expression, scopes)
725
+ message = "operator #{expression.operator} requires comparable types, got #{left_type} and #{right_type}"
726
+ hint = null_comparison_hint(left_type, right_type)
727
+ raise_sema_error(hint ? "#{message}; #{hint}" : message)
726
728
  end
727
729
 
728
730
  @ctx.types.fetch("bool")
@@ -731,6 +733,45 @@ module MilkTea
731
733
  end
732
734
  end
733
735
 
736
+ # A null comparison on a nullable binding stays legal after flow
737
+ # refinement has narrowed the binding to non-null: the declared storage
738
+ # is still nullable, and the check is a redundant-but-valid test (the
739
+ # linter reports it as redundant-null-check). Only bare `null` qualifies;
740
+ # a typed `null[...]` with a mismatched target stays a type error.
741
+ def refined_null_comparison?(expression, scopes)
742
+ if expression.left.is_a?(AST::NullLiteral) && expression.left.type.nil? && expression.right.is_a?(AST::Identifier)
743
+ identifier_expression = expression.right
744
+ elsif expression.right.is_a?(AST::NullLiteral) && expression.right.type.nil? && expression.left.is_a?(AST::Identifier)
745
+ identifier_expression = expression.left
746
+ else
747
+ return false
748
+ end
749
+
750
+ binding = lookup_value(identifier_expression.name, scopes)
751
+ binding&.storage_type.is_a?(Types::Nullable)
752
+ end
753
+
754
+ # A null comparison against a non-nullable operand is a contract mismatch,
755
+ # not just incompatible operand types: the message should say which fix
756
+ # applies (nullable declaration, raw zero comparison, or removal).
757
+ def null_comparison_hint(left_type, right_type)
758
+ other = if left_type.is_a?(Types::Null) && !right_type.is_a?(Types::Null)
759
+ right_type
760
+ elsif right_type.is_a?(Types::Null) && !left_type.is_a?(Types::Null)
761
+ left_type
762
+ end
763
+ return nil unless other
764
+ return nil if other.is_a?(Types::Nullable) || other.is_a?(Types::Null)
765
+
766
+ if ref_type?(other)
767
+ "#{other} is never null by design; refs cannot be compared against null"
768
+ elsif pointer_type?(other) || opaque_type?(other) || other == Types::Registry.primitive("cstr")
769
+ hint = "#{other} is declared non-null; declare it #{other}? if null is possible"
770
+ hint << ", or compare against zero[#{other}] for raw zero-pointer storage" if zero_supported_type?(other)
771
+ hint
772
+ end
773
+ end
774
+
734
775
  def infer_if_expression(expression, scopes:, expected_type: nil)
735
776
  condition_type = infer_expression(expression.condition, scopes:, expected_type: @ctx.types.fetch("bool"))
736
777
  ensure_assignable!(condition_type, @ctx.types.fetch("bool"), "if expression condition must be bool, got #{condition_type}")
@@ -740,6 +740,12 @@ module MilkTea
740
740
  end
741
741
 
742
742
  def zero_initializable_type?(type, operation: "zero")
743
+ return true if zero_supported_type?(type)
744
+
745
+ raise_sema_error("#{operation} does not support type #{type}")
746
+ end
747
+
748
+ def zero_supported_type?(type)
743
749
  return true if type.is_a?(Types::Primitive) && !type.void?
744
750
  return true if type.is_a?(Types::Nullable)
745
751
  return true if type.is_a?(Types::EnumBase)
@@ -761,7 +767,7 @@ module MilkTea
761
767
  return true if simd_type?(type)
762
768
  return true if atomic_type?(type)
763
769
 
764
- raise_sema_error("#{operation} does not support type #{type}")
770
+ false
765
771
  end
766
772
 
767
773
  def layout_aggregate_type?(type)
@@ -101,6 +101,11 @@ module MilkTea
101
101
  return missing_option_value(option) unless value
102
102
 
103
103
  options[:nullable_report_path] = value
104
+ when "--nullable-policy"
105
+ value = @argv.shift
106
+ return missing_option_value(option) unless value
107
+
108
+ options[:nullable_policy_path] = value
104
109
  else
105
110
  @err.puts("unknown bindgen option #{option}")
106
111
  print_help
@@ -109,9 +114,60 @@ module MilkTea
109
114
  end
110
115
 
111
116
  options[:include_directives] = nil if options[:include_directives].empty?
117
+ if options[:nullable_policy_path]
118
+ policy = load_nullable_policy(options.delete(:nullable_policy_path))
119
+ return nil unless policy
120
+
121
+ options[:function_return_type_overrides] = policy[:return_types]
122
+ options[:function_param_type_overrides] = policy[:parameters]
123
+ end
112
124
  options
113
125
  end
114
126
 
127
+ # A nullable policy lists the symbols a header documents as returning NULL
128
+ # (or taking NULL-able out-pointers) so generated raw bindings expose them
129
+ # as nullable `T?` without hand-annotating the C header. Entries feed the
130
+ # same function_return_type_overrides / function_param_type_overrides
131
+ # machinery as the raw binding registry and appear in the nullable report.
132
+ def load_nullable_policy(path)
133
+ require "json"
134
+ policy_path = File.expand_path(path)
135
+ unless File.file?(policy_path)
136
+ @err.puts("nullable policy file not found: #{path}")
137
+ return nil
138
+ end
139
+
140
+ document = begin
141
+ JSON.parse(File.read(policy_path))
142
+ rescue JSON::ParserError => e
143
+ @err.puts("nullable policy is not valid JSON: #{e.message}")
144
+ return nil
145
+ end
146
+
147
+ unless document.is_a?(Hash)
148
+ @err.puts("nullable policy must be a JSON object")
149
+ return nil
150
+ end
151
+
152
+ unknown_keys = document.keys - ["return_types", "parameters"]
153
+ unless unknown_keys.empty?
154
+ @err.puts("nullable policy has unknown keys: #{unknown_keys.join(', ')} (expected return_types, parameters)")
155
+ return nil
156
+ end
157
+
158
+ return_types = document["return_types"] || {}
159
+ parameters = document["parameters"] || {}
160
+ policy_shape_valid = return_types.is_a?(Hash) && return_types.keys.all?(String) && return_types.values.all?(String) &&
161
+ parameters.is_a?(Hash) && parameters.keys.all?(String) &&
162
+ parameters.values.all? { |value| value.is_a?(Hash) && value.keys.all?(String) && value.values.all?(String) }
163
+ unless policy_shape_valid
164
+ @err.puts('nullable policy must map {"return_types": {symbol: "type?"}, "parameters": {symbol: {param: "type?"}}}')
165
+ return nil
166
+ end
167
+
168
+ { return_types:, parameters: }
169
+ end
170
+
115
171
  def missing_option_value(option)
116
172
  @err.puts("missing value for #{option}")
117
173
  print_help
@@ -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
@@ -771,6 +776,7 @@ module MilkTea
771
776
 
772
777
  Options:
773
778
  -o, --output OUTPUT Write the generated module to this file.
779
+ --nullable-policy PATH Apply a JSON nullable policy of symbols documented as returning NULL.
774
780
  --nullable-report PATH Write the remaining manual nullable policy report to this file.
775
781
  --link LIB Link against this library (repeatable).
776
782
  --include HEADER Extra #include directive (repeatable).
@@ -796,6 +802,24 @@ module MilkTea
796
802
  --open, -o Open the docs in your default browser.
797
803
  --port, -p PORT Listen on a specific port (default: random).
798
804
  HELP
805
+ "std" => <<~HELP,
806
+ Usage: mtc std list [--json]
807
+ mtc std show MODULE
808
+
809
+ Inspect the Milk Tea standard library shipped in std/.
810
+
811
+ list Print every hand-written standard library module with a short
812
+ description and its path on disk, grouped by category.
813
+ Generated binding modules (std/c/* and imported-binding
814
+ wrappers such as raylib or zstd) are not listed.
815
+ show Print the source of a module. Accepts dotted or slashed names
816
+ (mem.arena, mem/arena) and resolves platform variants such as
817
+ fs to the active platform file. Generated modules stay
818
+ viewable here (for example c.cjson or raylib).
819
+
820
+ Options:
821
+ --json Emit list output as JSON.
822
+ HELP
799
823
  "snapshot" => <<~HELP,
800
824
  Usage: mtc snapshot INPUT.mt [OPTIONS]
801
825
 
@@ -888,9 +912,11 @@ module MilkTea
888
912
  io.puts(" mtc deps lock [PATH_OR_PACKAGE] [--check]")
889
913
  io.puts(" mtc deps publish [PATH_OR_PACKAGE] [--upstream]")
890
914
  io.puts(" mtc deps fetch [PATH_OR_PACKAGE]")
891
- io.puts(" mtc bindgen MODULE HEADER [-o OUTPUT] [--nullable-report PATH] [--link LIB] [--include HEADER] [--clang PATH] [--clang-arg ARG]")
915
+ io.puts(" mtc bindgen MODULE HEADER [-o OUTPUT] [--nullable-policy PATH] [--nullable-report PATH] [--link LIB] [--include HEADER] [--clang PATH] [--clang-arg ARG]")
892
916
  io.puts(" mtc cache purge|status")
893
917
  io.puts(" mtc docs [--open] [--port PORT]")
918
+ io.puts(" mtc std list [--json]")
919
+ io.puts(" mtc std show MODULE")
894
920
  io.puts(" mtc snapshot INPUT.mt [--theme PATH] [-o OUTPUT]")
895
921
  io.puts(" mtc lsp [--log-level LEVEL] [--stdio]")
896
922
  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