rigortype 0.1.7 → 0.1.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -355,6 +355,15 @@ module Rigor
355
355
  class_name = concrete_class_name(receiver_type)
356
356
  return nil if class_name.nil?
357
357
 
358
+ # ADR-26 — a plugin may declare a class "open": one
359
+ # known to respond beyond its RBS-declared method
360
+ # surface (e.g. `ActiveRecord::Relation`, which
361
+ # delegates an unbounded set of user-defined scopes to
362
+ # its model). Flagging an undefined method on a class
363
+ # with an open dynamic surface is unsound, so the rule
364
+ # skips it.
365
+ return nil if open_receiver?(class_name, scope)
366
+
358
367
  # Slice 7 phase 12 — suppress when the user has
359
368
  # declared the method in source (instance `def`,
360
369
  # `def self.foo`, or recognised `define_method`).
@@ -424,6 +433,17 @@ module Rigor
424
433
  nil
425
434
  end
426
435
 
436
+ # ADR-26 — whether `class_name` is declared "open" by a
437
+ # loaded plugin (manifest `open_receivers:`). An open
438
+ # class responds beyond its RBS surface, so the
439
+ # `call.undefined-method` rule must not fire for it.
440
+ def open_receiver?(class_name, scope)
441
+ registry = scope.environment&.plugin_registry
442
+ return false if registry.nil?
443
+
444
+ registry.open_receiver?(class_name)
445
+ end
446
+
427
447
  def definition_available?(receiver_type, class_name, scope)
428
448
  if receiver_type.is_a?(Type::Singleton)
429
449
  !Rigor::Reflection.singleton_definition(class_name, scope: scope).nil?
@@ -1215,7 +1235,9 @@ module Rigor
1215
1235
  line: location.start_line,
1216
1236
  column: location.start_column + 1,
1217
1237
  message: "undefined method `#{call_node.name}' for #{rendered_receiver}",
1218
- severity: :error
1238
+ severity: :error,
1239
+ receiver_type: rendered_receiver,
1240
+ method_name: call_node.name.to_s
1219
1241
  )
1220
1242
  end
1221
1243
 
@@ -8,7 +8,8 @@ module Rigor
8
8
  # baseline against which non-default families are recognised.
9
9
  DEFAULT_SOURCE_FAMILY = :builtin
10
10
 
11
- attr_reader :path, :line, :column, :message, :severity, :rule, :source_family
11
+ attr_reader :path, :line, :column, :message, :severity, :rule, :source_family,
12
+ :receiver_type, :method_name
12
13
 
13
14
  # `rule:` is the stable identifier (a kebab-case string)
14
15
  # of the diagnostic's source rule. It is used by the
@@ -24,8 +25,19 @@ module Rigor
24
25
  # ADR-2 § "Plugin Diagnostic Provenance") let consumers
25
26
  # distinguish where a diagnostic originated without committing
26
27
  # to the plugin API itself.
27
- def initialize(path:, line:, column:, message:, severity: :error, rule: nil,
28
- source_family: DEFAULT_SOURCE_FAMILY)
28
+ #
29
+ # `receiver_type:` / `method_name:` are optional structured
30
+ # fields populated by the call-related rules (`call.undefined-
31
+ # method`) — the rendered receiver type and the called method
32
+ # name as plain strings. ADR-23 WD3 / slice 4: `rigor triage`'s
33
+ # heuristic recognisers read these directly instead of parsing
34
+ # the diagnostic message, so the catalogue no longer couples to
35
+ # message wording. Both stay nil for rules that have no such
36
+ # pair; a consumer that finds them nil falls back to message
37
+ # parsing.
38
+ def initialize(path:, line:, column:, message:, severity: :error, rule: nil, # rubocop:disable Metrics/ParameterLists
39
+ source_family: DEFAULT_SOURCE_FAMILY,
40
+ receiver_type: nil, method_name: nil)
29
41
  @path = path
30
42
  @line = line
31
43
  @column = column
@@ -33,6 +45,8 @@ module Rigor
33
45
  @severity = severity
34
46
  @rule = rule
35
47
  @source_family = source_family
48
+ @receiver_type = receiver_type
49
+ @method_name = method_name
36
50
  end
37
51
 
38
52
  def error?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "tmpdir"
4
5
 
5
6
  require_relative "../environment"
6
7
  require_relative "../scope"
