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.
- checksums.yaml +4 -4
- data/README.md +186 -513
- data/lib/rigor/analysis/check_rules.rb +20 -0
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +274 -0
- data/lib/rigor/cli/baseline_command.rb +36 -16
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli/prism_colorizer.rb +111 -0
- data/lib/rigor/cli.rb +134 -6
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +168 -5
- data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
- data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +308 -18
- data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
- data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
- data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +148 -1
- data/lib/rigor/inference/method_parameter_binder.rb +67 -10
- data/lib/rigor/inference/narrowing.rb +29 -10
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +29 -3
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +67 -5
- data/lib/rigor/plugin/loader.rb +22 -1
- data/lib/rigor/plugin/manifest.rb +101 -10
- data/lib/rigor/plugin/protocol_contract.rb +185 -0
- data/lib/rigor/plugin/registry.rb +87 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/triage/catalogue.rb +2 -2
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/type/constant.rb +29 -2
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|