rigortype 0.1.8 → 0.1.10

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -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?
@@ -7,6 +7,7 @@ require_relative "../environment"
7
7
  require_relative "../scope"
8
8
  require_relative "../cache/store"
9
9
  require_relative "../plugin"
10
+ require_relative "../plugin/source_rbs_synthesis_reporter"
10
11
  require_relative "../rbs_extended/reporter"
11
12
  require_relative "../reflection"
12
13
  require_relative "../type/combinator"
@@ -97,6 +98,7 @@ module Rigor
97
98
  @dependency_source_index = DependencySourceInference::Index::EMPTY
98
99
  @rbs_extended_reporter = RbsExtended::Reporter.new
99
100
  @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
101
+ @source_rbs_synthesis_reporter = Plugin::SourceRbsSynthesisReporter.new
100
102
  # `#run` resets these for each invocation; pre-seed them to
101
103
  # empty containers so `build_run_stats` / `pre_file_diagnostics`
102
104
  # (private, called only from `#run`) can read them without
@@ -147,6 +149,7 @@ module Rigor
147
149
  diagnostics += analyze_files(target_files(expansion))
148
150
  diagnostics += rbs_extended_reporter_diagnostics
149
151
  diagnostics += boundary_cross_diagnostics
152
+ diagnostics += source_rbs_synthesis_diagnostics
150
153
 
151
154
  Result.new(
152
155
  diagnostics: apply_severity_profile(diagnostics),
@@ -294,7 +297,7 @@ module Rigor
294
297
  if pool_mode?
295
298
  dispatch_pool(files)
296
299
  else
297
- environment = resolve_sequential_environment
300
+ environment = resolve_sequential_environment(source_files: files)
298
301
  result = files.flat_map { |path| analyze_file(path, environment) }
299
302
  if @collect_stats
300
303
  loader = environment.rbs_loader
@@ -311,8 +314,8 @@ module Rigor
311
314
  # runner's diagnostics) when present; otherwise builds a
312
315
  # fresh Environment per-call via {#build_runner_environment}
313
316
  # — preserving the pre-override behaviour bit-for-bit.
314
- def resolve_sequential_environment
315
- return build_runner_environment unless @environment_override
317
+ def resolve_sequential_environment(source_files: [])
318
+ return build_runner_environment(source_files: source_files) unless @environment_override
316
319
 
317
320
  @environment_override.attach_reporters!(
318
321
  rbs_extended_reporter: @rbs_extended_reporter,
@@ -505,7 +508,14 @@ module Rigor
505
508
  # Coordinator-side Environment used by the sequential code
506
509
  # path. Pool mode builds one Environment per worker inside
507
510
  # the worker Ractor's body instead.
508
- def build_runner_environment
511
+ #
512
+ # ADR-32 WD4 — `source_files:` is threaded down so that
513
+ # `Environment.for_project` can invoke each loaded plugin's
514
+ # `source_rbs_synthesizer` callable per project source file
515
+ # at env-build time. Defaults to `[]` for callers that don't
516
+ # have a file list yet (e.g. pre-pass-only build paths); in
517
+ # that case no synthesised RBS is contributed.
518
+ def build_runner_environment(source_files: [])
509
519
  Environment.for_project(
510
520
  libraries: @configuration.libraries,
511
521
  signature_paths: @configuration.signature_paths,
@@ -514,13 +524,15 @@ module Rigor
514
524
  dependency_source_index: @dependency_source_index,
515
525
  rbs_extended_reporter: @rbs_extended_reporter,
516
526
  boundary_cross_reporter: @boundary_cross_reporter,
527
+ source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
517
528
  bundler_bundle_path: @configuration.bundler_bundle_path,
518
529
  bundler_auto_detect: @configuration.bundler_auto_detect,
519
530
  bundler_lockfile: @configuration.bundler_lockfile,
520
531
  rbs_collection_lockfile: @configuration.rbs_collection_lockfile,
521
532
  rbs_collection_auto_detect: @configuration.rbs_collection_auto_detect,
522
533
  synthetic_method_index: @synthetic_method_index,
523
- project_patched_methods: @project_patched_methods
534
+ project_patched_methods: @project_patched_methods,
535
+ source_files: source_files
524
536
  )
525
537
  end
526
538
 
@@ -559,7 +571,7 @@ module Rigor
559
571
  # which dedupe on the same keys as a single-session run
560
572
  # would. Net result: reporter state is identical to the
561
573
  # sequential path.
562
- def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
574
+ def analyze_files_in_pool(files) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
563
575
  # Pre-warm class-level lazy memos on the MAIN Ractor.
564
576
  # `Environment::ClassRegistry.default` is the
565
577
  # default kwarg threaded through `Environment.new`
@@ -591,15 +603,22 @@ module Rigor
591
603
  cache_root = @cache_store&.root
592
604
  blueprints = @plugin_registry.blueprints
593
605
  explain = @explain
606
+ # ADR-32 WD4 — the full project file list travels into
607
+ # every Ractor worker so each worker's WorkerSession
608
+ # can invoke loaded plugins' source_rbs_synthesizers at
609
+ # env-build time. The list is a frozen Array<String>;
610
+ # cheaply shareable.
611
+ shareable_source_files = files.map { |path| path.to_s.dup.freeze }.freeze
594
612
 
595
613
  pool = Array.new(@workers) do
596
- Ractor.new(configuration, cache_root, blueprints, explain) do |configuration, cache_root, blueprints, explain|
614
+ Ractor.new(configuration, cache_root, blueprints, explain, shareable_source_files) do |configuration, cache_root, blueprints, explain, shareable_source_files| # rubocop:disable Layout/LineLength
597
615
  cache_store = cache_root ? Rigor::Cache::Store.new(root: cache_root) : nil
598
616
  session = Rigor::Analysis::WorkerSession.new(
599
617
  configuration: configuration,
600
618
  cache_store: cache_store,
601
619
  plugin_blueprints: blueprints,
602
- explain: explain
620
+ explain: explain,
621
+ source_files: shareable_source_files
603
622
  )
604
623
  main = Ractor.main
605
624
  main.send([:prepare, session.prepare_diagnostics])
@@ -665,7 +684,8 @@ module Rigor
665
684
  plugin_blueprints: @plugin_registry.blueprints,
666
685
  explain: @explain,
667
686
  synthetic_method_index: @synthetic_method_index,
668
- project_patched_methods: @project_patched_methods
687
+ project_patched_methods: @project_patched_methods,
688
+ source_files: files
669
689
  )
670
690
  # Force the full RBS load on the parent so children
671
691
  # copy-on-write inherit a warm Environment rather than each
@@ -851,6 +871,15 @@ module Rigor
851
871
  rbs_display: entry.rbs_display
852
872
  )