@@ -104,6 +105,9 @@ module Rigor
104
105
  @signature_paths_snapshot = [].freeze
105
106
  @cached_plugin_prepare_diagnostics = [].freeze
106
107
  @project_discovered_classes = {}.freeze
108
+ @project_discovered_def_nodes = {}.freeze
109
+ @project_discovered_superclasses = {}.freeze
110
+ @project_discovered_includes = {}.freeze
107
111
  end
108
112
 
109
113
  # ADR-pending editor mode — present when the runner is wired
@@ -241,6 +245,16 @@ module Rigor
241
245
  # can share parses with the existing scanner passes.
242
246
  @project_discovered_classes =
243
247
  Inference::ScopeIndexer.discovered_classes_for_paths(expansion.fetch(:files), buffer: @buffer)
248
+ # ADR-24 slice 2 — cross-file def-node + class->superclass
249
+ # index so an implicit-self call inside a subclass
250
+ # resolves a superclass `def` declared in a sibling
251
+ # file. One extra parse pass over the project; shares
252
+ # the cost profile of the class-discovery pass above.
253
+ def_index =
254
+ Inference::ScopeIndexer.discovered_def_index_for_paths(expansion.fetch(:files), buffer: @buffer)
255
+ @project_discovered_def_nodes = def_index.fetch(:def_nodes)
256
+ @project_discovered_superclasses = def_index.fetch(:superclasses)
257
+ @project_discovered_includes = def_index.fetch(:includes)
244
258
  end
245
259
 
246
260
  # Internal: adopts a frozen {ProjectScan} snapshot supplied
@@ -278,7 +292,7 @@ module Rigor
278
292
  return [] if files.empty?
279
293
 
280
294
  if pool_mode?
281
- analyze_files_in_pool(files)
295
+ dispatch_pool(files)
282
296
  else
283
297
  environment = resolve_sequential_environment
284
298
  result = files.flat_map { |path| analyze_file(path, environment) }
@@ -460,6 +474,34 @@ module Rigor
460
474
  @buffer.nil?
461
475
  end
462
476
 
477
+ # ADR-15 Amendment (2026-05-20) — worker-pool backend selector.
478
+ # `fork` is the active backend: separate processes sidestep both
479
+ # the Ruby Bug #22075 use-after-free and the worker-side
480
+ # `Ractor::IsolationError` that make the Ractor pool unusable
481
+ # (see the ADR-15 Amendment +
482
+ # docs/notes/20260520-ractor-pool-cruby-uaf.md). The Ractor pool
483
+ # is preserved but off the default path — `RIGOR_POOL_BACKEND=ractor`
484
+ # opts back in so it stays testable. Platforms without `fork`
485
+ # (Windows) fall back to sequential.
486
+ def pool_backend
487
+ return :ractor if ENV["RIGOR_POOL_BACKEND"] == "ractor"
488
+ return :fork if Process.respond_to?(:fork)
489
+
490
+ :sequential
491
+ end
492
+
493
+ # Routes pool-mode analysis to the selected backend.
494
+ def dispatch_pool(files)
495
+ case pool_backend
496
+ when :ractor then analyze_files_in_pool(files)
497
+ when :fork then analyze_files_in_fork_pool(files)
498
+ else
499
+ analyze_files_sequentially_fallback(
500
+ files, reason: "fork-based parallelism is unavailable on this platform"
501
+ )
502
+ end
503
+ end
504
+
463
505
  # Coordinator-side Environment used by the sequential code
464
506
  # path. Pool mode builds one Environment per worker inside
465
507
  # the worker Ractor's body instead.
@@ -598,6 +640,122 @@ module Rigor
598
640
  Array(prepare_diagnostics) + files.flat_map { |path| results_by_path.fetch(path, []) }
599
641
  end
600
642
 
