mt-lang 0.3.17 → 0.3.20

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/lib/milk_tea/base.rb +1 -1
  3. data/lib/milk_tea/core/c_backend/expressions.rb +34 -23
  4. data/lib/milk_tea/core/c_backend/feature_detection.rb +25 -45
  5. data/lib/milk_tea/core/c_backend/type_collectors.rb +18 -30
  6. data/lib/milk_tea/core/c_backend/type_declaration.rb +0 -6
  7. data/lib/milk_tea/core/c_backend.rb +49 -39
  8. data/lib/milk_tea/core/compile_time.rb +98 -72
  9. data/lib/milk_tea/core/intrinsics.rb +7 -0
  10. data/lib/milk_tea/core/lexer.rb +46 -35
  11. data/lib/milk_tea/core/lowering/block.rb +9 -0
  12. data/lib/milk_tea/core/lowering/calls.rb +2 -0
  13. data/lib/milk_tea/core/lowering/declarations.rb +1 -1
  14. data/lib/milk_tea/core/lowering/functions.rb +11 -8
  15. data/lib/milk_tea/core/lowering/resolve.rb +10 -3
  16. data/lib/milk_tea/core/lowering/scans.rb +13 -18
  17. data/lib/milk_tea/core/module_binder.rb +9 -10
  18. data/lib/milk_tea/core/module_loader.rb +38 -42
  19. data/lib/milk_tea/core/module_path_resolver.rb +1 -4
  20. data/lib/milk_tea/core/parser/declarations.rb +43 -19
  21. data/lib/milk_tea/core/parser/expressions.rb +4 -7
  22. data/lib/milk_tea/core/parser/statements.rb +5 -5
  23. data/lib/milk_tea/core/parser.rb +26 -0
  24. data/lib/milk_tea/core/semantic_analyzer/calls.rb +24 -31
  25. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +20 -23
  26. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +117 -85
  27. data/lib/milk_tea/core/semantic_analyzer/statements.rb +19 -41
  28. data/lib/milk_tea/core/semantic_analyzer.rb +56 -37
  29. data/lib/milk_tea/lsp/server/semantic_tokens.rb +6 -0
  30. data/lib/milk_tea/tooling/cli/commands/bindgen.rb +11 -0
  31. data/lib/milk_tea/tooling/cli/commands/build.rb +37 -0
  32. data/lib/milk_tea/tooling/cli/commands/cache.rb +46 -0
  33. data/lib/milk_tea/tooling/cli/commands/check.rb +116 -0
  34. data/lib/milk_tea/tooling/cli/commands/command_base.rb +8 -0
  35. data/lib/milk_tea/tooling/cli/commands/completions.rb +48 -0
  36. data/lib/milk_tea/tooling/cli/commands/dap.rb +58 -0
  37. data/lib/milk_tea/tooling/cli/commands/debug.rb +77 -0
  38. data/lib/milk_tea/tooling/cli/commands/deps.rb +17 -0
  39. data/lib/milk_tea/tooling/cli/commands/docs.rb +58 -0
  40. data/lib/milk_tea/tooling/cli/commands/emit_c.rb +64 -0
  41. data/lib/milk_tea/tooling/cli/commands/format.rb +199 -0
  42. data/lib/milk_tea/tooling/cli/commands/lex.rb +46 -0
  43. data/lib/milk_tea/tooling/cli/commands/lint.rb +248 -0
  44. data/lib/milk_tea/tooling/cli/commands/lower.rb +50 -0
  45. data/lib/milk_tea/tooling/cli/commands/lsp.rb +43 -0
  46. data/lib/milk_tea/tooling/cli/commands/new.rb +26 -0
  47. data/lib/milk_tea/tooling/cli/commands/parse.rb +51 -0
  48. data/lib/milk_tea/tooling/cli/commands/run.rb +99 -0
  49. data/lib/milk_tea/tooling/cli/commands/snapshot.rb +117 -0
  50. data/lib/milk_tea/tooling/cli/commands/test.rb +557 -0
  51. data/lib/milk_tea/tooling/cli/commands/toolchain.rb +16 -0
  52. data/lib/milk_tea/tooling/cli.rb +101 -1893
  53. metadata +24 -2
@@ -146,1852 +146,130 @@ module MilkTea
146
146
  1
147
147
  end