853
873
  end
874
+ # ADR-32 WD6 — merge per-worker synthesizer failures
875
+ # back into the coordinator's reporter. Fetched with a
876
+ # default empty array so older drains (pre-slice-2)
877
+ # remain compatible.
878
+ Array(drained[:source_rbs_synthesis]).each do |entry|
879
+ @source_rbs_synthesis_reporter.record(
880
+ plugin_id: entry.plugin_id, path: entry.path, message: entry.message
881
+ )
882
+ end
854
883
  end
855
884
 
856
885
  # Loads project-configured plugins through {Rigor::Plugin::Loader}
@@ -1160,6 +1189,35 @@ module Rigor
1160
1189
  # per-call-site — the diagnostic anchors at `.rigor.yml`
1161
1190
  # like the other `dependency-source.*` diagnostics that
1162
1191
  # report on opt-in configuration.
1192
+ # ADR-32 WD6 — drains the per-run
1193
+ # {Plugin::SourceRbsSynthesisReporter} into
1194
+ # `source-rbs-synthesis-failed` `:info` diagnostics. Each
1195
+ # entry names the plugin that owns the synthesizer, the
1196
+ # source file the rbs-inline parser couldn't process, and
1197
+ # the upstream error message. The synthesizer-emitting
1198
+ # plugin (currently only `rigor-rbs-inline`) treats a
1199
+ # parse failure as a no-contribution event so analysis
1200
+ # continues; this stream surfaces the failure so the user
1201
+ # can see which files contributed nothing and why.
1202
+ #
1203
+ # Severity profile re-stamps the rule per project taste.
1204
+ def source_rbs_synthesis_diagnostics
1205
+ return [] if @source_rbs_synthesis_reporter.empty?
1206
+
1207
+ @source_rbs_synthesis_reporter.entries.map do |entry|
1208
+ Diagnostic.new(
1209
+ path: entry.path, line: 1, column: 1,
1210
+ message: "plugin `#{entry.plugin_id}` failed to synthesise RBS from this file: " \
1211
+ "#{entry.message}. The file's analysis falls back to no inline-RBS " \
1212
+ "contribution. Fix the inline-RBS comment grammar or remove the " \
1213
+ "annotation to silence this diagnostic.",
1214
+ severity: :info,
1215
+ rule: "source-rbs-synthesis-failed",
1216
+ source_family: :builtin
1217
+ )
1218
+ end
1219
+ end
1220
+
1163
1221
  def boundary_cross_diagnostics