643
+ # ADR-15 Amendment (2026-05-20) — fork-based worker pool, the
644
+ # active backend for `workers > 0`. Builds ONE {WorkerSession}
645
+ # on the parent, then `fork`s N children that copy-on-write
646
+ # inherit it. Each child analyses a contiguous slice of `files`
647
+ # and writes a Marshal'd `{results:, reporters:}` payload to a
648
+ # temp file; the parent `Process.wait`s every child, merges the
649
+ # payloads, and re-orders diagnostics by original path order.
650
+ #
651
+ # Separate processes have separate GC heaps and `vm->ci_table`
652
+ # (immune to Ruby Bug #22075) and copy-on-write-inherit every
653
+ # constant (no `Ractor.shareable?` constraint). See the ADR-15
654
+ # Amendment + docs/notes/20260520-ractor-pool-cruby-uaf.md.
655
+ #
656
+ # A child that exits non-zero (crash / unmarshalable payload) is
657
+ # degraded: the parent re-analyses that slice in-process and
658
+ # prepends a `pool-degraded` warning.
659
+ def analyze_files_in_fork_pool(files) # rubocop:disable Metrics/AbcSize
660
+ Environment::ClassRegistry.default
661
+
662
+ session = WorkerSession.new(
663
+ configuration: @configuration,
664
+ cache_store: @cache_store,
665
+ plugin_blueprints: @plugin_registry.blueprints,
666
+ explain: @explain,
667
+ synthetic_method_index: @synthetic_method_index,
668
+ project_patched_methods: @project_patched_methods
669
+ )
670
+ # Force the full RBS load on the parent so children
671
+ # copy-on-write inherit a warm Environment rather than each
672
+ # rebuilding it after the fork.
673
+ session.environment.rbs_loader&.prewarm
674
+ snapshot_fork_pool_stats(session) if @collect_stats
675
+
676
+ worker_count = [@workers, files.size].min
677
+ slices = files.each_slice((files.size.to_f / worker_count).ceil).to_a
678
+ results_by_path = {}
679
+
680
+ degraded = Dir.mktmpdir("rigor-fork-pool") do |tmpdir|
681
+ children = slices.each_with_index.map do |slice, index|
682
+ out_path = File.join(tmpdir, "worker-#{index}")
683
+ { pid: fork { run_fork_worker(session, slice, out_path) },
684
+ slice: slice, out_path: out_path }
685
+ end
686
+ collect_fork_results(children, results_by_path)
687
+ end
688
+
689
+ unless degraded.empty?
690
+ degraded.each { |path| results_by_path[path] = session.analyze(path) }
691
+ merge_worker_reporters(session.drain_reporters)
692
+ end
693
+
694
+ diagnostics = Array(session.prepare_diagnostics) +
695
+ files.flat_map { |path| results_by_path.fetch(path, []) }
696
+ degraded.empty? ? diagnostics : diagnostics.unshift(fork_degraded_diagnostic(degraded.size))
697
+ end
698
+
699
+ # Child-process body for {#analyze_files_in_fork_pool}. Analyses
700
+ # the slice with the copy-on-write-inherited session and writes
701
+ # the Marshal'd payload to `out_path`. `exit!` skips `at_exit` /
702
+ # stdio flush — the payload is already durable on disk by then.
703
+ def run_fork_worker(session, slice, out_path)
704
+ results = slice.to_h { |path| [path, session.analyze(path)] }
705
+ payload = { results: results, reporters: session.drain_reporters }
706
+ File.binwrite(out_path, Marshal.dump(payload))
707
+ exit!(0)
708
+ rescue StandardError
709
+ exit!(1)
710
+ end
711
+
712
+ # Snapshots `class_decl_paths` from the parent session's loader
713
+ # so end-of-run {RunStats} can attribute the RBS class universe.
714
+ def snapshot_fork_pool_stats(session)
715
+ loader = session.environment.rbs_loader
716
+ @class_decl_paths_snapshot = loader&.class_decl_paths || {}.freeze
717
+ @signature_paths_snapshot = loader&.signature_paths || [].freeze
718
+ end
719
+
720
+ # Waits for every forked child, merges each successful payload
721
+ # into `results_by_path`, and returns the file paths whose
722
+ # worker exited abnormally (for in-process degrade).
723
+ def collect_fork_results(children, results_by_path)
724
+ degraded = []
725
+ children.each do |child|
726
+ _, status = Process.waitpid2(child[:pid])
727
+ payload = fork_worker_payload(status, child[:out_path])
728
+ if payload
729
+ results_by_path.merge!(payload.fetch(:results))
730
+ merge_worker_reporters(payload.fetch(:reporters))
731
+ else
732
+ degraded.concat(child[:slice])
733
+ end
734
+ end
735
+ degraded
736
+ end
737
+
738
+ # @return [Hash, nil] the child's `{results:, reporters:}`
739
+ # payload, or nil when the child exited abnormally or wrote no
740
+ # readable payload. `Marshal.load` is safe here: the blob was
741
+ # written by our own forked child to a temp file we created.
742
+ def fork_worker_payload(status, out_path)
743
+ return nil unless status.success? && File.exist?(out_path)
744
+
745
+ Marshal.load(File.binread(out_path)) # rubocop:disable Security/MarshalLoad
746
+ rescue StandardError
747
+ nil
748
+ end
749
+
750
+ def fork_degraded_diagnostic(count)
751
+ Diagnostic.new(
752
+ path: ".rigor.yml", line: 1, column: 1,
753
+ message: "fork pool degraded: #{count} file(s) re-analysed in-process " \
754
+ "after a worker exited abnormally",
755
+ severity: :warning, rule: "pool-degraded", source_family: :builtin
756
+ )
757
+ end
758
+
601
759
  # End-of-run telemetry. Walks the cached
