mt-lang 0.2.0

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 (258) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/AUTHORS +3 -0
  4. data/Gemfile +19 -0
  5. data/Gemfile.lock +82 -0
  6. data/LICENSE +21 -0
  7. data/README.md +1208 -0
  8. data/Rakefile +332 -0
  9. data/bin/mtc +8 -0
  10. data/bin/profile-mtc-checks +133 -0
  11. data/bin/tracy-profiler +0 -0
  12. data/docs/build-guide.md +536 -0
  13. data/docs/index.html +2778 -0
  14. data/docs/language-design.md +1519 -0
  15. data/docs/language-manual.md +1534 -0
  16. data/lib/milk_tea/base.rb +49 -0
  17. data/lib/milk_tea/bindings/bindgen/ast_parser.rb +398 -0
  18. data/lib/milk_tea/bindings/bindgen/declaration.rb +319 -0
  19. data/lib/milk_tea/bindings/bindgen/emitter.rb +387 -0
  20. data/lib/milk_tea/bindings/bindgen/overrides.rb +134 -0
  21. data/lib/milk_tea/bindings/bindgen/type_mapper.rb +622 -0
  22. data/lib/milk_tea/bindings/bindgen.rb +207 -0
  23. data/lib/milk_tea/bindings/cli.rb +121 -0
  24. data/lib/milk_tea/bindings/imported_bindings/defaults.rb +210 -0
  25. data/lib/milk_tea/bindings/imported_bindings/generator.rb +1114 -0
  26. data/lib/milk_tea/bindings/imported_bindings/method_source.rb +190 -0
  27. data/lib/milk_tea/bindings/imported_bindings/naming.rb +286 -0
  28. data/lib/milk_tea/bindings/imported_bindings.rb +215 -0
  29. data/lib/milk_tea/bindings/opengl_registry.rb +547 -0
  30. data/lib/milk_tea/bindings/raw_bindings/defaults.rb +1338 -0
  31. data/lib/milk_tea/bindings/raw_bindings.rb +269 -0
  32. data/lib/milk_tea/bindings/steamworks.rb +629 -0
  33. data/lib/milk_tea/bindings/upstream_sources.rb +352 -0
  34. data/lib/milk_tea/bindings/vendored_box2d.rb +79 -0
  35. data/lib/milk_tea/bindings/vendored_c_library.rb +322 -0
  36. data/lib/milk_tea/bindings/vendored_cjson.rb +52 -0
  37. data/lib/milk_tea/bindings/vendored_flecs.rb +78 -0
  38. data/lib/milk_tea/bindings/vendored_glfw.rb +77 -0
  39. data/lib/milk_tea/bindings/vendored_libuv.rb +75 -0
  40. data/lib/milk_tea/bindings/vendored_pcre2.rb +84 -0
  41. data/lib/milk_tea/bindings/vendored_raylib.rb +131 -0
  42. data/lib/milk_tea/bindings/vendored_sdl3.rb +78 -0
  43. data/lib/milk_tea/bindings/vendored_steamworks.rb +71 -0
  44. data/lib/milk_tea/bindings/vendored_tool.rb +71 -0
  45. data/lib/milk_tea/bindings/vendored_tools.rb +23 -0
  46. data/lib/milk_tea/bindings/vendored_tracy.rb +37 -0
  47. data/lib/milk_tea/bindings.rb +23 -0
  48. data/lib/milk_tea/core/ast.rb +311 -0
  49. data/lib/milk_tea/core/async_runtime_installer.rb +28 -0
  50. data/lib/milk_tea/core/binding_types.rb +18 -0
  51. data/lib/milk_tea/core/bindings/attribute_binding.rb +58 -0
  52. data/lib/milk_tea/core/bindings/function_binding.rb +5 -0
  53. data/lib/milk_tea/core/bindings/module_binding.rb +58 -0
  54. data/lib/milk_tea/core/bindings/value_binding.rb +21 -0
  55. data/lib/milk_tea/core/c_backend/aggregate_utils.rb +103 -0
  56. data/lib/milk_tea/core/c_backend/control_flow_emission.rb +412 -0
  57. data/lib/milk_tea/core/c_backend/expressions.rb +468 -0
  58. data/lib/milk_tea/core/c_backend/feature_detection.rb +483 -0
  59. data/lib/milk_tea/core/c_backend/reachability.rb +398 -0
  60. data/lib/milk_tea/core/c_backend/reinterpret.rb +226 -0
  61. data/lib/milk_tea/core/c_backend/runtime_helpers.rb +1080 -0
  62. data/lib/milk_tea/core/c_backend/statements.rb +563 -0
  63. data/lib/milk_tea/core/c_backend/type_collectors.rb +1545 -0
  64. data/lib/milk_tea/core/c_backend/type_declaration.rb +223 -0
  65. data/lib/milk_tea/core/c_backend/type_system.rb +345 -0
  66. data/lib/milk_tea/core/c_backend.rb +287 -0
  67. data/lib/milk_tea/core/compatibility_helpers.rb +79 -0
  68. data/lib/milk_tea/core/compile_time/const_eval.rb +187 -0
  69. data/lib/milk_tea/core/compile_time.rb +329 -0
  70. data/lib/milk_tea/core/control_flow/builder.rb +582 -0
  71. data/lib/milk_tea/core/control_flow/constant_propagation.rb +143 -0
  72. data/lib/milk_tea/core/control_flow/dataflow.rb +74 -0
  73. data/lib/milk_tea/core/control_flow/definite_assignment.rb +79 -0
  74. data/lib/milk_tea/core/control_flow/graph.rb +90 -0
  75. data/lib/milk_tea/core/control_flow/liveness.rb +24 -0
  76. data/lib/milk_tea/core/control_flow/nullability_flow.rb +43 -0
  77. data/lib/milk_tea/core/control_flow/reachability.rb +22 -0
  78. data/lib/milk_tea/core/control_flow/termination.rb +31 -0
  79. data/lib/milk_tea/core/control_flow.rb +34 -0
  80. data/lib/milk_tea/core/cst.rb +48 -0
  81. data/lib/milk_tea/core/cst_builder.rb +19 -0
  82. data/lib/milk_tea/core/ir.rb +85 -0
  83. data/lib/milk_tea/core/keywords.rb +97 -0
  84. data/lib/milk_tea/core/lexer/character_classes.rb +60 -0
  85. data/lib/milk_tea/core/lexer/format_strings.rb +225 -0
  86. data/lib/milk_tea/core/lexer/heredocs.rb +199 -0
  87. data/lib/milk_tea/core/lexer/indentation.rb +62 -0
  88. data/lib/milk_tea/core/lexer/numbers.rb +96 -0
  89. data/lib/milk_tea/core/lexer/recovery.rb +32 -0
  90. data/lib/milk_tea/core/lexer/strings.rb +167 -0
  91. data/lib/milk_tea/core/lexer/symbols.rb +71 -0
  92. data/lib/milk_tea/core/lexer/trivia.rb +53 -0
  93. data/lib/milk_tea/core/lexer.rb +430 -0
  94. data/lib/milk_tea/core/lowering/artifacts.rb +26 -0
  95. data/lib/milk_tea/core/lowering/async/analysis.rb +245 -0
  96. data/lib/milk_tea/core/lowering/async/lowering.rb +1399 -0
  97. data/lib/milk_tea/core/lowering/async/normalization.rb +459 -0
  98. data/lib/milk_tea/core/lowering/async.rb +714 -0
  99. data/lib/milk_tea/core/lowering/block.rb +1052 -0
  100. data/lib/milk_tea/core/lowering/calls.rb +1565 -0
  101. data/lib/milk_tea/core/lowering/declarations.rb +214 -0
  102. data/lib/milk_tea/core/lowering/dyn.rb +206 -0
  103. data/lib/milk_tea/core/lowering/events.rb +1054 -0
  104. data/lib/milk_tea/core/lowering/expressions.rb +1645 -0
  105. data/lib/milk_tea/core/lowering/foreign_cstr.rb +206 -0
  106. data/lib/milk_tea/core/lowering/functions.rb +242 -0
  107. data/lib/milk_tea/core/lowering/loops.rb +1087 -0
  108. data/lib/milk_tea/core/lowering/lowering_context.rb +80 -0
  109. data/lib/milk_tea/core/lowering/proc.rb +419 -0
  110. data/lib/milk_tea/core/lowering/resolve.rb +2516 -0
  111. data/lib/milk_tea/core/lowering/scans.rb +220 -0
  112. data/lib/milk_tea/core/lowering/str_buffer.rb +125 -0
  113. data/lib/milk_tea/core/lowering/utils.rb +1453 -0
  114. data/lib/milk_tea/core/lowering.rb +378 -0
  115. data/lib/milk_tea/core/module_binder.rb +181 -0
  116. data/lib/milk_tea/core/module_loader/errors.rb +21 -0
  117. data/lib/milk_tea/core/module_loader.rb +479 -0
  118. data/lib/milk_tea/core/module_path_resolver.rb +153 -0
  119. data/lib/milk_tea/core/module_roots.rb +88 -0
  120. data/lib/milk_tea/core/parser/attributes.rb +71 -0
  121. data/lib/milk_tea/core/parser/blocks.rb +119 -0
  122. data/lib/milk_tea/core/parser/declarations.rb +749 -0
  123. data/lib/milk_tea/core/parser/expressions.rb +624 -0
  124. data/lib/milk_tea/core/parser/recovery.rb +131 -0
  125. data/lib/milk_tea/core/parser/statements.rb +756 -0
  126. data/lib/milk_tea/core/parser/types.rb +271 -0
  127. data/lib/milk_tea/core/parser.rb +400 -0
  128. data/lib/milk_tea/core/prelude_installer.rb +29 -0
  129. data/lib/milk_tea/core/pretty_printer/ast_formatter.rb +917 -0
  130. data/lib/milk_tea/core/pretty_printer/base_formatter.rb +87 -0
  131. data/lib/milk_tea/core/pretty_printer/ir_formatter.rb +300 -0
  132. data/lib/milk_tea/core/pretty_printer.rb +17 -0
  133. data/lib/milk_tea/core/semantic_analyzer/analysis_context.rb +314 -0
  134. data/lib/milk_tea/core/semantic_analyzer/attributes.rb +184 -0
  135. data/lib/milk_tea/core/semantic_analyzer/calls.rb +992 -0
  136. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +1735 -0
  137. data/lib/milk_tea/core/semantic_analyzer/flow_refinement.rb +355 -0
  138. data/lib/milk_tea/core/semantic_analyzer/foreign_functions.rb +155 -0
  139. data/lib/milk_tea/core/semantic_analyzer/function_binding.rb +354 -0
  140. data/lib/milk_tea/core/semantic_analyzer/generics.rb +383 -0
  141. data/lib/milk_tea/core/semantic_analyzer/interface_conformance.rb +78 -0
  142. data/lib/milk_tea/core/semantic_analyzer/module_context.rb +35 -0
  143. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +1438 -0
  144. data/lib/milk_tea/core/semantic_analyzer/nullability.rb +421 -0
  145. data/lib/milk_tea/core/semantic_analyzer/statements.rb +1308 -0
  146. data/lib/milk_tea/core/semantic_analyzer/top_level.rb +588 -0
  147. data/lib/milk_tea/core/semantic_analyzer/type_compatibility.rb +307 -0
  148. data/lib/milk_tea/core/semantic_analyzer/type_declaration.rb +851 -0
  149. data/lib/milk_tea/core/semantic_analyzer.rb +327 -0
  150. data/lib/milk_tea/core/token.rb +26 -0
  151. data/lib/milk_tea/core/token_stream.rb +30 -0
  152. data/lib/milk_tea/core/types/layout.rb +243 -0
  153. data/lib/milk_tea/core/types/predicates.rb +609 -0
  154. data/lib/milk_tea/core/types/registry.rb +83 -0
  155. data/lib/milk_tea/core/types/types.rb +1696 -0
  156. data/lib/milk_tea/core/types/visitor.rb +442 -0
  157. data/lib/milk_tea/core.rb +25 -0
  158. data/lib/milk_tea/dap/backends/lldb_dap.rb +158 -0
  159. data/lib/milk_tea/dap/protocol.rb +58 -0
  160. data/lib/milk_tea/dap/server/breakpoints.rb +66 -0
  161. data/lib/milk_tea/dap/server/debug_map.rb +291 -0
  162. data/lib/milk_tea/dap/server/handlers.rb +374 -0
  163. data/lib/milk_tea/dap/server/launch.rb +261 -0
  164. data/lib/milk_tea/dap/server/lldb_backend.rb +380 -0
  165. data/lib/milk_tea/dap/server/pause_diagnostics.rb +109 -0
  166. data/lib/milk_tea/dap/server/utilities.rb +160 -0
  167. data/lib/milk_tea/dap/server/wire.rb +55 -0
  168. data/lib/milk_tea/dap/server.rb +131 -0
  169. data/lib/milk_tea/dap/session.rb +152 -0
  170. data/lib/milk_tea/dap.rb +6 -0
  171. data/lib/milk_tea/lsp/dependency_resolution.rb +52 -0
  172. data/lib/milk_tea/lsp/diagnostics.rb +611 -0
  173. data/lib/milk_tea/lsp/protocol.rb +104 -0
  174. data/lib/milk_tea/lsp/server/call_hierarchy.rb +274 -0
  175. data/lib/milk_tea/lsp/server/code_actions.rb +446 -0
  176. data/lib/milk_tea/lsp/server/code_lens.rb +97 -0
  177. data/lib/milk_tea/lsp/server/completion.rb +1099 -0
  178. data/lib/milk_tea/lsp/server/configuration.rb +167 -0
  179. data/lib/milk_tea/lsp/server/debug_info.rb +45 -0
  180. data/lib/milk_tea/lsp/server/definition.rb +779 -0
  181. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +239 -0
  182. data/lib/milk_tea/lsp/server/execute_command.rb +25 -0
  183. data/lib/milk_tea/lsp/server/folding_range.rb +153 -0
  184. data/lib/milk_tea/lsp/server/formatting.rb +575 -0
  185. data/lib/milk_tea/lsp/server/hover.rb +1465 -0
  186. data/lib/milk_tea/lsp/server/inlay_hints.rb +204 -0
  187. data/lib/milk_tea/lsp/server/lifecycle.rb +234 -0
  188. data/lib/milk_tea/lsp/server/linked_editing_range.rb +43 -0
  189. data/lib/milk_tea/lsp/server/on_type_formatting.rb +73 -0
  190. data/lib/milk_tea/lsp/server/progress.rb +47 -0
  191. data/lib/milk_tea/lsp/server/references.rb +433 -0
  192. data/lib/milk_tea/lsp/server/rename.rb +598 -0
  193. data/lib/milk_tea/lsp/server/selection_range.rb +130 -0
  194. data/lib/milk_tea/lsp/server/semantic_tokens.rb +1745 -0
  195. data/lib/milk_tea/lsp/server/signature_help.rb +200 -0
  196. data/lib/milk_tea/lsp/server/text_documents.rb +125 -0
  197. data/lib/milk_tea/lsp/server/type_hierarchy.rb +167 -0
  198. data/lib/milk_tea/lsp/server/utilities.rb +520 -0
  199. data/lib/milk_tea/lsp/server.rb +415 -0
  200. data/lib/milk_tea/lsp/workspace/analysis.rb +209 -0
  201. data/lib/milk_tea/lsp/workspace/caches.rb +260 -0
  202. data/lib/milk_tea/lsp/workspace/collection.rb +98 -0
  203. data/lib/milk_tea/lsp/workspace/definition_index.rb +184 -0
  204. data/lib/milk_tea/lsp/workspace/dependency_graph.rb +282 -0
  205. data/lib/milk_tea/lsp/workspace/store.rb +154 -0
  206. data/lib/milk_tea/lsp/workspace/utilities.rb +490 -0
  207. data/lib/milk_tea/lsp/workspace.rb +127 -0
  208. data/lib/milk_tea/lsp.rb +13 -0
  209. data/lib/milk_tea/packages/atomic_write.rb +63 -0
  210. data/lib/milk_tea/packages/dependency_solver.rb +194 -0
  211. data/lib/milk_tea/packages/graph.rb +139 -0
  212. data/lib/milk_tea/packages/lock.rb +421 -0
  213. data/lib/milk_tea/packages/manager_cli.rb +485 -0
  214. data/lib/milk_tea/packages/manifest.rb +356 -0
  215. data/lib/milk_tea/packages/manifest_editor.rb +134 -0
  216. data/lib/milk_tea/packages/registry_metadata_provider.rb +34 -0
  217. data/lib/milk_tea/packages/registry_store.rb +420 -0
  218. data/lib/milk_tea/packages/services.rb +41 -0
  219. data/lib/milk_tea/packages/source_cache.rb +71 -0
  220. data/lib/milk_tea/packages/source_fetcher.rb +124 -0
  221. data/lib/milk_tea/packages/source_resolver.rb +389 -0
  222. data/lib/milk_tea/packages/version.rb +164 -0
  223. data/lib/milk_tea/packages.rb +16 -0
  224. data/lib/milk_tea/tooling/asset_pack.rb +141 -0
  225. data/lib/milk_tea/tooling/build.rb +1124 -0
  226. data/lib/milk_tea/tooling/build_cache.rb +240 -0
  227. data/lib/milk_tea/tooling/cli.rb +2688 -0
  228. data/lib/milk_tea/tooling/cst_formatter.rb +13 -0
  229. data/lib/milk_tea/tooling/debug_info_formatter.rb +456 -0
  230. data/lib/milk_tea/tooling/debug_map.rb +248 -0
  231. data/lib/milk_tea/tooling/docs_app.rb +369 -0
  232. data/lib/milk_tea/tooling/error_formatter.rb +86 -0
  233. data/lib/milk_tea/tooling/formatter.rb +787 -0
  234. data/lib/milk_tea/tooling/linter/doc_tags.rb +212 -0
  235. data/lib/milk_tea/tooling/linter/fix_engine.rb +237 -0
  236. data/lib/milk_tea/tooling/linter/flow_rules.rb +380 -0
  237. data/lib/milk_tea/tooling/linter/imports_platform.rb +841 -0
  238. data/lib/milk_tea/tooling/linter/release_rules.rb +744 -0
  239. data/lib/milk_tea/tooling/linter/reserved_names.rb +199 -0
  240. data/lib/milk_tea/tooling/linter/rules.rb +593 -0
  241. data/lib/milk_tea/tooling/linter/source_helpers.rb +407 -0
  242. data/lib/milk_tea/tooling/linter/trailing_comma.rb +139 -0
  243. data/lib/milk_tea/tooling/linter/visitors.rb +1286 -0
  244. data/lib/milk_tea/tooling/linter.rb +798 -0
  245. data/lib/milk_tea/tooling/project_scaffold.rb +82 -0
  246. data/lib/milk_tea/tooling/public/css/docs.css +166 -0
  247. data/lib/milk_tea/tooling/public/js/docs.js +94 -0
  248. data/lib/milk_tea/tooling/run.rb +408 -0
  249. data/lib/milk_tea/tooling/templates/wasm_shell.html +48 -0
  250. data/lib/milk_tea/tooling/toolchain_cli.rb +157 -0
  251. data/lib/milk_tea/tooling/views/404.erb +7 -0
  252. data/lib/milk_tea/tooling/views/index.erb +87 -0
  253. data/lib/milk_tea/tooling/views/layout.erb +69 -0
  254. data/lib/milk_tea/tooling/views/module.erb +97 -0
  255. data/lib/milk_tea/tooling/views/stdlib.erb +21 -0
  256. data/lib/milk_tea/tooling.rb +20 -0
  257. data/lib/milk_tea.rb +8 -0
  258. metadata +426 -0