1164
1222
  return [] if @boundary_cross_reporter.empty?
1165
1223
 
@@ -93,15 +93,20 @@ module Rigor
93
93
  # emits one `:info` `fallback` diagnostic per
94
94
  # directly-unrecognised node, mirroring
95
95
  # {Rigor::Analysis::Runner#explain_diagnostics}.
96
- def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength
96
+ def initialize(configuration:, cache_store: nil, # rubocop:disable Metrics/MethodLength,Metrics/ParameterLists
97
97
  plugin_blueprints: [], explain: false, buffer: nil,
98
- synthetic_method_index: nil, project_patched_methods: nil)
98
+ synthetic_method_index: nil, project_patched_methods: nil,
99
+ source_files: [])
99
100
  @configuration = configuration
100
101
  @cache_store = cache_store
101
102
  @explain = explain
102
103
  @buffer = buffer
103
104
  @synthetic_method_index = synthetic_method_index
104
105
  @project_patched_methods = project_patched_methods
106
+ # ADR-32 WD4 — full project file list (frozen
107
+ # Array<String>) for env-build-time invocation of any
108
+ # loaded plugin's `source_rbs_synthesizer` callable.
109
+ @source_files = source_files
105
110
 
106
111
  # NOTE: `Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths`
107
112
  # is process-global state. Writing it from a non-main
@@ -112,6 +117,7 @@ module Rigor
112
117
  # pool. The substrate stays Ractor-safe by construction.
113
118
  @rbs_extended_reporter = RbsExtended::Reporter.new
114
119
  @boundary_cross_reporter = DependencySourceInference::BoundaryCrossReporter.new
120
+ @source_rbs_synthesis_reporter = Plugin::SourceRbsSynthesisReporter.new
115
121
  @dependency_source_index = DependencySourceInference::Builder.build(configuration.dependencies)
116
122
 
117
123
  @services = Plugin::Services.new(
@@ -132,13 +138,15 @@ module Rigor
132
138
  dependency_source_index: @dependency_source_index,
133
139
  rbs_extended_reporter: @rbs_extended_reporter,
134
140
  boundary_cross_reporter: @boundary_cross_reporter,
141
+ source_rbs_synthesis_reporter: @source_rbs_synthesis_reporter,
135
142
  bundler_bundle_path: configuration.bundler_bundle_path,
136
143
  bundler_auto_detect: configuration.bundler_auto_detect,
137
144
  bundler_lockfile: configuration.bundler_lockfile,
138
145
  rbs_collection_lockfile: configuration.rbs_collection_lockfile,
139
146
  rbs_collection_auto_detect: configuration.rbs_collection_auto_detect,
140
147
  synthetic_method_index: @synthetic_method_index,
141
- project_patched_methods: @project_patched_methods
148
+ project_patched_methods: @project_patched_methods,
149
+ source_files: @source_files
142
150
  )
143
151
  @prepare_diagnostics = run_plugin_prepare.freeze
144
152
  end
@@ -180,7 +188,8 @@ module Rigor
180
188
  unresolved_payloads: @rbs_extended_reporter.unresolved_payloads,
181
189
  lossy_projections: @rbs_extended_reporter.lossy_projections
182
190
  },
183
- boundary_cross: @boundary_cross_reporter.entries
191
+ boundary_cross: @boundary_cross_reporter.entries,
192
+ source_rbs_synthesis: @source_rbs_synthesis_reporter.entries
184
193
  }