602
760
  # `class_decl_paths` snapshot (sequential mode: from
603
761
  # the coordinator's environment; pool mode: from the
@@ -1221,12 +1379,29 @@ module Rigor
1221
1379
  Prism.parse(File.read(physical), filepath: path, version: @configuration.target_ruby)
1222
1380
  end
1223
1381
 
1382
+ # Seeds the cross-file project pre-pass indexes onto a
1383
+ # fresh per-file scope: discovered classes, and the ADR-24
1384
+ # def-node / superclass / included-module maps. Each is
1385
+ # applied only when non-empty so a runner constructed
1386
+ # without the project pre-pass (e.g. a single-file probe)
1387
+ # keeps an empty seed.
1388
+ def seed_project_scope(scope)
1389
+ scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
1390
+ unless @project_discovered_def_nodes.empty?
1391
+ scope = scope.with_discovered_def_nodes(@project_discovered_def_nodes)
1392
+ end
1393
+ unless @project_discovered_superclasses.empty?
1394
+ scope = scope.with_discovered_superclasses(@project_discovered_superclasses)
1395
+ end
1396
+ scope = scope.with_discovered_includes(@project_discovered_includes) unless @project_discovered_includes.empty?
1397
+ scope
1398
+ end
1399
+
1224
1400
  def analyze_file(path, environment) # rubocop:disable Metrics/MethodLength
1225
1401
  parse_result = parse_source(path)
1226
1402
  return parse_diagnostics(path, parse_result) unless parse_result.errors.empty?
1227
1403
 
1228
- scope = Scope.empty(environment: environment, source_path: path)
1229
- scope = scope.with_discovered_classes(@project_discovered_classes) unless @project_discovered_classes.empty?
1404
+ scope = seed_project_scope(Scope.empty(environment: environment, source_path: path))
1230
1405
  index = Inference::ScopeIndexer.index(parse_result.value, default_scope: scope)
1231
1406
  diagnostics = CheckRules.diagnose(
1232
1407
  path: path,
@@ -38,6 +38,12 @@ module Rigor
38
38
  # - `plugin_blueprints` — Phase 3a
39
39
  # (`Array<Plugin::Blueprint>` is `Ractor.shareable?`).
40
40
  # - `explain` — Boolean.
41
+ # - `synthetic_method_index` / `project_patched_methods` —
42
+ # optional (default `nil`). NOT `Ractor.shareable?`, so the
43
+ # Ractor pool path leaves them unset; the fork backend
44
+ # (ADR-15 Amendment), which builds the session pre-fork on the
45
+ # parent, threads the runner's project-scan results through so
46
+ # per-file inference matches the sequential path exactly.
41
47
  #
42
48
  # Internally the session OWNS (and never shares):
43
49
  #
@@ -70,7 +76,7 @@ module Rigor
70
76
  # output (modulo severity-profile re-stamping, which the
71
77
  # session leaves to the caller because it is a per-run
72
78
  # aggregate concern).
73
- class WorkerSession
79
+ class WorkerSession # rubocop:disable Metrics/ClassLength
74
80
  attr_reader :configuration, :cache_store, :services, :plugin_registry,
75
81
  :dependency_source_index, :environment,
76
82
  :rbs_extended_reporter, :boundary_cross_reporter,
@@ -88,11 +94,14 @@ module Rigor
88
94
  # directly-unrecognised node, mirroring
89
95
  # {Rigor::Analysis::Runner#explain_diagnostics}.
90
96
  def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
91
- plugin_blueprints: [], explain: false, buffer: nil)
97
+ plugin_blueprints: [], explain: false, buffer: nil,
98
+ synthetic_method_index: nil, project_patched_methods: nil)
92
99
  @configuration = configuration