148
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
- sexpr = false
233
-
234
- args = @argv.dup
235
- @argv = []
236
- until args.empty?
237
- arg = args.shift
238
- next if arg == "--"
239
-
240
- if arg == "--sexpr"
241
- sexpr = true
242
- next
243
- end
244
-
245
- if path.nil?
246
- path = arg
247
- else
248
- @err.puts("unknown option: #{arg}")
249
- print_usage(@err)
250
- return 1
251
- end
252
- end
253
-
254
- unless path
255
- @err.puts("missing source file path")
256
- print_usage(@err)
257
- return 1
258
- end
259
-
260
- tokens = Lexer.lex(read_source_file(path), path: path)
261
- if sexpr
262
- @out.puts(SexprDumper.dump_tokens(tokens))
263
- else
264
- @out.write(PP.pp(tokens, +""))
265
- end
266
- 0
267
- end
268
-
269
- def parse_command
270
- sexpr = false
271
- args = @argv.dup
272
- @argv = []
273
- until args.empty?
274
- arg = args.shift
275
- if arg == "--sexpr"
276
- sexpr = true
277
- next
278
- end
279
- @argv << arg
280
- end
281
-
282
- unless @argv.any?
283
- @err.puts("missing source file path")
284
- print_usage(@err)
285
- return 1
286
- end
287
-
288
- resolution = extract_resolution_flags!
289
- input_paths = @argv.dup
290
- return 1 unless ensure_known_source_operands!("parse", input_paths)
291
-
292
- paths = expand_source_paths(input_paths)
293
- return 0 if print_no_source_files_if_empty(paths, input_paths)
294
-
295
- ensure_current_lockfiles!(paths) if resolution[:frozen]
296
-
297
- multiple = paths.length > 1
298
- paths.each_with_index do |path, index|
299
- ast = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).load_file(path)
300
- if multiple
301
- @out.puts("# --- #{path} ---") unless sexpr
302
- end
303
- if sexpr
304
- @out.puts(SexprDumper.dump_ast(ast))
305
- else
306
- @out.write(PrettyPrinter.format_ast(ast))
307
- end
308
- @out.puts if multiple && index < paths.length - 1
309
- end
310
- 0
311
- end
312
-
313
- def format_command
314
- parsed = parse_format_options
315
- return 1 unless parsed
316
-
317
- options = parsed[:options]
318
- input_paths = parsed[:input_paths]
319
-
320
- if input_paths.empty?
321
- @err.puts("missing source file path")
322
- print_usage(@err)
323
- return 1
324
- end
325
-
326
- paths = expand_source_paths(input_paths)
327
- return 0 if print_no_source_files_if_empty(paths, input_paths)
328
-
329
- multiple_sources = input_paths.length > 1 || input_paths.any? { |path| File.directory?(path) }
330
- if multiple_sources
331
- unless options[:check] || options[:write]
332
- @err.puts("format on multiple sources requires --check or --write")
333
- print_usage(@err)
334
- return 1
335
- end
336
-
337
- return format_paths(paths, options)
338
- end
339
-
340
- path = paths.first
341
-
342
- source = read_source_file(path)
343
- format_profile = options[:profile] ? Linter::Profile.new : nil
344
- start_time = options[:profile] ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
345
- result = Formatter.check_source(source, path: path, mode: options[:mode], max_line_length: options[:max_line_length], profile: format_profile)
346
- elapsed_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
347
-
348
- rc = if options[:check]
349
- announce_file_action(path, "format-check")
350
- if result.changed
351
- info("needs formatting #{path}")
352
- 1
353
- else
354
- info("already formatted #{path}")
355
- 0
356
- end
357
- elsif options[:write]
358
- announce_file_action(path, "format-write")
359
- if result.changed
360
- File.write(path, result.formatted_source)
361
- info("formatted #{path}")
362
- else
363
- info("already formatted #{path}")
364
- end
365
- 0
366
- else
367
- @out.write(result.formatted_source)
368
- 0
369
- end
370
-
371
- print_file_profiles([{ path:, total_ms: elapsed_ms, profile: format_profile }], "format") if options[:profile]
372
- rc
373
- end
374
-
375
- def format_paths(paths, options)
376
- format_profiles = []
377
- if options[:check]
378
- needs_fmt = []
379
- paths.each do |p|
380
- announce_file_action(p, "format-check")
381
- format_profile = options[:profile] ? Linter::Profile.new : nil
382
- start_time = options[:profile] ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
383
- result = Formatter.check_source(read_source_file(p), path: p, mode: options[:mode], max_line_length: options[:max_line_length], profile: format_profile)
384
- elapsed_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
385
- format_profiles << { path: p, total_ms: elapsed_ms, profile: format_profile } if options[:profile]
386
- needs_fmt << p if result.changed
387
- end
388
- print_file_profiles(format_profiles, "format") if options[:profile]
389
- if needs_fmt.empty?
390
- info("all #{paths.size} file(s) already formatted")
391
- return 0
392
- end
393
- needs_fmt.each { |p| info("needs formatting #{p}") }
394
- info("#{needs_fmt.size} file(s) need formatting")
395
- return 1
396
- end
397
-
398
- # --write
399
- changed = 0
400
- paths.each do |p|
401
- announce_file_action(p, "format-write")
402
- format_profile = options[:profile] ? Linter::Profile.new : nil
403
- start_time = options[:profile] ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
404
- result = Formatter.check_source(read_source_file(p), path: p, mode: options[:mode], max_line_length: options[:max_line_length], profile: format_profile)
405
- elapsed_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
406
- format_profiles << { path: p, total_ms: elapsed_ms, profile: format_profile } if options[:profile]
407
- if result.changed
408
- File.write(p, result.formatted_source)
409
- info("formatted #{p}")
410
- changed += 1
411
- end
412
- end
413
- print_file_profiles(format_profiles, "format") if options[:profile]
414
- info("formatted #{changed} of #{paths.size} file(s)")
415
- 0
416
- end
417
-
418
- def lint_command
419
- resolution = { locked: false, frozen: false }
420
- select = nil
421
- ignore = nil
422
- fix = false
423
- init = false
424
- ignore_generated = false
425
- profile = false
426
- input_paths = []
427
- until @argv.empty?
428
- arg = @argv.shift
429
- unless arg.start_with?("--")
430
- input_paths << arg
431
- next
432
- end
433
-
434
- flag = arg
435
- case flag
436
- when "--select"
437
- arg = @argv.shift
438
- unless arg
439
- @err.puts("--select requires a comma-separated list of rule codes")
440
- return 1
441
- end
442
- select = arg.split(",").map(&:strip).to_set
443
- when "--ignore"
444
- arg = @argv.shift
445
- unless arg
446
- @err.puts("--ignore requires a comma-separated list of rule codes")
447
- return 1
448
- end
449
- ignore = arg.split(",").map(&:strip).to_set
450
- when "--fix"
451
- fix = true
452
- when "--init"
453
- init = true
454
- when "--locked"
455
- resolution[:locked] = true
456
- when "--frozen"
457
- resolution[:locked] = true
458
- resolution[:frozen] = true
459
- when "--ignore-generated"
460
- ignore_generated = true
461
- when "--timings"
462
- profile = true
463
- when "--"
464
- input_paths.concat(@argv)
465
- @argv.clear
466
- else
467
- @err.puts("unknown lint flag: #{flag}")
468
- return 1
469
- end
470
- end
471
-
472
- if init
473
- if input_paths.empty? && !select && !ignore && !fix && !resolution[:locked] && !resolution[:frozen]
474
- return init_lint_config
475
- end
476
-
477
- @err.puts("--init does not accept source paths or lint options")
478
- return 1
479
- end
480
-
481
- if input_paths.empty?
482
- @err.puts("missing source file path")
483
- print_usage(@err)
484
- return 1
485
- end
486
-
487
- paths = input_paths.flat_map do |path|
488
- if File.directory?(path)
489
- Dir.glob(File.join(path, "**/*.mt")).sort
490
- else
491
- [path]
492
- end
493
- end.uniq
494
-
495
- if paths.empty?
496
- label = input_paths.length == 1 ? input_paths.first : input_paths.join(", ")
497
- @out.puts("no .mt files found in #{label}")
498
- return 0
499
- end
500
-
501
- ensure_current_lockfiles!(paths) if resolution[:frozen]
502
-
503
- if fix
504
- lint_profiles = []
505
- paths.each do |p|
506
- announce_file_action(p, "lint-fix")
507
- source = read_source_file(p)
508
- if ignore_generated && generated_source?(source)
509
- @out.puts("ignored generated #{p}")
510
- next
511
- end
512
-
513
- facts = lint_sema_facts_for(source, p, locked: resolution[:locked])
514
- prof = profile ? Linter::Profile.new : nil
515
- start_time = profile ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
516
-
517
- fixed = Linter.fix_source(
518
- source,
519
- path: p,
520
- sema_facts: facts,
521
- select:,
522
- ignore:,
523
- profile: prof,
524
- )
525
- total_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
526
- lint_profiles << { path: p, profile: prof, mode: :pre_fix_scan, total_ms: } if prof
527
- if fixed != source
528
- File.write(p, fixed)
529
- @out.puts("fixed #{p}")
530
- end
531
- end
532
- print_lint_rule_profiles(lint_profiles) if profile
533
- print_lint_file_profiles(lint_profiles) if profile
534
- return 0
535
- end
536
-
537
- lint_profiles = []
538
- all_warnings = paths.flat_map do |p|
539
- announce_file_action(p, "lint")
540
- source = read_source_file(p)
541
- next [] if ignore_generated && generated_source?(source)
542
-
543
- facts = lint_sema_facts_for(source, p, locked: resolution[:locked])
544
- prof = profile ? Linter::Profile.new : nil
545
- start_time = profile ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
546
- warnings = Linter.lint_source(source, path: p, select:, ignore:, sema_facts: facts, profile: prof)
547
- total_ms = start_time ? ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(1) : nil
548
- lint_profiles << { path: p, profile: prof, mode: :lint, total_ms: } if prof
549
- warnings
550
- end
551
-
552
- if all_warnings.empty?
553
- if input_paths.length == 1
554
- info("clean #{input_paths.first}")
555
- else
556
- info("clean #{paths.size} file(s)")
557
- end
558
- print_lint_file_profiles(lint_profiles) if profile
559
- return 0
560
- end
561
-
562
- all_warnings.each do |warning|
563
- @out.puts("#{warning.path}:#{warning.line}: #{warning.code}: #{warning.message}")
564
- end
565
-
566
- print_lint_rule_profiles(lint_profiles) if profile
567
- print_lint_file_profiles(lint_profiles) if profile
568
-
569
- file_count = all_warnings.map(&:path).uniq.size
570
- noun = all_warnings.size == 1 ? "warning" : "warnings"
571
- files_str = file_count == 1 ? "1 file" : "#{file_count} files"
572
- @out.puts("Found #{all_warnings.size} #{noun} in #{files_str}.")
573
- 1
574
- end
575
-
576
- def init_lint_config
577
- path = File.join(Dir.pwd, Linter::DEFAULT_CONFIG_FILE_NAME)
578
- if File.exist?(path)
579
- @err.puts("lint config already exists at #{path}")
580
- return 1
581
- end
582
-
583
- File.write(path, Linter.default_config_source)
584
- info("created #{path}")
585
- 0
586
- end
587
-
588
- def print_lint_rule_profiles(lint_profiles, limit: 12)
589
- lint_profiles.each do |entry|
590
- profile = entry[:profile]
591
- next unless profile
592
-
593
- rows = profile.rule_breakdown(limit:, min_ms: 0.0)
594
- rule_total = profile.total_time_ms(prefix: "rule.")
595
- overall_total = profile.total_time_ms
596
- mode_label = entry[:mode] == :pre_fix_scan ? "pre-fix scan" : "lint scan"
597
- @out.puts("lint profile #{entry[:path]} (#{mode_label}): rules=#{format('%.1f', rule_total)}ms total=#{format('%.1f', overall_total)}ms")
598
-
599
- if rows.empty?
600
- @out.puts(" no rule timing data captured")
601
- next
602
- end
603
-
604
- rows.each do |row|
605
- share = rule_total.positive? ? ((row[:total_ms] / rule_total) * 100.0) : 0.0
606
- @out.puts(
607
- " #{row[:code]}: #{row[:count]}x total=#{format('%.1f', row[:total_ms])}ms avg=#{format('%.2f', row[:avg_ms])}ms share=#{format('%.1f', share)}%"
608
- )
609
- end
610
-
611
- non_rule_rows = profile.timings_ms
612
- .filter_map do |name, total_ms|
613
- next if name.start_with?("rule.")
614
- next if total_ms < 1.0
615
-
616
- [name, total_ms]
617
- end
618
- .sort_by { |_name, total_ms| -total_ms }
619
- .first(5)
620
- .map do |name, total_ms|
621
- count = profile.counts[name]
622
- "#{name}:#{count}x/#{format('%.1f', total_ms)}ms"
623
- end
624
-
625
- @out.puts(" non-rule hot phases: #{non_rule_rows.join(', ')}") unless non_rule_rows.empty?
626
- end
627
- end
628
-
629
- def print_lint_file_profiles(lint_profiles)
630
- file_entries = lint_profiles.filter_map do |entry|
631
- total = entry[:total_ms]
632
- next unless total
633
-
634
- phases = entry[:profile]&.timings_ms&.reject { |name, _| name.start_with?("rule.") }&.sort_by { |_, ms| -ms }
635
- { path: entry[:path], total_ms: total, phases: }
636
- end
637
- return if file_entries.empty?
638
-
639
- sorted = file_entries.sort_by { |e| -e[:total_ms] }
640
- @out.puts
641
- @out.puts("Profile (lint): #{sorted.size} file(s)")
642
- sorted.each do |entry|
643
- phase_str = entry[:phases]&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 1.0 }&.join(", ")
644
- detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
645
- @out.puts(" #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
646
- end
647
- total = sorted.sum { |e| e[:total_ms] }
648
- @out.puts("Total: #{format('%.1f', total)}ms")
649
- end
650
-
651
- def print_file_profiles(file_profiles, label)
652
- sorted = file_profiles.sort_by { |fp| -fp[:total_ms] }
653
- return if sorted.empty?
654
-
655
- @out.puts
656
- if sorted.size == 1
657
- entry = sorted.first
658
- phases = entry[:profile]&.timings_ms&.sort_by { |_, ms| -ms }
659
- phase_str = phases&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 0.1 }&.join(", ")
660
- detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
661
- @out.puts("#{label} profile #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
662
- return
663
- end
664
-
665
- @out.puts("Profile (#{label}): #{sorted.size} file(s)")
666
- sorted.each do |entry|
667
- phases = entry[:profile]&.timings_ms&.sort_by { |_, ms| -ms }
668
- phase_str = phases&.filter_map { |name, ms| "#{name}: #{format('%.1f', ms)}ms" if ms >= 1.0 }&.join(", ")
669
- detail = phase_str && !phase_str.empty? ? " (#{phase_str})" : ""
670
- @out.puts(" #{entry[:path]}: #{format('%.1f', entry[:total_ms])}ms#{detail}")
671
- end
672
- total = sorted.sum { |fp| fp[:total_ms] }
673
- @out.puts("Total: #{format('%.1f', total)}ms")
674
- end
675
-
676
- def check_command
677
- args = @argv.dup
678
- @argv = []
679
- until args.empty?
680
- arg = args.shift
681
- @argv << arg
682
- end
683
-
684
- unless @argv.any?
685
- @err.puts("missing source file path")
686
- print_usage(@err)
687
- return 1
688
- end
689
-
690
- resolution = extract_resolution_flags!
691
- input_paths = @argv.dup
692
- return 1 unless ensure_known_source_operands!("check", input_paths)
693
-
694
- paths = expand_source_paths(input_paths)
695
- return 0 if print_no_source_files_if_empty(paths, input_paths)
696
-
697
- ensure_current_lockfiles!(paths) if resolution[:frozen]
698
-
699
- all_diagnostics = []
700
- paths.each do |path|
701
- diagnostics, module_name, closure_errors = check_single_reporting_all(path, locked: resolution[:locked])
702
- closure_errors = [] if paths.length > 1
703
- diagnostics = sort_by_location(diagnostics)
704
-
705
- if diagnostics.any? || closure_errors.any?
706
- main_source = read_source_file(path)
707
- main_abs = File.expand_path(path)
708
- diagnostics.each do |d|
709
- same_file = !d.respond_to?(:path) || d.path.nil? || File.expand_path(d.path) == main_abs
710
- source = same_file ? main_source : nil
711
- @err.puts(ErrorFormatter.format(d, source:, color: error_color?(@err)))
712
- end
713
- closure_errors.each do |d|
714
- same_file = !d.respond_to?(:path) || d.path.nil? || File.expand_path(d.path) == main_abs
715
- source = same_file ? main_source : nil
716
- @err.puts(ErrorFormatter.format(d, source:, color: error_color?(@err)))
717
- end
718
- all_diagnostics.concat(diagnostics)
719
- all_diagnostics.concat(closure_errors)
720
- elsif module_name
721
- info("checked #{path} as #{module_name}")
722
- end
723
- end
724
-
725
- return 0 if all_diagnostics.empty?
726
-
727
- error_count = all_diagnostics.count { |d| !d.respond_to?(:severity) || d.severity == :error }
728
- warning_count = all_diagnostics.count { |d| d.respond_to?(:severity) && d.severity == :warning }
729
- info_count = all_diagnostics.count { |d| d.respond_to?(:severity) && (d.severity == :info || d.severity == :hint) }
730
-
731
- @err.puts
732
- parts = []
733
- parts << "#{error_count} #{error_count == 1 ? 'error' : 'errors'}" if error_count > 0
734
- parts << "#{warning_count} #{warning_count == 1 ? 'warning' : 'warnings'}" if warning_count > 0
735
- parts << "#{info_count} #{info_count == 1 ? 'note' : 'notes'}" if info_count > 0
736
- body = parts.join("; ")
737
- if error_count > 0
738
- @err.puts("#{body} found")
739
- elsif warning_count > 0
740
- @err.puts("#{body}")
741
- end
742
- final_error_count = error_count + (resolution[:warnings_as_errors] ? warning_count : 0)
743
- final_error_count > 0 ? 1 : 0
744
- end
745
-
746
- def check_single_reporting_all(path, locked: false)
747
- loader = make_module_loader(path, locked:, platform: ModuleLoader.default_host_platform)
748
- resolved_path = File.expand_path(path)
749
- ast = loader.load_file(resolved_path)
750
- module_name = ast.module_name.to_s
751
-
752
- import_result = loader.send(:imported_modules_for_ast_collecting_errors, ast, importer_path: resolved_path)
753
- errors = import_result.errors.dup
754
-
755
- analysis = nil
756
- begin
757
- result = SemanticAnalyzer.check_collecting_errors(ast, imported_modules: import_result.modules, path: resolved_path)
758
- errors.concat(result[:errors])
759
- analysis = result[:analysis]
760
- rescue SemanticError => e
761
- errors << e
762
- end
763
-
764
- if analysis && errors.empty?
765
- source = read_source_file(path)
766
- warnings = Linter.lint_source(source, path: resolved_path, sema_facts: analysis, lint_tier: :full)
767
- errors.concat(warnings)
768
- end
769
-
770
- closure_errors = loader.collecting_path_errors.values.flatten.compact
771
- [errors, module_name, closure_errors]
772
- rescue ModuleLoadError, PackageLockError, SemanticError => e
773
- [[e], nil, []]
774
- end
775
-
776
- def sort_by_location(errors)
777
- errors.sort_by do |e|
778
- actual = e.respond_to?(:error) ? e.error : e
779
- line = actual.respond_to?(:line) ? actual.line.to_i : 0
780
- column = actual.respond_to?(:column) ? actual.column.to_i : 0
781
- [line, column]
782
- end
783
- end
784
-
785
- def lower_command
786
- sexpr = false
787
- args = @argv.dup
788
- @argv = []
789
- until args.empty?
790
- arg = args.shift
791
- if arg == "--sexpr"
792
- sexpr = true
793
- next
794
- end
795
- @argv << arg
796
- end
797
-
798
- unless @argv.any?
799
- @err.puts("missing source file path")
800
- print_usage(@err)
801
- return 1
802
- end
803
-
804
- resolution = extract_resolution_flags!
805
- input_paths = @argv.dup
806
- return 1 unless ensure_known_source_operands!("lower", input_paths)
807
-
808
- paths = expand_source_paths(input_paths)
809
- return 0 if print_no_source_files_if_empty(paths, input_paths)
810
-
811
- ensure_current_lockfiles!(paths) if resolution[:frozen]
812
-
813
- multiple = paths.length > 1
814
- paths.each_with_index do |path, index|
815
- program = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).check_program(path)
816
- if multiple
817
- @out.puts("# --- #{path} ---") unless sexpr
818
- end
819
- if sexpr
820
- @out.puts(SexprDumper.dump_ir(Lowering.lower(program)))
821
- else
822
- @out.write(PrettyPrinter.format_ir(Lowering.lower(program)))
823
- end
824
- end
825
- 0
826
- end
827
-
828
- def emit_c_command
829
- args = @argv.dup
830
- @argv = []
831
- until args.empty?
832
- arg = args.shift
833
- @argv << arg
834
- end
835
-
836
- unless @argv.any?
837
- @err.puts("missing source file path")
838
- print_usage(@err)
839
- return 1
840
- end
841
-
842
- resolution = extract_resolution_flags!
843
- input_paths = @argv.dup
844
- return 1 unless ensure_known_source_operands!("emit-c", input_paths)
845
-
846
- program_paths = input_paths.map { |p| resolve_program_path(p) }
847
- return 1 if program_paths.include?(nil)
848
-
849
- ensure_current_lockfiles!(input_paths) if resolution[:frozen]
850
-
851
- multiple = program_paths.length > 1
852
- program_paths.each_with_index do |path, index|
853
- program = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform).check_program(path)
854
- if multiple
855
- @out.puts("/* --- #{path} --- */")
856
- end
857
- @out.write(CBackend.generate_c(Lowering.lower(program), emit_line_directives: false))
858
- @out.puts if multiple && index < program_paths.length - 1
859
- end
860
- 0
861
- end
862
-
863
- def resolve_program_path(path)
864
- return path unless File.directory?(path)
865
-
866
- manifest_path = File.join(path, "package.toml")
867
- unless File.file?(manifest_path)
868
- @err.puts("no package.toml found in #{path}")
869
- return nil
870
- end
871
-
872
- manifest = PackageManifest.load(path)
873
- entry_path = manifest.source_path
874
- unless entry_path && File.file?(entry_path)
875
- @err.puts("no build entry found for #{path}")
876
- return nil
877
- end
878
-
879
- entry_path
880
- rescue PackageManifestError => e
881
- @err.puts("failed to load package manifest for #{path}: #{e.message}")
882
- nil
883
- end
884
-
885
- def build_command
886
- path, options = extract_path_and_options(allow_clean: true)
887
- return 1 unless path
888
-
889
- if options.delete(:clean)
890
- cleaned_path = Build.clean(path, output_path: options[:output_path], profile: options[:profile], platform: options[:platform], bundle: options[:bundle], archive: options[:archive])
891
- info("cleaned #{cleaned_path}")
892
- return 0
893
- end
894
-
895
- frozen = options.delete(:frozen)
896
- ensure_current_lockfile!(path) if frozen
897
- locked = options.delete(:locked)
898
- bundle = options[:bundle]
899
- package_graph = package_graph_for(path, locked:)
900
- result = Build.build(path, module_roots: module_roots_for(path, locked:), package_graph:, frontend: @build_frontend, **options.except(:timings))
901
- if bundle
902
- info("built #{path} -> #{File.dirname(result.output_path)}")
903
- info("entry executable #{result.output_path}")
904
- info(" [cached]") if result.cached
905
- info("archive #{result.archive_path}") if result.archive_path
906
- elsif result.cached
907
- info("built #{path} -> #{result.output_path} [cached]")
908
- else
909
- info("built #{path} -> #{result.output_path}")
910
- end
911
- info("saved C to #{result.c_path}") if result.c_path
912
- 0
913
- end
914
-
915
- def test_command
916
- limits = extract_test_limit_flags!
917
- return 1 unless limits
918
-
919
- @test_timeout_seconds, @test_memory_bytes, @test_jobs, @test_sanitize, @test_filter, @test_format = limits
920
-
921
- path, options = extract_path_and_options
922
- return 1 unless path
923
-
924
- frozen = options.delete(:frozen)
925
- ensure_current_lockfile!(path) if frozen
926
- locked = options.delete(:locked)
927
-
928
- return dispatch_test_run(path, options:, locked:) if @test_format == :human
929
-
930
- buffer = StringIO.new
931
- real_out = @out
932
- real_err = @err
933
- @out = buffer
934
- @err = buffer
935
- begin
936
- exit_code = dispatch_test_run(path, options:, locked:)
937
- ensure
938
- @out = real_out
939
- @err = real_err
940
- end
941
-
942
- emit_machine_results(parse_test_output(buffer.string), @test_format, real_out)
943
- exit_code
944
- end
945
-
946
- def dispatch_test_run(path, options:, locked:)
947
- if File.directory?(path)
948
- run_test_directory(path, options:, locked:)
949
- elsif File.file?(path)
950
- if compile_fail_fixture?(File.read(path))
951
- run_compile_fail_test(path) ? 0 : 1
952
- else
953
- run_test_file(path, options:, locked:)
954
- end
955
- else
956
- @err.puts("mtc test: not a file or directory: #{path}")
957
- 1
958
- end
959
- end
960
-
961
- def extract_test_limit_flags!
962
- timeout_seconds = TEST_RUN_TIMEOUT_SECONDS
963
- memory_bytes = TEST_RUN_MEMORY_LIMIT_BYTES
964
- jobs = 1
965
- sanitize = false
966
- filter = nil
967
- format = :human
968
- remaining = []
969
- until @argv.empty?
970
- arg = @argv.shift
971
- case arg
972
- when "--timeout"
973
- value = @argv.shift
974
- seconds = value && Integer(value, exception: false)
975
- unless seconds&.positive?
976
- @err.puts("--timeout requires a positive integer (seconds)")
977
- return nil
978
- end
979
- timeout_seconds = seconds
980
- when "--mem"
981
- value = @argv.shift
982
- megabytes = value && Integer(value, exception: false)
983
- unless megabytes&.positive?
984
- @err.puts("--mem requires a positive integer (megabytes)")
985
- return nil
986
- end
987
- memory_bytes = megabytes * 1024 * 1024
988
- when "--jobs"
989
- value = @argv.shift
990
- count = value && Integer(value, exception: false)
991
- unless count&.positive?
992
- @err.puts("--jobs requires a positive integer")
993
- return nil
994
- end
995
- jobs = count
996
- when "--sanitize"
997
- sanitize = true
998
- when "-n", "--name"
999
- value = @argv.shift
1000
- unless value
1001
- @err.puts("-n requires a name substring")
1002
- return nil
1003
- end
1004
- filter = value
1005
- when "--format"
1006
- value = @argv.shift
1007
- unless %w[human tap junit].include?(value)
1008
- @err.puts("--format must be human, tap, or junit")
1009
- return nil
1010
- end
1011
- format = value.to_sym
1012
- when "--"
1013
- remaining << arg
1014
- remaining.concat(@argv)
1015
- @argv.clear
1016
- else
1017
- remaining << arg
1018
- end
1019
- end
1020
- @argv.replace(remaining)
1021
- [timeout_seconds, memory_bytes, jobs, sanitize, filter, format]
1022
- end
1023
-
1024
- TestResult = Data.define(:name, :status, :detail)
1025
-
1026
- def parse_test_output(text)
1027
- results = []
1028
- current_file = nil
1029
- text.each_line do |raw|
1030
- line = raw.chomp
1031
- case line
1032
- when /\A# (.+)\z/
1033
- current_file = ::Regexp.last_match(1)
1034
- when /\Aok - (.+)\z/
1035
- results << TestResult.new(name: ::Regexp.last_match(1), status: :pass, detail: nil)
1036
- when /\Askip - (.+?)(?:: (.*))?\z/
1037
- results << TestResult.new(name: ::Regexp.last_match(1), status: :skip, detail: ::Regexp.last_match(2))
1038
- when /\AFAIL - (.+?)(?:: (.*))?\z/
1039
- results << TestResult.new(name: ::Regexp.last_match(1), status: :fail, detail: ::Regexp.last_match(2))
1040
- when /\AFAILED - (.+?) \(build error\)\z/
1041
- results << TestResult.new(name: "#{::Regexp.last_match(1)} (build error)", status: :fail, detail: "build error")
1042
- when /\Atest run (?:timed out|crashed)/, /\ASUMMARY: \w+Sanitizer/
1043
- results << TestResult.new(name: "#{current_file || 'test'} (#{line})", status: :fail, detail: line)
1044
- end
1045
- end
1046
- results
1047
- end
1048
-
1049
- def emit_machine_results(results, format, out)
1050
- case format
1051
- when :tap then emit_tap(results, out)
1052
- when :junit then emit_junit(results, out)
1053
- end
1054
- end
1055
-
1056
- def emit_tap(results, out)
1057
- out.puts("TAP version 13")
1058
- out.puts("1..#{results.length}")
1059
- results.each_with_index do |result, index|
1060
- number = index + 1
1061
- case result.status
1062
- when :pass
1063
- out.puts("ok #{number} - #{result.name}")
1064
- when :skip
1065
- out.puts("ok #{number} - #{result.name} # SKIP#{result.detail ? " #{result.detail}" : ''}")
1066
- when :fail
1067
- out.puts("not ok #{number} - #{result.name}")
1068
- next unless result.detail
1069
-
1070
- out.puts(" ---")
1071
- out.puts(" message: #{result.detail}")
1072
- out.puts(" ...")
1073
- end
1074
- end
1075
- end
1076
-
1077
- def emit_junit(results, out)
1078
- failures = results.count { |result| result.status == :fail }
1079
- skipped = results.count { |result| result.status == :skip }
1080
- out.puts(%(<?xml version="1.0" encoding="UTF-8"?>))
1081
- out.puts(%(<testsuites tests="#{results.length}" failures="#{failures}" skipped="#{skipped}">))
1082
- out.puts(%( <testsuite name="mtc test" tests="#{results.length}" failures="#{failures}" skipped="#{skipped}">))
1083
- results.each do |result|
1084
- name = xml_escape(result.name)
1085
- case result.status
1086
- when :pass
1087
- out.puts(%( <testcase name="#{name}"/>))
1088
- when :skip
1089
- out.puts(%( <testcase name="#{name}"><skipped/></testcase>))
1090
- when :fail
1091
- out.puts(%( <testcase name="#{name}"><failure message="#{xml_escape(result.detail || 'failed')}"/></testcase>))
1092
- end
1093
- end
1094
- out.puts(" </testsuite>")
1095
- out.puts("</testsuites>")
1096
- end
1097
-
1098
- def xml_escape(value)
1099
- value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub('"', "&quot;")
1100
- end
1101
-
1102
- def run_test_directory(directory, options:, locked:)
1103
- test_files = discover_test_files(directory)
1104
- if test_files.empty?
1105
- if @test_filter
1106
- @out.puts("no tests matched -n '#{@test_filter}' under #{directory}")
1107
- else
1108
- @out.puts("no @[test] functions or # expect-error: fixtures found under #{directory}")
1109
- end
1110
- return 0
1111
- end
1112
-
1113
- jobs = @test_jobs || 1
1114
- return run_test_files_parallel(test_files, jobs:, options:, locked:) if jobs > 1 && Process.respond_to?(:fork)
1115
-
1116
- failed = 0
1117
- test_files.each do |file, kind|
1118
- @out.puts("# #{file}")
1119
- @out.flush if @out.respond_to?(:flush)
1120
- failed += 1 unless run_classified_file(file, kind, options:, locked:).zero?
1121
- end
1122
-
1123
- @out.puts("")
1124
- @out.puts("#{test_files.length} test file(s), #{failed} failed")
1125
- failed.zero? ? 0 : 1
1126
- end
1127
-
1128
- def run_classified_file(file, kind, options:, locked:)
1129
- if kind == :compile_fail
1130
- run_compile_fail_test(file) ? 0 : 1
1131
- else
1132
- run_test_file_guarded(file, options:, locked:)
1133
- end
1134
- end
1135
-
1136
- def run_compile_fail_test(path)
1137
- source = File.read(path)
1138
- expectations = extract_expect_error_directives(source)
1139
- messages = compile_fail_diagnostics(path)
1140
-
1141
- if messages.empty?
1142
- @out.puts("FAIL - #{path} (compile-fail): expected a compile error, but it compiled cleanly")
1143
- @out.flush if @out.respond_to?(:flush)
1144
- return false
1145
- end
1146
-
1147
- unmatched = expectations.find { |expected| messages.none? { |message| message.include?(expected) } }
1148
- if unmatched
1149
- @out.puts("FAIL - #{path} (compile-fail): no diagnostic matched #{unmatched.inspect}")
1150
- @out.flush if @out.respond_to?(:flush)
1151
- return false
1152
- end
1153
-
1154
- @out.puts("ok - #{path} (compile-fail)")
1155
- @out.flush if @out.respond_to?(:flush)
1156
- true
1157
- end
1158
-
1159
- def extract_expect_error_directives(source)
1160
- source.each_line.filter_map do |line|
1161
- match = line.match(/^\s*#\s*expect-error:\s*(.+?)\s*$/)
1162
- match && match[1]
1163
- end
1164
- end
1165
-
1166
- def compile_fail_diagnostics(path)
1167
- errors, = check_single_reporting_all(path, locked: false)
1168
- errors
1169
- .select { |diagnostic| !diagnostic.respond_to?(:severity) || diagnostic.severity == :error }
1170
- .map { |diagnostic| ErrorFormatter.format(diagnostic, color: false) }
1171
- rescue StandardError => e
1172
- raise unless handled_cli_error?(e)
1173
-
1174
- [ErrorFormatter.format(e, color: false)]
1175
- end
1176
-
1177
- def run_test_file_guarded(file, options:, locked:)
1178
- run_test_file(file, options:, locked:)
1179
- rescue StandardError => e
1180
- raise unless handled_cli_error?(e)
1181
-
1182
- @err.puts("FAILED - #{file} (build error)")
1183
- @err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
1184
- 1
1185
- end
1186
-
1187
- def run_test_files_parallel(test_files, jobs:, options:, locked:)
1188
- results = Array.new(test_files.length)
1189
- result_paths = {}
1190
- active = {}
1191
- cursor = 0
1192
-
1193
- while cursor < test_files.length || !active.empty?
1194
- while active.size < jobs && cursor < test_files.length
1195
- index = cursor
1196
- cursor += 1
1197
- result_path = File.join(Dir.tmpdir, "mttest_result_#{Process.pid}_#{index}")
1198
- result_paths[index] = result_path
1199
- pid = fork do
1200
- captured = StringIO.new
1201
- @out = captured
1202
- @err = captured
1203
- file, kind = test_files[index]
1204
- code = run_classified_file(file, kind, options:, locked:)
1205
- File.binwrite(result_path, [code].pack("N") + captured.string)
1206
- exit!(0)
1207
- end
1208
- active[pid] = index
1209
- end
1210
-
1211
- finished_pid, = Process.wait2
1212
- index = active.delete(finished_pid)
1213
- next unless index
1214
-
1215
- path = result_paths[index]
1216
- data = begin
1217
- File.binread(path)
1218
- rescue StandardError
1219
- (+"").b
1220
- end
1221
- File.delete(path) if File.exist?(path)
1222
- results[index] =
1223
- if data.bytesize >= 4
1224
- [data[0, 4].unpack1("N"), data.byteslice(4..).force_encoding(Encoding::UTF_8)]
1225
- else
1226
- [1, +""]
1227
- end
1228
- end
1229
-
1230
- failed = 0
1231
- test_files.each_with_index do |(file, _kind), index|
1232
- code, output = results[index]
1233
- @out.puts("# #{file}")
1234
- @out.write(output.to_s)
1235
- failed += 1 unless code&.zero?
1236
- end
1237
- @out.flush if @out.respond_to?(:flush)
1238
- @out.puts("")
1239
- @out.puts("#{test_files.length} test file(s), #{failed} failed")
1240
- failed.zero? ? 0 : 1
1241
- end
1242
-
1243
- def discover_test_files(directory)
1244
- Dir.glob(File.join(directory, "**", "*.mt")).sort.filter_map do |file|
1245
- next if File.basename(file).start_with?("__mt_test_runner_")
1246
-
1247
- kind = classify_test_file(file)
1248
- kind && [file, kind]
1249
- end
1250
- end
1251
-
1252
- def classify_test_file(file)
1253
- source = File.read(file)
1254
- if compile_fail_fixture?(source)
1255
- return nil if @test_filter && !File.basename(file, ".mt").include?(@test_filter)
1256
-
1257
- return :compile_fail
1258
- end
1259
-
1260
- ast = begin
1261
- MilkTea::Parser.parse(source, path: file)
1262
- rescue ParseError
1263
- return nil
1264
- end
1265
- has_match = ast.declarations.any? do |decl|
1266
- decl.is_a?(AST::FunctionDef) && test_attribute?(decl) && matches_filter?(decl.name)
1267
- end
1268
- has_match ? :test : nil
1269
- end
1270
-
1271
- def compile_fail_fixture?(source)
1272
- source.match?(/^\s*#\s*expect-error:/)
1273
- end
1274
-
1275
- def test_attribute?(decl)
1276
- decl.attributes.any? { |attribute| attribute.name.parts == ["test"] }
1277
- end
1278
-
1279
- def matches_filter?(name)
1280
- @test_filter.nil? || name.include?(@test_filter)
1281
- end
1282
-
1283
- def run_test_file(path, options:, locked:)
1284
- source = File.read(path)
1285
- ast = MilkTea::Parser.parse(source, path:)
1286
-
1287
- if ast.declarations.any? { |decl| decl.is_a?(AST::FunctionDef) && decl.name == "main" }
1288
- @err.puts("a test file must not define 'main': #{path}")
1289
- return 1
1290
- end
1291
-
1292
- tests = ast.declarations.select { |decl| decl.is_a?(AST::FunctionDef) && test_attribute?(decl) }
1293
-
1294
- if tests.empty?
1295
- @out.puts("no @[test] functions found in #{path}")
1296
- return 0
1297
- end
1298
-
1299
- tests = tests.select { |test| matches_filter?(test.name) }
1300
- return 0 if tests.empty?
1301
-
1302
- invalid = tests.find { |test| !test.params.empty? }
1303
- if invalid
1304
- @err.puts("@[test] function '#{invalid.name}' must take no parameters")
1305
- return 1
1306
- end
1307
-
1308
- testing_import = ast.imports.find { |import| import.path.parts == %w[std testing] }
1309
- unless testing_import
1310
- @err.puts("a test file must import std.testing: #{path}")
1311
- return 1
1312
- end
1313
- testing_alias = testing_import.alias_name || testing_import.path.parts.last
1314
-
1315
- death_tests, normal_tests = tests.partition { |test| expect_fatal_attribute?(test) }
1316
-
1317
- exit_code = 0
1318
-
1319
- unless normal_tests.empty?
1320
- runner_source = source.dup
1321
- runner_source << "\n\n" << test_runner_main(testing_alias, normal_tests.map(&:name))
1322
- exit_code = run_synthesized_tests(path, runner_source, options:, locked:)
1323
- end
1324
-
1325
- death_tests.each do |death_test|
1326
- exit_code = 1 unless run_death_test(path, source, death_test.name, options:, locked:)
1327
- end
1328
-
1329
- exit_code
1330
- end
1331
-
1332
- def expect_fatal_attribute?(decl)
1333
- decl.attributes.any? { |attribute| attribute.name.parts == ["expect_fatal"] }
1334
- end
1335
-
1336
- def run_death_test(source_path, source, test_name, options:, locked:)
1337
- runner_source = source.dup
1338
- runner_source << "\n\n" << death_test_runner_main(test_name)
1339
- classification = with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
1340
- classify_death_test(binary_path)
1341
- end
1342
-
1343
- passed = classification == :aborted
1344
- line =
1345
- if passed
1346
- "ok - #{test_name} (expect_fatal)"
1347
- elsif classification == :timed_out
1348
- "FAIL - #{test_name} (expect_fatal): timed out"
1349
- else
1350
- "FAIL - #{test_name} (expect_fatal): expected a fatal abort, but the test returned"
1351
- end
1352
- @out.puts(line)
1353
- @out.flush if @out.respond_to?(:flush)
1354
- passed
1355
- end
1356
-
1357
- def classify_death_test(binary_path)
1358
- _output, status, timed_out = spawn_sandboxed(binary_path)
1359
- return :timed_out if timed_out
1360
- return :returned if status&.exited? && status.exitstatus&.zero?
1361
-
1362
- :aborted
1363
- end
1364
-
1365
- def death_test_runner_main(test_name)
1366
- [
1367
- "function main() -> int:",
1368
- " match #{test_name}():",
1369
- " Result.success:",
1370
- " return 0",
1371
- " Result.failure:",
1372
- " return 0",
1373
- ].join("\n") + "\n"
1374
- end
1375
-
1376
- def test_runner_main(testing_alias, test_names)
1377
- lines = ["function main() -> int:"]
1378
- lines << " var __mt_test_stats = #{testing_alias}.Stats.create()"
1379
- test_names.each do |name|
1380
- lines << " __mt_test_stats = #{testing_alias}.record(__mt_test_stats, #{name.inspect}, #{name}())"
1381
- end
1382
- lines << " return #{testing_alias}.summarize(__mt_test_stats)"
1383
- lines.join("\n") + "\n"
1384
- end
1385
-
1386
- def run_synthesized_tests(source_path, runner_source, options:, locked:)
1387
- with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
1388
- run_test_binary(binary_path)
1389
- end
1390
- end
1391
-
1392
- def with_synthesized_binary(source_path, runner_source, options:, locked:)
1393
- directory = File.dirname(File.expand_path(source_path))
1394
- runner_path = File.join(directory, "__mt_test_runner_#{Process.pid}.mt")
1395
- binary_path = File.join(Dir.tmpdir, "__mt_test_runner_#{Process.pid}")
1396
-
1397
- File.write(runner_path, runner_source)
1398
- begin
1399
- build_opts = options.except(:timings, :output_path, :bundle, :archive)
1400
- build_opts[:debug_guards] = false
1401
- Build.build(
1402
- runner_path,
1403
- output_path: binary_path,
1404
- module_roots: module_roots_for(source_path, locked:),
1405
- package_graph: package_graph_for(source_path, locked:),
1406
- frontend: @build_frontend,
1407
- sanitize: @test_sanitize,
1408
- **build_opts,
1409
- )
1410
- yield binary_path
1411
- ensure
1412
- File.delete(runner_path) if File.exist?(runner_path)
1413
- File.delete(binary_path) if File.exist?(binary_path)
1414
- end
1415
- end
1416
-
1417
- TEST_RUN_TIMEOUT_SECONDS = 30
1418
- TEST_RUN_MEMORY_LIMIT_BYTES = 1024 * 1024 * 1024
1419
-
1420
- def run_test_binary(binary_path)
1421
- output, status, timed_out = spawn_sandboxed(binary_path)
1422
- @out.write(output)
1423
- @out.flush if @out.respond_to?(:flush)
1424
-
1425
- if timed_out
1426
- @err.puts("test run timed out after #{@test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS}s")
1427
- return 1
1428
- end
1429
- if status&.signaled?
1430
- @err.puts("test run crashed (signal #{status.termsig})")
1431
- return 1
1432
- end
1433
-
1434
- status&.exitstatus || 1
1435
- end
1436
-
1437
- def spawn_sandboxed(binary_path)
1438
- timeout_seconds = @test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS
1439
- memory_bytes = @test_memory_bytes || TEST_RUN_MEMORY_LIMIT_BYTES
1440
- reader, writer = IO.pipe
1441
- spawn_options = { out: writer, err: writer, pgroup: true }
1442
- spawn_options[:rlimit_as] = memory_bytes unless @test_sanitize
1443
- pid = Process.spawn(binary_path, **spawn_options)
1444
- writer.close
1445
-
1446
- status = nil
1447
- timed_out = false
1448
- begin
1449
- Timeout.timeout(timeout_seconds) { _, status = Process.wait2(pid) }
1450
- rescue Timeout::Error
1451
- timed_out = true
1452
- begin
1453
- Process.kill("-KILL", Process.getpgid(pid))
1454
- Process.wait(pid)
1455
- rescue StandardError
1456
- nil
1457
- end
1458
- end
1459
-
1460
- output = reader.read
1461
- reader.close
1462
- [output, status, timed_out]
1463
- end
1464
-
1465
- def run_command
1466
- path, options = extract_path_and_options
1467
- return 1 unless path
1468
-
1469
- run_and_print_result(path, options)
1470
- end
1471
-
1472
- def app_command
1473
- options = parse_build_options
1474
- return 1 unless options
1475
-
1476
- module_name = @argv.shift
1477
- unless module_name
1478
- @err.puts("missing module name")
1479
- print_usage(@err)
1480
- return 1
1481
- end
1482
-
1483
- path = resolve_app_module(module_name)
1484
- unless path
1485
- @err.puts("run-module module not found: #{module_name}")
1486
- return 1
1487
- end
1488
-
1489
- frozen = options.delete(:frozen)
1490
- ensure_current_lockfile!(path) if frozen
1491
-
1492
- run_and_print_result(path, options)
1493
- end
1494
-
1495
- def extract_path_and_options(allow_clean: false)
1496
- options = parse_build_options(allow_clean:)
1497
- return nil unless options
1498
-
1499
- path = @argv.shift
1500
- unless path
1501
- if File.file?(File.join(Dir.pwd, "package.toml"))
1502
- path = Dir.pwd
1503
- else
1504
- @err.puts("missing source file path")
1505
- print_usage(@err)
1506
- return nil
1507
- end
1508
- end
1509
-
1510
- [path, options]
1511
- end
1512
-
1513
- def run_and_print_result(path, options)
1514
- frozen = options.delete(:frozen)
1515
- ensure_current_lockfile!(path) if frozen
1516
- locked = options.delete(:locked)
1517
- package_graph = package_graph_for(path, locked:)
1518
- preview_notice_emitted = false
1519
- preview_started = lambda do |message|
1520
- preview_notice_emitted = true
1521
- @out.write(message)
1522
- @out.flush if @out.respond_to?(:flush)
1523
- end
1524
-
1525
- result = Run.run(
1526
- path,
1527
- module_roots: module_roots_for(path, locked:),
1528
- package_graph:,
1529
- frontend: @build_frontend,
1530
- preview_started:,
1531
- argv: @argv.dup,
1532
- **options.except(:timings)
1533
- )
1534
- unless @out.equal?($stdout) || preview_notice_emitted
1535
- @out.write(result.stdout)
1536
- end
1537
- @out.flush if @out.respond_to?(:flush)
1538
- @err.write(result.stderr) unless @err.equal?($stderr)
1539
- info("[cached]") if result.cached
1540
- result.exit_status
1541
- end
1542
-
1543
- def resolve_app_module(name)
1544
- relative = name.tr(".", "/").sub(%r{^/}, "") + ".mt"
1545
-
1546
- module_roots_for(Dir.pwd).each do |root|
1547
- candidate = File.join(root, "std", relative)
1548
- return File.expand_path(candidate) if File.file?(candidate)
1549
-
1550
- candidate = File.join(root, relative)
1551
- return File.expand_path(candidate) if File.file?(candidate)
1552
- end
1553
-
1554
- nil
1555
- end
149
+ require_relative "cli/commands/command_base"
150
+ require_relative "cli/commands/lex"
151
+ require_relative "cli/commands/parse"
152
+ require_relative "cli/commands/format"
153
+ require_relative "cli/commands/lint"
154
+ require_relative "cli/commands/check"
155
+ require_relative "cli/commands/lower"
156
+ require_relative "cli/commands/emit_c"
157
+ require_relative "cli/commands/build"
158
+ require_relative "cli/commands/test"
159
+ require_relative "cli/commands/run"
160
+ require_relative "cli/commands/new"
161
+ require_relative "cli/commands/debug"
162
+ require_relative "cli/commands/toolchain"
163
+ require_relative "cli/commands/deps"
164
+ require_relative "cli/commands/bindgen"
165
+ require_relative "cli/commands/cache"
166
+ require_relative "cli/commands/docs"
167
+ require_relative "cli/commands/snapshot"
168
+ require_relative "cli/commands/lsp"
169
+ require_relative "cli/commands/dap"
170
+ require_relative "cli/commands/completions"
171
+
172
+ include CommandBase
173
+ include CommandLex
174
+ include CommandParse
175
+ include CommandFormat
176
+ include CommandLint
177
+ include CommandCheck
178
+ include CommandLower
179
+ include CommandEmitC
180
+ include CommandBuild
181
+ include CommandTest
182
+ include CommandRun
183
+ include CommandNew
184
+ include CommandDebug
185
+ include CommandToolchain
186
+ include CommandDeps
187
+ include CommandBindgen
188
+ include CommandCache
189
+ include CommandDocs
190
+ include CommandSnapshot
191
+ include CommandLsp
192
+ include CommandDap
193
+ include CommandCompletions
1556
194
 
1557
- def new_command
1558
- name = @argv.shift
1559
- unless name
1560
- @err.puts("missing project name")
1561
- print_usage(@err)
1562
- return 1
1563
- end
1564
-
1565
- if @argv.any?
1566
- @err.puts("unknown new option #{@argv.first}")
1567
- print_usage(@err)
1568
- return 1
1569
- end
1570
-
1571
- result = ProjectScaffold.create(name)
1572
- info("created #{result.root_path}")
1573
- 0
1574
- end
1575
-
1576
- def deps_command
1577
- PackageManagerCLI.start(
1578
- @argv,
1579
- out: @out,
1580
- err: @err,
1581
- help_printer: method(:print_deps_help),
1582
- services: package_services,
1583
- )
1584
- end
1585
-
1586
- def docs_command
1587
- port = nil
1588
- open_flag = false
1589
-
1590
- while (arg = @argv.first)
1591
- case arg
1592
- when "--port", "-p"
1593
- @argv.shift
1594
- port = @argv.shift.to_i
1595
- port = nil if port <= 0 || port > 65535
1596
- when "--open", "-o"
1597
- open_flag = true
1598
- @argv.shift
1599
- else
1600
- break
1601
- end
1602
- end
1603
-
1604
- port = resolve_docs_port(port)
1605
-
1606
- DocsApp.set :port, port
1607
- DocsApp.set :bind, "127.0.0.1"
1608
- DocsApp.set :environment, :production
1609
- DocsApp.set :server, :puma
1610
-
1611
- url = "http://127.0.0.1:#{port}/"
1612
-
1613
- @out.puts("Serving Milk Tea docs at #{url}")
1614
- @out.puts("Press Ctrl+C to stop.")
1615
-
1616
- if open_flag
1617
- open_browser(url)
1618
- end
195
+ private
1619
196
 
1620
- DocsApp.run!
1621
- 0
1622
- rescue Interrupt
1623
- 0
197
+ def version_request?(command)
198
+ %w[version --version -V].include?(command)
1624
199
  end
1625
200
 
1626
- def resolve_docs_port(preferred)
1627
- return preferred if preferred
1628
-
1629
- server = TCPServer.new("127.0.0.1", 0)
1630
- port = server.addr[1]
1631
- server.close
1632
- port
1633
- rescue StandardError
1634
- 4567
201
+ def help_request?(command)
202
+ command.nil? || %w[help --help -h].include?(command)
1635
203
  end
1636
204
 
1637
- def snapshot_command
1638
- input_path = nil
1639
- theme_path = nil
1640
- output_path = nil
1641
- textmate_only = false
1642
-
1643
- until @argv.empty?
1644
- arg = @argv.first
1645
- case arg
1646
- when "--theme", "-t"
1647
- @argv.shift
1648
- theme_path = @argv.shift
1649
- unless theme_path
1650
- @err.puts("snapshot: missing value for --theme")
1651
- return 1
1652
- end
1653
- when "--output", "-o"
1654
- @argv.shift
1655
- output_path = @argv.shift
1656
- unless output_path
1657
- @err.puts("snapshot: missing value for --output")
1658
- return 1
1659
- end
1660
- when "--textmate-only"
1661
- @argv.shift
1662
- textmate_only = true
1663
- else
1664
- if arg.start_with?("-")
1665
- @err.puts("snapshot: unknown option #{arg}")
1666
- return 1
1667
- end
1668
- unless input_path
1669
- input_path = @argv.shift
1670
- else
1671
- @err.puts("snapshot: unexpected argument #{@argv.shift}")
1672
- return 1
1673
- end
1674
- end
1675
- end
1676
-
1677
- unless input_path
1678
- @err.puts("snapshot: missing source file path")
1679
- print_usage(@err)
1680
- return 1
1681
- end
1682
-
1683
- unless File.file?(input_path)
1684
- @err.puts("snapshot: source file not found: #{input_path}")
1685
- return 1
1686
- end
1687
-
1688
- input_path = File.expand_path(input_path)
1689
- theme_path = File.expand_path(theme_path) if theme_path
1690
- output_path = File.expand_path(output_path) if output_path
1691
-
1692
- if theme_path && !File.file?(theme_path)
1693
- @err.puts("snapshot: theme file not found: #{theme_path}")
1694
- return 1
1695
- end
1696
-
1697
- snapshot_script = MilkTea.root.join("bindings/vscode/scripts/snapshot.js").to_s
1698
- unless File.file?(snapshot_script)
1699
- @err.puts("snapshot: internal script not found at #{snapshot_script}")
1700
- return 1
1701
- end
1702
-
1703
- args = ["node", snapshot_script, input_path]
1704
- args.push("-t", theme_path) if theme_path
1705
- args.push("-o", output_path) if output_path
1706
-
1707
- semantic_result = nil
1708
- unless textmate_only
1709
- begin
1710
- semantic_result = MilkTea::LSP::Server.semantic_tokens_for_path(input_path)
1711
- rescue => e
1712
- @err.puts("snapshot: semantic analysis skipped: #{e.message}")
1713
- end
1714
- end
1715
-
1716
- if semantic_result && semantic_result[:entries] && !semantic_result[:entries].empty?
1717
- source = File.read(input_path)
1718
- lines = source.split("\n", -1)
1719
- semantic_result[:entries].each do |entry|
1720
- byte_start = entry[:startChar]
1721
- byte_len = entry[:length]
1722
- line_text = lines[entry[:line]]
1723
- unless line_text && byte_start && byte_len
1724
- entry[:startChar] = 0
1725
- entry[:length] = 0
1726
- next
1727
- end
1728
- char_start = line_text.byteslice(0, byte_start).length
1729
- char_length = line_text.byteslice(byte_start, byte_len).length
1730
- char_length = line_text.length - char_start if char_start + char_length > line_text.length
1731
- entry[:startChar] = char_start
1732
- entry[:length] = char_length
205
+ # Pulls global options that may appear before the first `--` separator out of
206
+ # @argv so each subcommand parser sees a clean argument list. Stops at `--` so
207
+ # arguments forwarded to a run target (e.g. `mtc run app -- --verbose`) are
208
+ # preserved verbatim.
209
+ def extract_global_options!
210
+ remaining = []
211
+ forwarding = false
212
+ i = 0
213
+ while i < @argv.length
214
+ arg = @argv[i]
215
+ if forwarding
216
+ remaining << arg
217
+ i += 1
218
+ next
1733
219
  end
1734
220
 
1735
- temp = Tempfile.new(["mt_semantic", ".json"])
1736
- temp.write(JSON.generate(semantic_result[:entries]))
1737
- temp.close
1738
- args.push("-s", temp.path)
1739
- system(*args)
1740
- temp.unlink
1741
- else
1742
- system(*args)
1743
- end
1744
- $?.success? ? 0 : 1
1745
- end
1746
-
1747
- def lsp_command
1748
- log_level = nil
1749
-
1750
- until @argv.empty?
1751
- arg = @argv.shift
1752
221
  case arg
1753
- when "--log-level"
1754
- log_level = @argv.shift&.downcase
1755
- unless log_level && %w[trace debug info warn error].include?(log_level)
1756
- @err.puts("lsp: invalid --log-level #{log_level.inspect} (expected trace, debug, info, warn, or error)")
1757
- return 1
1758
- end
1759
- when /\A--log-level=(.+)\z/
1760
- log_level = ::Regexp.last_match(1).downcase
1761
- unless %w[trace debug info warn error].include?(log_level)
1762
- @err.puts("lsp: invalid --log-level #{log_level.inspect} (expected trace, debug, info, warn, or error)")
1763
- return 1
1764
- end
1765
- when "--stdio"
1766
- # stdio is the only transport; accept the flag as a no-op
1767
- else
1768
- if arg.start_with?("-")
1769
- @err.puts("lsp: unknown option #{arg}")
1770
- return 1
1771
- end
1772
- @err.puts("lsp: unexpected argument #{arg}")
1773
- return 1
1774
- end
1775
- end
1776
-
1777
- require "milk_tea/lsp/server" unless defined?(MilkTea::LSP::Server)
1778
- server = MilkTea::LSP::Server.new
1779
- server.run
1780
- 0
1781
- end
222
+ when "--"
223
+ forwarding = true
224
+ remaining << arg
225
+ i += 1
226
+ when "-v", "--verbose"
227
+ @verbose = true
228
+ i += 1
229
+ when "-q", "--quiet"
230
+ @quiet = true
231
+ i += 1
232
+ when "--color"
233
+ value = @argv[i + 1]
234
+ return invalid_color(value) unless valid_color?(value)
1782
235
 
1783
- def dap_command
1784
- preferred_backend_kind = "process"
1785
- adapter_command = nil
236
+ @color = value.to_sym
237
+ i += 2
238
+ when /\A--color=(.*)\z/
239
+ value = ::Regexp.last_match(1)
240
+ return invalid_color(value) unless valid_color?(value)
1786
241
 
1787
- until @argv.empty?
1788
- arg = @argv.shift
1789
- case arg
1790
- when "--log-level"
1791
- @argv.shift
1792
- when /\A--log-level=(.+)\z/
1793
- # accept and ignore
1794
- when "--backend"
1795
- preferred_backend_kind = @argv.shift&.downcase
1796
- when /\A--backend=(.+)\z/
1797
- preferred_backend_kind = ::Regexp.last_match(1).downcase
1798
- when "--adapter-path"
1799
- adapter_path = @argv.shift
1800
- adapter_command = resolve_adapter_path(adapter_path)
1801
- return 1 unless adapter_command
1802
- when /\A--adapter-path=(.+)\z/
1803
- adapter_path = ::Regexp.last_match(1)
1804
- adapter_command = resolve_adapter_path(adapter_path)
1805
- return 1 unless adapter_command
242
+ @color = value.to_sym
243
+ i += 1
1806
244
  else
1807
- if arg.start_with?("-")
1808
- @err.puts("dap: unknown option #{arg}")
1809
- return 1
1810
- end
1811
- @err.puts("dap: unexpected argument #{arg}")
1812
- return 1
1813
- end
1814
- end
1815
-
1816
- require "milk_tea/dap/server" unless defined?(MilkTea::DAP::Server)
1817
- server = MilkTea::DAP::Server.new(
1818
- preferred_backend_kind:,
1819
- adapter_command:,
1820
- )
1821
- server.run
1822
- 0
1823
- end
1824
-
1825
- def resolve_adapter_path(adapter_path)
1826
- unless adapter_path && File.file?(adapter_path)
1827
- @err.puts("dap: adapter path not found: #{adapter_path}")
1828
- return nil
1829
- end
1830
- expanded = File.expand_path(adapter_path)
1831
- adapter_path.end_with?(".rb") ? [RbConfig.ruby, expanded] : [expanded]
1832
- end
1833
-
1834
- def toolchain_command
1835
- ToolchainCLI.start(
1836
- @argv,
1837
- out: @out,
1838
- err: @err,
1839
- help_printer: method(:print_toolchain_help),
1840
- )
1841
- end
1842
-
1843
- def debug_command
1844
- unless @argv.any?
1845
- @err.puts("missing source file path")
1846
- print_usage(@err)
1847
- return 1
1848
- end
1849
-
1850
- resolution = extract_resolution_flags!
1851
- input_paths = @argv.dup
1852
- return 1 unless ensure_known_source_operands!("debug", input_paths)
1853
-
1854
- path = expand_source_paths(input_paths).first
1855
- unless path
1856
- @err.puts("no .mt files found in #{input_paths.join(', ')}")
1857
- return 1
1858
- end
1859
-
1860
- ensure_current_lockfile!(path) if resolution[:frozen]
1861
-
1862
- source = read_source_file(path)
1863
- resolved_path = File.expand_path(path)
1864
-
1865
- tokens = MilkTea::Lexer.lex(source, path: resolved_path)
1866
-
1867
- parse_result = MilkTea::Parser.parse_collecting_errors(source, path: resolved_path)
1868
- ast = parse_result.ast
1869
- parse_errors = parse_result.errors.dup
1870
-
1871
- facts = nil
1872
- snapshot = nil
1873
- loader_ast = ast
1874
-
1875
- if ast && parse_errors.empty?
1876
- begin
1877
- loader = make_module_loader(path, locked: resolution[:locked], platform: ModuleLoader.default_host_platform)
1878
- loader_ast = loader.load_file(resolved_path)
1879
-
1880
- import_result = loader.send(:imported_modules_for_ast_collecting_errors, loader_ast, importer_path: resolved_path)
1881
- import_errors = import_result.respond_to?(:errors) ? import_result.errors : []
1882
- parse_errors.concat(import_errors) unless import_errors.empty?
1883
-
1884
- snapshot = MilkTea::SemanticAnalyzer.tooling_snapshot(
1885
- loader_ast,
1886
- imported_modules: import_result.modules,
1887
- allow_missing_imports: true,
1888
- path: resolved_path,
1889
- )
1890
- facts = snapshot&.facts
1891
- rescue MilkTea::LexError, MilkTea::ParseError, ModuleLoadError, SemanticError => e
1892
- parse_errors << e
245
+ remaining << arg
246
+ i += 1
1893
247
  end
1894
248
  end
1895
-
1896
- text = DebugInfoFormatter.format_all(
1897
- content: source,
1898
- tokens: tokens,
1899
- ast: loader_ast,
1900
- parse_errors: parse_errors,
1901
- facts: facts,
1902
- snapshot: snapshot,
1903
- path: resolved_path,
1904
- )
1905
-
1906
- @out.puts(text)
1907
- 0
1908
- rescue MilkTea::LexError => e
1909
- @err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
1910
- 1
249
+ @argv = remaining
250
+ true
1911
251
  end
1912
252
 
1913
- def bindgen_command
1914
- BindgenCLI.start(@argv, out: @out, err: @err, help_printer: method(:print_bindgen_help))
253
+ def valid_color?(value)
254
+ %w[auto always never].include?(value)
1915
255
  end
1916
256
 
1917
- def cache_command
1918
- subcommand = @argv.shift
1919
- unless subcommand
1920
- @err.puts("missing cache subcommand")
1921
- print_command_help("cache", @err)
1922
- return 1
1923
- end
1924
-
1925
- cache_root = MilkTea.data_root.join("tmp", "mtc-cache")
1926
-
1927
- case subcommand
1928
- when "purge"
1929
- if File.directory?(cache_root)
1930
- FileUtils.rm_rf(cache_root)
1931
- @out.puts("purged #{cache_root}")
1932
- else
1933
- @out.puts("cache is already empty")
1934
- end
1935
- 0
1936
- when "status"
1937
- unless File.directory?(cache_root)
1938
- @out.puts("cache directory does not exist: #{cache_root}")
1939
- return 0
1940
- end
1941
- program_dirs = Dir.glob(File.join(cache_root, "programs", "*", "*")).select { |d| File.directory?(d) }
1942
- binary_files = Dir.glob(File.join(cache_root, "binaries", "*", "*", "binary")).select { |f| File.file?(f) }
1943
- total_size = (program_dirs + binary_files).sum { |p|
1944
- File.file?(p) ? File.size(p) : Dir.glob(File.join(p, "**", "*")).sum { |f| File.file?(f) ? File.size(f) : 0 }
1945
- }
1946
- @out.puts("cache #{program_dirs.length} programs, #{binary_files.length} binaries (#{format_size(total_size)})")
1947
- @out.puts(" root #{cache_root}")
1948
- 0
1949
- else
1950
- @err.puts("unknown cache subcommand #{subcommand}")
1951
- print_command_help("cache", @err)
1952
- 1
1953
- end
257
+ def invalid_color(value)
258
+ @err.puts("--color must be auto, always, or never#{value ? " (got #{value})" : ''}")
259
+ false
1954
260
  end
1955
261
 
1956
- def completions_command
1957
- shell = @argv.shift
1958
- unless %w[bash zsh fish].include?(shell)
1959
- @err.puts("completions: shell must be bash, zsh, or fish")
1960
- print_command_help("completions", @err)
1961
- return 1
262
+ def error_color?(io)
263
+ case @color
264
+ when :always then true
265
+ when :never then false
266
+ else io.respond_to?(:tty?) && io.tty?
1962
267
  end
1963
-
1964
- @out.puts(completion_script(shell))
1965
- 0
1966
268
  end
1967
269
 
1968
- def completion_script(shell)
1969
- names = COMMANDS.map(&:first)
1970
- case shell
1971
- when "bash"
1972
- [
1973
- "# mtc bash completion. Source this file or install it into your bash",
1974
- "# completion directory (e.g. /etc/bash_completion.d/mtc).",
1975
- "_mtc() {",
1976
- %( local cur="${COMP_WORDS[COMP_CWORD]}"),
1977
- %( if [ "${COMP_CWORD}" -eq 1 ]; then),
1978
- %( COMPREPLY=( $(compgen -W "#{(names + %w[help version]).join(' ')}" -- "${cur}") )),
1979
- " fi",
1980
- "}",
1981
- "complete -F _mtc mtc",
1982
- ].join("\n")
1983
- when "zsh"
1984
- lines = ["#compdef mtc", "# mtc zsh completion. Install onto your $fpath as _mtc.", "_mtc() {", " local -a commands", " commands=("]
1985
- COMMANDS.each { |name, summary| lines << " '#{name}:#{summary}'" }
1986
- lines.concat([" )", " if (( CURRENT == 2 )); then", " _describe 'mtc command' commands", " fi", "}", %(_mtc "$@")])
1987
- lines.join("\n")
1988
- when "fish"
1989
- lines = ["# mtc fish completion. Install into ~/.config/fish/completions/mtc.fish."]
1990
- COMMANDS.each do |name, summary|
1991
- lines << "complete -c mtc -f -n '__fish_use_subcommand' -a #{name} -d '#{summary}'"
1992
- end
1993
- lines.join("\n")
1994
- end
270
+ # Prints an informational/progress line unless --quiet was given.
271
+ def info(message)
272
+ @out.puts(message) unless @quiet
1995
273
  end
1996
274
 
1997
275
  def parse_build_options(allow_clean: false)
@@ -2097,68 +375,6 @@ module MilkTea
2097
375
  options
2098
376
  end
2099
377
 
2100
- def parse_format_options
2101
- options = {
2102
- check: false,
2103
- write: false,
2104
- mode: :safe,
2105
- max_line_length: nil,
2106
- profile: false,
2107
- }
2108
- input_paths = []
2109
-
2110
- until @argv.empty?
2111
- option = @argv.shift
2112
- if option.start_with?("-")
2113
- case option
2114
- when "--check"
2115
- options[:check] = true
2116
- when "--write", "-w"
2117
- options[:write] = true
2118
- when "--preserve"
2119
- options[:mode] = :preserve
2120
- when "--canonical"
2121
- options[:mode] = :canonical
2122
- when "--safe"
2123
- options[:mode] = :safe
2124
- when "--tidy"
2125
- options[:mode] = :tidy
2126
- when "--max-line-length"
2127
- value = @argv.shift
2128
- return missing_option_value(option) unless value
2129
-
2130
- line_length = Integer(value, exception: false)
2131
- unless line_length && line_length.positive?
2132
- @err.puts("--max-line-length must be a positive integer")
2133
- print_usage(@err)
2134
- return nil
2135
- end
2136
-
2137
- options[:max_line_length] = line_length
2138
- when "--timings"
2139
- options[:profile] = true
2140
- when "--"
2141
- input_paths.concat(@argv)
2142
- @argv.clear
2143
- else
2144
- @err.puts("unknown format option #{option}")
2145
- print_usage(@err)
2146
- return nil
2147
- end
2148
- else
2149
- input_paths << option
2150
- end
2151
- end
2152
-
2153
- if options[:check] && options[:write]
2154
- @err.puts("format options --check and --write cannot be combined")
2155
- print_usage(@err)
2156
- return nil
2157
- end
2158
-
2159
- { options:, input_paths: }
2160
- end
2161
-
2162
378
  def ensure_known_source_operands!(command, operands)
2163
379
  return true unless operands.any? { |arg| arg.start_with?("-") }
2164
380
 
@@ -2333,14 +549,6 @@ module MilkTea
2333
549
  end
2334
550
  end
2335
551
 
2336
- def lint_sema_facts_for(source, path, locked: false)
2337
- ast = Parser.parse(source, path: path)
2338
- imported_modules = make_module_loader(path, locked:, platform: ModuleLoader.default_host_platform).imported_modules_for_ast(ast, importer_path: path)
2339
- SemanticAnalyzer.tooling_snapshot(ast, imported_modules: imported_modules, path: path).facts
2340
- rescue MilkTea::LexError, MilkTea::ParseError, SemanticError, ModuleLoadError
2341
- nil
2342
- end
2343
-
2344
552
  def extract_include_paths!
2345
553
  remaining = []
2346
554
  i = 0