185
194
  end
186
195
 
@@ -19,7 +19,7 @@ module Rigor
19
19
  Descriptor.new(
20
20
  gems: [rbs_gem_entry],
21
21
  files: file_entries(loader),
22
- configs: [libraries_entry(loader)]
22
+ configs: [libraries_entry(loader), virtual_rbs_entry(loader)].compact
23
23
  )
24
24
  end
25
25
 
@@ -51,7 +51,26 @@ module Rigor
51
51
  )
52
52
  end
53
53
 
54
- private_class_method :rbs_gem_entry, :file_entries, :libraries_entry
54
+ # ADR-32 WD5 — encode the loader's virtual_rbs set into a
55
+ # `ConfigEntry` so the env cache invalidates when a
56
+ # plugin-contributed synthesised RBS string changes (or
57
+ # appears for the first time). Returns nil when the
58
+ # loader has no virtual_rbs entries, so callers without
59
+ # any synthesizer-emitting plugin pay zero descriptor
60
+ # cost.
61
+ def self.virtual_rbs_entry(loader)
62
+ return nil unless loader.respond_to?(:virtual_rbs)
63
+ return nil if loader.virtual_rbs.nil? || loader.virtual_rbs.empty?
64
+
65
+ sorted_pairs = loader.virtual_rbs.sort_by { |name, _content| name }
66
+ joined = sorted_pairs.map { |name, content| "#{name}\0#{content}" }.join("\n\0\n")
67
+ Descriptor::ConfigEntry.new(
68
+ key: "rbs.virtual_rbs",
69
+ value_hash: Digest::SHA256.hexdigest(joined)
70
+ )
71
+ end
72
+
73
+ private_class_method :rbs_gem_entry, :file_entries, :libraries_entry, :virtual_rbs_entry
55
74
  end
56
75
  end
57
76
  end
@@ -42,7 +42,8 @@ module Rigor
42
42
  def self.compute(loader)
43
43
  Rigor::Environment::RbsLoader.build_env_for(
44
44
  libraries: loader.libraries,
45
- signature_paths: loader.signature_paths
45
+ signature_paths: loader.signature_paths,
46
+ virtual_rbs: loader.respond_to?(:virtual_rbs) ? loader.virtual_rbs : []
46
47
  )
47
48
  end
48
49
 