93
100
  @cache_store = cache_store
94
101
  @explain = explain
95
102
  @buffer = buffer
103
+ @synthetic_method_index = synthetic_method_index
104
+ @project_patched_methods = project_patched_methods
96
105
 
97
106
  # NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
98
107
  # is process-global state. Writing it from a non-main
@@ -127,7 +136,9 @@ module Rigor
127
136
  bundler_auto_detect: configuration.bundler_auto_detect,
128
137
  bundler_lockfile: configuration.bundler_lockfile,
129
138
  rbs_collection_lockfile: configuration.rbs_collection_lockfile,
130
- rbs_collection_auto_detect: configuration.rbs_collection_auto_detect
139
+ rbs_collection_auto_detect: configuration.rbs_collection_auto_detect,
140
+ synthetic_method_index: @synthetic_method_index,
141
+ project_patched_methods: @project_patched_methods
131
142
  )
132
143
  @prepare_diagnostics = run_plugin_prepare.freeze
133
144
  end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optionparser"
4
+ require "prism"
5
+
6
+ require_relative "../configuration"
7
+ require_relative "../environment"
8
+ require_relative "../scope"
9
+ require_relative "../inference/scope_indexer"
10
+ require_relative "prism_colorizer"
11
+
12
+ module Rigor
13
+ class CLI
14
+ # Executes `rigor annotate FILE`.
15
+ #
16
+ # For every source line the command finds the expression the
17
+ # line evaluates to — the last statement that ends on the line
18
+ # (so `1; 2; 3` reports `3`), or, for a line that no statement
19
+ # closes, the widest expression ending there (so the `if nil`
20
+ # header reports its condition). It infers that expression's
21
+ # type and appends a `#=> dump_type: <type>` comment.
22
+ #
23
+ # The annotated source is re-parsed with Prism — a sanity gate,
24
+ # since the appended text is always a comment — and printed to
25
+ # stdout with IRB-style syntax highlighting via
26
+ # {PrismColorizer}.
27
+ class AnnotateCommand
28
+ USAGE = "Usage: rigor annotate [options] FILE"
29
+
30
+ # Appended ` #=> dump_type: <type>` suffix. Matched and
31
+ # stripped before re-annotating so re-running is idempotent.
32
+ ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
33
+
34
+ def initialize(argv:, out:, err:)
35
+ @argv = argv
36
+ @out = out
37
+ @err = err
38
+ end
39
+
40
+ # @return [Integer] CLI exit status.
41
+ def run
42
+ options = parse_options
43
+ file = @argv.shift
44
+ if file.nil?
45
+ @err.puts(USAGE)
46
+ return CLI::EXIT_USAGE
47
+ end
48
+ unless File.file?(file)
49
+ @err.puts("annotate: file not found: #{file}")
50
+ return 1
51
+ end
52
+
53
+ execute(file, options)
54
+ end
55
+
56
+ private
57
+
58
+ def parse_options
59
+ # Default: colour a tty, unless `NO_COLOR` opts out. An
60
+ # explicit `--color` / `--no-color` overrides both.
61
+ options = { config: nil, color: @out.tty? && !no_color_env? }
62
+
63
+ parser = OptionParser.new do |opts|
64
+ opts.banner = USAGE
65
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
66
+ opts.on("--[no-]color",
67
+ "Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
68
+ options[:color] = value
69
+ end
70
+ end
71
+ parser.parse!(@argv)
72
+
73
+ options
74
+ end
75
+
76
+ # https://no-color.org — colour output is suppressed by
77
+ # default when `NO_COLOR` is present and not an empty string,
78
+ # regardless of its value.
79
+ def no_color_env?
80
+ value = ENV.fetch("NO_COLOR", nil)
81
+ !value.nil? && !value.empty?
82
+ end
83
+
84
+ def execute(file, options)
85
+ configuration = Configuration.load(options.fetch(:config))
86
+ source = File.read(file)
87
+ parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
88
+ return 1 if parse_errors?(parse_result, file)
89
+
90
+ scope_index = Inference::ScopeIndexer.index(
91
+ parse_result.value, default_scope: base_scope(configuration)
92
+ )
93
+ line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
94
+
95
+ @out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
96
+ 0
97
+ end
98
+
99
+ def base_scope(configuration)
100
+ Scope.empty(
101
+ environment: Environment.for_project(
102
+ libraries: configuration.libraries,
103
+ signature_paths: configuration.signature_paths
104
+ )
105
+ )
106
+ end
107
+
108
+ def parse_errors?(parse_result, file)
109
+ return false if parse_result.success?
110
+
111
+ parse_result.errors.each do |error|
112
+ @err.puts("#{file}:#{error.location.start_line}: #{error.message}")
113
+ end
114
+ true
115
+ end
116
+
117
+ # Appends ` #=> dump_type: <type>` to every line a type was
118
+ # inferred for, aligning the comment column.
119
+ def annotate(source, line_types)
120
+ lines = source.lines
121
+ column = annotation_column(lines, line_types)
122
+
123
+ lines.each_with_index.map do |line, index|
124
+ type = line_types[index + 1]
125
+ eol = line.end_with?("\n") ? "\n" : ""
126
+ code = line.chomp.sub(ANNOTATION_PATTERN, "")
127
+ next "#{code}#{eol}" if type.nil?
128
+
129
+ "#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
130
+ end.join
131
+ end
132
+
133
+ def annotation_column(lines, line_types)
134
+ widths = lines.each_index.filter_map do |index|
135
+ next unless line_types.key?(index + 1)
136
+
137
+ lines[index].chomp.sub(ANNOTATION_PATTERN, "").length
138
+ end
139
+ widths.max || 0
140
+ end
141
+
142
+ def render(annotated, color:)
143
+ return annotated unless color
144
+ return annotated unless Prism.parse(annotated).success?
145
+
146
+ PrismColorizer.colorize(annotated)
147
+ end
148
+ end
149
+
150
+ # Walks a parsed program and resolves, per source line, the
151
+ # type of the expression the line evaluates to. Used only by
152
+ # {AnnotateCommand}.
153
+ class LineTypeCollector
154
+ def initialize(scope_index)
155
+ @scope_index = scope_index
156
+ end
157
+
158
+ # @param program [Prism::ProgramNode]
159
+ # @return [Hash{Integer => Rigor::Type}] 1-indexed line => type.
160
+ def collect(program)
161
+ by_line = {}
162
+ each_statement(program) do |statement|
163
+ type = type_of(statement)
164
+ by_line[statement.location.end_line] = type unless type.nil?
165
+ end
166
+ fill_uncovered_lines(program, by_line)
167
+ by_line
168
+ end
169
+
170
+ private
171
+
172
+ # Yields each statement node (a child of any `StatementsNode`
173
+ # anywhere in the tree) in source order, so a later statement
174
+ # ending on a line overwrites an earlier one — `1; 2; 3`
175
+ # resolves to `3`.
176
+ def each_statement(node, &)
177
+ return if node.nil?
178
+
179
+ node.body.each(&) if node.is_a?(Prism::StatementsNode)
180
+ node.compact_child_nodes.each { |child| each_statement(child, &) }
181
+ end
182
+
183
+ # For a line no statement closes (the `if` / block header
184
+ # lines), fall back to the widest expression ending there.
185
+ def fill_uncovered_lines(program, by_line)
186
+ widest_per_line(program).each do |line, node|
187
+ next if by_line.key?(line)
188
+
189
+ type = type_of(node)
190
+ by_line[line] = type unless type.nil?
191
+ end
192
+ end
193
+
194
+ def widest_per_line(program)
195
+ widest = {}
196
+ walk(program) do |node|
197
+ next if node.is_a?(Prism::ProgramNode) || node.is_a?(Prism::StatementsNode)
198
+
199
+ line = node.location.end_line
200
+ current = widest[line]
201
+ widest[line] = node if current.nil? || span(node) > span(current)
202
+ end
203
+ widest
204
+ end
205
+
206
+ def span(node)
207
+ node.location.end_offset - node.location.start_offset
208
+ end
209
+
210
+ def walk(node, &block)
211
+ return if node.nil?
212
+
213
+ block.call(node)
214
+ node.compact_child_nodes.each { |child| walk(child, &block) }
215
+ end
216
+
217
+ def type_of(node)
218
+ @scope_index[node].type_of(node)
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ end
223
+ end
224
+ end