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,2688 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pp"
4
+ require "json"
5
+ require "socket"
6
+ require "stringio"
7
+ require "tempfile"
8
+ require "timeout"
9
+ require "tmpdir"
10
+
11
+ module MilkTea
12
+ class CLI
13
+ GENERATED_FILE_HEADER_PREFIX = "# generated by mtc".freeze
14
+
15
+ # Single source of truth for the top-level command set: used by the help
16
+ # overview and shell-completion generation so they cannot drift.
17
+ COMMANDS = [
18
+ ["check", "Type-check and lint source files"],
19
+ ["build", "Compile a source file or package"],
20
+ ["run", "Build and execute a program"],
21
+ ["run-module", "Resolve, build, and run a module by name"],
22
+ ["test", "Discover and run @[test] functions"],
23
+ ["new", "Scaffold a new package"],
24
+ ["format", "Format source files in place or check formatting"],
25
+ ["lint", "Lint source files and report warnings"],
26
+ ["lex", "Print the lexer token stream"],
27
+ ["parse", "Parse source and print the AST"],
28
+ ["lower", "Lower source to IR and print it"],
29
+ ["emit-c", "Compile source to C and print it"],
30
+ ["debug", "Print tokens, AST, facts, and diagnostics"],
31
+ ["deps", "Manage package dependencies"],
32
+ ["toolchain", "Manage the native toolchain"],
33
+ ["bindgen", "Generate a binding module from a C header"],
34
+ ["cache", "Inspect and manage the build cache"],
35
+ ["docs", "Serve the local documentation site"],
36
+ ["snapshot", "Render a highlighted HTML snapshot"],
37
+ ["lsp", "Start the Language Server Protocol server"],
38
+ ["dap", "Start the Debug Adapter Protocol server"],
39
+ ["completions", "Print a shell completion script"],
40
+ ].freeze
41
+
42
+ def self.start(argv = ARGV, out: $stdout, err: $stderr, source_overrides: nil, build_frontend: nil)
43
+ new(argv, out:, err:, source_overrides:, build_frontend:).start
44
+ end
45
+
46
+ def initialize(argv, out:, err:, source_overrides: nil, build_frontend: nil)
47
+ @argv = argv.dup
48
+ @out = out
49
+ @err = err
50
+ @include_module_roots = []
51
+ @include_path_flags = []
52
+ @ambient_module_roots = MilkTea::ModuleRoots.roots_for_path(Dir.pwd)
53
+ @source_overrides = normalize_source_overrides(source_overrides)
54
+ @build_frontend = build_frontend
55
+ @color = :auto
56
+ @verbose = false
57
+ @quiet = false
58
+ end
59
+
60
+ def start
61
+ return 1 unless extract_global_options!
62
+
63
+ extract_include_paths!
64
+ command = @argv.shift
65
+
66
+ if version_request?(command)
67
+ @out.puts("mtc #{MilkTea::VERSION}")
68
+ return 0
69
+ end
70
+
71
+ if help_request?(command)
72
+ topic = command == "help" ? @argv.shift : nil
73
+ if topic
74
+ print_command_help(topic, @out)
75
+ else
76
+ print_help(@out)
77
+ end
78
+ return 0
79
+ end
80
+
81
+ if command && @include_module_roots.any? && !command_supports_include_paths?(command)
82
+ @err.puts("unknown option #{@include_path_flags.first} for #{command}")
83
+ print_usage(@err)
84
+ return 1
85
+ end
86
+
87
+ if @argv.first == "--help" || @argv.first == "-h"
88
+ @argv.shift
89
+ print_command_help(command, @out)
90
+ return 0
91
+ end
92
+
93
+ case command
94
+ when "lex"
95
+ lex_command
96
+ when "parse"
97
+ parse_command
98
+ when "format"
99
+ format_command
100
+ when "lint"
101
+ lint_command
102
+ when "check"
103
+ check_command
104
+ when "lower"
105
+ lower_command
106
+ when "emit-c"
107
+ emit_c_command
108
+ when "build"
109
+ build_command
110
+ when "test"
111
+ test_command
112
+ when "run"
113
+ run_command
114
+ when "run-module"
115
+ app_command
116
+ when "new"
117
+ new_command
118
+ when "debug"
119
+ debug_command
120
+ when "toolchain"
121
+ toolchain_command
122
+ when "deps"
123
+ deps_command
124
+ when "bindgen"
125
+ bindgen_command
126
+ when "cache"
127
+ cache_command
128
+ when "docs"
129
+ docs_command
130
+ when "snapshot"
131
+ snapshot_command
132
+ when "lsp"
133
+ lsp_command
134
+ when "dap"
135
+ dap_command
136
+ when "completions"
137
+ completions_command
138
+ else
139
+ print_usage(@err)
140
+ 1
141
+ end
142
+ rescue StandardError => e
143
+ raise unless handled_cli_error?(e)
144
+
145
+ @err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
146
+ 1
147
+ end
148
+
149
+ private
150
+
151
+ def version_request?(command)
152
+ %w[version --version -V].include?(command)
153
+ end
154
+
155
+ def help_request?(command)
156
+ command.nil? || %w[help --help -h].include?(command)
157
+ end
158
+
159
+ # Pulls global options that may appear before the first `--` separator out of
160
+ # @argv so each subcommand parser sees a clean argument list. Stops at `--` so
161
+ # arguments forwarded to a run target (e.g. `mtc run app -- --verbose`) are
162
+ # preserved verbatim.
163
+ def extract_global_options!
164
+ remaining = []
165
+ forwarding = false
166
+ i = 0
167
+ while i < @argv.length
168
+ arg = @argv[i]
169
+ if forwarding
170
+ remaining << arg
171
+ i += 1
172
+ next
173
+ end
174
+
175
+ case arg
176
+ when "--"
177
+ forwarding = true
178
+ remaining << arg
179
+ i += 1
180
+ when "-v", "--verbose"
181
+ @verbose = true
182
+ i += 1
183
+ when "-q", "--quiet"
184
+ @quiet = true
185
+ i += 1
186
+ when "--color"
187
+ value = @argv[i + 1]
188
+ return invalid_color(value) unless valid_color?(value)
189
+
190
+ @color = value.to_sym
191
+ i += 2
192
+ when /\A--color=(.*)\z/
193
+ value = ::Regexp.last_match(1)
194
+ return invalid_color(value) unless valid_color?(value)
195
+
196
+ @color = value.to_sym
197
+ i += 1
198
+ else
199
+ remaining << arg
200
+ i += 1
201
+ end
202
+ end
203
+ @argv = remaining
204
+ true
205
+ end
206
+
207
+ def valid_color?(value)
208
+ %w[auto always never].include?(value)
209
+ end
210
+
211
+ def invalid_color(value)
212
+ @err.puts("--color must be auto, always, or never#{value ? " (got #{value})" : ''}")
213
+ false
214
+ end
215
+
216
+ def error_color?(io)
217
+ case @color
218
+ when :always then true
219
+ when :never then false
220
+ else io.respond_to?(:tty?) && io.tty?
221
+ end
222
+ end
223
+
224
+ # Prints an informational/progress line unless --quiet was given.
225
+ def info(message)
226
+ @out.puts(message) unless @quiet
227
+ end
228
+
229
+
230
+ def lex_command
231
+ path = nil
232
+
233
+ args = @argv.dup
234
+ @argv = []
235
+ until args.empty?
236
+ arg = args.shift
237
+ next if arg == "--"
238
+
239
+ if path.nil?
240
+ path = arg
241
+ else
242
+ @err.puts("unknown option: #{arg}")
243
+ print_usage(@err)
244
+ return 1
245
+ end
246
+ end
247
+
248
+ unless path
249
+ @err.puts("missing source file path")
250
+ print_usage(@err)
251
+ return 1
252
+ end
253
+
254
+ tokens = Lexer.lex(read_source_file(path), path: path)
255
+ @out.write(PP.pp(tokens, +""))
256
+ 0
257
+ end
258
+
259
+ def parse_command
260
+ args = @argv.dup
261
+ @argv = []
262
+ until args.empty?
263
+ arg = args.shift
264
+ @argv << arg
265
+ end
266
+
267
+ unless @argv.any?
268
+ @err.puts("missing source file path")
269
+ print_usage(@err)
270
+ return 1
271
+ end
272
+
273
+ resolution = extract_resolution_flags!
274
+ input_paths = @argv.dup
275
+ return 1 unless ensure_known_source_operands!("parse", input_paths)
276
+
277
+ paths = expand_source_paths(input_paths)
278
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
279
+
280
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
281
+
282
+ multiple = paths.length > 1
283
+ paths.each_with_index do |path, index|
284
+ ast = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).load_file(path)
285
+ if multiple
286
+ @out.puts("# --- #{path} ---")
287
+ end
288
+ @out.write(PrettyPrinter.format_ast(ast))
289
+ @out.puts if multiple && index < paths.length - 1
290
+ end
291
+ 0
292
+ end
293
+
294
+ def format_command
295
+ parsed = parse_format_options
296
+ return 1 unless parsed
297
+
298
+ options = parsed[:options]
299
+ input_paths = parsed[:input_paths]
300
+
301
+ if input_paths.empty?
302
+ @err.puts("missing source file path")
303
+ print_usage(@err)
304
+ return 1
305
+ end
306
+
307
+ paths = expand_source_paths(input_paths)
308
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
309
+
310
+ multiple_sources = input_paths.length > 1 || input_paths.any? { |path| File.directory?(path) }
311
+ if multiple_sources
312
+ unless options[:check] || options[:write]
313
+ @err.puts("format on multiple sources requires --check or --write")
314
+ print_usage(@err)
315
+ return 1
316
+ end
317
+
318
+ return format_paths(paths, options)
319
+ end
320
+
321
+ path = paths.first
322
+
323
+ source = read_source_file(path)
324
+ format_profile = options[:profile] ? Linter::Profile.new : nil
325
+ start_time = options[:profile] ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
326
+ result = Formatter.check_source(source, path: path, mode: options[:mode], max_line_length: options[:max_line_length], profile: format_profile)
327
+ elapsed_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
328
+
329
+ rc = if options[:check]
330
+ announce_file_action(path, "format-check")
331
+ if result.changed
332
+ info("needs formatting #{path}")
333
+ 1
334
+ else
335
+ info("already formatted #{path}")
336
+ 0
337
+ end
338
+ elsif options[:write]
339
+ announce_file_action(path, "format-write")
340
+ if result.changed
341
+ File.write(path, result.formatted_source)
342
+ info("formatted #{path}")
343
+ else
344
+ info("already formatted #{path}")
345
+ end
346
+ 0
347
+ else
348
+ @out.write(result.formatted_source)
349
+ 0
350
+ end
351
+
352
+ print_file_profiles([{ path:, total_ms: elapsed_ms, profile: format_profile }], "format") if options[:profile]
353
+ rc
354
+ end
355
+
356
+ def format_paths(paths, options)
357
+ format_profiles = []
358
+ if options[:check]
359
+ needs_fmt = []
360
+ paths.each do |p|
361
+ announce_file_action(p, "format-check")
362
+ format_profile = options[:profile] ? Linter::Profile.new : nil
363
+ start_time = options[:profile] ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
364
+ result = Formatter.check_source(read_source_file(p), path: p, mode: options[:mode], max_line_length: options[:max_line_length], profile: format_profile)
365
+ elapsed_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
366
+ format_profiles << { path: p, total_ms: elapsed_ms, profile: format_profile } if options[:profile]
367
+ needs_fmt << p if result.changed
368
+ end
369
+ print_file_profiles(format_profiles, "format") if options[:profile]
370
+ if needs_fmt.empty?
371
+ info("all #{paths.size} file(s) already formatted")
372
+ return 0
373
+ end
374
+ needs_fmt.each { |p| info("needs formatting #{p}") }
375
+ info("#{needs_fmt.size} file(s) need formatting")
376
+ return 1
377
+ end
378
+
379
+ # --write
380
+ changed = 0
381
+ paths.each do |p|
382
+ announce_file_action(p, "format-write")
383
+ format_profile = options[:profile] ? Linter::Profile.new : nil
384
+ start_time = options[:profile] ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
385
+ result = Formatter.check_source(read_source_file(p), path: p, mode: options[:mode], max_line_length: options[:max_line_length], profile: format_profile)
386
+ elapsed_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
387
+ format_profiles << { path: p, total_ms: elapsed_ms, profile: format_profile } if options[:profile]
388
+ if result.changed
389
+ File.write(p, result.formatted_source)
390
+ info("formatted #{p}")
391
+ changed += 1
392
+ end
393
+ end
394
+ print_file_profiles(format_profiles, "format") if options[:profile]
395
+ info("formatted #{changed} of #{paths.size} file(s)")
396
+ 0
397
+ end
398
+
399
+ def lint_command
400
+ resolution = { locked: false, frozen: false }
401
+ select = nil
402
+ ignore = nil
403
+ fix = false
404
+ init = false
405
+ ignore_generated = false
406
+ profile = false
407
+ input_paths = []
408
+ until @argv.empty?
409
+ arg = @argv.shift
410
+ unless arg.start_with?("--")
411
+ input_paths << arg
412
+ next
413
+ end
414
+
415
+ flag = arg
416
+ case flag
417
+ when "--select"
418
+ arg = @argv.shift
419
+ unless arg
420
+ @err.puts("--select requires a comma-separated list of rule codes")
421
+ return 1
422
+ end
423
+ select = arg.split(",").map(&:strip).to_set
424
+ when "--ignore"
425
+ arg = @argv.shift
426
+ unless arg
427
+ @err.puts("--ignore requires a comma-separated list of rule codes")
428
+ return 1
429
+ end
430
+ ignore = arg.split(",").map(&:strip).to_set
431
+ when "--fix"
432
+ fix = true
433
+ when "--init"
434
+ init = true
435
+ when "--locked"
436
+ resolution[:locked] = true
437
+ when "--frozen"
438
+ resolution[:locked] = true
439
+ resolution[:frozen] = true
440
+ when "--ignore-generated"
441
+ ignore_generated = true
442
+ when "--timings"
443
+ profile = true
444
+ when "--"
445
+ input_paths.concat(@argv)
446
+ @argv.clear
447
+ else
448
+ @err.puts("unknown lint flag: #{flag}")
449
+ return 1
450
+ end
451
+ end
452
+
453
+ if init
454
+ if input_paths.empty? && !select && !ignore && !fix && !resolution[:locked] && !resolution[:frozen]
455
+ return init_lint_config
456
+ end
457
+
458
+ @err.puts("--init does not accept source paths or lint options")
459
+ return 1
460
+ end
461
+
462
+ if input_paths.empty?
463
+ @err.puts("missing source file path")
464
+ print_usage(@err)
465
+ return 1
466
+ end
467
+
468
+ paths = input_paths.flat_map do |path|
469
+ if File.directory?(path)
470
+ Dir.glob(File.join(path, "**/*.mt")).sort
471
+ else
472
+ [path]
473
+ end
474
+ end.uniq
475
+
476
+ if paths.empty?
477
+ label = input_paths.length == 1 ? input_paths.first : input_paths.join(", ")
478
+ @out.puts("no .mt files found in #{label}")
479
+ return 0
480
+ end
481
+
482
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
483
+
484
+ if fix
485
+ lint_profiles = []
486
+ paths.each do |p|
487
+ announce_file_action(p, "lint-fix")
488
+ source = read_source_file(p)
489
+ if ignore_generated && generated_source?(source)
490
+ @out.puts("ignored generated #{p}")
491
+ next
492
+ end
493
+
494
+ facts = lint_sema_facts_for(source, p, locked: resolution[:locked])
495
+ prof = profile ? Linter::Profile.new : nil
496
+ start_time = profile ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
497
+
498
+ fixed = Linter.fix_source(
499
+ source,
500
+ path: p,
501
+ sema_facts: facts,
502
+ select:,
503
+ ignore:,
504
+ profile: prof,
505
+ )
506
+ total_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
507
+ lint_profiles << { path: p, profile: prof, mode: :pre_fix_scan, total_ms: } if prof
508
+ if fixed != source
509
+ File.write(p, fixed)
510
+ @out.puts("fixed #{p}")
511
+ end
512
+ end
513
+ print_lint_rule_profiles(lint_profiles) if profile
514
+ print_lint_file_profiles(lint_profiles) if profile
515
+ return 0
516
+ end
517
+
518
+ lint_profiles = []
519
+ all_warnings = paths.flat_map do |p|
520
+ announce_file_action(p, "lint")
521
+ source = read_source_file(p)
522
+ next [] if ignore_generated && generated_source?(source)
523
+
524
+ facts = lint_sema_facts_for(source, p, locked: resolution[:locked])
525
+ prof = profile ? Linter::Profile.new : nil
526
+ start_time = profile ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
527
+ warnings = Linter.lint_source(source, path: p, select:, ignore:, sema_facts: facts, profile: prof)
528
+ total_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
529
+ lint_profiles << { path: p, profile: prof, mode: :lint, total_ms: } if prof
530
+ warnings
531
+ end
532
+
533
+ if all_warnings.empty?
534
+ if input_paths.length == 1
535
+ info("clean #{input_paths.first}")
536
+ else
537
+ info("clean #{paths.size} file(s)")
538
+ end
539
+ print_lint_file_profiles(lint_profiles) if profile
540
+ return 0
541
+ end
542
+
543
+ all_warnings.each do |warning|
544
+ @out.puts("#{warning.path}:#{warning.line}: #{warning.code}: #{warning.message}")
545
+ end
546
+
547
+ print_lint_rule_profiles(lint_profiles) if profile
548
+ print_lint_file_profiles(lint_profiles) if profile
549
+
550
+ file_count = all_warnings.map(&:path).uniq.size
551
+ noun = all_warnings.size == 1 ? "warning" : "warnings"
552
+ files_str = file_count == 1 ? "1 file" : "#{file_count} files"
553
+ @out.puts("Found #{all_warnings.size} #{noun} in #{files_str}.")
554
+ 1
555
+ end
556
+
557
+ def init_lint_config
558
+ path = File.join(Dir.pwd, Linter::DEFAULT_CONFIG_FILE_NAME)
559
+ if File.exist?(path)
560
+ @err.puts("lint config already exists at #{path}")
561
+ return 1
562
+ end
563
+
564
+ File.write(path, Linter.default_config_source)
565
+ info("created #{path}")
566
+ 0
567
+ end
568
+
569
+ def print_lint_rule_profiles(lint_profiles, limit: 12)
570
+ lint_profiles.each do |entry|
571
+ profile = entry[:profile]
572
+ next unless profile
573
+
574
+ rows = profile.rule_breakdown(limit:, min_ms: 0.0)
575
+ rule_total = profile.total_time_ms(prefix: "rule.")
576
+ overall_total = profile.total_time_ms
577
+ mode_label = entry[:mode] == :pre_fix_scan ? "pre-fix scan" : "lint scan"
578
+ @out.puts("lint profile #{entry[:path]} (#{mode_label}): rules=#{format('%.1f', rule_total)}ms total=#{format('%.1f', overall_total)}ms")
579
+
580
+ if rows.empty?
581
+ @out.puts(" no rule timing data captured")
582
+ next
583
+ end
584
+
585
+ rows.each do |row|
586
+ share = rule_total.positive? ? ((row[:total_ms] / rule_total) * 100.0) : 0.0
587
+ @out.puts(
588
+ " #{row[:code]}: #{row[:count]}x total=#{format('%.1f', row[:total_ms])}ms avg=#{format('%.2f', row[:avg_ms])}ms share=#{format('%.1f', share)}%"
589
+ )
590
+ end
591
+
592
+ non_rule_rows = profile.timings_ms
593
+ .filter_map do |name, total_ms|
594
+ next if name.start_with?("rule.")
595
+ next if total_ms < 1.0
596
+
597
+ [name, total_ms]
598
+ end
599
+ .sort_by { |_name, total_ms| -total_ms }
600
+ .first(5)
601
+ .map do |name, total_ms|
602
+ count = profile.counts[name]
603
+ "#{name}:#{count}x/#{format('%.1f', total_ms)}ms"
604
+ end
605
+
606
+ @out.puts(" non-rule hot phases: #{non_rule_rows.join(', ')}") unless non_rule_rows.empty?
607
+ end
608
+ end
609
+
610
+ def print_lint_file_profiles(lint_profiles)
611
+ file_entries = lint_profiles.filter_map do |entry|
612
+ total = entry[:total_ms]
613
+ next unless total
614
+
615
+ phases = entry[:profile]&.timings_ms&.reject { |name, _| name.start_with?("rule.") }&.sort_by { |_, ms| -ms }
616
+ { path: entry[:path], total_ms: total, phases: }
617
+ end
618
+ return if file_entries.empty?
619
+
620
+ sorted = file_entries.sort_by { |e| -e[:total_ms] }
621
+ @out.puts
622
+ @out.puts("Profile (lint): #{sorted.size} file(s)")
623
+ sorted.each do |entry|
624
+ phase_str = entry[:phases]&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 1.0 }&.join(", ")
625
+ detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
626
+ @out.puts(" #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
627
+ end
628
+ total = sorted.sum { |e| e[:total_ms] }
629
+ @out.puts("Total: #{format('%.1f', total)}ms")
630
+ end
631
+
632
+ def print_file_profiles(file_profiles, label)
633
+ sorted = file_profiles.sort_by { |fp| -fp[:total_ms] }
634
+ return if sorted.empty?
635
+
636
+ @out.puts
637
+ if sorted.size == 1
638
+ entry = sorted.first
639
+ phases = entry[:profile]&.timings_ms&.sort_by { |_, ms| -ms }
640
+ phase_str = phases&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 0.1 }&.join(", ")
641
+ detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
642
+ @out.puts("#{label} profile #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
643
+ return
644
+ end
645
+
646
+ @out.puts("Profile (#{label}): #{sorted.size} file(s)")
647
+ sorted.each do |entry|
648
+ phases = entry[:profile]&.timings_ms&.sort_by { |_, ms| -ms }
649
+ phase_str = phases&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 1.0 }&.join(", ")
650
+ detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
651
+ @out.puts(" #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
652
+ end
653
+ total = sorted.sum { |fp| fp[:total_ms] }
654
+ @out.puts("Total: #{format('%.1f', total)}ms")
655
+ end
656
+
657
+ def check_command
658
+ args = @argv.dup
659
+ @argv = []
660
+ until args.empty?
661
+ arg = args.shift
662
+ @argv << arg
663
+ end
664
+
665
+ unless @argv.any?
666
+ @err.puts("missing source file path")
667
+ print_usage(@err)
668
+ return 1
669
+ end
670
+
671
+ resolution = extract_resolution_flags!
672
+ input_paths = @argv.dup
673
+ return 1 unless ensure_known_source_operands!("check", input_paths)
674
+
675
+ paths = expand_source_paths(input_paths)
676
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
677
+
678
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
679
+
680
+ all_diagnostics = []
681
+ paths.each do |path|
682
+ diagnostics, module_name, closure_errors = check_single_reporting_all(path, locked: resolution[:locked])
683
+ closure_errors = [] if paths.length > 1
684
+ diagnostics = sort_by_location(diagnostics)
685
+
686
+ if diagnostics.any? || closure_errors.any?
687
+ source = read_source_file(path)
688
+ diagnostics.each do |d|
689
+ @err.puts(ErrorFormatter.format(d, source:, color: error_color?(@err)))
690
+ end
691
+ closure_errors.each do |d|
692
+ @err.puts(ErrorFormatter.format(d, color: error_color?(@err)))
693
+ end
694
+ all_diagnostics.concat(diagnostics)
695
+ all_diagnostics.concat(closure_errors)
696
+ elsif module_name
697
+ info("checked #{path} as #{module_name}")
698
+ end
699
+ end
700
+
701
+ return 0 if all_diagnostics.empty?
702
+
703
+ error_count = all_diagnostics.count { |d| !d.respond_to?(:severity) || d.severity == :error }
704
+ warning_count = all_diagnostics.count { |d| d.respond_to?(:severity) && d.severity == :warning }
705
+ info_count = all_diagnostics.count { |d| d.respond_to?(:severity) && (d.severity == :info || d.severity == :hint) }
706
+
707
+ @err.puts
708
+ parts = []
709
+ parts << "#{error_count} #{error_count == 1 ? 'error' : 'errors'}" if error_count > 0
710
+ parts << "#{warning_count} #{warning_count == 1 ? 'warning' : 'warnings'}" if warning_count > 0
711
+ parts << "#{info_count} #{info_count == 1 ? 'note' : 'notes'}" if info_count > 0
712
+ body = parts.join("; ")
713
+ if error_count > 0
714
+ @err.puts("error: could not check due to #{body}")
715
+ elsif warning_count > 0
716
+ @err.puts("warning: #{body}")
717
+ end
718
+ final_error_count = error_count + (resolution[:warnings_as_errors] ? warning_count : 0)
719
+ final_error_count > 0 ? 1 : 0
720
+ end
721
+
722
+ def check_single_reporting_all(path, locked: false)
723
+ loader = make_module_loader(path, locked:, platform: ModuleLoader.default_host_platform)
724
+ resolved_path = File.expand_path(path)
725
+ ast = loader.load_file(resolved_path)
726
+ module_name = ast.module_name.to_s
727
+
728
+ import_result = loader.send(:imported_modules_for_ast_collecting_errors, ast, importer_path: resolved_path)
729
+ errors = import_result.errors.dup
730
+
731
+ analysis = nil
732
+ begin
733
+ result = SemanticAnalyzer.check_collecting_errors(ast, imported_modules: import_result.modules, path: resolved_path)
734
+ errors.concat(result[:errors])
735
+ analysis = result[:analysis]
736
+ rescue SemanticError => e
737
+ errors << e
738
+ end
739
+
740
+ if analysis && errors.empty?
741
+ source = read_source_file(path)
742
+ warnings = Linter.lint_source(source, path: resolved_path, sema_facts: analysis, lint_tier: :full)
743
+ errors.concat(warnings)
744
+ end
745
+
746
+ closure_errors = loader.collecting_path_errors.values.flatten.compact
747
+ [errors, module_name, closure_errors]
748
+ rescue ModuleLoadError, PackageLockError, SemanticError => e
749
+ [[e], nil, []]
750
+ end
751
+
752
+ def sort_by_location(errors)
753
+ errors.sort_by do |e|
754
+ actual = e.respond_to?(:error) ? e.error : e
755
+ line = actual.respond_to?(:line) ? actual.line.to_i : 0
756
+ column = actual.respond_to?(:column) ? actual.column.to_i : 0
757
+ [line, column]
758
+ end
759
+ end
760
+
761
+ def lower_command
762
+ args = @argv.dup
763
+ @argv = []
764
+ until args.empty?
765
+ arg = args.shift
766
+ @argv << arg
767
+ end
768
+
769
+ unless @argv.any?
770
+ @err.puts("missing source file path")
771
+ print_usage(@err)
772
+ return 1
773
+ end
774
+
775
+ resolution = extract_resolution_flags!
776
+ input_paths = @argv.dup
777
+ return 1 unless ensure_known_source_operands!("lower", input_paths)
778
+
779
+ paths = expand_source_paths(input_paths)
780
+ return 0 if print_no_source_files_if_empty(paths, input_paths)
781
+
782
+ ensure_current_lockfiles!(paths) if resolution[:frozen]
783
+
784
+ multiple = paths.length > 1
785
+ paths.each_with_index do |path, index|
786
+ program = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).check_program(path)
787
+ if multiple
788
+ @out.puts("# --- #{path} ---")
789
+ end
790
+ @out.write(PrettyPrinter.format_ir(Lowering.lower(program)))
791
+ @out.puts if multiple && index < paths.length - 1
792
+ end
793
+ 0
794
+ end
795
+
796
+ def emit_c_command
797
+ args = @argv.dup
798
+ @argv = []
799
+ until args.empty?
800
+ arg = args.shift
801
+ @argv << arg
802
+ end
803
+
804
+ unless @argv.any?
805
+ @err.puts("missing source file path")
806
+ print_usage(@err)
807
+ return 1
808
+ end
809
+
810
+ resolution = extract_resolution_flags!
811
+ input_paths = @argv.dup
812
+ return 1 unless ensure_known_source_operands!("emit-c", input_paths)
813
+
814
+ program_paths = input_paths.map { |p| resolve_program_path(p) }
815
+ return 1 if program_paths.include?(nil)
816
+
817
+ ensure_current_lockfiles!(input_paths) if resolution[:frozen]
818
+
819
+ multiple = program_paths.length > 1
820
+ program_paths.each_with_index do |path, index|
821
+ program = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).check_program(path)
822
+ if multiple
823
+ @out.puts("/* --- #{path} --- */")
824
+ end
825
+ @out.write(CBackend.generate_c(Lowering.lower(program), emit_line_directives: false))
826
+ @out.puts if multiple && index < program_paths.length - 1
827
+ end
828
+ 0
829
+ end
830
+
831
+ def resolve_program_path(path)
832
+ return path unless File.directory?(path)
833
+
834
+ manifest_path = File.join(path, "package.toml")
835
+ unless File.file?(manifest_path)
836
+ @err.puts("no package.toml found in #{path}")
837
+ return nil
838
+ end
839
+
840
+ manifest = PackageManifest.load(path)
841
+ entry_path = manifest.source_path
842
+ unless entry_path && File.file?(entry_path)
843
+ @err.puts("no build entry found for #{path}")
844
+ return nil
845
+ end
846
+
847
+ entry_path
848
+ rescue PackageManifestError => e
849
+ @err.puts("failed to load package manifest for #{path}: #{e.message}")
850
+ nil
851
+ end
852
+
853
+ def build_command
854
+ path, options = extract_path_and_options(allow_clean: true)
855
+ return 1 unless path
856
+
857
+ if options.delete(:clean)
858
+ cleaned_path = Build.clean(path, output_path: options[:output_path], profile: options[:profile], platform: options[:platform], bundle: options[:bundle], archive: options[:archive])
859
+ info("cleaned #{cleaned_path}")
860
+ return 0
861
+ end
862
+
863
+ frozen = options.delete(:frozen)
864
+ ensure_current_lockfile!(path) if frozen
865
+ locked = options.delete(:locked)
866
+ bundle = options[:bundle]
867
+ package_graph = package_graph_for(path, locked:)
868
+ result = Build.build(path, module_roots: module_roots_for(path, locked:), package_graph:, frontend: @build_frontend, **options.except(:timings))
869
+ if bundle
870
+ info("built #{path} -> #{File.dirname(result.output_path)}")
871
+ info("entry executable #{result.output_path}")
872
+ info(" [cached]") if result.cached
873
+ info("archive #{result.archive_path}") if result.archive_path
874
+ elsif result.cached
875
+ info("built #{path} -> #{result.output_path} [cached]")
876
+ else
877
+ info("built #{path} -> #{result.output_path}")
878
+ end
879
+ info("saved C to #{result.c_path}") if result.c_path
880
+ 0
881
+ end
882
+
883
+ def test_command
884
+ limits = extract_test_limit_flags!
885
+ return 1 unless limits
886
+
887
+ @test_timeout_seconds, @test_memory_bytes, @test_jobs, @test_sanitize, @test_filter, @test_format = limits
888
+
889
+ path, options = extract_path_and_options
890
+ return 1 unless path
891
+
892
+ frozen = options.delete(:frozen)
893
+ ensure_current_lockfile!(path) if frozen
894
+ locked = options.delete(:locked)
895
+
896
+ return dispatch_test_run(path, options:, locked:) if @test_format == :human
897
+
898
+ buffer = StringIO.new
899
+ real_out = @out
900
+ real_err = @err
901
+ @out = buffer
902
+ @err = buffer
903
+ begin
904
+ exit_code = dispatch_test_run(path, options:, locked:)
905
+ ensure
906
+ @out = real_out
907
+ @err = real_err
908
+ end
909
+
910
+ emit_machine_results(parse_test_output(buffer.string), @test_format, real_out)
911
+ exit_code
912
+ end
913
+
914
+ def dispatch_test_run(path, options:, locked:)
915
+ if File.directory?(path)
916
+ run_test_directory(path, options:, locked:)
917
+ elsif File.file?(path)
918
+ if compile_fail_fixture?(File.read(path))
919
+ run_compile_fail_test(path) ? 0 : 1
920
+ else
921
+ run_test_file(path, options:, locked:)
922
+ end
923
+ else
924
+ @err.puts("mtc test: not a file or directory: #{path}")
925
+ 1
926
+ end
927
+ end
928
+
929
+ def extract_test_limit_flags!
930
+ timeout_seconds = TEST_RUN_TIMEOUT_SECONDS
931
+ memory_bytes = TEST_RUN_MEMORY_LIMIT_BYTES
932
+ jobs = 1
933
+ sanitize = false
934
+ filter = nil
935
+ format = :human
936
+ remaining = []
937
+ until @argv.empty?
938
+ arg = @argv.shift
939
+ case arg
940
+ when "--timeout"
941
+ value = @argv.shift
942
+ seconds = value && Integer(value, exception: false)
943
+ unless seconds&.positive?
944
+ @err.puts("--timeout requires a positive integer (seconds)")
945
+ return nil
946
+ end
947
+ timeout_seconds = seconds
948
+ when "--mem"
949
+ value = @argv.shift
950
+ megabytes = value && Integer(value, exception: false)
951
+ unless megabytes&.positive?
952
+ @err.puts("--mem requires a positive integer (megabytes)")
953
+ return nil
954
+ end
955
+ memory_bytes = megabytes * 1024 * 1024
956
+ when "--jobs"
957
+ value = @argv.shift
958
+ count = value && Integer(value, exception: false)
959
+ unless count&.positive?
960
+ @err.puts("--jobs requires a positive integer")
961
+ return nil
962
+ end
963
+ jobs = count
964
+ when "--sanitize"
965
+ sanitize = true
966
+ when "-n", "--name"
967
+ value = @argv.shift
968
+ unless value
969
+ @err.puts("-n requires a name substring")
970
+ return nil
971
+ end
972
+ filter = value
973
+ when "--format"
974
+ value = @argv.shift
975
+ unless %w[human tap junit].include?(value)
976
+ @err.puts("--format must be human, tap, or junit")
977
+ return nil
978
+ end
979
+ format = value.to_sym
980
+ when "--"
981
+ remaining << arg
982
+ remaining.concat(@argv)
983
+ @argv.clear
984
+ else
985
+ remaining << arg
986
+ end
987
+ end
988
+ @argv.replace(remaining)
989
+ [timeout_seconds, memory_bytes, jobs, sanitize, filter, format]
990
+ end
991
+
992
+ TestResult = Data.define(:name, :status, :detail)
993
+
994
+ def parse_test_output(text)
995
+ results = []
996
+ current_file = nil
997
+ text.each_line do |raw|
998
+ line = raw.chomp
999
+ case line
1000
+ when /\A# (.+)\z/
1001
+ current_file = ::Regexp.last_match(1)
1002
+ when /\Aok - (.+)\z/
1003
+ results << TestResult.new(name: ::Regexp.last_match(1), status: :pass, detail: nil)
1004
+ when /\Askip - (.+?)(?:: (.*))?\z/
1005
+ results << TestResult.new(name: ::Regexp.last_match(1), status: :skip, detail: ::Regexp.last_match(2))
1006
+ when /\AFAIL - (.+?)(?:: (.*))?\z/
1007
+ results << TestResult.new(name: ::Regexp.last_match(1), status: :fail, detail: ::Regexp.last_match(2))
1008
+ when /\AFAILED - (.+?) \(build error\)\z/
1009
+ results << TestResult.new(name: "#{::Regexp.last_match(1)} (build error)", status: :fail, detail: "build error")
1010
+ when /\Atest run (?:timed out|crashed)/, /\ASUMMARY: \w+Sanitizer/
1011
+ results << TestResult.new(name: "#{current_file || 'test'} (#{line})", status: :fail, detail: line)
1012
+ end
1013
+ end
1014
+ results
1015
+ end
1016
+
1017
+ def emit_machine_results(results, format, out)
1018
+ case format
1019
+ when :tap then emit_tap(results, out)
1020
+ when :junit then emit_junit(results, out)
1021
+ end
1022
+ end
1023
+
1024
+ def emit_tap(results, out)
1025
+ out.puts("TAP version 13")
1026
+ out.puts("1..#{results.length}")
1027
+ results.each_with_index do |result, index|
1028
+ number = index + 1
1029
+ case result.status
1030
+ when :pass
1031
+ out.puts("ok #{number} - #{result.name}")
1032
+ when :skip
1033
+ out.puts("ok #{number} - #{result.name} # SKIP#{result.detail ? " #{result.detail}" : ''}")
1034
+ when :fail
1035
+ out.puts("not ok #{number} - #{result.name}")
1036
+ next unless result.detail
1037
+
1038
+ out.puts(" ---")
1039
+ out.puts(" message: #{result.detail}")
1040
+ out.puts(" ...")
1041
+ end
1042
+ end
1043
+ end
1044
+
1045
+ def emit_junit(results, out)
1046
+ failures = results.count { |result| result.status == :fail }
1047
+ skipped = results.count { |result| result.status == :skip }
1048
+ out.puts(%(<?xml version="1.0" encoding="UTF-8"?>))
1049
+ out.puts(%(<testsuites tests="#{results.length}" failures="#{failures}" skipped="#{skipped}">))
1050
+ out.puts(%( <testsuite name="mtc test" tests="#{results.length}" failures="#{failures}" skipped="#{skipped}">))
1051
+ results.each do |result|
1052
+ name = xml_escape(result.name)
1053
+ case result.status
1054
+ when :pass
1055
+ out.puts(%( <testcase name="#{name}"/>))
1056
+ when :skip
1057
+ out.puts(%( <testcase name="#{name}"><skipped/></testcase>))
1058
+ when :fail
1059
+ out.puts(%( <testcase name="#{name}"><failure message="#{xml_escape(result.detail || 'failed')}"/></testcase>))
1060
+ end
1061
+ end
1062
+ out.puts(" </testsuite>")
1063
+ out.puts("</testsuites>")
1064
+ end
1065
+
1066
+ def xml_escape(value)
1067
+ value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
1068
+ end
1069
+
1070
+ def run_test_directory(directory, options:, locked:)
1071
+ test_files = discover_test_files(directory)
1072
+ if test_files.empty?
1073
+ if @test_filter
1074
+ @out.puts("no tests matched -n '#{@test_filter}' under #{directory}")
1075
+ else
1076
+ @out.puts("no @[test] functions or # expect-error: fixtures found under #{directory}")
1077
+ end
1078
+ return 0
1079
+ end
1080
+
1081
+ jobs = @test_jobs || 1
1082
+ return run_test_files_parallel(test_files, jobs:, options:, locked:) if jobs > 1 && Process.respond_to?(:fork)
1083
+
1084
+ failed = 0
1085
+ test_files.each do |file, kind|
1086
+ @out.puts("# #{file}")
1087
+ @out.flush if @out.respond_to?(:flush)
1088
+ failed += 1 unless run_classified_file(file, kind, options:, locked:).zero?
1089
+ end
1090
+
1091
+ @out.puts("")
1092
+ @out.puts("#{test_files.length} test file(s), #{failed} failed")
1093
+ failed.zero? ? 0 : 1
1094
+ end
1095
+
1096
+ def run_classified_file(file, kind, options:, locked:)
1097
+ if kind == :compile_fail
1098
+ run_compile_fail_test(file) ? 0 : 1
1099
+ else
1100
+ run_test_file_guarded(file, options:, locked:)
1101
+ end
1102
+ end
1103
+
1104
+ def run_compile_fail_test(path)
1105
+ source = File.read(path)
1106
+ expectations = extract_expect_error_directives(source)
1107
+ messages = compile_fail_diagnostics(path)
1108
+
1109
+ if messages.empty?
1110
+ @out.puts("FAIL - #{path} (compile-fail): expected a compile error, but it compiled cleanly")
1111
+ @out.flush if @out.respond_to?(:flush)
1112
+ return false
1113
+ end
1114
+
1115
+ unmatched = expectations.find { |expected| messages.none? { |message| message.include?(expected) } }
1116
+ if unmatched
1117
+ @out.puts("FAIL - #{path} (compile-fail): no diagnostic matched #{unmatched.inspect}")
1118
+ @out.flush if @out.respond_to?(:flush)
1119
+ return false
1120
+ end
1121
+
1122
+ @out.puts("ok - #{path} (compile-fail)")
1123
+ @out.flush if @out.respond_to?(:flush)
1124
+ true
1125
+ end
1126
+
1127
+ def extract_expect_error_directives(source)
1128
+ source.each_line.filter_map do |line|
1129
+ match = line.match(/^\s*#\s*expect-error:\s*(.+?)\s*$/)
1130
+ match && match[1]
1131
+ end
1132
+ end
1133
+
1134
+ def compile_fail_diagnostics(path)
1135
+ errors, = check_single_reporting_all(path, locked: false)
1136
+ errors
1137
+ .select { |diagnostic| !diagnostic.respond_to?(:severity) || diagnostic.severity == :error }
1138
+ .map { |diagnostic| ErrorFormatter.format(diagnostic, color: false) }
1139
+ rescue StandardError => e
1140
+ raise unless handled_cli_error?(e)
1141
+
1142
+ [ErrorFormatter.format(e, color: false)]
1143
+ end
1144
+
1145
+ def run_test_file_guarded(file, options:, locked:)
1146
+ run_test_file(file, options:, locked:)
1147
+ rescue StandardError => e
1148
+ raise unless handled_cli_error?(e)
1149
+
1150
+ @err.puts("FAILED - #{file} (build error)")
1151
+ @err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
1152
+ 1
1153
+ end
1154
+
1155
+ def run_test_files_parallel(test_files, jobs:, options:, locked:)
1156
+ results = Array.new(test_files.length)
1157
+ result_paths = {}
1158
+ active = {}
1159
+ cursor = 0
1160
+
1161
+ while cursor < test_files.length || !active.empty?
1162
+ while active.size < jobs && cursor < test_files.length
1163
+ index = cursor
1164
+ cursor += 1
1165
+ result_path = File.join(Dir.tmpdir, "mttest_result_#{Process.pid}_#{index}")
1166
+ result_paths[index] = result_path
1167
+ pid = fork do
1168
+ captured = StringIO.new
1169
+ @out = captured
1170
+ @err = captured
1171
+ file, kind = test_files[index]
1172
+ code = run_classified_file(file, kind, options:, locked:)
1173
+ File.binwrite(result_path, [code].pack("N") + captured.string)
1174
+ exit!(0)
1175
+ end
1176
+ active[pid] = index
1177
+ end
1178
+
1179
+ finished_pid, = Process.wait2
1180
+ index = active.delete(finished_pid)
1181
+ next unless index
1182
+
1183
+ path = result_paths[index]
1184
+ data = begin
1185
+ File.binread(path)
1186
+ rescue StandardError
1187
+ (+"").b
1188
+ end
1189
+ File.delete(path) if File.exist?(path)
1190
+ results[index] =
1191
+ if data.bytesize >= 4
1192
+ [data[0, 4].unpack1("N"), data.byteslice(4..).force_encoding(Encoding::UTF_8)]
1193
+ else
1194
+ [1, +""]
1195
+ end
1196
+ end
1197
+
1198
+ failed = 0
1199
+ test_files.each_with_index do |(file, _kind), index|
1200
+ code, output = results[index]
1201
+ @out.puts("# #{file}")
1202
+ @out.write(output.to_s)
1203
+ failed += 1 unless code&.zero?
1204
+ end
1205
+ @out.flush if @out.respond_to?(:flush)
1206
+ @out.puts("")
1207
+ @out.puts("#{test_files.length} test file(s), #{failed} failed")
1208
+ failed.zero? ? 0 : 1
1209
+ end
1210
+
1211
+ def discover_test_files(directory)
1212
+ Dir.glob(File.join(directory, "**", "*.mt")).sort.filter_map do |file|
1213
+ next if File.basename(file).start_with?("__mt_test_runner_")
1214
+
1215
+ kind = classify_test_file(file)
1216
+ kind && [file, kind]
1217
+ end
1218
+ end
1219
+
1220
+ def classify_test_file(file)
1221
+ source = File.read(file)
1222
+ if compile_fail_fixture?(source)
1223
+ return nil if @test_filter && !File.basename(file, ".mt").include?(@test_filter)
1224
+
1225
+ return :compile_fail
1226
+ end
1227
+
1228
+ ast = begin
1229
+ MilkTea::Parser.parse(source, path: file)
1230
+ rescue ParseError
1231
+ return nil
1232
+ end
1233
+ has_match = ast.declarations.any? do |decl|
1234
+ decl.is_a?(AST::FunctionDef) && test_attribute?(decl) && matches_filter?(decl.name)
1235
+ end
1236
+ has_match ? :test : nil
1237
+ end
1238
+
1239
+ def compile_fail_fixture?(source)
1240
+ source.match?(/^\s*#\s*expect-error:/)
1241
+ end
1242
+
1243
+ def test_attribute?(decl)
1244
+ decl.attributes.any? { |attribute| attribute.name.parts == ["test"] }
1245
+ end
1246
+
1247
+ def matches_filter?(name)
1248
+ @test_filter.nil? || name.include?(@test_filter)
1249
+ end
1250
+
1251
+ def run_test_file(path, options:, locked:)
1252
+ source = File.read(path)
1253
+ ast = MilkTea::Parser.parse(source, path:)
1254
+
1255
+ if ast.declarations.any? { |decl| decl.is_a?(AST::FunctionDef) && decl.name == "main" }
1256
+ @err.puts("a test file must not define 'main': #{path}")
1257
+ return 1
1258
+ end
1259
+
1260
+ tests = ast.declarations.select { |decl| decl.is_a?(AST::FunctionDef) && test_attribute?(decl) }
1261
+
1262
+ if tests.empty?
1263
+ @out.puts("no @[test] functions found in #{path}")
1264
+ return 0
1265
+ end
1266
+
1267
+ tests = tests.select { |test| matches_filter?(test.name) }
1268
+ return 0 if tests.empty?
1269
+
1270
+ invalid = tests.find { |test| !test.params.empty? }
1271
+ if invalid
1272
+ @err.puts("@[test] function '#{invalid.name}' must take no parameters")
1273
+ return 1
1274
+ end
1275
+
1276
+ testing_import = ast.imports.find { |import| import.path.parts == %w[std testing] }
1277
+ unless testing_import
1278
+ @err.puts("a test file must import std.testing: #{path}")
1279
+ return 1
1280
+ end
1281
+ testing_alias = testing_import.alias_name || testing_import.path.parts.last
1282
+
1283
+ death_tests, normal_tests = tests.partition { |test| expect_fatal_attribute?(test) }
1284
+
1285
+ exit_code = 0
1286
+
1287
+ unless normal_tests.empty?
1288
+ runner_source = source.dup
1289
+ runner_source << "\n\n" << test_runner_main(testing_alias, normal_tests.map(&:name))
1290
+ exit_code = run_synthesized_tests(path, runner_source, options:, locked:)
1291
+ end
1292
+
1293
+ death_tests.each do |death_test|
1294
+ exit_code = 1 unless run_death_test(path, source, death_test.name, options:, locked:)
1295
+ end
1296
+
1297
+ exit_code
1298
+ end
1299
+
1300
+ def expect_fatal_attribute?(decl)
1301
+ decl.attributes.any? { |attribute| attribute.name.parts == ["expect_fatal"] }
1302
+ end
1303
+
1304
+ def run_death_test(source_path, source, test_name, options:, locked:)
1305
+ runner_source = source.dup
1306
+ runner_source << "\n\n" << death_test_runner_main(test_name)
1307
+ classification = with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
1308
+ classify_death_test(binary_path)
1309
+ end
1310
+
1311
+ passed = classification == :aborted
1312
+ line =
1313
+ if passed
1314
+ "ok - #{test_name} (expect_fatal)"
1315
+ elsif classification == :timed_out
1316
+ "FAIL - #{test_name} (expect_fatal): timed out"
1317
+ else
1318
+ "FAIL - #{test_name} (expect_fatal): expected a fatal abort, but the test returned"
1319
+ end
1320
+ @out.puts(line)
1321
+ @out.flush if @out.respond_to?(:flush)
1322
+ passed
1323
+ end
1324
+
1325
+ def classify_death_test(binary_path)
1326
+ _output, status, timed_out = spawn_sandboxed(binary_path)
1327
+ return :timed_out if timed_out
1328
+ return :returned if status&.exited? && status.exitstatus&.zero?
1329
+
1330
+ :aborted
1331
+ end
1332
+
1333
+ def death_test_runner_main(test_name)
1334
+ [
1335
+ "function main() -> int:",
1336
+ " match #{test_name}():",
1337
+ " Result.success:",
1338
+ " return 0",
1339
+ " Result.failure:",
1340
+ " return 0",
1341
+ ].join("\n") + "\n"
1342
+ end
1343
+
1344
+ def test_runner_main(testing_alias, test_names)
1345
+ lines = ["function main() -> int:"]
1346
+ lines << " var __mt_test_stats = #{testing_alias}.Stats.create()"
1347
+ test_names.each do |name|
1348
+ lines << " __mt_test_stats = #{testing_alias}.record(__mt_test_stats, #{name.inspect}, #{name}())"
1349
+ end
1350
+ lines << " return #{testing_alias}.summarize(__mt_test_stats)"
1351
+ lines.join("\n") + "\n"
1352
+ end
1353
+
1354
+ def run_synthesized_tests(source_path, runner_source, options:, locked:)
1355
+ with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
1356
+ run_test_binary(binary_path)
1357
+ end
1358
+ end
1359
+
1360
+ def with_synthesized_binary(source_path, runner_source, options:, locked:)
1361
+ directory = File.dirname(File.expand_path(source_path))
1362
+ runner_path = File.join(directory, "__mt_test_runner_#{Process.pid}.mt")
1363
+ binary_path = File.join(Dir.tmpdir, "__mt_test_runner_#{Process.pid}")
1364
+
1365
+ File.write(runner_path, runner_source)
1366
+ begin
1367
+ build_opts = options.except(:timings, :output_path, :bundle, :archive)
1368
+ build_opts[:debug_guards] = false
1369
+ Build.build(
1370
+ runner_path,
1371
+ output_path: binary_path,
1372
+ module_roots: module_roots_for(source_path, locked:),
1373
+ package_graph: package_graph_for(source_path, locked:),
1374
+ frontend: @build_frontend,
1375
+ sanitize: @test_sanitize,
1376
+ **build_opts,
1377
+ )
1378
+ yield binary_path
1379
+ ensure
1380
+ File.delete(runner_path) if File.exist?(runner_path)
1381
+ File.delete(binary_path) if File.exist?(binary_path)
1382
+ end
1383
+ end
1384
+
1385
+ TEST_RUN_TIMEOUT_SECONDS = 30
1386
+ TEST_RUN_MEMORY_LIMIT_BYTES = 1024 * 1024 * 1024
1387
+
1388
+ def run_test_binary(binary_path)
1389
+ output, status, timed_out = spawn_sandboxed(binary_path)
1390
+ @out.write(output)
1391
+ @out.flush if @out.respond_to?(:flush)
1392
+
1393
+ if timed_out
1394
+ @err.puts("test run timed out after #{@test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS}s")
1395
+ return 1
1396
+ end
1397
+ if status&.signaled?
1398
+ @err.puts("test run crashed (signal #{status.termsig})")
1399
+ return 1
1400
+ end
1401
+
1402
+ status&.exitstatus || 1
1403
+ end
1404
+
1405
+ def spawn_sandboxed(binary_path)
1406
+ timeout_seconds = @test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS
1407
+ memory_bytes = @test_memory_bytes || TEST_RUN_MEMORY_LIMIT_BYTES
1408
+ reader, writer = IO.pipe
1409
+ spawn_options = { out: writer, err: writer, pgroup: true }
1410
+ spawn_options[:rlimit_as] = memory_bytes unless @test_sanitize
1411
+ pid = Process.spawn(binary_path, **spawn_options)
1412
+ writer.close
1413
+
1414
+ status = nil
1415
+ timed_out = false
1416
+ begin
1417
+ Timeout.timeout(timeout_seconds) { _, status = Process.wait2(pid) }
1418
+ rescue Timeout::Error
1419
+ timed_out = true
1420
+ begin
1421
+ Process.kill("-KILL", Process.getpgid(pid))
1422
+ Process.wait(pid)
1423
+ rescue StandardError
1424
+ nil
1425
+ end
1426
+ end
1427
+
1428
+ output = reader.read
1429
+ reader.close
1430
+ [output, status, timed_out]
1431
+ end
1432
+
1433
+ def run_command
1434
+ path, options = extract_path_and_options
1435
+ return 1 unless path
1436
+
1437
+ run_and_print_result(path, options)
1438
+ end
1439
+
1440
+ def app_command
1441
+ options = parse_build_options
1442
+ return 1 unless options
1443
+
1444
+ module_name = @argv.shift
1445
+ unless module_name
1446
+ @err.puts("missing module name")
1447
+ print_usage(@err)
1448
+ return 1
1449
+ end
1450
+
1451
+ path = resolve_app_module(module_name)
1452
+ unless path
1453
+ @err.puts("run-module module not found: #{module_name}")
1454
+ return 1
1455
+ end
1456
+
1457
+ frozen = options.delete(:frozen)
1458
+ ensure_current_lockfile!(path) if frozen
1459
+
1460
+ run_and_print_result(path, options)
1461
+ end
1462
+
1463
+ def extract_path_and_options(allow_clean: false)
1464
+ options = parse_build_options(allow_clean:)
1465
+ return nil unless options
1466
+
1467
+ path = @argv.shift
1468
+ unless path
1469
+ if File.file?(File.join(Dir.pwd, "package.toml"))
1470
+ path = Dir.pwd
1471
+ else
1472
+ @err.puts("missing source file path")
1473
+ print_usage(@err)
1474
+ return nil
1475
+ end
1476
+ end
1477
+
1478
+ [path, options]
1479
+ end
1480
+
1481
+ def run_and_print_result(path, options)
1482
+ frozen = options.delete(:frozen)
1483
+ ensure_current_lockfile!(path) if frozen
1484
+ locked = options.delete(:locked)
1485
+ package_graph = package_graph_for(path, locked:)
1486
+ preview_notice_emitted = false
1487
+ preview_started = lambda do |message|
1488
+ preview_notice_emitted = true
1489
+ @out.write(message)
1490
+ @out.flush if @out.respond_to?(:flush)
1491
+ end
1492
+
1493
+ result = Run.run(
1494
+ path,
1495
+ module_roots: module_roots_for(path, locked:),
1496
+ package_graph:,
1497
+ frontend: @build_frontend,
1498
+ preview_started:,
1499
+ argv: @argv.dup,
1500
+ **options.except(:timings)
1501
+ )
1502
+ unless @out.equal?($stdout) || preview_notice_emitted
1503
+ @out.write(result.stdout)
1504
+ end
1505
+ @out.flush if @out.respond_to?(:flush)
1506
+ @err.write(result.stderr) unless @err.equal?($stderr)
1507
+ info("[cached]") if result.cached
1508
+ result.exit_status
1509
+ end
1510
+
1511
+ def resolve_app_module(name)
1512
+ relative = name.tr(".", "/").sub(%r{^/}, "") + ".mt"
1513
+
1514
+ module_roots_for(Dir.pwd).each do |root|
1515
+ candidate = File.join(root, "std", relative)
1516
+ return File.expand_path(candidate) if File.file?(candidate)
1517
+
1518
+ candidate = File.join(root, relative)
1519
+ return File.expand_path(candidate) if File.file?(candidate)
1520
+ end
1521
+
1522
+ nil
1523
+ end
1524
+
1525
+ def new_command
1526
+ name = @argv.shift
1527
+ unless name
1528
+ @err.puts("missing project name")
1529
+ print_usage(@err)
1530
+ return 1
1531
+ end
1532
+
1533
+ if @argv.any?
1534
+ @err.puts("unknown new option #{@argv.first}")
1535
+ print_usage(@err)
1536
+ return 1
1537
+ end
1538
+
1539
+ result = ProjectScaffold.create(name)
1540
+ info("created #{result.root_path}")
1541
+ 0
1542
+ end
1543
+
1544
+ def deps_command
1545
+ PackageManagerCLI.start(
1546
+ @argv,
1547
+ out: @out,
1548
+ err: @err,
1549
+ help_printer: method(:print_deps_help),
1550
+ services: package_services,
1551
+ )
1552
+ end
1553
+
1554
+ def docs_command
1555
+ port = nil
1556
+ open_flag = false
1557
+
1558
+ while (arg = @argv.first)
1559
+ case arg
1560
+ when "--port", "-p"
1561
+ @argv.shift
1562
+ port = @argv.shift.to_i
1563
+ port = nil if port <= 0 || port > 65535
1564
+ when "--open", "-o"
1565
+ open_flag = true
1566
+ @argv.shift
1567
+ else
1568
+ break
1569
+ end
1570
+ end
1571
+
1572
+ port = resolve_docs_port(port)
1573
+
1574
+ DocsApp.set :port, port
1575
+ DocsApp.set :bind, "127.0.0.1"
1576
+ DocsApp.set :environment, :production
1577
+ DocsApp.set :server, :puma
1578
+
1579
+ url = "http://127.0.0.1:#{port}/"
1580
+
1581
+ @out.puts("Serving Milk Tea docs at #{url}")
1582
+ @out.puts("Press Ctrl+C to stop.")
1583
+
1584
+ if open_flag
1585
+ open_browser(url)
1586
+ end
1587
+
1588
+ DocsApp.run!
1589
+ 0
1590
+ rescue Interrupt
1591
+ 0
1592
+ end
1593
+
1594
+ def resolve_docs_port(preferred)
1595
+ return preferred if preferred
1596
+
1597
+ server = TCPServer.new("127.0.0.1", 0)
1598
+ port = server.addr[1]
1599
+ server.close
1600
+ port
1601
+ rescue StandardError
1602
+ 4567
1603
+ end
1604
+
1605
+ def snapshot_command
1606
+ input_path = nil
1607
+ theme_path = nil
1608
+ output_path = nil
1609
+
1610
+ until @argv.empty?
1611
+ arg = @argv.first
1612
+ case arg
1613
+ when "--theme", "-t"
1614
+ @argv.shift
1615
+ theme_path = @argv.shift
1616
+ unless theme_path
1617
+ @err.puts("snapshot: missing value for --theme")
1618
+ return 1
1619
+ end
1620
+ when "--output", "-o"
1621
+ @argv.shift
1622
+ output_path = @argv.shift
1623
+ unless output_path
1624
+ @err.puts("snapshot: missing value for --output")
1625
+ return 1
1626
+ end
1627
+ else
1628
+ if arg.start_with?("-")
1629
+ @err.puts("snapshot: unknown option #{arg}")
1630
+ return 1
1631
+ end
1632
+ unless input_path
1633
+ input_path = @argv.shift
1634
+ else
1635
+ @err.puts("snapshot: unexpected argument #{@argv.shift}")
1636
+ return 1
1637
+ end
1638
+ end
1639
+ end
1640
+
1641
+ unless input_path
1642
+ @err.puts("snapshot: missing source file path")
1643
+ print_usage(@err)
1644
+ return 1
1645
+ end
1646
+
1647
+ unless File.file?(input_path)
1648
+ @err.puts("snapshot: source file not found: #{input_path}")
1649
+ return 1
1650
+ end
1651
+
1652
+ input_path = File.expand_path(input_path)
1653
+ theme_path = File.expand_path(theme_path) if theme_path
1654
+ output_path = File.expand_path(output_path) if output_path
1655
+
1656
+ if theme_path && !File.file?(theme_path)
1657
+ @err.puts("snapshot: theme file not found: #{theme_path}")
1658
+ return 1
1659
+ end
1660
+
1661
+ snapshot_script = MilkTea.root.join("bindings/vscode/scripts/snapshot.js").to_s
1662
+ unless File.file?(snapshot_script)
1663
+ @err.puts("snapshot: internal script not found at #{snapshot_script}")
1664
+ return 1
1665
+ end
1666
+
1667
+ args = ["node", snapshot_script, input_path]
1668
+ args.push("-t", theme_path) if theme_path
1669
+ args.push("-o", output_path) if output_path
1670
+
1671
+ semantic_result = nil
1672
+ begin
1673
+ semantic_result = MilkTea::LSP::Server.semantic_tokens_for_path(input_path)
1674
+ rescue => e
1675
+ @err.puts("snapshot: semantic analysis skipped: #{e.message}")
1676
+ end
1677
+
1678
+ if semantic_result && semantic_result[:entries] && !semantic_result[:entries].empty?
1679
+ source = File.read(input_path)
1680
+ lines = source.split("\n", -1)
1681
+ semantic_result[:entries].each do |entry|
1682
+ byte_start = entry[:startChar]
1683
+ byte_len = entry[:length]
1684
+ line_text = lines[entry[:line]]
1685
+ unless line_text && byte_start && byte_len
1686
+ entry[:startChar] = 0
1687
+ entry[:length] = 0
1688
+ next
1689
+ end
1690
+ char_start = line_text.byteslice(0, byte_start).length
1691
+ char_length = line_text.byteslice(byte_start, byte_len).length
1692
+ char_length = line_text.length - char_start if char_start + char_length > line_text.length
1693
+ entry[:startChar] = char_start
1694
+ entry[:length] = char_length
1695
+ end
1696
+
1697
+ temp = Tempfile.new(["mt_semantic", ".json"])
1698
+ temp.write(JSON.generate(semantic_result[:entries]))
1699
+ temp.close
1700
+ args.push("-s", temp.path)
1701
+ system(*args)
1702
+ temp.unlink
1703
+ else
1704
+ system(*args)
1705
+ end
1706
+ $?.success? ? 0 : 1
1707
+ end
1708
+
1709
+ def lsp_command
1710
+ log_level = nil
1711
+
1712
+ until @argv.empty?
1713
+ arg = @argv.shift
1714
+ case arg
1715
+ when "--log-level"
1716
+ log_level = @argv.shift&.downcase
1717
+ unless log_level && %w[trace debug info warn error].include?(log_level)
1718
+ @err.puts("lsp: invalid --log-level #{log_level.inspect} (expected trace, debug, info, warn, or error)")
1719
+ return 1
1720
+ end
1721
+ when /\A--log-level=(.+)\z/
1722
+ log_level = ::Regexp.last_match(1).downcase
1723
+ unless %w[trace debug info warn error].include?(log_level)
1724
+ @err.puts("lsp: invalid --log-level #{log_level.inspect} (expected trace, debug, info, warn, or error)")
1725
+ return 1
1726
+ end
1727
+ when "--stdio"
1728
+ # stdio is the only transport; accept the flag as a no-op
1729
+ else
1730
+ if arg.start_with?("-")
1731
+ @err.puts("lsp: unknown option #{arg}")
1732
+ return 1
1733
+ end
1734
+ @err.puts("lsp: unexpected argument #{arg}")
1735
+ return 1
1736
+ end
1737
+ end
1738
+
1739
+ require "milk_tea/lsp/server" unless defined?(MilkTea::LSP::Server)
1740
+ server = MilkTea::LSP::Server.new
1741
+ server.run
1742
+ 0
1743
+ end
1744
+
1745
+ def dap_command
1746
+ preferred_backend_kind = "process"
1747
+ adapter_command = nil
1748
+
1749
+ until @argv.empty?
1750
+ arg = @argv.shift
1751
+ case arg
1752
+ when "--log-level"
1753
+ @argv.shift
1754
+ when /\A--log-level=(.+)\z/
1755
+ # accept and ignore
1756
+ when "--backend"
1757
+ preferred_backend_kind = @argv.shift&.downcase
1758
+ when /\A--backend=(.+)\z/
1759
+ preferred_backend_kind = ::Regexp.last_match(1).downcase
1760
+ when "--adapter-path"
1761
+ adapter_path = @argv.shift
1762
+ adapter_command = resolve_adapter_path(adapter_path)
1763
+ return 1 unless adapter_command
1764
+ when /\A--adapter-path=(.+)\z/
1765
+ adapter_path = ::Regexp.last_match(1)
1766
+ adapter_command = resolve_adapter_path(adapter_path)
1767
+ return 1 unless adapter_command
1768
+ else
1769
+ if arg.start_with?("-")
1770
+ @err.puts("dap: unknown option #{arg}")
1771
+ return 1
1772
+ end
1773
+ @err.puts("dap: unexpected argument #{arg}")
1774
+ return 1
1775
+ end
1776
+ end
1777
+
1778
+ require "milk_tea/dap/server" unless defined?(MilkTea::DAP::Server)
1779
+ server = MilkTea::DAP::Server.new(
1780
+ preferred_backend_kind:,
1781
+ adapter_command:,
1782
+ )
1783
+ server.run
1784
+ 0
1785
+ end
1786
+
1787
+ def resolve_adapter_path(adapter_path)
1788
+ unless adapter_path && File.file?(adapter_path)
1789
+ @err.puts("dap: adapter path not found: #{adapter_path}")
1790
+ return nil
1791
+ end
1792
+ expanded = File.expand_path(adapter_path)
1793
+ adapter_path.end_with?(".rb") ? [RbConfig.ruby, expanded] : [expanded]
1794
+ end
1795
+
1796
+ def toolchain_command
1797
+ ToolchainCLI.start(
1798
+ @argv,
1799
+ out: @out,
1800
+ err: @err,
1801
+ help_printer: method(:print_toolchain_help),
1802
+ )
1803
+ end
1804
+
1805
+ def debug_command
1806
+ unless @argv.any?
1807
+ @err.puts("missing source file path")
1808
+ print_usage(@err)
1809
+ return 1
1810
+ end
1811
+
1812
+ resolution = extract_resolution_flags!
1813
+ input_paths = @argv.dup
1814
+ return 1 unless ensure_known_source_operands!("debug", input_paths)
1815
+
1816
+ path = expand_source_paths(input_paths).first
1817
+ unless path
1818
+ @err.puts("no .mt files found in #{input_paths.join(', ')}")
1819
+ return 1
1820
+ end
1821
+
1822
+ ensure_current_lockfile!(path) if resolution[:frozen]
1823
+
1824
+ source = read_source_file(path)
1825
+ resolved_path = File.expand_path(path)
1826
+
1827
+ tokens = MilkTea::Lexer.lex(source, path: resolved_path)
1828
+
1829
+ parse_result = MilkTea::Parser.parse_collecting_errors(source, path: resolved_path)
1830
+ ast = parse_result.ast
1831
+ parse_errors = parse_result.errors.dup
1832
+
1833
+ facts = nil
1834
+ snapshot = nil
1835
+ loader_ast = ast
1836
+
1837
+ if ast && parse_errors.empty?
1838
+ begin
1839
+ loader = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform)
1840
+ loader_ast = loader.load_file(resolved_path)
1841
+
1842
+ import_result = loader.send(:imported_modules_for_ast_collecting_errors, loader_ast, importer_path: resolved_path)
1843
+ import_errors = import_result.respond_to?(:errors) ? import_result.errors : []
1844
+ parse_errors.concat(import_errors) unless import_errors.empty?
1845
+
1846
+ snapshot = MilkTea::SemanticAnalyzer.tooling_snapshot(
1847
+ loader_ast,
1848
+ imported_modules: import_result.modules,
1849
+ allow_missing_imports: true,
1850
+ path: resolved_path,
1851
+ )
1852
+ facts = snapshot&.facts
1853
+ rescue MilkTea::LexError, MilkTea::ParseError, ModuleLoadError, SemanticError => e
1854
+ parse_errors << e
1855
+ end
1856
+ end
1857
+
1858
+ text = DebugInfoFormatter.format_all(
1859
+ content: source,
1860
+ tokens: tokens,
1861
+ ast: loader_ast,
1862
+ parse_errors: parse_errors,
1863
+ facts: facts,
1864
+ snapshot: snapshot,
1865
+ path: resolved_path,
1866
+ )
1867
+
1868
+ @out.puts(text)
1869
+ 0
1870
+ rescue MilkTea::LexError => e
1871
+ @err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
1872
+ 1
1873
+ end
1874
+
1875
+ def bindgen_command
1876
+ BindgenCLI.start(@argv, out: @out, err: @err, help_printer: method(:print_bindgen_help))
1877
+ end
1878
+
1879
+ def cache_command
1880
+ subcommand = @argv.shift
1881
+ unless subcommand
1882
+ @err.puts("missing cache subcommand")
1883
+ print_command_help("cache", @err)
1884
+ return 1
1885
+ end
1886
+
1887
+ cache_root = MilkTea.data_root.join("tmp", "mtc-cache")
1888
+
1889
+ case subcommand
1890
+ when "purge"
1891
+ if File.directory?(cache_root)
1892
+ FileUtils.rm_rf(cache_root)
1893
+ @out.puts("purged #{cache_root}")
1894
+ else
1895
+ @out.puts("cache is already empty")
1896
+ end
1897
+ 0
1898
+ when "status"
1899
+ unless File.directory?(cache_root)
1900
+ @out.puts("cache directory does not exist: #{cache_root}")
1901
+ return 0
1902
+ end
1903
+ program_dirs = Dir.glob(File.join(cache_root, "programs", "*", "*")).select { |d| File.directory?(d) }
1904
+ binary_files = Dir.glob(File.join(cache_root, "binaries", "*", "*", "binary")).select { |f| File.file?(f) }
1905
+ total_size = (program_dirs + binary_files).sum { |p|
1906
+ File.file?(p) ? File.size(p) : Dir.glob(File.join(p, "**", "*")).sum { |f| File.file?(f) ? File.size(f) : 0 }
1907
+ }
1908
+ @out.puts("cache #{program_dirs.length} programs, #{binary_files.length} binaries (#{format_size(total_size)})")
1909
+ @out.puts(" root #{cache_root}")
1910
+ 0
1911
+ else
1912
+ @err.puts("unknown cache subcommand #{subcommand}")
1913
+ print_command_help("cache", @err)
1914
+ 1
1915
+ end
1916
+ end
1917
+
1918
+ def completions_command
1919
+ shell = @argv.shift
1920
+ unless %w[bash zsh fish].include?(shell)
1921
+ @err.puts("completions: shell must be bash, zsh, or fish")
1922
+ print_command_help("completions", @err)
1923
+ return 1
1924
+ end
1925
+
1926
+ @out.puts(completion_script(shell))
1927
+ 0
1928
+ end
1929
+
1930
+ def completion_script(shell)
1931
+ names = COMMANDS.map(&:first)
1932
+ case shell
1933
+ when "bash"
1934
+ [
1935
+ "# mtc bash completion. Source this file or install it into your bash",
1936
+ "# completion directory (e.g. /etc/bash_completion.d/mtc).",
1937
+ "_mtc() {",
1938
+ %( local cur="${COMP_WORDS[COMP_CWORD]}"),
1939
+ %( if [ "${COMP_CWORD}" -eq 1 ]; then),
1940
+ %( COMPREPLY=( $(compgen -W "#{(names + %w[help version]).join(' ')}" -- "${cur}") )),
1941
+ " fi",
1942
+ "}",
1943
+ "complete -F _mtc mtc",
1944
+ ].join("\n")
1945
+ when "zsh"
1946
+ lines = ["#compdef mtc", "# mtc zsh completion. Install onto your $fpath as _mtc.", "_mtc() {", " local -a commands", " commands=("]
1947
+ COMMANDS.each { |name, summary| lines << " '#{name}:#{summary}'" }
1948
+ lines.concat([" )", " if (( CURRENT == 2 )); then", " _describe 'mtc command' commands", " fi", "}", %(_mtc "$@")])
1949
+ lines.join("\n")
1950
+ when "fish"
1951
+ lines = ["# mtc fish completion. Install into ~/.config/fish/completions/mtc.fish."]
1952
+ COMMANDS.each do |name, summary|
1953
+ lines << "complete -c mtc -f -n '__fish_use_subcommand' -a #{name} -d '#{summary}'"
1954
+ end
1955
+ lines.join("\n")
1956
+ end
1957
+ end
1958
+
1959
+ def parse_build_options(allow_clean: false)
1960
+ options = {
1961
+ output_path: nil,
1962
+ cc: ENV.fetch("CC", "cc"),
1963
+ keep_c_path: nil,
1964
+ profile: nil,
1965
+ platform: nil,
1966
+ bundle: false,
1967
+ archive: false,
1968
+ locked: false,
1969
+ frozen: false,
1970
+ no_cache: false,
1971
+ kind: :executable,
1972
+ timings: false,
1973
+ debug_guards: nil,
1974
+ }
1975
+ options[:clean] = false if allow_clean
1976
+
1977
+ positional = []
1978
+ until @argv.empty?
1979
+ option = @argv.shift
1980
+ case option
1981
+ when "-o", "--output"
1982
+ value = @argv.shift
1983
+ return missing_option_value(option) unless value
1984
+
1985
+ options[:output_path] = value
1986
+ when "--cc"
1987
+ value = @argv.shift
1988
+ return missing_option_value(option) unless value
1989
+
1990
+ options[:cc] = value
1991
+ when "--keep-c"
1992
+ value = @argv.shift
1993
+ return missing_option_value(option) unless value
1994
+
1995
+ options[:keep_c_path] = value
1996
+ when "--profile"
1997
+ value = @argv.shift
1998
+ return missing_option_value(option) unless value
1999
+
2000
+ options[:profile] = value
2001
+ when "--platform"
2002
+ value = @argv.shift
2003
+ return missing_option_value(option) unless value
2004
+
2005
+ options[:platform] = value
2006
+ when "--bundle"
2007
+ options[:bundle] = true
2008
+ when "--archive"
2009
+ options[:bundle] = true
2010
+ options[:archive] = true
2011
+ when "--locked"
2012
+ options[:locked] = true
2013
+ when "--frozen"
2014
+ options[:locked] = true
2015
+ options[:frozen] = true
2016
+ when "--no-cache"
2017
+ options[:no_cache] = true
2018
+ when "--debug-guards"
2019
+ options[:debug_guards] = true
2020
+ when "--no-debug-guards"
2021
+ options[:debug_guards] = false
2022
+ when "--timings"
2023
+ options[:timings] = true
2024
+ when "--kind"
2025
+ value = @argv.shift
2026
+ return missing_option_value(option) unless value
2027
+
2028
+ options[:kind] = case value
2029
+ when "executable", "exe", "bin" then :executable
2030
+ when "static", "staticlib" then :static
2031
+ when "shared", "dylib", "dll", "so" then :shared
2032
+ else
2033
+ @err.puts("unknown build kind #{value}; expected executable|static|shared")
2034
+ print_usage(@err)
2035
+ return nil
2036
+ end
2037
+ when "--clean"
2038
+ if allow_clean
2039
+ options[:clean] = true
2040
+ else
2041
+ @err.puts("unknown build option #{option}")
2042
+ print_usage(@err)
2043
+ return nil
2044
+ end
2045
+ when "--"
2046
+ positional.concat(@argv)
2047
+ @argv.clear
2048
+ else
2049
+ if option.start_with?("-")
2050
+ @err.puts("unknown build option #{option}")
2051
+ print_usage(@err)
2052
+ return nil
2053
+ end
2054
+ positional << option
2055
+ end
2056
+ end
2057
+
2058
+ @argv = positional
2059
+ options
2060
+ end
2061
+
2062
+ def parse_format_options
2063
+ options = {
2064
+ check: false,
2065
+ write: false,
2066
+ mode: :safe,
2067
+ max_line_length: nil,
2068
+ profile: false,
2069
+ }
2070
+ input_paths = []
2071
+
2072
+ until @argv.empty?
2073
+ option = @argv.shift
2074
+ if option.start_with?("-")
2075
+ case option
2076
+ when "--check"
2077
+ options[:check] = true
2078
+ when "--write", "-w"
2079
+ options[:write] = true
2080
+ when "--preserve"
2081
+ options[:mode] = :preserve
2082
+ when "--canonical"
2083
+ options[:mode] = :canonical
2084
+ when "--safe"
2085
+ options[:mode] = :safe
2086
+ when "--tidy"
2087
+ options[:mode] = :tidy
2088
+ when "--max-line-length"
2089
+ value = @argv.shift
2090
+ return missing_option_value(option) unless value
2091
+
2092
+ line_length = Integer(value, exception: false)
2093
+ unless line_length && line_length.positive?
2094
+ @err.puts("--max-line-length must be a positive integer")
2095
+ print_usage(@err)
2096
+ return nil
2097
+ end
2098
+
2099
+ options[:max_line_length] = line_length
2100
+ when "--timings"
2101
+ options[:profile] = true
2102
+ when "--"
2103
+ input_paths.concat(@argv)
2104
+ @argv.clear
2105
+ else
2106
+ @err.puts("unknown format option #{option}")
2107
+ print_usage(@err)
2108
+ return nil
2109
+ end
2110
+ else
2111
+ input_paths << option
2112
+ end
2113
+ end
2114
+
2115
+ if options[:check] && options[:write]
2116
+ @err.puts("format options --check and --write cannot be combined")
2117
+ print_usage(@err)
2118
+ return nil
2119
+ end
2120
+
2121
+ { options:, input_paths: }
2122
+ end
2123
+
2124
+ def ensure_known_source_operands!(command, operands)
2125
+ return true unless operands.any? { |arg| arg.start_with?("-") }
2126
+
2127
+ @err.puts("unexpected argument(s) for #{command}: #{operands.join(' ')}")
2128
+ print_command_help(command, @err)
2129
+ false
2130
+ end
2131
+
2132
+ def expand_source_paths(input_paths)
2133
+ input_paths.flat_map do |path|
2134
+ if File.directory?(path)
2135
+ Dir.glob(File.join(path, "**/*.mt")).sort
2136
+ else
2137
+ [path]
2138
+ end
2139
+ end.uniq
2140
+ end
2141
+
2142
+ def print_no_source_files_if_empty(paths, input_paths)
2143
+ return false unless paths.empty?
2144
+
2145
+ label = input_paths.length == 1 ? input_paths.first : input_paths.join(", ")
2146
+ @out.puts("no .mt files found in #{label}")
2147
+ true
2148
+ end
2149
+
2150
+ def missing_option_value(option)
2151
+ @err.puts("missing value for #{option}")
2152
+ print_usage(@err)
2153
+ nil
2154
+ end
2155
+
2156
+ def handled_cli_error?(error)
2157
+ handled_error_classes.any? { |klass| error.is_a?(klass) }
2158
+ end
2159
+
2160
+ def handled_error_classes
2161
+ classes = [LexError, ParseError, ModuleLoadError, SemanticError, LoweringError, CBackendError, BuildError, RunError, FormatterError, PackageManifestError, PackageManifestEditorError, PackageGraphError, PackageLockError, PackageSourceResolverError, PackageSourceFetcherError, PackageRegistryStoreError, PackageRegistryMetadataProviderError, PackageDependencySolverError, PackageVersionError, ProjectScaffoldError]
2162
+ classes << BindgenError if MilkTea.const_defined?(:BindgenError, false)
2163
+ classes << UpstreamSources::Error if MilkTea.const_defined?(:UpstreamSources, false)
2164
+ classes
2165
+ end
2166
+
2167
+ def read_source_file(path)
2168
+ resolved_path = File.expand_path(path.to_s)
2169
+ @source_overrides.fetch(resolved_path) { File.read(resolved_path) }
2170
+ rescue Errno::ENOENT
2171
+ raise ModuleLoadError.new("source file not found", path: path)
2172
+ rescue Errno::EISDIR
2173
+ raise ModuleLoadError.new("expected a source file, got a directory", path: path)
2174
+ end
2175
+
2176
+ def generated_source?(source)
2177
+ source.start_with?(GENERATED_FILE_HEADER_PREFIX)
2178
+ end
2179
+
2180
+ ACTION_LABELS = {
2181
+ "format-check" => "checking",
2182
+ "format-write" => "formatting",
2183
+ "lint" => "linting",
2184
+ "lint-fix" => "fixing",
2185
+ }.freeze
2186
+
2187
+ def announce_file_action(path, action)
2188
+ return unless @verbose
2189
+
2190
+ @out.puts("#{ACTION_LABELS.fetch(action, action)} #{path}")
2191
+ end
2192
+
2193
+ def make_module_loader(path = nil, locked: false, platform: nil)
2194
+ ModuleLoader.new(module_roots: module_roots_for(path, locked:), package_graph: package_graph_for(path, locked:), source_overrides: @source_overrides, platform:)
2195
+ end
2196
+
2197
+ def normalize_source_overrides(source_overrides)
2198
+ return {} unless source_overrides
2199
+
2200
+ source_overrides.each_with_object({}) do |(path, source), overrides|
2201
+ overrides[File.expand_path(path.to_s)] = source.to_s
2202
+ end
2203
+ end
2204
+
2205
+ def module_roots_for(path = nil, locked: false)
2206
+ roots = @include_module_roots.dup
2207
+
2208
+ if path
2209
+ MilkTea::ModuleRoots.roots_for_path(path, locked:).each do |root|
2210
+ roots << root unless roots.include?(root)
2211
+ end
2212
+ end
2213
+
2214
+ @ambient_module_roots.each do |root|
2215
+ roots << root unless roots.include?(root)
2216
+ end
2217
+ roots
2218
+ end
2219
+
2220
+ def package_graph_for(path = nil, locked: false)
2221
+ return nil unless path
2222
+
2223
+ PackageGraph.load(path, locked:)
2224
+ rescue PackageManifestError
2225
+ nil
2226
+ end
2227
+
2228
+ def package_services
2229
+ @package_services ||= PackageServices.new
2230
+ end
2231
+
2232
+ def extract_resolution_flags!
2233
+ locked = false
2234
+ frozen = false
2235
+ warnings_as_errors = false
2236
+ remaining = []
2237
+ i = 0
2238
+ while i < @argv.length
2239
+ arg = @argv[i]
2240
+ if arg == "--locked"
2241
+ locked = true
2242
+ elsif arg == "--frozen"
2243
+ locked = true
2244
+ frozen = true
2245
+ elsif arg == "-Werror" || arg == "--warnings-as-errors"
2246
+ warnings_as_errors = true
2247
+ elsif arg == "--"
2248
+ remaining.concat(@argv[i + 1..])
2249
+ break
2250
+ else
2251
+ remaining << arg
2252
+ end
2253
+ i += 1
2254
+ end
2255
+ @argv = remaining
2256
+ { locked:, frozen:, warnings_as_errors: }
2257
+ end
2258
+
2259
+ def ensure_no_extra_arguments!(command)
2260
+ return true if @argv.empty?
2261
+
2262
+ @err.puts("unexpected argument(s) for #{command}: #{@argv.join(' ')}")
2263
+ print_command_help(command, @err)
2264
+ false
2265
+ end
2266
+
2267
+ def ensure_current_lockfile!(path)
2268
+ result = PackageLock.check(path, source_resolver: package_services.source_resolver(:cache))
2269
+ return if result.current?
2270
+
2271
+ message = if result.missing?
2272
+ "package.lock is missing: #{result.lock_path}"
2273
+ else
2274
+ "package.lock is out of date: #{result.lock_path}"
2275
+ end
2276
+ raise PackageLockError, message
2277
+ end
2278
+
2279
+ def ensure_current_lockfiles!(paths)
2280
+ checked = {}
2281
+
2282
+ paths.each do |path|
2283
+ result = PackageLock.check(path, source_resolver: package_services.source_resolver(:cache))
2284
+ next if checked[result.lock_path]
2285
+
2286
+ checked[result.lock_path] = true
2287
+ next if result.current?
2288
+
2289
+ message = if result.missing?
2290
+ "package.lock is missing: #{result.lock_path}"
2291
+ else
2292
+ "package.lock is out of date: #{result.lock_path}"
2293
+ end
2294
+ raise PackageLockError, message
2295
+ end
2296
+ end
2297
+
2298
+ def lint_sema_facts_for(source, path, locked: false)
2299
+ ast = Parser.parse(source, path: path)
2300
+ imported_modules = make_module_loader(path, locked:, platform: ModuleLoader.default_host_platform).imported_modules_for_ast(ast, importer_path: path)
2301
+ SemanticAnalyzer.tooling_snapshot(ast, imported_modules: imported_modules, path: path).facts
2302
+ rescue MilkTea::LexError, MilkTea::ParseError, SemanticError, ModuleLoadError
2303
+ nil
2304
+ end
2305
+
2306
+ def extract_include_paths!
2307
+ remaining = []
2308
+ i = 0
2309
+ while i < @argv.length
2310
+ if @argv[i] == "-I" || @argv[i] == "--include-path"
2311
+ value = @argv[i + 1]
2312
+ if value && !value.start_with?("-")
2313
+ @include_path_flags << @argv[i]
2314
+ @include_module_roots << File.expand_path(value)
2315
+ i += 2
2316
+ else
2317
+ @err.puts("missing value for #{@argv[i]}")
2318
+ i += 1
2319
+ end
2320
+ else
2321
+ remaining << @argv[i]
2322
+ i += 1
2323
+ end
2324
+ end
2325
+ @argv = remaining
2326
+ end
2327
+
2328
+ def open_browser(url)
2329
+ command = host_platform == :windows ? ["cmd", "/c", "start", "", url] : ["xdg-open", url]
2330
+ pid = Process.spawn(*command, out: File::NULL, err: File::NULL)
2331
+ Process.detach(pid)
2332
+ rescue Errno::ENOENT
2333
+ @err.puts("note: could not open browser (#{command.first} not found)")
2334
+ end
2335
+
2336
+ def host_platform
2337
+ MilkTea.host_platform
2338
+ end
2339
+
2340
+ def command_supports_include_paths?(command)
2341
+ %w[parse lint check lower emit-c build run test debug].include?(command)
2342
+ end
2343
+
2344
+ COMMAND_HELP = {
2345
+ "test" => <<~HELP,
2346
+ Usage: mtc test PATH|DIR [--cc COMPILER] [--profile debug|release] [--platform linux|windows|wasm] [--timeout SECONDS] [--mem MB] [--jobs N] [--sanitize] [-n SUBSTRING] [--format human|tap|junit] [--locked] [--frozen] [-I PATH]
2347
+
2348
+ Discover and run @[test] functions in a Milk Tea source file or directory.
2349
+
2350
+ Given a file, runs its @[test] functions. Given a directory (or a package
2351
+ root), recursively discovers every .mt file that contains @[test] functions,
2352
+ runs each as its own test binary, and prints an aggregate summary.
2353
+
2354
+ Functions annotated with @[test] must take no parameters and return
2355
+ std.testing.Check. `mtc test` synthesizes a runner that invokes each
2356
+ test through std.testing and reports the results; a test file must import
2357
+ std.testing and must not define `main`.
2358
+
2359
+ Each test binary runs under a wall-clock timeout and an address-space
2360
+ memory cap so a hanging or runaway test cannot stall or exhaust the host.
2361
+ Override them with --timeout SECONDS (default 30) and --mem MB (default 1024).
2362
+ For a directory, --jobs N builds and runs N files in parallel (default 1; output
2363
+ stays in file order).
2364
+ A file containing `# expect-error: <text>` is a compile-fail fixture: `mtc test`
2365
+ requires the compiler to reject it with a diagnostic containing that text.
2366
+ --sanitize builds test binaries with AddressSanitizer/UBSan (including leak
2367
+ detection); any sanitizer error fails the run.
2368
+ -n SUBSTRING runs only @[test] functions whose name contains SUBSTRING (and, in a
2369
+ directory, compile-fail fixtures whose filename matches).
2370
+ --format tap|junit emits machine-readable results (TAP / JUnit XML) instead of the
2371
+ human summary, for CI.
2372
+ HELP
2373
+ "debug" => "Usage: mtc debug PATH [--locked] [--frozen] [-I PATH]\n\n Print debug information for a source file: tokens, AST, semantic facts,\n binding resolution, and diagnostics.",
2374
+ "lex" => "Usage: mtc lex PATH\n\n Tokenize a source file and print the token stream.",
2375
+ "parse" => "Usage: mtc parse PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-I PATH]\n\n Parse one or more source files and print the AST.\n\n Options:\n --locked Resolve dependencies from package.lock.\n --frozen Require a current package.lock.\n -I, --include-path Add an extra module root.",
2376
+ "format" => <<~HELP,
2377
+ Usage: mtc format PATH|DIR [PATH|DIR ...] [OPTIONS]
2378
+
2379
+ Format Milk Tea source files.
2380
+
2381
+ Options:
2382
+ --check Report files that need formatting without writing them.
2383
+ --write, -w Rewrite files in place.
2384
+ --safe Format only unambiguous style changes (default).
2385
+ --canonical Apply all canonical style normalisations.
2386
+ --preserve Preserve existing formatting where possible.
2387
+ --tidy Apply tidy formatting with line wrapping and blank-line normalization.
2388
+ --max-line-length N
2389
+ Override the line length used by tidy mode.
2390
+ --timings Print per-file format timing breakdown.
2391
+
2392
+ When formatting directories or multiple files, --check or --write is required.
2393
+ HELP
2394
+ "lint" => <<~HELP,
2395
+ Usage: mtc lint PATH|DIR [OPTIONS]
2396
+ mtc lint --init
2397
+
2398
+ Lint Milk Tea source files and report warnings.
2399
+ Some rules and auto-fixes are semantic/import-aware, so lint can use
2400
+ the same dependency resolution mode as check/build.
2401
+
2402
+ Options:
2403
+ --init Create a default .mt-lint.yml in the current directory.
2404
+ --select RULES Comma-separated list of rule codes to enable.
2405
+ --ignore RULES Comma-separated list of rule codes to suppress.
2406
+ --fix Apply auto-fixable changes in place.
2407
+ --ignore-generated Skip files that start with '# generated by mtc'.
2408
+ --timings Print lint timing: per-file summary and per-rule breakdown.
2409
+ --locked Use package.lock for semantic dependency resolution.
2410
+ --frozen Require a current package.lock before semantic dependency resolution.
2411
+ -I, --include-path PATH Add an extra module root for semantic resolution.
2412
+ HELP
2413
+ "check" => <<~HELP,
2414
+ Usage: mtc check PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-Werror] [-I PATH]
2415
+
2416
+ Run semantic analysis on one or more source files and report errors.
2417
+
2418
+ Options:
2419
+ -Werror Treat warnings as errors.
2420
+ --locked / --frozen Dependency resolution mode.
2421
+ -I, --include-path PATH Add an extra module root.
2422
+ HELP
2423
+ "lower" => "Usage: mtc lower PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-I PATH]\n\n Lower one or more source files to IR and print it.",
2424
+ "emit-c" => "Usage: mtc emit-c PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-I PATH]\n\n Compile one or more source files to C and print the output.",
2425
+ "build" => <<~HELP,
2426
+ Usage: mtc build [PATH_OR_PACKAGE] [OPTIONS]
2427
+
2428
+ Compile a source file or package. Defaults to current directory if a
2429
+ package.toml is present.
2430
+
2431
+ Options:
2432
+ -o, --output OUTPUT Output path for the compiled artifact.
2433
+ --cc COMPILER C compiler to use (default: $CC or cc).
2434
+ --keep-c C_PATH Write the generated C source to this path.
2435
+ --profile PROFILE debug (default) | release.
2436
+ --platform PLATFORM linux (default) | windows | wasm.
2437
+ --bundle Package a native package build into a distributable directory.
2438
+ --archive Also write a .tar.gz archive for the native bundle (implies --bundle).
2439
+ --locked Resolve dependencies from package.lock.
2440
+ --frozen Require a current package.lock and use locked resolution.
2441
+ --no-cache Skip build cache, force rebuild from source.
2442
+ --debug-guards Enable loop iteration and recursion guards (default: on in debug, off in release).
2443
+ --no-debug-guards Disable loop iteration and recursion guards.
2444
+ --clean Remove existing build outputs and exit.
2445
+ -I, --include-path PATH Add an extra module root.
2446
+ HELP
2447
+ "new" => <<~HELP,
2448
+ Usage: mtc new NAME
2449
+
2450
+ Create a new application package scaffold with package.toml and src/main.mt.
2451
+ NAME selects the target directory, and its basename is normalized to
2452
+ snake_case for package.name and the generated module declaration.
2453
+ The target may be a new directory or an existing empty directory.
2454
+ HELP
2455
+ "run" => <<~HELP,
2456
+ Usage: mtc run [PATH_OR_PACKAGE] [OPTIONS]
2457
+
2458
+ Build and execute an executable target. For wasm targets this starts a
2459
+ local preview server, opens the generated HTML in your browser, and
2460
+ keeps serving until you press Ctrl-C.
2461
+ Defaults to current directory if a package.toml is present.
2462
+
2463
+ Options:
2464
+ -o, --output OUTPUT Output path for the compiled binary.
2465
+ --cc COMPILER C compiler to use (default: $CC or cc).
2466
+ --keep-c C_PATH Write the generated C source to this path.
2467
+ --profile PROFILE debug (default) | release.
2468
+ --platform PLATFORM linux (default) | windows | wasm.
2469
+ --locked Resolve dependencies from package.lock.
2470
+ --frozen Require a current package.lock and use locked resolution.
2471
+ --no-cache Skip build cache, force rebuild from source.
2472
+ --debug-guards Enable loop iteration and recursion guards (default: on in debug, off in release).
2473
+ --no-debug-guards Disable loop iteration and recursion guards.
2474
+ -I, --include-path PATH Add an extra module root.
2475
+ HELP
2476
+ "run-module" => <<~HELP,
2477
+ Usage: mtc run-module MODULE [OPTIONS] [-- ARGS...]
2478
+
2479
+ Resolve a standard-library module by name, build it, and run it.
2480
+ Like `python -m module.name`, this finds the module in the module
2481
+ roots (e.g., std/http/server.mt for `http/server`), compiles it, and
2482
+ forwards remaining arguments to the program.
2483
+
2484
+ Module name can use / or . separators (e.g., http/server or http.server).
2485
+
2486
+ Options:
2487
+ --cc COMPILER C compiler to use (default: $CC or cc).
2488
+ --profile PROFILE debug (default) | release.
2489
+ --platform PLATFORM linux (default) | windows | wasm.
2490
+ --locked Resolve dependencies from package.lock.
2491
+ --frozen Require a current package.lock and use locked resolution.
2492
+ -I, --include-path PATH Add an extra module root.
2493
+
2494
+ Arguments after -- are forwarded to the module as-is.
2495
+ HELP
2496
+ "toolchain" => <<~HELP,
2497
+ Usage: mtc toolchain SUBCOMMAND
2498
+
2499
+ Manage the local native toolchain and upstream native library checkouts.
2500
+
2501
+ Subcommands:
2502
+ bootstrap Download and prepare all upstream native libraries.
2503
+ doctor Check that all required tools and libraries are available.
2504
+ tools Build vendored tool binaries (e.g. tracy-profiler).
2505
+ HELP
2506
+ "deps" => <<~HELP,
2507
+ Usage: mtc deps SUBCOMMAND
2508
+
2509
+ Manage package dependencies and package.lock state.
2510
+
2511
+ Subcommands:
2512
+ add Add or update a package dependency and refresh package.lock.
2513
+ remove Remove a package dependency and refresh package.lock.
2514
+ update Re-resolve dependencies and refresh package.lock.
2515
+ tree Print the package dependency tree for a local package.
2516
+ lock Write or verify a deterministic package.lock for a local package.
2517
+ publish Publish an exact-version package into the local or configured upstream registry store.
2518
+ fetch Materialize cache-backed sources from package.lock explicitly.
2519
+ HELP
2520
+ "bindgen" => <<~HELP,
2521
+ Usage: mtc bindgen MODULE HEADER [OPTIONS]
2522
+
2523
+ Generate a Milk Tea binding module from a C header.
2524
+
2525
+ Options:
2526
+ -o, --output OUTPUT Write the generated module to this file.
2527
+ --nullable-report PATH Write the remaining manual nullable policy report to this file.
2528
+ --link LIB Link against this library (repeatable).
2529
+ --include HEADER Extra #include directive (repeatable).
2530
+ --clang PATH Clang binary to use (default: $CLANG or clang).
2531
+ --clang-arg ARG Extra argument to pass to clang (repeatable).
2532
+ HELP
2533
+ "cache" => <<~HELP,
2534
+ Usage: mtc cache SUBCOMMAND
2535
+
2536
+ Inspect and manage the build cache.
2537
+
2538
+ Subcommands:
2539
+ purge Remove the entire build cache.
2540
+ status Show cache directory, entry counts, and total size.
2541
+ HELP
2542
+ "docs" => <<~HELP,
2543
+ Usage: mtc docs [OPTIONS]
2544
+
2545
+ Start a local documentation server for Milk Tea with language reference
2546
+ and standard library exploration.
2547
+
2548
+ Options:
2549
+ --open, -o Open the docs in your default browser.
2550
+ --port, -p PORT Listen on a specific port (default: random).
2551
+ HELP
2552
+ "snapshot" => <<~HELP,
2553
+ Usage: mtc snapshot INPUT.mt [OPTIONS]
2554
+
2555
+ Generate an HTML snapshot of a Milk Tea source file with VS Code syntax
2556
+ highlighting using the active TextMate grammar and the configured theme.
2557
+
2558
+ Options:
2559
+ --theme, -t PATH VS Code theme JSON file (default: 2026 Dark).
2560
+ --output, -o PATH Write HTML to PATH instead of stdout.
2561
+ HELP
2562
+ "lsp" => <<~HELP,
2563
+ Usage: mtc lsp [OPTIONS]
2564
+
2565
+ Start the Milk Tea Language Server. Reads JSON-RPC messages from stdin
2566
+ and writes responses to stdout. Stderr is reserved for logging.
2567
+
2568
+ Options:
2569
+ --log-level LEVEL Log verbosity: trace, debug, info, warn, or
2570
+ error (default: warn, set by extension).
2571
+ --stdio Enable stdio transport (default).
2572
+ HELP
2573
+ "dap" => <<~HELP,
2574
+ Usage: mtc dap [OPTIONS]
2575
+
2576
+ Start the Milk Tea Debug Adapter. Reads DAP messages from stdin
2577
+ and writes responses to stdout.
2578
+
2579
+ Options:
2580
+ --backend KIND Debug backend kind (default: process).
2581
+ --adapter-path PATH Path to an lldb-dap or cppdbg adapter
2582
+ executable or Ruby bridge script.
2583
+ HELP
2584
+ "completions" => <<~HELP,
2585
+ Usage: mtc completions bash|zsh|fish
2586
+
2587
+ Print a shell completion script to stdout. Source it (or install it into
2588
+ your shell's completion directory) to get tab-completion for mtc commands.
2589
+
2590
+ Examples:
2591
+ mtc completions bash > /etc/bash_completion.d/mtc
2592
+ mtc completions zsh > ~/.zsh/completions/_mtc
2593
+ mtc completions fish > ~/.config/fish/completions/mtc.fish
2594
+ HELP
2595
+ }.freeze
2596
+
2597
+ def format_size(bytes)
2598
+ return "0 B" if bytes.zero?
2599
+
2600
+ units = %w[B KiB MiB GiB]
2601
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
2602
+ exp = units.length - 1 if exp >= units.length
2603
+ format("%.1f %s", bytes.to_f / (1024**exp), units[exp])
2604
+ end
2605
+
2606
+ def print_command_help(command, io)
2607
+ text = COMMAND_HELP[command]
2608
+ if text
2609
+ io.puts(text.chomp)
2610
+ else
2611
+ print_usage(io)
2612
+ end
2613
+ end
2614
+
2615
+ def print_toolchain_help(io)
2616
+ print_command_help("toolchain", io)
2617
+ end
2618
+
2619
+ def print_deps_help(io)
2620
+ print_command_help("deps", io)
2621
+ end
2622
+
2623
+ def print_bindgen_help(io)
2624
+ print_command_help("bindgen", io)
2625
+ end
2626
+
2627
+ def print_cache_help(io)
2628
+ print_command_help("cache", io)
2629
+ end
2630
+
2631
+ def print_usage(io)
2632
+ io.puts("Usage: mtc lex PATH")
2633
+ io.puts(" mtc parse PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-I PATH]")
2634
+ io.puts(" mtc format PATH|DIR [PATH|DIR ...] [--check|--write] [--safe|--canonical|--preserve|--tidy] [--max-line-length N] [--timings]")
2635
+ io.puts(" mtc lint PATH|DIR [--select RULES] [--ignore RULES] [--fix] [--ignore-generated] [--timings] [--locked] [--frozen] [-I PATH]")
2636
+ io.puts(" mtc lint --init")
2637
+ io.puts(" mtc check PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-Werror] [-I PATH]")
2638
+ io.puts(" mtc debug PATH [--locked] [--frozen] [-I PATH]")
2639
+ io.puts(" mtc lower PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-I PATH]")
2640
+ io.puts(" mtc emit-c PATH|DIR [PATH|DIR ...] [--locked] [--frozen] [-I PATH]")
2641
+ io.puts(" mtc build [PATH_OR_PACKAGE] [-o OUTPUT] [--cc COMPILER] [--keep-c C_PATH] [--profile debug|release] [--platform linux|windows|wasm] [--bundle] [--archive] [--locked] [--frozen] [--no-cache] [--debug-guards|--no-debug-guards] [--clean] [-I PATH]")
2642
+ io.puts(" mtc new NAME")
2643
+ io.puts(" mtc run [PATH_OR_PACKAGE] [-o OUTPUT] [--cc COMPILER] [--keep-c C_PATH] [--profile debug|release] [--platform linux|windows|wasm] [--locked] [--frozen] [-I PATH] [--debug-guards|--no-debug-guards]")
2644
+ io.puts(" mtc test PATH|DIR [--cc COMPILER] [--profile debug|release] [--platform linux|windows|wasm] [--timeout SECONDS] [--mem MB] [--jobs N] [--sanitize] [-n SUBSTRING] [--format human|tap|junit] [--locked] [--frozen] [-I PATH]")
2645
+ io.puts(" mtc run-module MODULE [--cc COMPILER] [--profile debug|release] [--platform linux|windows|wasm] [--locked] [--frozen] [-I PATH] [-- ARGS...]")
2646
+ io.puts(" mtc toolchain bootstrap")
2647
+ io.puts(" mtc toolchain doctor")
2648
+ io.puts(" mtc toolchain tools")
2649
+ io.puts(" mtc deps add [PATH_OR_PACKAGE] NAME[@VERSION_REQ] [--path PATH] [--git URL --rev REV [--subdir DIR]] [--version VERSION_REQ]")
2650
+ io.puts(" mtc deps remove [PATH_OR_PACKAGE] NAME")
2651
+ io.puts(" mtc deps update [PATH_OR_PACKAGE] [NAME ...]")
2652
+ io.puts(" mtc deps tree [PATH_OR_PACKAGE]")
2653
+ io.puts(" mtc deps lock [PATH_OR_PACKAGE] [--check]")
2654
+ io.puts(" mtc deps publish [PATH_OR_PACKAGE] [--upstream]")
2655
+ io.puts(" mtc deps fetch [PATH_OR_PACKAGE]")
2656
+ io.puts(" mtc bindgen MODULE HEADER [-o OUTPUT] [--nullable-report PATH] [--link LIB] [--include HEADER] [--clang PATH] [--clang-arg ARG]")
2657
+ io.puts(" mtc cache purge|status")
2658
+ io.puts(" mtc docs [--open] [--port PORT]")
2659
+ io.puts(" mtc snapshot INPUT.mt [--theme PATH] [-o OUTPUT]")
2660
+ io.puts(" mtc lsp [--log-level LEVEL] [--stdio]")
2661
+ io.puts(" mtc dap [--backend KIND] [--adapter-path PATH]")
2662
+ io.puts(" mtc completions bash|zsh|fish")
2663
+ end
2664
+
2665
+ def print_help(io)
2666
+ io.puts("mtc #{MilkTea::VERSION} \u2014 the Milk Tea compiler")
2667
+ io.puts
2668
+ io.puts("Usage: mtc <command> [options]")
2669
+ io.puts
2670
+ io.puts("Commands:")
2671
+ width = COMMANDS.map { |name, _| name.length }.max
2672
+ COMMANDS.each do |name, summary|
2673
+ io.puts(" #{name.ljust(width)} #{summary}")
2674
+ end
2675
+ io.puts
2676
+ io.puts("Global options:")
2677
+ io.puts(" -h, --help Show this help (use `mtc help <command>` for command help)")
2678
+ io.puts(" -V, --version Print the compiler version")
2679
+ io.puts(" -q, --quiet Suppress informational output")
2680
+ io.puts(" -v, --verbose Print per-file progress")
2681
+ io.puts(" --color WHEN Colorize diagnostics: auto (default), always, never")
2682
+ io.puts
2683
+ io.puts("Run `mtc <command> --help` for detailed usage of a command.")
2684
+ io.puts
2685
+ print_usage(io)
2686
+ end
2687
+ end
2688
+ end