rigortype 0.2.9 → 0.3.1
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 +2 -2
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/string_scanner.rbs +6 -5
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/03-narrowing.md +2 -1
- data/docs/handbook/04-tuples-and-shapes.md +7 -3
- data/docs/handbook/06-classes.md +19 -10
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -246
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/11-sig-gen.md +11 -1
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-mypy.md +4 -3
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/install.md +14 -6
- data/docs/manual/01-installation.md +98 -4
- data/docs/manual/02-cli-reference.md +81 -12
- data/docs/manual/03-configuration.md +34 -2
- data/docs/manual/04-diagnostics.md +59 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/07-plugins.md +4 -4
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +7 -6
- data/docs/manual/11-ci.md +51 -0
- data/docs/manual/12-caching.md +65 -0
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +9 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/duplicate_hash_key_collector.rb +128 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +60 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -2
- data/lib/rigor/analysis/check_rules/return_in_ensure_collector.rb +117 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +125 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -1
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +283 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +10 -1
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +112 -0
- data/lib/rigor/analysis/check_rules.rb +581 -98
- data/lib/rigor/analysis/dependency_recorder.rb +93 -9
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental_session.rb +456 -51
- data/lib/rigor/analysis/path_expansion.rb +42 -0
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +188 -0
- data/lib/rigor/analysis/rule_catalog.rb +226 -1
- data/lib/rigor/analysis/run_cache_key.rb +78 -0
- data/lib/rigor/analysis/run_cache_probe.rb +72 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +181 -28
- data/lib/rigor/analysis/runner/pool_coordinator.rb +77 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +95 -49
- data/lib/rigor/analysis/runner/run_snapshots.rb +9 -2
- data/lib/rigor/analysis/runner.rb +344 -68
- data/lib/rigor/analysis/severity_stamp.rb +43 -0
- data/lib/rigor/analysis/worker_session.rb +30 -2
- data/lib/rigor/bleeding_edge.rb +37 -8
- data/lib/rigor/builtins/regex_refinement.rb +36 -1
- data/lib/rigor/cache/descriptor.rb +88 -28
- data/lib/rigor/cache/file_digest.rb +158 -0
- data/lib/rigor/cache/incremental_snapshot.rb +41 -5
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_descriptor.rb +56 -10
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +132 -27
- data/lib/rigor/ci_detector.rb +90 -0
- data/lib/rigor/cli/annotate_command.rb +15 -9
- data/lib/rigor/cli/baseline_command.rb +4 -1
- data/lib/rigor/cli/check_command.rb +223 -32
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +6 -1
- data/lib/rigor/cli/docs_command.rb +0 -30
- data/lib/rigor/cli/doctor_command.rb +169 -31
- data/lib/rigor/cli/lsp_command.rb +5 -0
- data/lib/rigor/cli/mcp_command.rb +5 -0
- data/lib/rigor/cli/plugins_command.rb +36 -6
- data/lib/rigor/cli/plugins_renderer.rb +44 -12
- data/lib/rigor/cli/probe_environment.rb +85 -0
- data/lib/rigor/cli/sig_gen_command.rb +36 -9
- data/lib/rigor/cli/skill_command.rb +22 -33
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/cli/trace_command.rb +5 -6
- data/lib/rigor/cli/type_of_command.rb +9 -8
- data/lib/rigor/cli/type_scan_command.rb +8 -8
- data/lib/rigor/cli.rb +15 -4
- data/lib/rigor/config_audit.rb +48 -2
- data/lib/rigor/configuration/severity_profile.rb +31 -0
- data/lib/rigor/configuration.rb +156 -8
- data/lib/rigor/environment/default_libraries.rb +36 -0
- data/lib/rigor/environment/rbs_loader.rb +164 -5
- data/lib/rigor/environment.rb +29 -28
- data/lib/rigor/inference/budget_trace.rb +77 -2
- data/lib/rigor/inference/closure_escape_analyzer.rb +14 -1
- data/lib/rigor/inference/def_handle.rb +23 -0
- data/lib/rigor/inference/def_node_resolver.rb +90 -0
- data/lib/rigor/inference/def_return_typer.rb +2 -1
- data/lib/rigor/inference/expression_typer.rb +361 -113
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +30 -5
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +200 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +22 -16
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +33 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +17 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +42 -9
- data/lib/rigor/inference/method_dispatcher/receiver_affinity.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +28 -35
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +40 -8
- data/lib/rigor/inference/method_dispatcher.rb +57 -1
- data/lib/rigor/inference/mutation_widening.rb +42 -8
- data/lib/rigor/inference/narrowing.rb +127 -17
- data/lib/rigor/inference/parameter_inference_collector.rb +15 -0
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +8 -1
- data/lib/rigor/inference/scope_indexer.rb +451 -36
- data/lib/rigor/inference/statement_evaluator.rb +64 -15
- data/lib/rigor/inference/struct_fold_safety.rb +48 -11
- data/lib/rigor/inference/synthetic_method_scanner.rb +15 -14
- data/lib/rigor/inference/void_origin.rb +25 -0
- data/lib/rigor/inference/void_tail_summary.rb +220 -0
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +46 -6
- data/lib/rigor/language_server/completion_provider.rb +2 -1
- data/lib/rigor/language_server/diagnostic_publisher.rb +4 -0
- data/lib/rigor/language_server/document_symbol_provider.rb +2 -1
- data/lib/rigor/language_server/folding_range_provider.rb +2 -1
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/selection_range_provider.rb +2 -1
- data/lib/rigor/language_server/server.rb +19 -9
- data/lib/rigor/language_server/signature_help_provider.rb +2 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/base.rb +38 -24
- data/lib/rigor/plugin/inflector.rb +12 -3
- data/lib/rigor/plugin/io_boundary.rb +5 -1
- data/lib/rigor/plugin/isolation.rb +81 -11
- data/lib/rigor/plugin/load_error.rb +10 -1
- data/lib/rigor/plugin/loader.rb +96 -14
- data/lib/rigor/plugin/node_rule_walk.rb +5 -3
- data/lib/rigor/plugin/registry.rb +32 -23
- data/lib/rigor/plugin.rb +26 -0
- data/lib/rigor/protection/mutator.rb +3 -2
- data/lib/rigor/reflection.rb +64 -0
- data/lib/rigor/runtime/jit.rb +128 -0
- data/lib/rigor/scope/discovery_index.rb +12 -2
- data/lib/rigor/scope.rb +122 -28
- data/lib/rigor/sig_gen/classification.rb +5 -1
- data/lib/rigor/sig_gen/generator.rb +38 -6
- data/lib/rigor/sig_gen/observation_collector.rb +4 -3
- data/lib/rigor/sig_gen/rbs_validity.rb +44 -0
- data/lib/rigor/sig_gen/renderer.rb +10 -0
- data/lib/rigor/sig_gen/write_result.rb +9 -4
- data/lib/rigor/sig_gen/writer.rb +207 -49
- data/lib/rigor/source/node_children.rb +116 -0
- data/lib/rigor/source/node_locator.rb +3 -1
- data/lib/rigor/source/node_walker.rb +4 -2
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/difference.rb +28 -24
- data/lib/rigor/type/hash_shape.rb +34 -10
- data/lib/rigor/version.rb +1 -1
- data/lib/rigortype.rb +24 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +4 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +6 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +5 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +3 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +4 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +4 -2
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +38 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -1
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +3 -1
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +10 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +11 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +3 -1
- data/plugins/rigor-rails/lib/rigor-rails.rb +11 -12
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/grape_api_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_discoverer.rb +3 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +7 -5
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +83 -9
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +9 -5
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +3 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +11 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +3 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +74 -14
- data/sig/prism_node_children.rbs +9 -0
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +18 -0
- data/sig/rigor/inference.rbs +7 -0
- data/sig/rigor/plugin/base.rbs +6 -6
- data/sig/rigor/reflection.rbs +1 -0
- data/sig/rigor/scope.rbs +12 -1
- data/skills/rigor-ci-setup/SKILL.md +10 -0
- data/skills/rigor-editor-setup/SKILL.md +11 -0
- data/skills/rigor-next-steps/SKILL.md +23 -2
- data/skills/rigor-plugin-author/SKILL.md +3 -3
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +2 -2
- data/skills/rigor-plugin-review/SKILL.md +2 -2
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +5 -4
- data/skills/rigor-project-init/SKILL.md +29 -3
- data/skills/rigor-project-init/references/01-detect.md +1 -1
- data/skills/rigor-project-init/references/02-configure.md +39 -5
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +20 -0
- data/skills/rigor-project-init/references/05-jit-performance.md +125 -0
- metadata +48 -26
- data/lib/rigor/cli/ci_detector.rb +0 -89
|
@@ -6,6 +6,7 @@ require_relative "../environment"
|
|
|
6
6
|
require_relative "../scope"
|
|
7
7
|
require_relative "../type"
|
|
8
8
|
require_relative "../source/literals"
|
|
9
|
+
require_relative "../source/node_children"
|
|
9
10
|
require_relative "../inference/scope_indexer"
|
|
10
11
|
|
|
11
12
|
module Rigor
|
|
@@ -126,7 +127,7 @@ module Rigor
|
|
|
126
127
|
end
|
|
127
128
|
end
|
|
128
129
|
|
|
129
|
-
node.
|
|
130
|
+
node.rigor_each_child { |child| walk_class_decls(child, prefix, accumulator) }
|
|
130
131
|
end
|
|
131
132
|
|
|
132
133
|
def qualified_constant_path(constant_path)
|
|
@@ -146,7 +147,7 @@ module Rigor
|
|
|
146
147
|
return unless node.is_a?(Prism::Node)
|
|
147
148
|
|
|
148
149
|
record_call(node, scope_index, bindings, observations) if node.is_a?(Prism::CallNode)
|
|
149
|
-
node.
|
|
150
|
+
node.rigor_each_child { |child| walk_calls(child, scope_index, bindings, observations) }
|
|
150
151
|
end
|
|
151
152
|
|
|
152
153
|
def record_call(call_node, scope_index, bindings, observations)
|
|
@@ -244,7 +245,7 @@ module Rigor
|
|
|
244
245
|
recognise_describe(node, bindings)
|
|
245
246
|
recognise_subject_or_let(node, bindings, scope_index)
|
|
246
247
|
|
|
247
|
-
node.
|
|
248
|
+
node.rigor_each_child { |child| walk_rspec_bindings(child, bindings, scope_index) }
|
|
248
249
|
end
|
|
249
250
|
|
|
250
251
|
def recognise_describe(node, bindings)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbs"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module SigGen
|
|
7
|
+
# Parses what sig-gen is about to emit, before it is emitted.
|
|
8
|
+
#
|
|
9
|
+
# Two rendering bugs have shipped RBS that `rbs` itself rejects — a non-identifier record key
|
|
10
|
+
# (`{ :"data-contrast" => T }`) and a `&block` constructor parameter rendered before the parens
|
|
11
|
+
# (`(**untyped, ?{ (?) -> void })`). Both were found downstream, as a poisoned `sig/` tree: the file is
|
|
12
|
+
# quarantined, its types vanish, and (for a project adopting `reject-unparseable-signatures`) the build
|
|
13
|
+
# fails. A generator that can poison its own consumer's build is worth guarding at the source.
|
|
14
|
+
#
|
|
15
|
+
# So this is not a check for ONE bug — it is the guard that turns the whole rendering-bug *class* from a
|
|
16
|
+
# silent bad artifact into a caught error naming the method that could not be rendered. It is cheap: the
|
|
17
|
+
# text is already in memory, and `RBS::Parser` is the same C parser the consumer will use, so it is exactly
|
|
18
|
+
# the oracle that matters (not an approximation of it).
|
|
19
|
+
module RbsValidity
|
|
20
|
+
# A rendered method line (`def foo: () -> String`) is a fragment, not a compilation unit — it has to be
|
|
21
|
+
# wrapped to be parsed at all. The wrapper name is irrelevant to the parse and never reaches disk.
|
|
22
|
+
PROBE_CLASS = "RigorSigGenProbe"
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param line [String] a rendered RBS method line, e.g. `def self.parse: (String) -> Integer`.
|
|
27
|
+
# @return [String, nil] the parse error's first line, or nil when the line is valid.
|
|
28
|
+
def method_line_error(line)
|
|
29
|
+
return nil if line.nil?
|
|
30
|
+
|
|
31
|
+
source_error("class #{PROBE_CLASS}\n #{line}\nend\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param source [String] a complete `.rbs` file's text.
|
|
35
|
+
# @return [String, nil] the parse error's first line, or nil when the file is valid.
|
|
36
|
+
def source_error(source)
|
|
37
|
+
::RBS::Parser.parse_signature(::RBS::Buffer.new(name: "(rigor sig-gen)", content: source))
|
|
38
|
+
nil
|
|
39
|
+
rescue ::RBS::ParsingError => e
|
|
40
|
+
e.message.to_s.lines.first.to_s.strip
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -126,6 +126,7 @@ module Rigor
|
|
|
126
126
|
when :created then render_write_created(result)
|
|
127
127
|
when :updated then render_write_updated(result)
|
|
128
128
|
when :skipped_outside_sig_root then render_write_skipped(result)
|
|
129
|
+
when :skipped_invalid_rbs then render_write_invalid(result)
|
|
129
130
|
end
|
|
130
131
|
end
|
|
131
132
|
end
|
|
@@ -143,6 +144,15 @@ module Rigor
|
|
|
143
144
|
@out.puts("skipped #{result.source_path} -> #{result.target_path} (outside sig root)")
|
|
144
145
|
end
|
|
145
146
|
|
|
147
|
+
# The assembled file does not parse, so it was NOT written. Writing it would poison the sig tree — the
|
|
148
|
+
# consumer quarantines an unparseable `.rbs` whole, taking every other type in that file down with it,
|
|
149
|
+
# including the user's own hand-written ones.
|
|
150
|
+
def render_write_invalid(result)
|
|
151
|
+
@out.puts("REFUSED #{result.target_path} — the generated RBS does not parse, so it was not written")
|
|
152
|
+
@out.puts(" #{result.error}")
|
|
153
|
+
@out.puts(" This is a bug in Rigor's RBS rendering, not in your code — please report it.")
|
|
154
|
+
end
|
|
155
|
+
|
|
146
156
|
def render_write_json(results)
|
|
147
157
|
@out.puts(JSON.pretty_generate({ results: results.map(&:to_h) }))
|
|
148
158
|
end
|
|
@@ -10,19 +10,24 @@ module Rigor
|
|
|
10
10
|
# - `source_path` — original `.rb` file.
|
|
11
11
|
# - `target_path` — `.rbs` file the writer was responsible for (`nil` when the source path falls outside
|
|
12
12
|
# the project signature tree, in which case `action` is `:skipped_outside_sig_root`).
|
|
13
|
-
# - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root
|
|
13
|
+
# - `action` — one of `:created` / `:updated` / `:noop` / `:skipped_outside_sig_root` /
|
|
14
|
+
# `:skipped_invalid_rbs`.
|
|
14
15
|
# - `applied` — the {MethodCandidate}s that actually landed on disk.
|
|
15
16
|
# - `skipped` — the {MethodCandidate}s the writer declined (e.g. tighter-return without `--overwrite`). Each
|
|
16
17
|
# entry pairs the candidate with a skip reason keyword (`:user_authored`).
|
|
18
|
+
# - `error` — the parse error, when `action` is `:skipped_invalid_rbs`: the file the writer assembled does
|
|
19
|
+
# not parse, so it was NOT written (writing it would poison the project's sig tree — the consumer
|
|
20
|
+
# quarantines an unparseable `.rbs`, taking every other type in that file down with it).
|
|
17
21
|
class WriteResult
|
|
18
|
-
attr_reader :source_path, :target_path, :action, :applied, :skipped
|
|
22
|
+
attr_reader :source_path, :target_path, :action, :applied, :skipped, :error
|
|
19
23
|
|
|
20
|
-
def initialize(source_path:, target_path:, action:, applied: [], skipped: [])
|
|
24
|
+
def initialize(source_path:, target_path:, action:, applied: [], skipped: [], error: nil)
|
|
21
25
|
@source_path = source_path
|
|
22
26
|
@target_path = target_path
|
|
23
27
|
@action = action
|
|
24
28
|
@applied = applied.freeze
|
|
25
29
|
@skipped = skipped.freeze
|
|
30
|
+
@error = error
|
|
26
31
|
freeze
|
|
27
32
|
end
|
|
28
33
|
|
|
@@ -33,7 +38,7 @@ module Rigor
|
|
|
33
38
|
action: action.to_s,
|
|
34
39
|
applied: applied.map(&:to_h),
|
|
35
40
|
skipped: skipped.map { |c, reason| c.to_h.merge(write_skip_reason: reason.to_s) }
|
|
36
|
-
}
|
|
41
|
+
}.tap { |h| h[:error] = error if error }
|
|
37
42
|
end
|
|
38
43
|
end
|
|
39
44
|
end
|
data/lib/rigor/sig_gen/writer.rb
CHANGED
|
@@ -108,8 +108,15 @@ module Rigor
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def create_new(source_path, target, candidates)
|
|
111
|
+
content = render_new_file(candidates)
|
|
112
|
+
error = RbsValidity.source_error(content)
|
|
113
|
+
if error
|
|
114
|
+
return WriteResult.new(source_path: source_path, target_path: target,
|
|
115
|
+
action: :skipped_invalid_rbs, error: error)
|
|
116
|
+
end
|
|
117
|
+
|
|
111
118
|
FileUtils.mkdir_p(target.dirname)
|
|
112
|
-
target.write(
|
|
119
|
+
target.write(content)
|
|
113
120
|
WriteResult.new(source_path: source_path, target_path: target,
|
|
114
121
|
action: :created, applied: candidates)
|
|
115
122
|
end
|
|
@@ -272,35 +279,65 @@ module Rigor
|
|
|
272
279
|
return WriteResult.new(source_path: source_path, target_path: target, action: :noop) if decls.nil?
|
|
273
280
|
|
|
274
281
|
state = MergeState.new(source: source, decls: decls, applied: [], skipped: [])
|
|
275
|
-
|
|
276
|
-
candidates.group_by(&:class_name).each { |class_name, methods| merge_class(state, class_name, methods, supers) }
|
|
277
|
-
merge_class_shells(state, collect_class_shells(candidates), merged_namespace_kinds(candidates))
|
|
282
|
+
merge_candidates(state, candidates)
|
|
278
283
|
|
|
279
284
|
action = state.applied.empty? ? :noop : :updated
|
|
280
|
-
|
|
285
|
+
unless action == :updated
|
|
286
|
+
return WriteResult.new(source_path: source_path, target_path: target, action: action,
|
|
287
|
+
applied: state.applied, skipped: state.skipped)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The merge splices text into an existing file by byte offset, so a bug here can produce a file neither
|
|
291
|
+
# the generator nor the target was responsible for. Parse the assembled result and refuse to write it if
|
|
292
|
+
# it is broken: a `.rbs` the consumer cannot parse is quarantined whole, so a bad splice would delete
|
|
293
|
+
# every type in the file — including the user's own, which were fine before we touched them.
|
|
294
|
+
error = RbsValidity.source_error(state.source)
|
|
295
|
+
if error
|
|
296
|
+
return WriteResult.new(source_path: source_path, target_path: target,
|
|
297
|
+
action: :skipped_invalid_rbs, error: error)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
target.write(state.source)
|
|
281
301
|
WriteResult.new(source_path: source_path, target_path: target,
|
|
282
302
|
action: action, applied: state.applied, skipped: state.skipped)
|
|
283
303
|
end
|
|
284
304
|
|
|
305
|
+
# Applies every class group, then every requested shell, then normalises the layout the three steps
|
|
306
|
+
# leave behind. Each step mutates `state` in place and re-parses, so the next one sees current offsets.
|
|
307
|
+
def merge_candidates(state, candidates)
|
|
308
|
+
supers = merged_superclasses(candidates)
|
|
309
|
+
kinds = merged_namespace_kinds(candidates)
|
|
310
|
+
shells = collect_class_shells(candidates)
|
|
311
|
+
groups = candidates.group_by(&:class_name)
|
|
312
|
+
# Ancestors first, so `Foo` is created (or found) before `Foo::Bar` looks for a parent to nest under.
|
|
313
|
+
# A plain sort suffices: a name always sorts before every name it is a strict prefix of.
|
|
314
|
+
groups.keys.sort.each { |name| merge_class(state, name, groups.fetch(name), kinds, supers) }
|
|
315
|
+
merge_class_shells(state, shells, kinds, supers)
|
|
316
|
+
collapse_nested_declarations(state, groups.keys + shells.to_a)
|
|
317
|
+
end
|
|
318
|
+
|
|
285
319
|
# ADR-14 gap-#3 (e): for every requested class shell that isn't already declared in the target file,
|
|
286
320
|
# insert an empty `class Const\nend` block inside the nearest existing ancestor. Shells already covered by
|
|
287
321
|
# an existing declaration are silently a no-op. The `applied` accumulator does NOT grow — shells are
|
|
288
322
|
# structural declarations, not methods, so the action-count surface (`updated +N`) keeps reflecting method
|
|
289
323
|
# changes only.
|
|
290
|
-
def merge_class_shells(state, shells, kinds)
|
|
324
|
+
def merge_class_shells(state, shells, kinds, supers)
|
|
291
325
|
shells.each do |qualified|
|
|
292
326
|
next if find_class_decl(state.decls, qualified)
|
|
293
327
|
|
|
294
|
-
|
|
328
|
+
insert_namespace_chain(state, qualified, kinds, supers, [])
|
|
295
329
|
end
|
|
296
330
|
end
|
|
297
331
|
|
|
298
|
-
|
|
332
|
+
# Splices the missing part of `qualified`'s namespace chain into the nearest declaration the file
|
|
333
|
+
# already has for one of its ancestors, or at top level when it has none. `methods` land on the leaf
|
|
334
|
+
# node, so this is the single insertion path for both an empty class shell and a brand-new class.
|
|
335
|
+
def insert_namespace_chain(state, qualified, kinds, supers, methods)
|
|
299
336
|
segments = qualified.split("::")
|
|
300
337
|
anchor_segs, missing = split_at_existing_ancestor(state.decls, segments)
|
|
301
338
|
anchor_decl = anchor_segs.empty? ? nil : find_class_decl(state.decls, anchor_segs.join("::"))
|
|
302
|
-
depth = anchor_decl ?
|
|
303
|
-
snippet =
|
|
339
|
+
depth = anchor_decl ? member_indent_depth(anchor_decl) : 0
|
|
340
|
+
snippet = render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
|
|
304
341
|
state.source = if anchor_decl
|
|
305
342
|
insert_before_end(state.source, anchor_decl, snippet)
|
|
306
343
|
else
|
|
@@ -309,6 +346,113 @@ module Rigor
|
|
|
309
346
|
state.decls = parse_signature(state.source) || state.decls
|
|
310
347
|
end
|
|
311
348
|
|
|
349
|
+
# ADR-14 gap-#3 follow-up (c), update half: the create path folds a strict-prefix pair into one nested
|
|
350
|
+
# tree, but a file written before that fix — or by hand — can still carry the flat sibling layout
|
|
351
|
+
# (`class Foo` next to a top-level `class Foo::Bar`). After merging, relocate each declaration this run
|
|
352
|
+
# touched underneath its parent's declaration when the same file holds both, so an update converges on
|
|
353
|
+
# the same canonical layout a fresh generation would produce.
|
|
354
|
+
#
|
|
355
|
+
# Scope is deliberately the touched names only: an unrelated flat pair elsewhere in the file is none of
|
|
356
|
+
# sig-gen's business, and rewriting it would be a layout change the user never asked for. Shallowest
|
|
357
|
+
# first, so `Foo::Bar` has already moved under `Foo` by the time `Foo::Bar::Baz` looks for its parent.
|
|
358
|
+
def collapse_nested_declarations(state, names)
|
|
359
|
+
names.uniq.sort.each { |name| relocate_under_parent(state, name) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def relocate_under_parent(state, qualified)
|
|
363
|
+
segments = qualified.split("::")
|
|
364
|
+
return if segments.size < 2
|
|
365
|
+
|
|
366
|
+
parent_name = segments[0...-1].join("::")
|
|
367
|
+
parent = find_class_decl(state.decls, parent_name)
|
|
368
|
+
decl = parent && find_class_decl(state.decls, qualified)
|
|
369
|
+
return if decl.nil? || overlapping?(parent, decl)
|
|
370
|
+
|
|
371
|
+
region = decl_region(state.source, decl)
|
|
372
|
+
block = region && relocated_block(state.source, decl, parent, segments.last, region)
|
|
373
|
+
return if block.nil?
|
|
374
|
+
|
|
375
|
+
apply_relocation(state, region, parent_name, block)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Cuts the declaration's region out, re-parses so the parent's byte range reflects the removal, and
|
|
379
|
+
# splices the re-indented block back in as the parent's last member. Any step that cannot be carried
|
|
380
|
+
# out cleanly (an unparseable intermediate, a parent that vanished) abandons the move and leaves the
|
|
381
|
+
# merged source exactly as it was — a flat layout is cosmetic, a mangled `.rbs` is not.
|
|
382
|
+
def apply_relocation(state, region, parent_name, block)
|
|
383
|
+
source = splice_out(state.source, region)
|
|
384
|
+
decls = parse_signature(source)
|
|
385
|
+
anchor = decls && find_class_decl(decls, parent_name)
|
|
386
|
+
return if anchor.nil?
|
|
387
|
+
|
|
388
|
+
state.source = insert_before_end(source, anchor, block)
|
|
389
|
+
state.decls = parse_signature(state.source) || state.decls
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# True when the two declarations' source ranges are not disjoint — either one already nests the other,
|
|
393
|
+
# or the file's shape is one this pass does not understand. Both are reasons not to move anything.
|
|
394
|
+
def overlapping?(one, other)
|
|
395
|
+
one.location.start_pos < other.location.end_pos && other.location.start_pos < one.location.end_pos
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The declaration's full source region, extended to cover its leading comment and annotations and to
|
|
399
|
+
# end just past the newline that closes it. Returns `nil` when either edge shares a line with something
|
|
400
|
+
# else, because cutting there would move — or strand — text the declaration does not own.
|
|
401
|
+
def decl_region(source, decl)
|
|
402
|
+
start_pos = ([decl.location.start_pos] + leading_positions(decl)).min
|
|
403
|
+
line_start = line_start_index(source, start_pos)
|
|
404
|
+
return nil unless blank_range?(source, line_start, start_pos)
|
|
405
|
+
|
|
406
|
+
finish = decl.location.end_pos
|
|
407
|
+
line_end = source.index("\n", finish) || source.size
|
|
408
|
+
return nil unless blank_range?(source, finish, line_end)
|
|
409
|
+
|
|
410
|
+
line_start...(line_end < source.size ? line_end + 1 : line_end)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def leading_positions(decl)
|
|
414
|
+
positions = decl.annotations.filter_map { |a| a.location&.start_pos }
|
|
415
|
+
comment_location = decl.comment&.location
|
|
416
|
+
positions << comment_location.start_pos if comment_location
|
|
417
|
+
positions
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# The moved text, with its compact `Foo::Bar` head shortened to `Bar` and every line pushed in to the
|
|
421
|
+
# parent's member depth. The name's own sub-location drives the rewrite, so a superclass, type
|
|
422
|
+
# parameters, and the whole body survive byte-for-byte.
|
|
423
|
+
def relocated_block(source, decl, parent, short_name, region)
|
|
424
|
+
name_location = decl.location[:name]
|
|
425
|
+
return nil if name_location.nil?
|
|
426
|
+
|
|
427
|
+
text = source[region].to_s
|
|
428
|
+
head = name_location.start_pos - region.begin
|
|
429
|
+
tail = name_location.end_pos - region.begin
|
|
430
|
+
renamed = text[0...head].to_s + short_name + text[tail..].to_s
|
|
431
|
+
reindent(renamed, member_indent_depth(parent) - decl_indent_depth(decl))
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def reindent(text, delta)
|
|
435
|
+
return text unless delta.positive?
|
|
436
|
+
|
|
437
|
+
prefix = INDENT * delta
|
|
438
|
+
text.lines.map { |line| line.match?(/\A\s*\z/) ? line : prefix + line }.join
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Removes `region`, collapsing the newline run left behind at the seam so the cut never shows up as a
|
|
442
|
+
# widening gap (or a trailing blank line at EOF) in the file it edited. Spacing that was already fine
|
|
443
|
+
# is left exactly as the user wrote it.
|
|
444
|
+
def splice_out(source, region)
|
|
445
|
+
before = source[0...region.begin].to_s
|
|
446
|
+
after = source[region.end..].to_s
|
|
447
|
+
return after.sub(/\A\n+/, "") if before.match?(/\A\s*\z/)
|
|
448
|
+
return before.sub(/\n{2,}\z/, "\n") if after.match?(/\A\s*\z/)
|
|
449
|
+
|
|
450
|
+
seam = before[/\n+\z/].to_s.size + after[/\A\n+/].to_s.size
|
|
451
|
+
return before + after if seam <= 2
|
|
452
|
+
|
|
453
|
+
"#{before.sub(/\n+\z/, "\n\n")}#{after.sub(/\A\n+/, '')}"
|
|
454
|
+
end
|
|
455
|
+
|
|
312
456
|
def split_at_existing_ancestor(decls, segments)
|
|
313
457
|
(segments.size - 1).downto(0).each do |i|
|
|
314
458
|
ancestor = segments[0...i].join("::")
|
|
@@ -317,37 +461,61 @@ module Rigor
|
|
|
317
461
|
[[], segments]
|
|
318
462
|
end
|
|
319
463
|
|
|
320
|
-
#
|
|
321
|
-
# Pre-existing members might be missing (an empty `class Foo; end`)
|
|
322
|
-
# signal.
|
|
323
|
-
def
|
|
324
|
-
|
|
325
|
-
|
|
464
|
+
# The indent depth (in `INDENT` units) a member of `decl` sits at: one level deeper than the
|
|
465
|
+
# declaration's own keyword column. Pre-existing members might be missing (an empty `class Foo; end`)
|
|
466
|
+
# so the keyword column is the robust signal.
|
|
467
|
+
def member_indent_depth(decl)
|
|
468
|
+
decl_indent_depth(decl) + 1
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def decl_indent_depth(decl)
|
|
472
|
+
decl.location[:keyword].start_column / INDENT.size
|
|
326
473
|
end
|
|
327
474
|
|
|
328
|
-
|
|
475
|
+
# Renders the `missing` segment chain as one nested block, carrying `methods` on its leaf, by handing a
|
|
476
|
+
# synthesised tree node to the create path's renderer. Both paths therefore agree on the keyword, the
|
|
477
|
+
# superclass suffix, and the indentation of every level. A leaf with no methods is a class shell, which
|
|
478
|
+
# is exactly the create path's `shell:` node (gap-#3 (e)).
|
|
479
|
+
def render_chain_snippet(missing, anchor_segs, kinds, supers, depth, methods)
|
|
329
480
|
return "" if missing.empty?
|
|
330
481
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if rest.empty?
|
|
335
|
-
keyword = kinds[qualified] || :class
|
|
336
|
-
"#{indent}#{keyword} #{head}\n#{indent}end\n"
|
|
337
|
-
else
|
|
338
|
-
inner = build_shell_snippet(rest, anchor_segs + [head], kinds, depth + 1)
|
|
339
|
-
keyword = kinds[qualified] || :module
|
|
340
|
-
"#{indent}#{keyword} #{head}\n#{inner}#{indent}end\n"
|
|
482
|
+
node = missing.reverse.each_with_index.inject(nil) do |child, (segment, index)|
|
|
483
|
+
{ name: segment, children: child ? { child[:name] => child } : {},
|
|
484
|
+
methods: index.zero? ? methods : [], shell: index.zero? && methods.empty? }
|
|
341
485
|
end
|
|
486
|
+
render_tree_node(node, kinds, supers, depth, anchor_segs)
|
|
342
487
|
end
|
|
343
488
|
|
|
489
|
+
# Splices `snippet` in just before the declaration's closing `end`. When that `end` starts its own line
|
|
490
|
+
# the insertion point moves to the START of that line: the snippet carries its own indentation, and
|
|
491
|
+
# anchoring at the keyword would otherwise donate the `end`'s indent to the snippet's first line and
|
|
492
|
+
# strand the `end` in column zero.
|
|
344
493
|
def insert_before_end(source, decl, snippet)
|
|
494
|
+
return source if snippet.empty?
|
|
495
|
+
|
|
345
496
|
end_pos = decl.location[:end].start_pos
|
|
346
|
-
|
|
497
|
+
line_start = line_start_index(source, end_pos)
|
|
498
|
+
return source[0...line_start] + snippet + source[line_start..] if blank_range?(source, line_start, end_pos)
|
|
499
|
+
|
|
500
|
+
"#{source[0...end_pos]}\n#{snippet}#{source[end_pos..]}"
|
|
347
501
|
end
|
|
348
502
|
|
|
503
|
+
# Appends a top-level block, separated from whatever precedes it by exactly one blank line.
|
|
349
504
|
def append_top_level(source, snippet)
|
|
350
|
-
|
|
505
|
+
return snippet if source.empty?
|
|
506
|
+
|
|
507
|
+
base = ends_with_newline?(source) ? source : "#{source}\n"
|
|
508
|
+
base.end_with?("\n\n") ? base + snippet : "#{base}\n#{snippet}"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def line_start_index(source, pos)
|
|
512
|
+
return 0 unless pos.positive?
|
|
513
|
+
|
|
514
|
+
(source.rindex("\n", pos - 1) || -1) + 1
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def blank_range?(source, from, to)
|
|
518
|
+
source[from...to].to_s.match?(/\A[ \t]*\z/)
|
|
351
519
|
end
|
|
352
520
|
|
|
353
521
|
def parse_signature(source)
|
|
@@ -357,14 +525,15 @@ module Rigor
|
|
|
357
525
|
nil
|
|
358
526
|
end
|
|
359
527
|
|
|
360
|
-
def merge_class(state, class_name, methods, supers
|
|
528
|
+
def merge_class(state, class_name, methods, kinds, supers)
|
|
361
529
|
decl = find_class_decl(state.decls, class_name)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
530
|
+
if decl.nil?
|
|
531
|
+
state.applied.concat(methods)
|
|
532
|
+
insert_namespace_chain(state, class_name, kinds, supers, methods)
|
|
533
|
+
else
|
|
534
|
+
state.source = merge_into_existing_class(state.source, decl, methods, state.applied, state.skipped)
|
|
535
|
+
state.decls = parse_signature(state.source) || state.decls
|
|
536
|
+
end
|
|
368
537
|
end
|
|
369
538
|
|
|
370
539
|
# Walks the parsed decl tree recursively, tracking the enclosing module/class prefix, and returns the
|
|
@@ -389,16 +558,6 @@ module Rigor
|
|
|
389
558
|
nil
|
|
390
559
|
end
|
|
391
560
|
|
|
392
|
-
# Appends an entirely new `class Foo … end` block at the end of the file (with a leading blank line as
|
|
393
|
-
# separator).
|
|
394
|
-
def append_new_class(source, class_name, methods, applied, superclass = nil)
|
|
395
|
-
body = methods.map { |c| "#{INDENT}#{c.rbs}" }.join("\n")
|
|
396
|
-
header = superclass ? "class #{class_name} < #{superclass}" : "class #{class_name}"
|
|
397
|
-
snippet = "\n#{header}\n#{body}\nend\n"
|
|
398
|
-
applied.concat(methods)
|
|
399
|
-
ends_with_newline?(source) ? source + snippet : "#{source}\n#{snippet}"
|
|
400
|
-
end
|
|
401
|
-
|
|
402
561
|
def ends_with_newline?(source)
|
|
403
562
|
source.end_with?("\n")
|
|
404
563
|
end
|
|
@@ -455,9 +614,8 @@ module Rigor
|
|
|
455
614
|
def insert_into_class(source, decl, new_methods)
|
|
456
615
|
return source if new_methods.empty?
|
|
457
616
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
source[0...end_pos] + addition + source[end_pos..]
|
|
617
|
+
indent = INDENT * member_indent_depth(decl)
|
|
618
|
+
insert_before_end(source, decl, new_methods.map { |c| "#{indent}#{c.rbs}\n" }.join)
|
|
461
619
|
end
|
|
462
620
|
|
|
463
621
|
# Walks the class's existing method declarations; for each replaceable candidate that matches a member
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Source
|
|
7
|
+
# Allocation-free replacement for the `node.compact_child_nodes.each { … }` idiom.
|
|
8
|
+
#
|
|
9
|
+
# `Prism::Node#compact_child_nodes` allocates a fresh Array on every call — for the ~43 leaf node classes
|
|
10
|
+
# (`IntegerNode`, `LocalVariableReadNode`, `NilNode`, …) it is literally `def compact_child_nodes; []; end`.
|
|
11
|
+
# Rigor's tree walkers call it unconditionally on every node of every walk, so one full-tree walk allocates one
|
|
12
|
+
# Array per node visited. On leaf-heavy sources (a Ragel-generated parser has hundreds of thousands of
|
|
13
|
+
# integer-literal leaves) these throwaway arrays are the single largest allocation source in a run — over half
|
|
14
|
+
# of all allocations on mail's `lib`.
|
|
15
|
+
#
|
|
16
|
+
# Loading this file compiles a `#rigor_each_child` method onto every `Prism::*Node` class (additive reopening of
|
|
17
|
+
# a dependency's class, the `Cache::RbsEnvironmentMarshalPatch` precedent; the `rigor_` prefix keeps it
|
|
18
|
+
# collision-free). It yields the same child nodes, in the same order, without materialising the intermediate
|
|
19
|
+
# array: each child-bearing field is read directly in field declaration order — exactly the order
|
|
20
|
+
# `compact_child_nodes` emits — and a `NodeListField`'s already-materialised array is iterated in place (the
|
|
21
|
+
# reader returns the stored array, not a copy). Nil optional children and nil list elements are skipped,
|
|
22
|
+
# mirroring `compact_child_nodes`'s "compact" semantics. Leaf classes compile to an empty method.
|
|
23
|
+
#
|
|
24
|
+
# A method compiled onto the node class is the fastest dispatch available for this shape — one virtual send on
|
|
25
|
+
# the receiver, the same mechanism `compact_child_nodes` itself uses, with zero allocation. The reflective
|
|
26
|
+
# alternatives were measured and rejected: a `public_send`-per-field loop is ~45 % *slower* than
|
|
27
|
+
# `compact_child_nodes` on a full-tree walk (dynamic dispatch per field swamps the allocation win), and a
|
|
28
|
+
# central generated-method table dispatched via `Module#send` + a per-class Hash lookup still regressed
|
|
29
|
+
# Rails-corpus wall ~5 % under YJIT. Walkers therefore call `node.rigor_each_child { … }` directly.
|
|
30
|
+
#
|
|
31
|
+
# The field map is derived once at load from `Prism::Reflection`, so it tracks whatever `prism` version resolves
|
|
32
|
+
# at runtime (ADR-79) instead of a hand-maintained table. `spec/rigor/source/node_children_spec.rb` is the
|
|
33
|
+
# binding contract: over a corpus of real source it asserts — for every node reached — that both
|
|
34
|
+
# `#rigor_each_child` and {each_child} yield output element-for-element identical (object identity and order) to
|
|
35
|
+
# `compact_child_nodes`.
|
|
36
|
+
module NodeChildren
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# The concrete `Prism::*Node` classes (`< Prism::Node`).
|
|
40
|
+
NODE_CLASSES =
|
|
41
|
+
Prism.constants.grep(/Node\z/).filter_map do |name|
|
|
42
|
+
const = Prism.const_get(name)
|
|
43
|
+
const if const.is_a?(Class) && const < Prism::Node
|
|
44
|
+
end.freeze
|
|
45
|
+
|
|
46
|
+
# Shared frozen empty entry for leaf classes. Identity (`equal?`) distinguishes a leaf entry from a
|
|
47
|
+
# childless-in-practice non-leaf.
|
|
48
|
+
EMPTY = [].freeze
|
|
49
|
+
|
|
50
|
+
# node class => frozen Array of `[reader_symbol, :node | :node_optional | :list]` pairs, in field declaration
|
|
51
|
+
# order (the order `compact_child_nodes` emits its children). Leaf classes map to {EMPTY}. This is the source
|
|
52
|
+
# of truth the per-class methods are compiled from, exposed for introspection and the equivalence spec.
|
|
53
|
+
CHILD_READERS =
|
|
54
|
+
NODE_CLASSES.each_with_object({}) do |klass, map|
|
|
55
|
+
pairs = Prism::Reflection.fields_for(klass).filter_map do |field|
|
|
56
|
+
case field
|
|
57
|
+
when Prism::Reflection::OptionalNodeField
|
|
58
|
+
[field.name, :node_optional].freeze
|
|
59
|
+
when Prism::Reflection::NodeField
|
|
60
|
+
[field.name, :node].freeze
|
|
61
|
+
when Prism::Reflection::NodeListField
|
|
62
|
+
[field.name, :list].freeze
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
map[klass] = pairs.empty? ? EMPTY : pairs.freeze
|
|
66
|
+
end.freeze
|
|
67
|
+
|
|
68
|
+
# The leaf classes — no child-bearing field, so `compact_child_nodes` is always `[]` and `#rigor_each_child`
|
|
69
|
+
# compiles to an empty method. Exposed for the equivalence spec and introspection.
|
|
70
|
+
LEAF_CLASSES = NODE_CLASSES.select { |klass| CHILD_READERS[klass].equal?(EMPTY) }.to_set.freeze
|
|
71
|
+
|
|
72
|
+
# Codegen (not per-call reflection) so iteration reads fields directly at hand-written speed — see the module
|
|
73
|
+
# comment for the measurements ruling out the reflective forms. Each statement mirrors `compact_child_nodes`'s
|
|
74
|
+
# own form for that field kind exactly: a required `NodeField` is yielded unconditionally (`compact <<
|
|
75
|
+
# predicate`), an `OptionalNodeField` behind a truthiness guard (`compact << receiver if receiver`), and a
|
|
76
|
+
# `NodeListField`'s stored array is iterated unfiltered (`compact.concat(requireds)`) — no extra nil checks
|
|
77
|
+
# beyond what Prism itself performs. The explicit `self.` receiver keeps a hypothetical future field named
|
|
78
|
+
# `child` from parsing as the just-assigned local. For a `CallNode` (three optional-node fields) the emitted
|
|
79
|
+
# source is:
|
|
80
|
+
#
|
|
81
|
+
# def rigor_each_child
|
|
82
|
+
# child = self.receiver
|
|
83
|
+
# yield child if child
|
|
84
|
+
# child = self.arguments
|
|
85
|
+
# yield child if child
|
|
86
|
+
# child = self.block
|
|
87
|
+
# yield child if child
|
|
88
|
+
# end
|
|
89
|
+
NODE_CLASSES.each do |klass|
|
|
90
|
+
statements = CHILD_READERS[klass].map do |reader, kind|
|
|
91
|
+
case kind
|
|
92
|
+
when :list then "self.#{reader}.each { |child| yield child }"
|
|
93
|
+
when :node_optional then "child = self.#{reader}\n yield child if child"
|
|
94
|
+
else "yield self.#{reader}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
klass.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
|
|
98
|
+
def rigor_each_child
|
|
99
|
+
#{statements.join("\n ")}
|
|
100
|
+
end
|
|
101
|
+
RUBY
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Yield each direct child `Prism::Node` of `node` in `compact_child_nodes` order, without allocating an
|
|
105
|
+
# intermediate array. Nil / non-node input yields nothing (an optional field can be nil, so a caller may pass
|
|
106
|
+
# e.g. `node.body`). Pure and re-entrant; `break` / `next` / `return` in the block behave exactly as they
|
|
107
|
+
# would with `compact_child_nodes.each`. Walkers holding a known `Prism::Node` call `#rigor_each_child`
|
|
108
|
+
# directly; this wrapper is the nil-tolerant entry.
|
|
109
|
+
#
|
|
110
|
+
# @yieldparam child [Prism::Node]
|
|
111
|
+
def each_child(node, &)
|
|
112
|
+
node.rigor_each_child(&) if node.is_a?(Prism::Node)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
+
require_relative "node_children"
|
|
6
|
+
|
|
5
7
|
module Rigor
|
|
6
8
|
module Source
|
|
7
9
|
# Locates the deepest Prism AST node enclosing a given source position.
|
|
@@ -83,7 +85,7 @@ module Rigor
|
|
|
83
85
|
return nil unless node.is_a?(Prism::Node)
|
|
84
86
|
return nil unless contains?(node, offset)
|
|
85
87
|
|
|
86
|
-
node.
|
|
88
|
+
node.rigor_each_child do |child|
|
|
87
89
|
deeper = descend(child, offset)
|
|
88
90
|
return deeper if deeper
|
|
89
91
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
+
require_relative "node_children"
|
|
6
|
+
|
|
5
7
|
module Rigor
|
|
6
8
|
module Source
|
|
7
9
|
# Yields every `Prism::Node` reachable from a root in DFS pre-order.
|
|
@@ -28,7 +30,7 @@ module Rigor
|
|
|
28
30
|
return unless node.is_a?(Prism::Node)
|
|
29
31
|
|
|
30
32
|
yield node
|
|
31
|
-
node.
|
|
33
|
+
node.rigor_each_child { |child| walk(child, &) }
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
# Like {.each}, but also yields the node's lexical ancestor chain (outermost first, EXCLUDING the node
|
|
@@ -51,7 +53,7 @@ module Rigor
|
|
|
51
53
|
|
|
52
54
|
block.call(node, ancestors)
|
|
53
55
|
ancestors.push(node)
|
|
54
|
-
node.
|
|
56
|
+
node.rigor_each_child { |child| walk_with_ancestors(child, ancestors, &block) }
|
|
55
57
|
ancestors.pop
|
|
56
58
|
end
|
|
57
59
|
end
|