@@ -0,0 +1,798 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "set"
5
+ require "uri"
6
+ require_relative "linter/doc_tags.rb"
7
+ require_relative "linter/fix_engine.rb"
8
+ require_relative "linter/flow_rules.rb"
9
+ require_relative "linter/imports_platform.rb"
10
+ require_relative "linter/release_rules.rb"
11
+ require_relative "linter/reserved_names.rb"
12
+ require_relative "linter/rules.rb"
13
+ require_relative "linter/source_helpers.rb"
14
+ require_relative "linter/trailing_comma.rb"
15
+ require_relative "linter/visitors.rb"
16
+
17
+ module MilkTea
18
+ class Linter
19
+ UNSET = Object.new.freeze
20
+ DEFAULT_CONFIG_FILE_NAME = ".mt-lint.yml".freeze
21
+ KNOWN_RULE_CODES = %w[
22
+ borrow-and-mutate
23
+ constant-condition
24
+ dead-assignment
25
+ duplicate-if-condition
26
+ directional-ffi-arg
27
+ doc-tag
28
+ event-capacity
29
+ line-too-long
30
+ loop-single-iteration
31
+ missing-return
32
+ noop-compound-assignment
33
+ platform-api-drift
34
+ owning-release-leak
35
+ owning-release-double
36
+ prefer-conditional-expression
37
+ prefer-inline-if
38
+ prefer-is-variant
39
+ prefer-let
40
+ prefer-let-else
41
+ prefer-or-pattern
42
+ prefer-own-ptr
43
+ prefer-struct-with
44
+ prefer-try
45
+ prefer-var-else
46
+ redundant-bool-compare
47
+ redundant-cast
48
+ redundant-else
49
+ redundant-ignored-match-binding
50
+ redundant-null-check
51
+ redundant-return
52
+ redundant-type-annotation
53
+ reserved-primitive-name
54
+ self-assignment
55
+ self-comparison
56
+ shadow
57
+ trailing-list-comma
58
+ unreachable-code
59
+ unused-import
60
+ unused-local
61
+ unused-param
62
+ useless-expression
63
+ ].freeze
64
+ RESERVED_VALUE_TYPE_NAMES = Types::RESERVED_VALUE_TYPE_NAMES.to_set.freeze
65
+ RESERVED_IMPORT_ALIAS_NAMES = Types::RESERVED_IMPORT_ALIAS_NAMES.to_set.freeze
66
+ RESERVED_TYPE_BINDING_NAMES = Types::RESERVED_TYPE_BINDING_NAMES.to_set.freeze
67
+ AUTO_FIXABLE_RULE_CODES = %w[
68
+ prefer-let
69
+ redundant-ignored-match-binding
70
+ prefer-let-else
71
+ prefer-var-else
72
+ redundant-bool-compare
73
+ redundant-cast
74
+ redundant-else
75
+ redundant-return
76
+ redundant-type-annotation
77
+ reserved-primitive-name
78
+ trailing-list-comma
79
+ ].freeze
80
+ # NOTE: `unused-import` is intentionally NOT auto-fixable. Removing an import
81
+ # has non-local effects the linter cannot see under per-file validation:
82
+ # it may drop extension methods / canonical hooks (`hash[T]`, `equal[T]`),
83
+ # a bare-name type provided via the prelude bootstrap (e.g. `std.option`),
84
+ # or break downstream consumers of a library module. It remains a reported
85
+ # hint so authors can remove genuinely-unused imports deliberately.
86
+ LINT_TIERS = %i[fast full].freeze
87
+ STATIC_QUICK_FIX_TITLES = {
88
+ "line-too-long" => "Wrap long line",
89
+ "prefer-let" => "Replace 'var' with 'let'",
90
+ "redundant-ignored-match-binding" => "Remove redundant as _",
91
+ "redundant-bool-compare" => "Simplify boolean comparison",
92
+ "redundant-cast" => "Remove redundant cast",
93
+ "redundant-else" => "Remove redundant else",
94
+ "redundant-return" => "Remove redundant return",
95
+ "redundant-type-annotation" => "Remove redundant type annotation",
96
+ "prefer-let-else" => "Rewrite as let-else",
97
+ "prefer-var-else" => "Rewrite as var-else",
98
+ "trailing-list-comma" => "Remove trailing list comma",
99
+ }.freeze
100
+ EVENT_STACK_SNAPSHOT_WARNING_THRESHOLD = 128
101
+ FIX_ALL_TITLE = "Apply all auto-fixes".freeze
102
+
103
+ # severity: :error | :warning | :hint
104
+ Warning = Data.define(:path, :line, :column, :length, :code, :message, :severity, :symbol_name) do
105
+ def initialize(path:, line:, column: nil, length: nil, code:, message:, severity: :warning, symbol_name: nil) = super
106
+
107
+ def to_diagnostic
108
+ Diagnostic.new(path:, line:, column:, length:, code:, message:, severity:, symbol_name:)
109
+ end
110
+
111
+ def as_json(*)
112
+ {
113
+ path:,
114
+ line:,
115
+ column:,
116
+ length:,
117
+ code:,
118
+ message:,
119
+ severity: severity.to_s,
120
+ symbol_name:,
121
+ }.compact
122
+ end
123
+ end
124
+ # binding_kind: :local | :param
125
+ # allow_prefer_let: true only for `var` locals — flag prefer-let if never mutated
126
+ # mutated: true if ever the target of a plain `=` or compound assignment
127
+ Binding = Struct.new(
128
+ :name, :line, :column, :used, :binding_kind, :allow_prefer_let, :mutated,
129
+ :replacement_name, :replacement_base_name, :fix_index,
130
+ keyword_init: true
131
+ )
132
+ ReservedPrimitiveNameSite = Data.define(:line, :column, :length)
133
+ ReservedPrimitiveNameFix = Data.define(:kind, :original_name, :replacement_name, :sites)
134
+
135
+ class Profile
136
+ attr_reader :timings_ms, :counts
137
+
138
+ def initialize
139
+ @timings_ms = Hash.new(0.0)
140
+ @counts = Hash.new(0)
141
+ end
142
+
143
+ def measure(name)
144
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
+ result = yield
146
+ @timings_ms[name] += (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0
147
+ @counts[name] += 1
148
+ result
149
+ end
150
+
151
+ def empty?
152
+ @timings_ms.empty?
153
+ end
154
+
155
+ def summary(limit: 10, min_ms: 0.1)
156
+ @timings_ms
157
+ .sort_by { |_name, total_ms| -total_ms }
158
+ .filter_map do |name, total_ms|
159
+ rounded_ms = total_ms.round(1)
160
+ next if rounded_ms < min_ms
161
+
162
+ count = @counts[name]
163
+ count > 1 ? "#{name}:#{count}x/#{rounded_ms}" : "#{name}:#{rounded_ms}"
164
+ end
165
+ .first(limit)
166
+ .join(',')
167
+ end
168
+
169
+ def total_time_ms(prefix: nil)
170
+ return @timings_ms.values.sum unless prefix
171
+
172
+ @timings_ms.sum { |name, total_ms| name.start_with?(prefix) ? total_ms : 0.0 }
173
+ end
174
+
175
+ def rule_breakdown(limit: 10, min_ms: 0.1)
176
+ @timings_ms
177
+ .filter_map do |name, total_ms|
178
+ next unless name.start_with?("rule.")
179
+ next if total_ms < min_ms
180
+
181
+ count = @counts[name]
182
+ {
183
+ phase: name,
184
+ code: name.delete_prefix("rule.").tr("_", "-"),
185
+ total_ms: total_ms,
186
+ count:,
187
+ avg_ms: count.positive? ? (total_ms / count) : total_ms,
188
+ }
189
+ end
190
+ .sort_by { |entry| -entry[:total_ms] }
191
+ .first(limit)
192
+ end
193
+ end
194
+
195
+ StatementFlowAnalysis = Data.define(:graph, :reachability, :nullability, :constant_propagation, :loop_body_nodes)
196
+ DeadAssignmentAnalysis = Data.define(:graph, :liveness, :readable_bindings, :locally_declared)
197
+
198
+ def self.normalize_lint_tier(tier)
199
+ normalized = tier.to_s.strip.downcase.to_sym
200
+ LINT_TIERS.include?(normalized) ? normalized : :full
201
+ end
202
+
203
+ def self.lint_source(source, path: nil, select: nil, ignore: nil, sema_facts: UNSET, unresolved_import_paths: UNSET, profile: nil, lint_tier: :full)
204
+ sema_facts_provided = !sema_facts.equal?(UNSET)
205
+ unresolved_import_paths_provided = !unresolved_import_paths.equal?(UNSET)
206
+ if sema_facts_provided && !unresolved_import_paths_provided
207
+ # Callers that already computed semantic facts should not pay for a
208
+ # second context bootstrap only to derive unresolved imports.
209
+ unresolved_import_paths = Set.new
210
+ unresolved_import_paths_provided = true
211
+ end
212
+ cfg = load_config(path)
213
+ context = nil
214
+ if !sema_facts_provided || !unresolved_import_paths_provided
215
+ context = best_effort_lint_context(source, path:, profile:, label: "context_bootstrap")
216
+ sema_facts = context[:facts] unless sema_facts_provided
217
+ unresolved_import_paths = context[:unresolved_import_paths] unless unresolved_import_paths_provided
218
+ end
219
+
220
+ ast = profile_phase(profile, "resolve_ast") do
221
+ sema_facts&.ast || context&.fetch(:ast, nil) || Parser.parse(source, path:)
222
+ end
223
+ imported_modules = context&.fetch(:imported_modules, nil)
224
+ imported_modules ||= imported_modules_from_facts(ast, sema_facts)
225
+ suppressions = {}
226
+ if source.match?(/#\s*lint:\s*ignore(?:\(|\b)/)
227
+ trivia = profile_phase(profile, "lex_trivia") { Lexer.lex_with_trivia(source, path:).trivia }
228
+ suppressions = profile_phase(profile, "parse_suppressions") { parse_suppressions(trivia) }
229
+ end
230
+ warnings = new(
231
+ path:,
232
+ sema_facts:,
233
+ source:,
234
+ unresolved_import_paths:,
235
+ imported_modules:,
236
+ source_ast: ast,
237
+ profile:,
238
+ lint_tier: normalize_lint_tier(lint_tier),
239
+ max_line_length: cfg&.fetch(:max_line_length, nil),
240
+ ).lint(ast)
241
+ warnings = profile_phase(profile, "apply_suppressions") { apply_suppressions(warnings, suppressions) }
242
+
243
+ # Layer in config-file defaults before per-call overrides
244
+ if cfg
245
+ select ||= cfg[:select]
246
+ ignore ||= cfg[:ignore]
247
+ end
248
+
249
+ warnings = profile_phase(profile, "filter_rules") { filter_by_rules(warnings, select:, ignore:) }
250
+ warnings
251
+ end
252
+
253
+ def self.quick_fix_title(code)
254
+ STATIC_QUICK_FIX_TITLES[code]
255
+ end
256
+
257
+ def self.effective_max_line_length(path = nil)
258
+ load_config(path)&.fetch(:max_line_length, nil) || Formatter::DEFAULT_MAX_LINE_LENGTH
259
+ end
260
+
261
+ def self.default_config_source
262
+ lines = [
263
+ "# Default Milk Tea lint configuration.",
264
+ "# Remove entries from `select` to disable rules globally.",
265
+ "max_line_length: #{Formatter::DEFAULT_MAX_LINE_LENGTH}",
266
+ "select:",
267
+ ]
268
+ KNOWN_RULE_CODES.each do |code|
269
+ lines << " - #{code}"
270
+ end
271
+ lines << "ignore: []"
272
+ "#{lines.join("\n")}\n"
273
+ end
274
+
275
+ def self.collect_reserved_primitive_name_fixes(source, path: nil, sema_facts: UNSET, unresolved_import_paths: UNSET)
276
+ sema_facts_provided = !sema_facts.equal?(UNSET)
277
+ unresolved_import_paths_provided = !unresolved_import_paths.equal?(UNSET)
278
+ context = nil
279
+ if !sema_facts_provided || !unresolved_import_paths_provided
280
+ context = best_effort_lint_context(source, path:)
281
+ sema_facts = context[:facts] unless sema_facts_provided
282
+ unresolved_import_paths = context[:unresolved_import_paths] unless unresolved_import_paths_provided
283
+ end
284
+
285
+ ast = sema_facts&.ast || context&.fetch(:ast, nil) || Parser.parse(source, path:)
286
+ imported_modules = context&.fetch(:imported_modules, nil)
287
+ imported_modules ||= imported_modules_from_facts(ast, sema_facts)
288
+ linter = new(path:, sema_facts:, source:, unresolved_import_paths:, imported_modules:, source_ast: ast)
289
+ linter.lint(ast)
290
+ linter.reserved_primitive_name_fixes
291
+ end
292
+
293
+ # Load the nearest .mt-lint.yml walking up from the source file's directory.
294
+ # Returns Hash { select: Set|nil, ignore: Set|nil } or nil if no config found.
295
+ def self.load_config(path)
296
+ resolved_path = resolve_lint_path(path)
297
+ return nil unless resolved_path
298
+
299
+ dir = File.directory?(resolved_path) ? resolved_path : File.dirname(resolved_path)
300
+ # Walk up until we either find a config or leave the project
301
+ 100.times do
302
+ candidate = File.join(dir, DEFAULT_CONFIG_FILE_NAME)
303
+ if File.exist?(candidate)
304
+ return parse_config_file(candidate)
305
+ end
306
+ parent = File.dirname(dir)
307
+ break if parent == dir # filesystem root
308
+
309
+ dir = parent
310
+ end
311
+ nil
312
+ rescue StandardError
313
+ nil
314
+ end
315
+
316
+ def self.parse_config_file(path)
317
+ require "yaml"
318
+ raw = YAML.safe_load_file(path, symbolize_names: true) || {}
319
+ result = {}
320
+ if (s = raw[:select])
321
+ result[:select] = Array(s).map(&:to_s).to_set
322
+ end
323
+ if (i = raw[:ignore])
324
+ result[:ignore] = Array(i).map(&:to_s).to_set
325
+ end
326
+ if raw.key?(:max_line_length)
327
+ max_line_length = raw[:max_line_length].to_i
328
+ result[:max_line_length] = max_line_length if max_line_length.positive?
329
+ end
330
+ result
331
+ rescue StandardError
332
+ {}
333
+ end
334
+
335
+ def self.profile_phase(profile, name)
336
+ return yield unless profile
337
+
338
+ profile.measure(name) { yield }
339
+ end
340
+
341
+ def self.imported_modules_from_facts(ast, sema_facts)
342
+ return {} unless ast && sema_facts
343
+
344
+ ast.imports.each_with_object({}) do |import, imported_modules|
345
+ alias_name = import.alias_name || import.path.parts.last
346
+ module_binding = sema_facts.imports[alias_name]
347
+ imported_modules[import.path.to_s] = module_binding if module_binding
348
+ end
349
+ end
350
+
351
+ # Apply auto-fixable rules to source text.
352
+ # Handles: prefer-let, redundant-ignored-match-binding,
353
+ # prefer-let-else, prefer-var-else, redundant-bool-compare,
354
+ # redundant-else,
355
+ # redundant-return, reserved-primitive-name,
356
+ # trailing-list-comma.
357
+ # Returns the fixed source (may be identical if nothing was fixable).
358
+ def self.fix_source(source, path: nil, sema_facts: nil, select: nil, ignore: nil, max_passes: 5, profile: nil)
359
+ pass_limit = [max_passes.to_i, 1].max
360
+ current_source = source
361
+ current_sema_facts = sema_facts
362
+
363
+ pass_limit.times do
364
+ updated_source = fix_source_single_pass_isolating_rules(
365
+ current_source,
366
+ path:,
367
+ sema_facts: current_sema_facts,
368
+ select:,
369
+ ignore:,
370
+ profile:,
371
+ )
372
+ return current_source if updated_source == current_source
373
+
374
+ current_source = updated_source
375
+ # Facts passed by callers are only valid for the first source snapshot.
376
+ current_sema_facts = nil
377
+ end
378
+
379
+ current_source
380
+ end
381
+
382
+ def self.fix_source_single_pass_isolating_rules(source, path: nil, sema_facts: nil, select: nil, ignore: nil, profile: nil)
383
+ cfg = load_config(path)
384
+ effective_select = select || cfg&.fetch(:select, nil)
385
+ effective_ignore = ignore || cfg&.fetch(:ignore, nil)
386
+ enabled_rules = AUTO_FIXABLE_RULE_CODES.select do |rule_code|
387
+ next false if effective_select && !effective_select.include?(rule_code)
388
+ next false if effective_ignore && effective_ignore.include?(rule_code)
389
+
390
+ true
391
+ end
392
+ return source if enabled_rules.empty?
393
+
394
+ current_source = source
395
+ current_sema_facts = sema_facts
396
+
397
+ # When no semantic facts are supplied, omit the argument entirely so
398
+ # lint_source rebuilds a best-effort context. Passing an explicit `nil`
399
+ # would suppress sema-dependent rules (e.g. widening redundant-cast).
400
+ preflight_args = {
401
+ path:,
402
+ select: Set.new(enabled_rules),
403
+ ignore: effective_ignore,
404
+ profile:,
405
+ }
406
+ preflight_args[:sema_facts] = current_sema_facts if current_sema_facts
407
+ preflight_warnings = lint_source(current_source, **preflight_args)
408
+ return current_source if preflight_warnings.empty?
409
+
410
+ preflight_codes = preflight_warnings.map(&:code).to_set
411
+ active_rules = enabled_rules.select { |rule_code| preflight_codes.include?(rule_code) }
412
+ return current_source if active_rules.empty?
413
+
414
+ active_rules.each do |rule_code|
415
+ updated_source = fix_source_single_pass(
416
+ current_source,
417
+ path:,
418
+ sema_facts: current_sema_facts,
419
+ select: Set[rule_code],
420
+ ignore: effective_ignore,
421
+ )
422
+ next if updated_source == current_source
423
+
424
+ current_source = updated_source
425
+ current_sema_facts = nil
426
+ end
427
+
428
+ current_source
429
+ end
430
+
431
+ def self.fix_source_single_pass(source, path: nil, sema_facts: nil, select: nil, ignore: nil)
432
+ cfg = load_config(path)
433
+ effective_select = select || cfg&.fetch(:select, nil)
434
+ effective_ignore = ignore || cfg&.fetch(:ignore, nil)
435
+ rule_enabled = lambda do |code|
436
+ next false if effective_select && !effective_select.include?(code)
437
+ next false if effective_ignore && effective_ignore.include?(code)
438
+
439
+ true
440
+ end
441
+
442
+ working_context = sema_facts ? nil : best_effort_lint_context(source, path:)
443
+ working_sema_facts = sema_facts || working_context[:facts]
444
+ unresolved_import_paths = sema_facts ? Set.new : working_context[:unresolved_import_paths]
445
+ warnings = lint_source(
446
+ source,
447
+ path:,
448
+ sema_facts: working_sema_facts,
449
+ unresolved_import_paths:,
450
+ select:,
451
+ ignore:,
452
+ )
453
+ lines = source.lines
454
+
455
+ warnings.group_by(&:code).each do |code, code_warnings|
456
+ next unless rule_enabled.call(code)
457
+
458
+ code_warnings.sort_by { |w| [-(w.line || 0), -(w.column || 0)] }.each do |warning|
459
+ edits = FixEngine.edits_for_rule(code, lines, warning)
460
+ FixEngine.apply_fix_edits(lines, edits)
461
+ end
462
+ end
463
+
464
+ fixed_source = lines.join
465
+
466
+ if rule_enabled.call("reserved-primitive-name")
467
+ reserved_warnings = lint_source(fixed_source, path:, select: Set["reserved-primitive-name"]).select do |w|
468
+ w.code == "reserved-primitive-name" && w.line && w.column && w.symbol_name
469
+ end
470
+ unless reserved_warnings.empty?
471
+ warning_sites = reserved_warnings.each_with_object(Set.new) do |warning, sites|
472
+ sites << [warning.line, warning.column, warning.symbol_name]
473
+ end
474
+ reserved_fixes = collect_reserved_primitive_name_fixes(fixed_source, path:).select do |fix|
475
+ declaration_site = fix.sites.first
476
+ warning_sites.include?([declaration_site.line, declaration_site.column, fix.original_name])
477
+ end
478
+
479
+ fixed_source = apply_reserved_primitive_name_fixes(fixed_source, reserved_fixes)
480
+ end
481
+ end
482
+ # Validation compares a best-effort re-analysis of the fixed source against
483
+ # a baseline. When the caller supplied rich (full-package) facts there is
484
+ # no working_context, but the fixed-source check is still best-effort — so
485
+ # the baseline must also come from a best-effort analysis of the ORIGINAL
486
+ # source, otherwise pre-existing best-effort-only errors (e.g. unresolved
487
+ # cross-module imports in std files) would be counted as "new" and reject
488
+ # every otherwise-valid fix.
489
+ baseline_context = working_context || best_effort_lint_context(source, path:)
490
+ validated_fixed_source(source, fixed_source, path:, baseline_errors: baseline_context[:errors])
491
+ end
492
+
493
+ def self.validated_fixed_source(original_source, fixed_source, path:, baseline_errors:)
494
+ return fixed_source if fixed_source == original_source
495
+
496
+ fixed_context = best_effort_lint_context(fixed_source, path:)
497
+ return fixed_source if fixed_context[:ast] && !introduces_new_errors?(
498
+ error_signature_counts(fixed_context[:errors]),
499
+ error_signature_counts(baseline_errors),
500
+ )
501
+
502
+ original_source
503
+ end
504
+
505
+ def self.error_signature_counts(errors)
506
+ Array(errors).each_with_object(Hash.new(0)) do |error, counts|
507
+ counts[[error.line, error.column, error.message]] += 1
508
+ end
509
+ end
510
+
511
+ def self.introduces_new_errors?(modified_counts, baseline_counts)
512
+ modified_counts.any? do |signature, count|
513
+ count > baseline_counts.fetch(signature, 0)
514
+ end
515
+ end
516
+
517
+ def self.apply_reserved_primitive_name_fixes(source, fixes)
518
+ return source if fixes.empty?
519
+
520
+ line_offsets = line_start_offsets(source)
521
+ edits = fixes.flat_map do |fix|
522
+ fix.sites.uniq { |site| [site.line, site.column] }.filter_map do |site|
523
+ next unless site.line && site.column
524
+ next unless site.line >= 1 && site.line <= line_offsets.length
525
+
526
+ {
527
+ start_offset: line_offsets[site.line - 1] + site.column - 1,
528
+ length: site.length,
529
+ replacement: fix.replacement_name,
530
+ }
531
+ end
532
+ end
533
+ return source if edits.empty?
534
+
535
+ updated_source = source.dup
536
+ edits.sort_by { |edit| [edit[:start_offset], edit[:length]] }.reverse_each do |edit|
537
+ updated_source[edit[:start_offset], edit[:length]] = edit[:replacement]
538
+ end
539
+ updated_source
540
+ end
541
+
542
+ def self.line_start_offsets(source)
543
+ offsets = []
544
+ start_offset = 0
545
+ source.lines.each do |line|
546
+ offsets << start_offset
547
+ start_offset += line.length
548
+ end
549
+ offsets
550
+ end
551
+
552
+ def self.build_prefer_let_else_fix(lines, if_idx, symbol_name: nil)
553
+ return nil if if_idx.nil? || if_idx <= 0 || if_idx >= lines.length
554
+
555
+ declaration = lines[if_idx - 1].delete_suffix("\n")
556
+ guard = lines[if_idx].delete_suffix("\n")
557
+ name = symbol_name ||
558
+ guard[/\A\s*if\s+([A-Za-z_][A-Za-z0-9_]*)\s*==\s*null\s*:/, 1] ||
559
+ guard[/\A\s*if\s+null\s*==\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/, 1]
560
+ return nil unless name
561
+
562
+ declaration_comment = declaration[/\s+#.*\z/] || ""
563
+ declaration_base = declaration.sub(/\s+#.*\z/, "").rstrip
564
+ declaration_match = declaration_base.match(/\A(\s*)(let|var)\s+#{Regexp.escape(name)}\s*=\s*.+\z/)
565
+ return nil unless declaration_match
566
+ return nil if declaration_base.end_with?(" else:")
567
+ return nil unless guard.match?(/\A\s*if\s+(?:#{Regexp.escape(name)}\s*==\s*null|null\s*==\s*#{Regexp.escape(name)})\s*:\s*(?:#.*)?\z/)
568
+
569
+ new_text = +"#{declaration_base} else:"
570
+ new_text << declaration_comment unless declaration_comment.empty?
571
+ new_text << "\n"
572
+
573
+ { start_line_idx: if_idx - 1, end_line_idx: if_idx, new_text: }
574
+ end
575
+
576
+ def self.best_effort_lint_context(source, path: nil, profile: nil, label: "best_effort_lint_context")
577
+ ast = profile_phase(profile, "#{label}.parse") { Parser.parse(source, path:) }
578
+ imported_modules = {}
579
+ unresolved_import_paths = Set.new
580
+
581
+ resolved_path = resolve_lint_path(path)
582
+ if resolved_path && File.file?(resolved_path)
583
+ effective_platform = ModuleLoader.effective_platform_for_path(resolved_path)
584
+ loader = ModuleLoader.new(
585
+ module_roots: MilkTea::ModuleRoots.roots_for_path(resolved_path),
586
+ package_graph: load_lint_package_graph(resolved_path),
587
+ source_overrides: { resolved_path => source },
588
+ platform: effective_platform,
589
+ )
590
+ ast = profile_phase(profile, "#{label}.load_root") { loader.load_file(resolved_path) }
591
+ resolution = profile_phase(profile, "#{label}.imports") do
592
+ loader.imported_modules_for_ast_collecting_errors(ast, importer_path: resolved_path)
593
+ end
594
+ imported_modules = resolution.modules
595
+ unresolved_import_paths.merge(resolution.errors.filter_map { |entry| entry.import&.path&.to_s })
596
+ end
597
+
598
+ sema_snapshot = profile_phase(profile, "#{label}.sema") do
599
+ SemanticAnalyzer.tooling_snapshot(ast, imported_modules: imported_modules, path: resolved_path || path)
600
+ end
601
+
602
+ {
603
+ ast: ast,
604
+ facts: sema_snapshot.facts,
605
+ sema_snapshot: sema_snapshot,
606
+ errors: sema_snapshot.diagnostics,
607
+ imported_modules: imported_modules,
608
+ unresolved_import_paths: unresolved_import_paths,
609
+ }
610
+ rescue StandardError
611
+ unresolved = ast ? ast.imports.map { |i| i.path.to_s }.to_set : Set.new
612
+ { ast: nil, facts: nil, sema_snapshot: nil, errors: nil, imported_modules: {}, unresolved_import_paths: unresolved }
613
+ end
614
+
615
+ def self.best_effort_sema_facts(source, path: nil)
616
+ best_effort_lint_context(source, path:).fetch(:facts)
617
+ end
618
+
619
+ def self.resolve_lint_path(path)
620
+ return nil unless path.is_a?(String) && !path.empty?
621
+
622
+ if path.start_with?("file://")
623
+ CGI.unescape(URI.parse(path).path)
624
+ else
625
+ File.expand_path(path)
626
+ end
627
+ rescue StandardError
628
+ nil
629
+ end
630
+
631
+ def self.redundant_ignored_match_binding_span(line, column:)
632
+ anchor_idx = column - 1
633
+ return nil unless anchor_idx >= 0 && anchor_idx < line.length
634
+
635
+ scan_start = [anchor_idx - 5, 0].max
636
+ match = line[scan_start..]&.match(/\s+as\s+_/)
637
+ return nil unless match
638
+
639
+ start_char = scan_start + match.begin(0)
640
+ end_char = scan_start + match.end(0)
641
+ return nil unless anchor_idx >= start_char && anchor_idx < end_char
642
+
643
+ {
644
+ start_char:,
645
+ end_char:,
646
+ }
647
+ end
648
+
649
+ def self.redundant_bool_compare_replacement(expression_text)
650
+ match = expression_text.match(/\A\s*(.+?)\s*(==|!=)\s*(.+?)\s*\z/m)
651
+ return nil unless match
652
+
653
+ left_text = match[1].strip
654
+ operator = match[2]
655
+ right_text = match[3].strip
656
+ left_bool = bool_literal_value(left_text)
657
+ right_bool = bool_literal_value(right_text)
658
+ return nil if left_bool.nil? == right_bool.nil?
659
+
660
+ literal_value = left_bool.nil? ? right_bool : left_bool
661
+ compared_text = left_bool.nil? ? left_text : right_text
662
+ negate = if operator == "=="
663
+ literal_value == false
664
+ else
665
+ literal_value == true
666
+ end
667
+
668
+ negate ? negate_expression_text(compared_text) : compared_text
669
+ end
670
+
671
+ def self.bool_literal_value(text)
672
+ case text
673
+ when "true" then true
674
+ when "false" then false
675
+ else nil
676
+ end
677
+ end
678
+
679
+ def self.negate_expression_text(text)
680
+ stripped = text.strip
681
+ return "not #{stripped}" if stripped.match?(/\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*\z/)
682
+
683
+ "not (#{stripped})"
684
+ end
685
+
686
+ def self.load_lint_package_graph(path)
687
+ PackageGraph.load(path)
688
+ rescue PackageManifestError, PackageLockError
689
+ nil
690
+ end
691
+
692
+ # Parse `# lint: ignore` / `# lint: ignore(rule1, rule2)` comments.
693
+ # Returns Hash { line_number => Set<code> | :all }
694
+ def self.parse_suppressions(trivia)
695
+ result = {}
696
+ trivia.select { |t| t.kind == :comment }.each do |t|
697
+ text = t.text.strip
698
+ if (m = text.match(/\A#\s*lint:\s*ignore\(([^)]+)\)\z/))
699
+ codes = m[1].split(",").map(&:strip).to_set
700
+ result[t.line] = codes
701
+ elsif text.match(/\A#\s*lint:\s*ignore\z/)
702
+ result[t.line] = :all
703
+ end
704
+ end
705
+ result
706
+ end
707
+
708
+ def self.apply_suppressions(warnings, suppressions)
709
+ return warnings if suppressions.empty?
710
+
711
+ warnings.reject do |w|
712
+ next false unless w.line
713
+ # Suppression on same line (trailing) or preceding line (leading comment)
714
+ [w.line, w.line - 1].any? do |check_line|
715
+ entry = suppressions[check_line]
716
+ entry == :all || (entry.is_a?(Set) && entry.include?(w.code))
717
+ end
718
+ end
719
+ end
720
+
721
+ def self.filter_by_rules(warnings, select:, ignore:)
722
+ warnings = warnings.select { |w| select.include?(w.code) } if select
723
+ warnings = warnings.reject { |w| ignore.include?(w.code) } if ignore
724
+ warnings
725
+ end
726
+
727
+ def initialize(path: nil, sema_facts: nil, source: nil, unresolved_import_paths: nil, imported_modules: nil, source_ast: nil, profile: nil, lint_tier: :full, max_line_length: nil)
728
+ @path = path
729
+ @sema_facts = sema_facts
730
+ @unresolved_import_paths = (unresolved_import_paths || Set.new).to_set
731
+ @imported_modules = (imported_modules || {}).dup
732
+ @source = source.to_s
733
+ @source_lines = source ? source.lines.map { |line| line.delete_suffix("\n") } : []
734
+ @tokens = begin
735
+ Lexer.lex(@source, path: @path)
736
+ rescue StandardError
737
+ []
738
+ end
739
+ @token_index_by_location = @tokens.each_with_index.each_with_object({}) do |(token, index), locations|
740
+ locations[[token.line, token.column]] ||= index
741
+ end
742
+ @tokens_by_line = @tokens.each_with_object(Hash.new { |hash, line| hash[line] = [] }) do |token, by_line|
743
+ next if %i[newline indent dedent eof].include?(token.type)
744
+
745
+ by_line[token.line] << token
746
+ end
747
+ @source_ast = source_ast
748
+ @profile = profile
749
+ @warnings = []
750
+ @scopes = []
751
+ @module_bindings = {}
752
+ @declared_callable_names = Set.new
753
+ @declared_directional_functions = {}
754
+ @generic_function_depth = 0
755
+ @current_function_stack = []
756
+ @unsafe_depth = 0
757
+ @binding_ptr_unsafe_uses = Hash.new { |h, k| h[k] = { safe: 0, unsafe: 0 } }
758
+ @ptr_candidates = Set.new
759
+ @reserved_primitive_name_fixes = []
760
+ @lint_tier = self.class.normalize_lint_tier(lint_tier)
761
+ @max_line_length = max_line_length&.to_i&.positive? ? max_line_length.to_i : Formatter::DEFAULT_MAX_LINE_LENGTH
762
+ @cfg_binding_resolution = nil
763
+ @cfg_binding_resolution_computed = false
764
+ @statement_flow_analysis_cache = {}
765
+ @dead_assignment_analysis_cache = {}
766
+ end
767
+
768
+ def lint(ast)
769
+ @source_ast ||= ast
770
+ visit_source_file(ast)
771
+ profile_phase("rule.doc_tag") { emit_doc_tag_warnings(ast) } if full_tier?
772
+ profile_phase("rule.event_capacity") { emit_event_capacity_warnings(ast) }
773
+ profile_phase("rule.trailing_list_comma") { emit_trailing_list_comma_warnings(ast) }
774
+ profile_phase("rule.line_too_long") { emit_line_too_long_warnings }
775
+ @warnings
776
+ end
777
+
778
+ def full_tier?
779
+ @lint_tier == :full
780
+ end
781
+
782
+ def reserved_primitive_name_fixes
783
+ @reserved_primitive_name_fixes
784
+ end
785
+
786
+ private
787
+
788
+ include LinterDocTags
789
+ include LinterFlowRules
790
+ include LinterImportsPlatform
791
+ include LinterReleaseRules
792
+ include LinterReservedNames
793
+ include LinterRules
794
+ include LinterSourceHelpers
795
+ include LinterTrailingComma
796
+ include LinterVisitors
797
+ end
798
+ end