@@ -0,0 +1,274 @@
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/def_return_typer"
10
+ require_relative "../inference/scope_indexer"
11
+ require_relative "prism_colorizer"
12
+
13
+ module Rigor
14
+ class CLI
15
+ # Executes `rigor annotate FILE`.
16
+ #
17
+ # For every source line the command finds the expression the
18
+ # line evaluates to — the last statement that ends on the line
19
+ # (so `1; 2; 3` reports `3`), or, for a line that no statement
20
+ # closes, the widest expression ending there (so the `if nil`
21
+ # header reports its condition). It infers that expression's
22
+ # type and appends a `#=> dump_type: <type>` comment.
23
+ #
24
+ # The annotated source is re-parsed with Prism — a sanity gate,
25
+ # since the appended text is always a comment — and printed to
26
+ # stdout with IRB-style syntax highlighting via
27
+ # {PrismColorizer}.
28
+ class AnnotateCommand
29
+ USAGE = "Usage: rigor annotate [options] FILE"
30
+
31
+ # Appended ` #=> dump_type: <type>` suffix. Matched and
32
+ # stripped before re-annotating so re-running is idempotent.
33
+ ANNOTATION_PATTERN = /\s*#=>\s*dump_type:.*\z/
34
+
35
+ def initialize(argv:, out:, err:)
36
+ @argv = argv
37
+ @out = out
38
+ @err = err
39
+ end
40
+
41
+ # @return [Integer] CLI exit status.
42
+ def run
43
+ options = parse_options
44
+ file = @argv.shift
45
+ if file.nil?
46
+ @err.puts(USAGE)
47
+ return CLI::EXIT_USAGE
48
+ end
49
+ unless File.file?(file)
50
+ @err.puts("annotate: file not found: #{file}")
51
+ return 1
52
+ end
53
+
54
+ execute(file, options)
55
+ end
56
+
57
+ private
58
+
59
+ def parse_options
60
+ # Default: colour a tty, unless `NO_COLOR` opts out. An
61
+ # explicit `--color` / `--no-color` overrides both.
62
+ options = { config: nil, color: @out.tty? && !no_color_env? }
63
+
64
+ parser = OptionParser.new do |opts|
65
+ opts.banner = USAGE
66
+ opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
67
+ opts.on("--[no-]color",
68
+ "Force or disable ANSI colour (default: auto-detect a tty; honours NO_COLOR)") do |value|
69
+ options[:color] = value
70
+ end
71
+ end
72
+ parser.parse!(@argv)
73
+
74
+ options
75
+ end
76
+
77
+ # https://no-color.org — colour output is suppressed by
78
+ # default when `NO_COLOR` is present and not an empty string,
79
+ # regardless of its value.
80
+ def no_color_env?
81
+ value = ENV.fetch("NO_COLOR", nil)
82
+ !value.nil? && !value.empty?
83
+ end
84
+
85
+ def execute(file, options)
86
+ configuration = Configuration.load(options.fetch(:config))
87
+ # Force UTF-8 (with BOM tolerance) regardless of
88
+ # `Encoding.default_external`. Under a minimal locale
89
+ # the default is US-ASCII; reading multi-byte source
90
+ # under that tag makes the post-parse `String#sub` /
91
+ # `String#lines` calls in `#annotate` raise
92
+ # `invalid byte sequence in US-ASCII`. Ruby source is
93
+ # UTF-8 by convention (the parser's own assumption
94
+ # absent a magic comment).
95
+ source = File.read(file, mode: "r:bom|utf-8")
96
+ parse_result = Prism.parse(source, filepath: file, version: configuration.target_ruby)
97
+ return 1 if parse_errors?(parse_result, file)
98
+
99
+ scope_index = Inference::ScopeIndexer.index(
100
+ parse_result.value, default_scope: base_scope(configuration)
101
+ )
102
+ line_types = LineTypeCollector.new(scope_index).collect(parse_result.value)
103
+
104
+ @out.puts(render(annotate(source, line_types), color: options.fetch(:color)))
105
+ 0
106
+ end
107
+
108
+ def base_scope(configuration)
109
+ Scope.empty(
110
+ environment: Environment.for_project(
111
+ libraries: configuration.libraries,
112
+ signature_paths: configuration.signature_paths
113
+ )
114
+ )
115
+ end
116
+
117
+ def parse_errors?(parse_result, file)
118
+ return false if parse_result.success?
119
+
120
+ parse_result.errors.each do |error|
121
+ @err.puts("#{file}:#{error.location.start_line}: #{error.message}")
122
+ end
123
+ true
124
+ end
125
+
126
+ # Appends ` #=> dump_type: <type>` to every line a type was
127
+ # inferred for, aligning the comment column.
128
+ def annotate(source, line_types)
129
+ lines = source.lines
130
+ column = annotation_column(lines, line_types)
131
+
132
+ lines.each_with_index.map do |line, index|
133
+ type = line_types[index + 1]
134
+ eol = line.end_with?("\n") ? "\n" : ""
135
+ code = line.chomp.sub(ANNOTATION_PATTERN, "")
136
+ next "#{code}#{eol}" if type.nil?
137
+
138
+ "#{code.ljust(column)} #=> dump_type: #{type.describe(:short)}#{eol}"
139
+ end.join
140
+ end
141
+
142
+ def annotation_column(lines, line_types)
143
+ widths = lines.each_index.filter_map do |index|
144
+ next unless line_types.key?(index + 1)
145
+
146
+ lines[index].chomp.sub(ANNOTATION_PATTERN, "").length
147
+ end
148
+ widths.max || 0
149
+ end
150
+
151
+ def render(annotated, color:)
152
+ return annotated unless color
153
+ return annotated unless Prism.parse(annotated).success?
154
+
155
+ PrismColorizer.colorize(annotated)
156
+ end
157
+ end
158
+
159
+ # Walks a parsed program and resolves, per source line, the
160
+ # type of the expression the line evaluates to. Used only by
161
+ # {AnnotateCommand}.
162
+ class LineTypeCollector
163
+ def initialize(scope_index)
164
+ @scope_index = scope_index
165
+ end
166
+
167
+ # @param program [Prism::ProgramNode]
168
+ # @return [Hash{Integer => Rigor::Type}] 1-indexed line => type.
169
+ def collect(program)
170
+ by_line = {}
171
+ each_statement(program) do |statement|
172
+ type = type_of(statement)
173
+ by_line[statement.location.end_line] = type unless type.nil?
174
+ end
175
+ fill_uncovered_lines(program, by_line)
176
+ override_def_header_lines(program, by_line)
177
+ by_line
178
+ end
179
+
180
+ private
181
+
182
+ # Yields each statement node (a child of any `StatementsNode`
183
+ # anywhere in the tree) in post-order: nested statements are
184
+ # yielded before the enclosing statement that contains them.
185
+ # `by_line[end_line] = type` overwrites earlier entries, so
186
+ # post-order means the *outermost* statement closing a line
187
+ # wins — for `b = if cond then :then else :else end` the
188
+ # line resolves to the assignment's type (the if-expression's
189
+ # union), not the else-branch's inner `:else`. Direct siblings
190
+ # (`1; 2; 3`) are still yielded in source order so the last
191
+ # sibling wins.
192
+ def each_statement(node, &block)
193
+ return if node.nil?
194
+
195
+ if node.is_a?(Prism::StatementsNode)
196
+ node.body.each do |stmt|
197
+ each_statement(stmt, &block)
198
+ block.call(stmt)
199
+ end
200
+ else
201
+ node.compact_child_nodes.each { |child| each_statement(child, &block) }
202
+ end
203
+ end
204
+
205
+ # For a line no statement closes (the `if` / block header
206
+ # lines), fall back to the widest expression ending there.
207
+ def fill_uncovered_lines(program, by_line)
208
+ widest_per_line(program).each do |line, node|
209
+ next if by_line.key?(line)
210
+
211
+ type = type_of(node)
212
+ by_line[line] = type unless type.nil?
213
+ end
214
+ end
215
+
216
+ def widest_per_line(program)
217
+ widest = {}
218
+ walk(program) do |node|
219
+ next if node.is_a?(Prism::ProgramNode) || node.is_a?(Prism::StatementsNode)
220
+
221
+ line = node.location.end_line
222
+ current = widest[line]
223
+ widest[line] = node if current.nil? || span(node) > span(current)
224
+ end
225
+ widest
226
+ end
227
+
228
+ def span(node)
229
+ node.location.end_offset - node.location.start_offset
230
+ end
231
+
232
+ def walk(node, &block)
233
+ return if node.nil?
234
+
235
+ block.call(node)
236
+ node.compact_child_nodes.each { |child| walk(child, &block) }
237
+ end
238
+
239
+ def type_of(node)
240
+ @scope_index[node].type_of(node)
241
+ rescue StandardError
242
+ nil
243
+ end
244
+
245
+ # For every `def`, replace the annotation on the header line
246
+ # (where the `def` keyword sits) with the method's inferred
247
+ # return type. The default annotation there comes from the
248
+ # parameter list (`name` typing as `Dynamic[top]`), which is
249
+ # noise; the return type is what readers actually want next
250
+ # to the method signature. When the return type cannot be
251
+ # inferred (empty body, scope-lookup miss, or any error
252
+ # under `DefReturnTyper.call`), the entry is deleted so no
253
+ # annotation is shown on that line.
254
+ def override_def_header_lines(program, by_line)
255
+ each_def_node(program) do |def_node|
256
+ line = def_node.location.start_line
257
+ return_type = Inference::DefReturnTyper.call(def_node, @scope_index)
258
+ if return_type.nil?
259
+ by_line.delete(line)
260
+ else
261
+ by_line[line] = return_type
262
+ end
263
+ end
264
+ end
265
+
266
+ def each_def_node(node, &block)
267
+ return if node.nil?
268
+
269
+ block.call(node) if node.is_a?(Prism::DefNode)
270
+ node.compact_child_nodes.each { |child| each_def_node(child, &block) }
271
+ end
272
+ end
273
+ end
274
+ end