rigortype 0.3.9 → 0.4.0
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/core_overlay/enumerable.rbs +51 -0
- data/data/core_overlay/enumerator.rbs +84 -0
- data/data/core_overlay/hash_rbs3.rbs +41 -0
- data/data/core_overlay/process.rbs +40 -0
- data/data/core_overlay/string_io.rbs +33 -0
- data/data/effects/core.yml +3 -3
- data/data/gem_overlay/activesupport/core_ext.rbs +127 -4
- data/docs/handbook/03-narrowing.md +95 -10
- data/docs/handbook/04-tuples-and-shapes.md +8 -6
- data/docs/handbook/07-rbs-and-extended.md +16 -11
- data/docs/handbook/10-sorbet.md +9 -10
- data/docs/handbook/11-sig-gen.md +454 -13
- data/docs/manual/02-cli-reference.md +63 -2
- data/docs/manual/03-configuration.md +7 -0
- data/docs/manual/04-diagnostics.md +4 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +3 -2
- data/docs/manual/16-rbs-extended-annotations.md +40 -7
- data/docs/manual/19-effect-labels.md +10 -0
- data/docs/manual/plugins/README.md +7 -0
- data/docs/manual/plugins/rigor-actioncable.md +8 -1
- data/docs/manual/plugins/rigor-actionmailer.md +7 -0
- data/docs/manual/plugins/rigor-actionpack.md +243 -0
- data/docs/manual/plugins/rigor-active-model-serializers.md +153 -0
- data/docs/manual/plugins/rigor-activejob.md +7 -0
- data/docs/manual/plugins/rigor-activerecord.md +110 -4
- data/docs/manual/plugins/rigor-activestorage.md +8 -1
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +11 -5
- data/docs/manual/plugins/rigor-grape.md +106 -0
- data/docs/manual/plugins/rigor-graphql.md +23 -2
- data/docs/manual/plugins/rigor-pundit.md +8 -1
- data/docs/manual/plugins/rigor-rails-i18n.md +10 -5
- data/docs/manual/plugins/rigor-rails-routes.md +7 -0
- data/docs/manual/plugins/rigor-rbs-inline.md +212 -25
- data/docs/manual/plugins/rigor-sidekiq.md +8 -1
- data/docs/manual/plugins/rigor-sorbet.md +21 -5
- data/exe/rigor +19 -4
- data/lib/rigor/analysis/baseline.rb +1 -1
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +167 -2
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +189 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +5 -3
- data/lib/rigor/analysis/check_rules/rule_ids.rb +20 -5
- data/lib/rigor/analysis/check_rules/rule_walk.rb +2 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +323 -0
- data/lib/rigor/analysis/check_rules/special_global_setters.rb +146 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +36 -2
- data/lib/rigor/analysis/check_rules.rb +520 -49
- data/lib/rigor/analysis/dependency_recorder.rb +23 -0
- data/lib/rigor/analysis/fact_store.rb +9 -0
- data/lib/rigor/analysis/incremental.rb +26 -0
- data/lib/rigor/analysis/incremental_session.rb +36 -4
- data/lib/rigor/analysis/project_scan.rb +11 -1
- data/lib/rigor/analysis/reachability/plugin_roots.rb +0 -1
- data/lib/rigor/analysis/reachability/scan_cache.rb +1 -0
- data/lib/rigor/analysis/rule_catalog.rb +127 -0
- data/lib/rigor/analysis/run_cache_key.rb +20 -11
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +167 -11
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +9 -4
- data/lib/rigor/analysis/runner/pool_coordinator.rb +107 -15
- data/lib/rigor/analysis/runner/project_pre_passes.rb +26 -7
- data/lib/rigor/analysis/runner.rb +278 -20
- data/lib/rigor/analysis/template_unit_collector.rb +303 -0
- data/lib/rigor/analysis/template_unit_paths.rb +91 -0
- data/lib/rigor/analysis/template_unit_positions.rb +293 -0
- data/lib/rigor/analysis/template_units.rb +399 -0
- data/lib/rigor/analysis/worker_session.rb +53 -10
- data/lib/rigor/bleeding_edge.rb +0 -2
- data/lib/rigor/builtins/hkt_builtins.rb +1 -0
- data/lib/rigor/builtins/imported_refinements.rb +4 -0
- data/lib/rigor/builtins/regex_refinement.rb +17 -9
- data/lib/rigor/builtins/static_return_refinements.rb +2 -0
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +29 -1
- data/lib/rigor/cache/incremental_snapshot.rb +33 -1
- data/lib/rigor/cache/rbs_cache_producer.rb +4 -0
- data/lib/rigor/cache/rbs_descriptor.rb +37 -7
- data/lib/rigor/cache/store.rb +0 -1
- data/lib/rigor/ci_detector.rb +1 -0
- data/lib/rigor/cli/doc_links.rb +1 -1
- data/lib/rigor/cli/docs_command.rb +4 -4
- data/lib/rigor/cli/plugin_command.rb +3 -3
- data/lib/rigor/cli/prism_colorizer.rb +0 -1
- data/lib/rigor/cli/sig_gen_command.rb +210 -29
- data/lib/rigor/cli/skill_command.rb +1 -1
- data/lib/rigor/cli/skill_describe.rb +0 -1
- data/lib/rigor/cli/type_of_command.rb +19 -5
- data/lib/rigor/cli/type_of_renderer.rb +20 -9
- data/lib/rigor/cli/type_of_template_probe.rb +189 -0
- data/lib/rigor/cli.rb +21 -3
- data/lib/rigor/configuration/severity_profile.rb +19 -3
- data/lib/rigor/configuration.rb +66 -3
- data/lib/rigor/effects/ancestry_recorder.rb +191 -0
- data/lib/rigor/effects/attribution.rb +11 -2
- data/lib/rigor/effects/callee_rule.rb +368 -0
- data/lib/rigor/effects/catalog.rb +7 -4
- data/lib/rigor/effects/collector.rb +11 -5
- data/lib/rigor/effects/config_envelopes.rb +9 -2
- data/lib/rigor/effects/definition_context.rb +179 -0
- data/lib/rigor/effects/effect_table.rb +11 -3
- data/lib/rigor/effects/envelope_check.rb +1 -1
- data/lib/rigor/effects/envelope_index.rb +15 -0
- data/lib/rigor/effects/file_collection.rb +59 -5
- data/lib/rigor/effects/framework_units.rb +1 -1
- data/lib/rigor/effects/identity.rb +16 -0
- data/lib/rigor/effects/local_ownership.rb +38 -12
- data/lib/rigor/effects/method_key.rb +21 -0
- data/lib/rigor/effects/mutation_classifier.rb +23 -12
- data/lib/rigor/effects/plugin_facts.rb +43 -31
- data/lib/rigor/effects/propagator.rb +295 -16
- data/lib/rigor/effects/registry.rb +1 -1
- data/lib/rigor/effects/scanner.rb +121 -72
- data/lib/rigor/effects/signature_sources.rb +1 -1
- data/lib/rigor/effects/snapshot.rb +2 -1
- data/lib/rigor/effects/summary.rb +27 -4
- data/lib/rigor/effects/unit_scan.rb +385 -30
- data/lib/rigor/effects/visibility.rb +101 -0
- data/lib/rigor/environment/lockfile_resolver.rb +17 -0
- data/lib/rigor/environment/member_consistency/comparator.rb +708 -0
- data/lib/rigor/environment/member_consistency.rb +298 -0
- data/lib/rigor/environment/rbs_loader.rb +399 -133
- data/lib/rigor/environment.rb +103 -38
- data/lib/rigor/hashing/xxh3.rb +264 -0
- data/lib/rigor/inference/acceptance.rb +139 -9
- data/lib/rigor/inference/block_auto_splat.rb +216 -0
- data/lib/rigor/inference/block_call_timing.rb +338 -0
- data/lib/rigor/inference/block_parameter_binder.rb +73 -27
- data/lib/rigor/inference/block_repetition.rb +71 -0
- data/lib/rigor/inference/body_fixpoint.rb +2 -1
- data/lib/rigor/inference/budget_trace.rb +2 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/builtins/string_catalog.rb +1 -1
- data/lib/rigor/inference/captured_locals.rb +387 -15
- data/lib/rigor/inference/closure_escape_analyzer.rb +157 -13
- data/lib/rigor/inference/content_join.rb +200 -27
- data/lib/rigor/inference/def_return_typer.rb +11 -7
- data/lib/rigor/inference/define_method_block_self.rb +64 -0
- data/lib/rigor/inference/element_read_widening.rb +22 -10
- data/lib/rigor/inference/error_info.rb +196 -0
- data/lib/rigor/inference/expression_typer.rb +1532 -496
- data/lib/rigor/inference/external_ancestor_resolution.rb +267 -0
- data/lib/rigor/inference/fresh_frame_blocks.rb +127 -0
- data/lib/rigor/inference/global_write_census.rb +239 -0
- data/lib/rigor/inference/guard_rebinding.rb +447 -0
- data/lib/rigor/inference/hash_lookup_mutation.rb +88 -0
- data/lib/rigor/inference/index_write_widening.rb +16 -3
- data/lib/rigor/inference/indexed_narrowing.rb +61 -9
- data/lib/rigor/inference/jump_targets.rb +82 -0
- data/lib/rigor/inference/last_line/implicit_self.rb +210 -0
- data/lib/rigor/inference/last_line/self_evidence.rb +329 -0
- data/lib/rigor/inference/last_line.rb +340 -0
- data/lib/rigor/inference/last_status.rb +144 -0
- data/lib/rigor/inference/macro_block_self_type.rb +167 -17
- data/lib/rigor/inference/match_rebinding/calls.rb +281 -0
- data/lib/rigor/inference/match_rebinding/frame.rb +148 -0
- data/lib/rigor/inference/match_rebinding/operands.rb +236 -0
- data/lib/rigor/inference/match_rebinding/self_calls.rb +83 -0
- data/lib/rigor/inference/match_rebinding.rb +392 -0
- data/lib/rigor/inference/method_dispatcher/alias_strict_nominals.rb +36 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +85 -24
- data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +147 -0
- data/lib/rigor/inference/method_dispatcher/hash_transform_keys_folding.rb +191 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +159 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +113 -73
- data/lib/rigor/inference/method_dispatcher/process_folding.rb +56 -0
- data/lib/rigor/inference/method_dispatcher/proven_overload.rb +72 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +881 -38
- data/lib/rigor/inference/method_dispatcher/self_substitute.rb +142 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +128 -53
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher.rb +214 -13
- data/lib/rigor/inference/method_parameter_binder.rb +8 -3
- data/lib/rigor/inference/multi_target_binder.rb +340 -52
- data/lib/rigor/inference/mutation_rejoin.rb +4 -1
- data/lib/rigor/inference/mutation_widening.rb +70 -48
- data/lib/rigor/inference/narrowing.rb +540 -120
- data/lib/rigor/inference/operand_effects.rb +167 -0
- data/lib/rigor/inference/operand_walk.rb +88 -0
- data/lib/rigor/inference/optimistic_origin.rb +152 -9
- data/lib/rigor/inference/parameter_inference_collector.rb +3 -2
- data/lib/rigor/inference/project_method_ownership.rb +136 -0
- data/lib/rigor/inference/project_patched_methods.rb +7 -2
- data/lib/rigor/inference/project_patched_scanner.rb +7 -3
- data/lib/rigor/inference/receiver_alias.rb +90 -1
- data/lib/rigor/inference/receiver_blind_block.rb +219 -0
- data/lib/rigor/inference/refinement_mutation.rb +15 -11
- data/lib/rigor/inference/repeated_or_writes.rb +463 -0
- data/lib/rigor/inference/return_barrier.rb +54 -0
- data/lib/rigor/inference/rewrite_mutation.rb +120 -0
- data/lib/rigor/inference/scope_indexer.rb +4615 -551
- data/lib/rigor/inference/statement_evaluator.rb +3176 -490
- data/lib/rigor/inference/stored_block_call.rb +54 -0
- data/lib/rigor/inference/string_mutation.rb +44 -7
- data/lib/rigor/inference/unknown_store_widening.rb +200 -0
- data/lib/rigor/inference/unthreaded_rebinds.rb +282 -0
- data/lib/rigor/language_server/debouncer.rb +0 -1
- data/lib/rigor/language_server/diagnostic_publisher.rb +3 -2
- data/lib/rigor/language_server/hover_renderer.rb +3 -3
- data/lib/rigor/language_server/project_context.rb +5 -3
- data/lib/rigor/mcp/server.rb +2 -1
- data/lib/rigor/plugin/base.rb +168 -5
- data/lib/rigor/plugin/box_probe.rb +91 -0
- data/lib/rigor/plugin/bundled_catalog.rb +1 -1
- data/lib/rigor/plugin/effect_attribution.rb +58 -4
- data/lib/rigor/plugin/loader.rb +2 -1
- data/lib/rigor/plugin/macro/block_as_method.rb +45 -6
- data/lib/rigor/plugin/manifest.rb +71 -10
- data/lib/rigor/plugin/registry.rb +35 -1
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/plugin/template_unit.rb +196 -0
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/protection/discovery_seed.rb +3 -1
- data/lib/rigor/protection/kill_signature.rb +0 -1
- data/lib/rigor/protection/mutation_cache.rb +1 -2
- data/lib/rigor/rbs_extended.rb +27 -0
- data/lib/rigor/reflection/constant_ancestors.rb +97 -0
- data/lib/rigor/reflection/constant_path.rb +19 -6
- data/lib/rigor/reflection.rb +72 -105
- data/lib/rigor/scope/discovery_index.rb +135 -2
- data/lib/rigor/scope.rb +859 -49
- data/lib/rigor/sig_gen/alias_index.rb +289 -0
- data/lib/rigor/sig_gen/classification.rb +22 -5
- data/lib/rigor/sig_gen/declaration_equivalence.rb +127 -0
- data/lib/rigor/sig_gen/effect_annotation.rb +202 -0
- data/lib/rigor/sig_gen/generator.rb +558 -31
- data/lib/rigor/sig_gen/inline_declarations.rb +184 -0
- data/lib/rigor/sig_gen/method_candidate.rb +47 -3
- data/lib/rigor/sig_gen/observation_collector.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +124 -9
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +44 -1
- data/lib/rigor/sig_gen/write_result.rb +19 -3
- data/lib/rigor/sig_gen/writer.rb +166 -35
- data/lib/rigor/sig_gen.rb +3 -0
- data/lib/rigor/signature_path_audit.rb +1 -1
- data/lib/rigor/source/node_walker.rb +0 -3
- data/lib/rigor/source/parameter_envelope.rb +72 -0
- data/lib/rigor/source.rb +1 -0
- data/lib/rigor/type/combinator.rb +88 -12
- data/lib/rigor/type/difference.rb +1 -0
- data/lib/rigor/type/hash_shape.rb +1 -1
- data/lib/rigor/type/refined.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/effects.rb +1 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +9 -5
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -4
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +0 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_scan.rb +96 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/effects.rb +65 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/erb_compiler.rb +270 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +402 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +370 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_units.rb +132 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +239 -4
- data/plugins/rigor-actionpack/sig/action_view.rbs +43 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +220 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_index.rb +56 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +263 -0
- data/plugins/rigor-active-model-serializers/lib/rigor-active-model-serializers.rb +7 -0
- data/plugins/rigor-active-model-serializers/sig/active_model_serializers.rbs +45 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +1 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +9 -5
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +35 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/effects.rb +72 -12
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +380 -3
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +14 -6
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +281 -35
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +206 -31
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -14
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +9 -2
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +171 -5
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +2 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +1 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +1 -1
- data/plugins/rigor-grape/lib/rigor/plugin/grape.rb +141 -0
- data/plugins/rigor-grape/lib/rigor-grape.rb +6 -0
- data/plugins/rigor-grape/sig/grape.rbs +263 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +72 -2
- data/plugins/rigor-graphql/sig/graphql.rbs +485 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +2 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +11 -7
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +35 -37
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +0 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +2 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/doorkeeper_routes.rb +1 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +9 -17
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline/same_line_annotations.rb +149 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +217 -36
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +1 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +9 -5
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +0 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +46 -1
- data/plugins/rigor-sorbet/sig/sorbet.rbs +277 -0
- data/sig/rigor/analysis/baseline.rbs +69 -7
- data/sig/rigor/analysis/fact_store.rbs +1 -1
- data/sig/rigor/analysis/project_scan.rbs +74 -0
- data/sig/rigor/effects/config_envelopes.rbs +100 -0
- data/sig/rigor/effects/effect_table.rbs +60 -0
- data/sig/rigor/effects/envelope.rbs +97 -0
- data/sig/rigor/effects/envelope_index.rbs +33 -0
- data/sig/rigor/effects/file_collection.rbs +81 -0
- data/sig/rigor/effects/label.rbs +26 -0
- data/sig/rigor/effects/label_set.rbs +46 -0
- data/sig/rigor/effects/method_key.rbs +24 -0
- data/sig/rigor/effects/origin.rbs +50 -0
- data/sig/rigor/effects/plugin_facts.rbs +141 -0
- data/sig/rigor/effects/registry.rbs +68 -0
- data/sig/rigor/effects/summary.rbs +50 -0
- data/sig/rigor/effects/taint_cause.rbs +12 -0
- data/sig/rigor/environment.rbs +12 -10
- data/sig/rigor/inference/optimistic_origin.rbs +10 -0
- data/sig/rigor/inference.rbs +6 -4
- data/sig/rigor/plugin/additional_initializer.rbs +36 -0
- data/sig/rigor/plugin/base.rbs +30 -7
- data/sig/rigor/plugin/effect_ancestry.rbs +27 -0
- data/sig/rigor/plugin/effect_attribution.rbs +66 -0
- data/sig/rigor/plugin/effect_edge.rbs +33 -0
- data/sig/rigor/plugin/effect_entry_points.rbs +25 -0
- data/sig/rigor/plugin/io_boundary.rbs +1 -1
- data/sig/rigor/plugin/loader.rbs +3 -3
- data/sig/rigor/plugin/manifest.rbs +50 -11
- data/sig/rigor/plugin/protocol_contract.rbs +68 -0
- data/sig/rigor/plugin/registry.rbs +62 -1
- data/sig/rigor/plugin.rbs +1 -1
- data/sig/rigor/rbs_extended.rbs +1 -1
- data/sig/rigor/reflection.rbs +7 -6
- data/sig/rigor/scope.rbs +135 -20
- data/sig/rigor/sig_gen/skip_reason_catalog.rbs +14 -7
- data/sig/rigor/source.rbs +4 -4
- data/sig/rigor/testing.rbs +10 -4
- data/sig/rigor/type.rbs +6 -0
- data/sig/rigor.rbs +38 -20
- data/skills/rigor-ask/SKILL.md +8 -5
- data/skills/rigor-baseline-reduce/SKILL.md +4 -6
- data/skills/rigor-ci-setup/SKILL.md +17 -21
- data/skills/rigor-doctor/SKILL.md +24 -22
- data/skills/rigor-doctor/references/01-checks.md +97 -33
- data/skills/rigor-editor-setup/SKILL.md +6 -4
- data/skills/rigor-mcp-setup/SKILL.md +5 -4
- data/skills/rigor-monkeypatch-resolve/SKILL.md +5 -3
- data/skills/rigor-next-steps/SKILL.md +4 -2
- data/skills/rigor-plugin-author/SKILL.md +19 -23
- data/skills/rigor-plugin-author/references/01-plan-and-scaffold.md +9 -8
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +12 -25
- data/skills/rigor-plugin-author/references/03-test-and-ship.md +6 -8
- data/skills/rigor-plugin-review/SKILL.md +6 -4
- data/skills/rigor-plugin-review/references/01-best-practices-checklist.md +2 -1
- data/skills/rigor-plugin-tune/SKILL.md +4 -2
- data/skills/rigor-project-init/SKILL.md +9 -7
- data/skills/rigor-project-init/references/01-detect.md +13 -9
- data/skills/rigor-project-init/references/02-configure.md +33 -8
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +14 -14
- data/skills/rigor-project-init/references/04-sig-uplift.md +27 -14
- data/skills/rigor-protection-uplift/SKILL.md +4 -6
- data/skills/rigor-rbs-setup/SKILL.md +4 -2
- data/skills/rigor-type-oracle/SKILL.md +4 -6
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +12 -8
- data/skills/rigor-type-oracle/references/03-gap-protocol.md +0 -3
- data/skills/rigor-unused-adjudicate/SKILL.md +4 -2
- data/skills/rigor-upgrade/SKILL.md +13 -8
- metadata +108 -1
|
@@ -9,16 +9,26 @@ require_relative "../source/node_walker"
|
|
|
9
9
|
require_relative "../source/node_children"
|
|
10
10
|
require_relative "../source/constant_path"
|
|
11
11
|
require_relative "anonymous_meta_class"
|
|
12
|
+
require_relative "block_repetition"
|
|
12
13
|
require_relative "block_parameter_binder"
|
|
13
14
|
require_relative "body_fixpoint"
|
|
14
15
|
require_relative "captured_locals"
|
|
15
16
|
require_relative "dynamic_origin"
|
|
17
|
+
require_relative "error_info"
|
|
18
|
+
require_relative "jump_targets"
|
|
19
|
+
require_relative "last_status"
|
|
20
|
+
require_relative "../analysis/check_rules/declaration_sourced_guard"
|
|
16
21
|
require_relative "../analysis/check_rules/inferred_param_guard"
|
|
17
22
|
require_relative "../analysis/check_rules/published_constant_guard"
|
|
18
23
|
require_relative "struct_fold_safety"
|
|
19
24
|
require_relative "closure_escape_analyzer"
|
|
20
25
|
require_relative "content_join"
|
|
26
|
+
require_relative "define_method_block_self"
|
|
27
|
+
require_relative "macro_block_self_type"
|
|
28
|
+
require_relative "guard_rebinding"
|
|
29
|
+
require_relative "match_rebinding"
|
|
21
30
|
require_relative "element_read_widening"
|
|
31
|
+
require_relative "hash_lookup_mutation"
|
|
22
32
|
require_relative "indexed_narrowing"
|
|
23
33
|
require_relative "index_write_widening"
|
|
24
34
|
require_relative "method_dispatcher"
|
|
@@ -26,7 +36,12 @@ require_relative "method_parameter_binder"
|
|
|
26
36
|
require_relative "multi_target_binder"
|
|
27
37
|
require_relative "mutation_widening"
|
|
28
38
|
require_relative "narrowing"
|
|
39
|
+
require_relative "operand_effects"
|
|
40
|
+
require_relative "operand_walk"
|
|
29
41
|
require_relative "optimistic_origin"
|
|
42
|
+
require_relative "return_barrier"
|
|
43
|
+
require_relative "rewrite_mutation"
|
|
44
|
+
require_relative "unknown_store_widening"
|
|
30
45
|
require_relative "version_guard"
|
|
31
46
|
|
|
32
47
|
module Rigor
|
|
@@ -84,9 +99,16 @@ module Rigor
|
|
|
84
99
|
Prism::IndexOrWriteNode => :eval_index_or_write,
|
|
85
100
|
Prism::IndexAndWriteNode => :eval_index_write,
|
|
86
101
|
Prism::IndexOperatorWriteNode => :eval_index_write,
|
|
102
|
+
Prism::CallOrWriteNode => :eval_attribute_compound_write,
|
|
103
|
+
Prism::CallAndWriteNode => :eval_attribute_compound_write,
|
|
104
|
+
Prism::CallOperatorWriteNode => :eval_attribute_compound_write,
|
|
87
105
|
Prism::MultiWriteNode => :eval_multi_write,
|
|
88
106
|
Prism::ConstantWriteNode => :eval_constant_write,
|
|
89
107
|
Prism::ConstantPathWriteNode => :eval_constant_write,
|
|
108
|
+
# Issue #963 — `Const ||= Struct.new(:a) do … end` opens the same class body. The handler's own result is
|
|
109
|
+
# the default expression pair, so routing the or-writes here adds the body entry and nothing else.
|
|
110
|
+
Prism::ConstantOrWriteNode => :eval_constant_write,
|
|
111
|
+
Prism::ConstantPathOrWriteNode => :eval_constant_write,
|
|
90
112
|
Prism::IfNode => :eval_if,
|
|
91
113
|
Prism::UnlessNode => :eval_unless,
|
|
92
114
|
Prism::ElseNode => :eval_else,
|
|
@@ -94,7 +116,7 @@ module Rigor
|
|
|
94
116
|
Prism::CaseMatchNode => :eval_case,
|
|
95
117
|
Prism::WhenNode => :eval_when_or_in,
|
|
96
118
|
Prism::InNode => :eval_when_or_in,
|
|
97
|
-
Prism::BeginNode => :
|
|
119
|
+
Prism::BeginNode => :eval_begin_node,
|
|
98
120
|
Prism::RescueNode => :eval_rescue,
|
|
99
121
|
Prism::EnsureNode => :eval_ensure,
|
|
100
122
|
Prism::WhileNode => :eval_loop,
|
|
@@ -113,10 +135,41 @@ module Rigor
|
|
|
113
135
|
Prism::ReturnNode => :eval_return,
|
|
114
136
|
Prism::NextNode => :eval_next,
|
|
115
137
|
Prism::BreakNode => :eval_break,
|
|
116
|
-
Prism::MatchWriteNode => :eval_match_write
|
|
138
|
+
Prism::MatchWriteNode => :eval_match_write,
|
|
139
|
+
Prism::MatchPredicateNode => :eval_match_pattern,
|
|
140
|
+
Prism::MatchRequiredNode => :eval_match_pattern,
|
|
141
|
+
Prism::RescueModifierNode => :eval_rescue_modifier,
|
|
142
|
+
Prism::ArrayNode => :eval_value_container,
|
|
143
|
+
Prism::HashNode => :eval_value_container,
|
|
144
|
+
Prism::InterpolatedStringNode => :eval_value_container,
|
|
145
|
+
Prism::InterpolatedSymbolNode => :eval_value_container,
|
|
146
|
+
Prism::XStringNode => :eval_xstring,
|
|
147
|
+
Prism::InterpolatedXStringNode => :eval_xstring,
|
|
148
|
+
Prism::RangeNode => :eval_value_container
|
|
117
149
|
}.freeze
|
|
118
150
|
private_constant :HANDLERS
|
|
119
151
|
|
|
152
|
+
# Issue #1223 — the expressions that evaluate every child, in child order, before producing their value, so
|
|
153
|
+
# a write inside one is threaded child by child ({#thread_operand}). A construct that may skip a child is
|
|
154
|
+
# left out: a `rescue` modifier has its own handler, and a regexp interpolation with the `o` flag runs its
|
|
155
|
+
# parts once per process.
|
|
156
|
+
OPERAND_CONTAINERS = Set[
|
|
157
|
+
Prism::ArgumentsNode, Prism::KeywordHashNode, Prism::AssocNode, Prism::AssocSplatNode, Prism::SplatNode,
|
|
158
|
+
Prism::BlockArgumentNode, Prism::ArrayNode, Prism::HashNode, Prism::InterpolatedStringNode,
|
|
159
|
+
Prism::InterpolatedSymbolNode, Prism::InterpolatedXStringNode, Prism::EmbeddedStatementsNode,
|
|
160
|
+
Prism::RangeNode
|
|
161
|
+
].freeze
|
|
162
|
+
private_constant :OPERAND_CONTAINERS
|
|
163
|
+
|
|
164
|
+
# Statement sequences an operand may hold, threaded statement by statement like a container ({#thread_operand}).
|
|
165
|
+
OPERAND_SEQUENCES = Set[Prism::StatementsNode, Prism::ParenthesesNode].freeze
|
|
166
|
+
private_constant :OPERAND_SEQUENCES
|
|
167
|
+
|
|
168
|
+
# The keywords of a pass whose scopes the per-node scope index must not keep — neither the nodes it
|
|
169
|
+
# evaluates nor the later operands an {OperandWalk} inside it takes ({#walk_recorder}).
|
|
170
|
+
UNRECORDED = { on_enter: nil, operand_recorder: nil }.freeze
|
|
171
|
+
private_constant :UNRECORDED
|
|
172
|
+
|
|
120
173
|
# Thread-local sink (an Array) collecting the value types of explicit `return value` nodes reached while
|
|
121
174
|
# evaluating a method body, so `ExpressionTyper#infer_user_method_return` can join them into the method's inferred
|
|
122
175
|
# return type. The flow value of a `return` is still `Bot` (it transfers control rather than producing a value);
|
|
@@ -141,9 +194,8 @@ module Rigor
|
|
|
141
194
|
# evaluating a loop body, so `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true; break`) into
|
|
142
195
|
# the loop continuation that the fall-through would otherwise drop. Stacks like the return sink: a nested loop
|
|
143
196
|
# installs its own sink, restored on exit, so an inner loop's break does not leak to the outer one. A `break`
|
|
144
|
-
# inside a block / nested loop targets that inner construct, not the lexical loop — filtered out by the
|
|
145
|
-
#
|
|
146
|
-
# docs/notes/20260615-loop-break-binding-propagation-design.md.
|
|
197
|
+
# inside a block / nested loop targets that inner construct, not the lexical loop — filtered out by the loop's
|
|
198
|
+
# {JumpTargets} set ({#loop_jumps}). See docs/notes/20260615-loop-break-binding-propagation-design.md.
|
|
147
199
|
BREAK_SINK_KEY = :rigor_break_sink
|
|
148
200
|
private_constant :BREAK_SINK_KEY
|
|
149
201
|
|
|
@@ -162,8 +214,14 @@ module Rigor
|
|
|
162
214
|
|
|
163
215
|
# Lexical class frame: the `name:` field is the qualified class name as it would render in Ruby (e.g.,
|
|
164
216
|
# `"Foo::Bar"`); the `singleton:` field is `true` for `class << self` frames so nested defs resolve to
|
|
165
|
-
# singleton-method RBS lookups.
|
|
166
|
-
|
|
217
|
+
# singleton-method RBS lookups. Issue #1120 — `refinement:` is `true` for the frame a `refine X do … end`
|
|
218
|
+
# block is entered under: its `def`s redefine X's methods, so none of them binds its parameters from X's
|
|
219
|
+
# RBS signature for the name ({#build_method_entry_scope}).
|
|
220
|
+
ClassFrame = Data.define(:name, :singleton, :refinement) do
|
|
221
|
+
def initialize(name:, singleton:, refinement: false)
|
|
222
|
+
super
|
|
223
|
+
end
|
|
224
|
+
end
|
|
167
225
|
|
|
168
226
|
# Issue #652 — Ruby's `Module.nesting` for the body currently being evaluated, innermost first, built as
|
|
169
227
|
# the walk ENTERS each declaration rather than reconstructed from the qualified name afterwards. A
|
|
@@ -193,14 +251,41 @@ module Rigor
|
|
|
193
251
|
# assumption (`result *= i` annotating `1 | 2` rather than
|
|
194
252
|
# `Integer`). Display-path only — `rigor check` leaves it off,
|
|
195
253
|
# keeping its diagnostics and wall-clock unchanged.
|
|
196
|
-
|
|
197
|
-
|
|
254
|
+
# @param next_scope_sink — the Array of `[NextNode, Scope]` pairs
|
|
255
|
+
# the innermost enclosing block invocation or loop body collects
|
|
256
|
+
# its `next` exits into ({#evaluate_invocation},
|
|
257
|
+
# {#loop_iteration}), or nil. The scope twin of the thread-local
|
|
258
|
+
# `next` VALUE sink, threaded through `sub_eval` instead so an
|
|
259
|
+
# evaluation `ExpressionTyper` starts elsewhere — the block-return
|
|
260
|
+
# pass, a recursive method's inference — can never feed it a
|
|
261
|
+
# `next` from another context. A `->` body's `next` still lands
|
|
262
|
+
# here; the consumer filters by node identity.
|
|
263
|
+
# @param operand_scope — the scope a call's receiver and arguments
|
|
264
|
+
# were typed under, when {#eval_call} runs the rest of that call
|
|
265
|
+
# from the scope its operands left ({#invoke_call}); nil otherwise,
|
|
266
|
+
# where it is the receiver scope itself ({#operand_scope}).
|
|
267
|
+
# @param in_operand — true for an evaluator {#thread_operand} opened, and every evaluator it opens: the
|
|
268
|
+
# calls it runs are inside another expression's operand, which {#invoke_call} leaves the resets of a
|
|
269
|
+
# statement-position call out of; the statement that holds the operand answers for its match globals.
|
|
270
|
+
# @param operand_recorder — the per-node scope index's recorder, for an evaluator {#thread_operand} opened
|
|
271
|
+
# (whose own `on_enter` is nil) and every evaluator it opens but an unrecorded pass ({UNRECORDED}), so an
|
|
272
|
+
# {OperandWalk} rooted inside an operand still records its later operands ({#walk_recorder}).
|
|
273
|
+
# @param operand_types — the later operands' own values ({OperandWalk#types}) for the evaluator that runs a
|
|
274
|
+
# call from the scope its operands left, read by {#type_operand}.
|
|
275
|
+
def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze, # rubocop:disable Metrics/ParameterLists
|
|
276
|
+
lexical_nesting: EMPTY_NESTING, converged_loop_recording: false, next_scope_sink: nil,
|
|
277
|
+
operand_scope: nil, in_operand: false, operand_recorder: nil, operand_types: nil)
|
|
198
278
|
@scope = scope
|
|
199
279
|
@tracer = tracer
|
|
200
280
|
@on_enter = on_enter
|
|
201
281
|
@class_context = class_context.freeze
|
|
202
282
|
@lexical_nesting = lexical_nesting.freeze
|
|
203
283
|
@converged_loop_recording = converged_loop_recording
|
|
284
|
+
@next_scope_sink = next_scope_sink
|
|
285
|
+
@operand_scope = operand_scope
|
|
286
|
+
@in_operand = in_operand
|
|
287
|
+
@operand_recorder = operand_recorder
|
|
288
|
+
@operand_types = operand_types
|
|
204
289
|
end
|
|
205
290
|
|
|
206
291
|
# Runs `block` with a fresh return sink installed, then yields the collected explicit-`return` value types to the
|
|
@@ -255,14 +340,77 @@ module Rigor
|
|
|
255
340
|
# Evaluate `node` under the receiver scope. Returns `[type, scope']` where `type` is the value the node produces
|
|
256
341
|
# and `scope'` is the scope observable after the node has run. The receiver scope is never mutated.
|
|
257
342
|
def evaluate(node)
|
|
343
|
+
return evaluator_at(@scope.forget_last_line).evaluate(node) if forget_last_line_first?(node)
|
|
344
|
+
|
|
258
345
|
@on_enter&.call(node, @scope)
|
|
259
346
|
|
|
260
347
|
handler = HANDLERS[node.class]
|
|
261
|
-
return send(handler, node) if handler
|
|
348
|
+
return forget_implicit_call_guards(node, send(handler, node)) if handler
|
|
262
349
|
|
|
263
350
|
# Default: the node is treated as a pure expression. Type it through the existing expression typer (which
|
|
264
|
-
# observes the current scope's locals) and leave the scope unchanged
|
|
265
|
-
|
|
351
|
+
# observes the current scope's locals) and leave the scope unchanged, but for the match globals a call in it
|
|
352
|
+
# may rebind (`super(line.sub(re, ""))`, issue #1365).
|
|
353
|
+
[@scope.type_of(node, tracer: @tracer), forget_rebound_specials(@scope, node)]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Issue #1429 — a compound write or a `for` loop calls a method its syntax does not spell (`r += r` calls `r.+`,
|
|
357
|
+
# `r[0] ||= 1` calls `r.[]` and `r.[]=`, `for x in r` calls `r.each`). When that method may run code that rebinds
|
|
358
|
+
# a global or constant, the guard narrowings past the node are restored
|
|
359
|
+
# ({GuardRebinding.implicit_call_may_rebind?}).
|
|
360
|
+
def forget_implicit_call_guards(node, result)
|
|
361
|
+
type, after = result
|
|
362
|
+
return result unless after.is_a?(Scope) && after.guard_narrowed? &&
|
|
363
|
+
GuardRebinding.implicit_call_node?(node) &&
|
|
364
|
+
GuardRebinding.implicit_call_may_rebind?(node, @scope)
|
|
365
|
+
|
|
366
|
+
[type, after.forget_guard_narrowings]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Issue #1359 — the nodes whose handler runs their parts in order, each from the scope the parts before it
|
|
370
|
+
# left, so a `$_` reader among them forgets it where it runs ({LastLine}) and a read before it keeps the
|
|
371
|
+
# narrowing.
|
|
372
|
+
SEQUENCED_NODES = Set[
|
|
373
|
+
Prism::ProgramNode, Prism::StatementsNode, Prism::ParenthesesNode, Prism::IfNode, Prism::UnlessNode,
|
|
374
|
+
Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::CaseNode, Prism::CaseMatchNode, Prism::BeginNode,
|
|
375
|
+
Prism::AndNode, Prism::OrNode, Prism::ElseNode, Prism::WhenNode, Prism::InNode, Prism::RescueNode,
|
|
376
|
+
Prism::EnsureNode, Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode,
|
|
377
|
+
Prism::BlockNode, Prism::LambdaNode
|
|
378
|
+
].freeze
|
|
379
|
+
private_constant :SEQUENCED_NODES
|
|
380
|
+
|
|
381
|
+
# True when `node` may set `$_` while `$_` is narrowed, and its handler types parts of it from the scope it
|
|
382
|
+
# starts in rather than threading them: a call's receiver and arguments, a literal's elements, a value a write
|
|
383
|
+
# stores. A read of `$_` there may run after the reader (`bar(gets, $_)`, `[gets, $_]`, `gets.to_s + $_`), so
|
|
384
|
+
# the whole node is evaluated with `$_` forgotten, which costs only a read that runs before the reader.
|
|
385
|
+
def forget_last_line_first?(node)
|
|
386
|
+
@scope.last_line_bound? && !SEQUENCED_NODES.include?(node.class) && LastLine.may_set?(node, @scope)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# One invocation of `block_node`'s body, from the receiver scope (which the caller has already bound the block's
|
|
390
|
+
# parameters onto). Returns `[type, fall_through, exit]`: the body's tail type, the scope it falls off the end
|
|
391
|
+
# with, and the scope the invocation ends with.
|
|
392
|
+
#
|
|
393
|
+
# A `next` ends the invocation as surely as falling off the end does, so `exit` is `fall_through` joined
|
|
394
|
+
# (`Scope#join`) with the scope at every `next` that targets this block ({JumpTargets}). Without that join a
|
|
395
|
+
# rebind on a jumping branch (`if e.odd?; n = e; next; end`) vanished — `eval_if` carries only the arm that falls
|
|
396
|
+
# through — and both readers of the exit scope, ADR-56's write-back fixpoint ({#block_exit_bindings}) and issue
|
|
397
|
+
# #587 (b)'s per-element fold (`ExpressionTyper#captured_exit_bindings`), kept the pre-call binding. A `break` is
|
|
398
|
+
# NOT joined: it ends the call, so its scope feeds no further invocation ({#join_block_break_bindings}).
|
|
399
|
+
# `fall_through` is exposed for the fold's unmoved-pin test, which must not count what a `next` arm adds.
|
|
400
|
+
#
|
|
401
|
+
# A body with no block-level `next` pays one allocation-free scan and nothing else.
|
|
402
|
+
def evaluate_invocation(block_node)
|
|
403
|
+
body = block_node.body
|
|
404
|
+
return [Type::Combinator.constant_of(nil), scope, scope] if body.nil?
|
|
405
|
+
|
|
406
|
+
unless JumpTargets.any?(body, Prism::NextNode)
|
|
407
|
+
type, fall_through = sub_eval(body, scope, next_scope_sink: nil)
|
|
408
|
+
return [type, fall_through, fall_through]
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
sink = []
|
|
412
|
+
type, fall_through = sub_eval(body, scope, next_scope_sink: sink)
|
|
413
|
+
[type, fall_through, join_jump_scopes(fall_through, sink, JumpTargets.of(body, Prism::NextNode))]
|
|
266
414
|
end
|
|
267
415
|
|
|
268
416
|
# ADR-89 WD2 — the sorted positions of the positional parameters whose CONTENT `def_node` mutates
|
|
@@ -275,6 +423,62 @@ module Rigor
|
|
|
275
423
|
callee_content_mutated_parameters(def_node).values.uniq.sort
|
|
276
424
|
end
|
|
277
425
|
|
|
426
|
+
# The local-variable reads among `call_node`'s positional arguments whose matching parameter the callee
|
|
427
|
+
# content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's scope
|
|
428
|
+
# (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
|
|
429
|
+
# callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
|
|
430
|
+
# {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
|
|
431
|
+
def content_mutated_arguments(call_node)
|
|
432
|
+
return NO_ARGUMENT_READS unless self_dispatch_call?(call_node)
|
|
433
|
+
# Fast path — only a local passed as an argument can be reported, so a call with none skips the def
|
|
434
|
+
# resolution and the body scan entirely (the overwhelming common case).
|
|
435
|
+
return NO_ARGUMENT_READS unless call_passes_local_argument?(call_node)
|
|
436
|
+
|
|
437
|
+
def_node = resolve_self_callee_def(call_node)
|
|
438
|
+
return NO_ARGUMENT_READS if def_node.nil?
|
|
439
|
+
|
|
440
|
+
mutated = callee_content_mutated_parameters(def_node)
|
|
441
|
+
return NO_ARGUMENT_READS if mutated.empty?
|
|
442
|
+
|
|
443
|
+
argument_nodes = call_node.arguments.arguments
|
|
444
|
+
mutated.values.uniq.filter_map do |index|
|
|
445
|
+
argument = argument_nodes[index]
|
|
446
|
+
argument if argument.is_a?(Prism::LocalVariableReadNode)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
NO_ARGUMENT_READS = [].freeze
|
|
451
|
+
private_constant :NO_ARGUMENT_READS
|
|
452
|
+
|
|
453
|
+
# The value `h[k] += v` / `h[k] ||= v` / `h[k] &&= v` evaluates to in this evaluator's scope: what it stores
|
|
454
|
+
# through `[]=` ({#index_write_stored_type}). The `[]=` widening and the indexed-narrowing record are scope
|
|
455
|
+
# effects, so they stay with {#eval_index_or_write} / {#eval_index_write}. `ExpressionTyper` types a
|
|
456
|
+
# value-position index compound write from here.
|
|
457
|
+
#
|
|
458
|
+
# One reading departs from the statement's: a `||=` whose `[]` read is wholly gradual (`Dynamic`, not a
|
|
459
|
+
# union with a `Dynamic` member) reads as the rvalue. That is the memoization idiom — `CACHE[key] ||=
|
|
460
|
+
# build(key)`, `(@memo ||= {})[[a, b]] ||= compute`, `@targets[name] ||= new(name)` on an ivar the method
|
|
461
|
+
# never writes — where the value the idiom returns is the one it stores, and `Dynamic[top] | rhs` sent
|
|
462
|
+
# every such method to `sig.skipped.untyped-return`. It is the variable form's optimism for an unbound `||=`
|
|
463
|
+
# target (`ExpressionTyper#type_of_compound_variable_write`) keyed on the slot, and no wider: `&&=` is no
|
|
464
|
+
# memo (`h[k] &&= v` on an absent slot is `nil`), an operator write has no such reading, and an rvalue
|
|
465
|
+
# with no truthy part stores nothing truthy, so the slot's own value is the answer whenever it is set:
|
|
466
|
+
# `opts[k] ||= raise KeyError` is a guard, never `bot`, and `@flags[n] ||= false` is `true` after an
|
|
467
|
+
# `@flags[n] = true` elsewhere, never provably `false`.
|
|
468
|
+
#
|
|
469
|
+
# Nor is a site a block-return pass marked (`Scope#repeated_or_write?`, {RepeatedOrWrites}): the pass types
|
|
470
|
+
# every run of a repeating body from one entry scope, so its slot's gradual type may be what an EARLIER run
|
|
471
|
+
# stored rather than an absence of evidence.
|
|
472
|
+
def index_compound_write_value(node)
|
|
473
|
+
return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
|
|
474
|
+
|
|
475
|
+
current = index_read_type(node, scope)
|
|
476
|
+
rhs = scope.type_of(node.value, tracer: tracer)
|
|
477
|
+
return rhs if memoizing_index_read?(node, current, rhs)
|
|
478
|
+
|
|
479
|
+
index_write_stored_type(node, scope, current: current, rhs: rhs)
|
|
480
|
+
end
|
|
481
|
+
|
|
278
482
|
private
|
|
279
483
|
|
|
280
484
|
attr_reader :scope, :tracer
|
|
@@ -282,11 +486,16 @@ module Rigor
|
|
|
282
486
|
# Thread the scope through every child statement in declaration order. The body's value is the type of the last
|
|
283
487
|
# statement (or `Constant[nil]` for an empty body); intermediate statements' types are discarded, but their scope
|
|
284
488
|
# effects are preserved.
|
|
489
|
+
#
|
|
490
|
+
# Inside a retrying `begin`'s primary body, each statement's post-scope is also a point the body can raise from
|
|
491
|
+
# ({#record_raise_points}).
|
|
285
492
|
def eval_statements(node)
|
|
286
493
|
result_type = Type::Combinator.constant_of(nil)
|
|
287
494
|
current = scope
|
|
495
|
+
raising = Thread.current[RETRY_FRAMES_KEY]
|
|
288
496
|
node.body.each do |stmt|
|
|
289
497
|
result_type, current = sub_eval(stmt, current)
|
|
498
|
+
record_raise_points(raising, stmt, current) if raising
|
|
290
499
|
end
|
|
291
500
|
[result_type, current]
|
|
292
501
|
end
|
|
@@ -321,8 +530,8 @@ module Rigor
|
|
|
321
530
|
# computed on the RHS *value*'s provenance — a pure ivar read of a currently declaration-sourced ivar — so it
|
|
322
531
|
# survives the local copy. Any other RHS (a call result, a method-local-nil-bearing value) leaves the local
|
|
323
532
|
# flow-live and the diagnostic fires as before.
|
|
324
|
-
|
|
325
|
-
|
|
533
|
+
copied = declaration_sourced_copy(node, rhs_type, post_rhs)
|
|
534
|
+
return copied if copied
|
|
326
535
|
|
|
327
536
|
bound = post_rhs.with_local(node.name, rhs_type)
|
|
328
537
|
# ADR-67 WD6b — a local whose RHS is (transitively) rooted at an inferred parameter inherits the
|
|
@@ -335,7 +544,10 @@ module Rigor
|
|
|
335
544
|
|
|
336
545
|
bound = bound.without_inferred_param_mark(node.name)
|
|
337
546
|
bound = bound.with_local_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
|
|
338
|
-
|
|
547
|
+
cause = optimistic_rhs_origin(node.value, post_rhs)
|
|
548
|
+
return bound if cause.nil?
|
|
549
|
+
|
|
550
|
+
bound.with_optimistic_local(node.name, cause, miss: optimistic_rhs_miss(node.value, post_rhs))
|
|
339
551
|
end
|
|
340
552
|
|
|
341
553
|
# Issue #667 — true when this write copies a value whose constancy rests on a foreign published
|
|
@@ -358,6 +570,13 @@ module Rigor
|
|
|
358
570
|
optimistic_origin_for(value_node, scope_after_rhs)
|
|
359
571
|
end
|
|
360
572
|
|
|
573
|
+
# Issue #1302 — what the value a marked write binds answers on a miss, recorded beside the mark so a predicate
|
|
574
|
+
# read through the binding widens only as far as the inline form does: `x = recv&.empty?; !x` keeps its
|
|
575
|
+
# `true`, while `v = h[k]; v.nil?` still widens. Asked only once {#optimistic_rhs_origin} found a mark.
|
|
576
|
+
def optimistic_rhs_miss(value_node, scope_after_rhs)
|
|
577
|
+
Inference::OptimisticOrigin.miss_answer(value_node, scope_after_rhs)
|
|
578
|
+
end
|
|
579
|
+
|
|
361
580
|
# The effective optimistic-nil-free cause of an expression. {Inference::OptimisticOrigin.resolve} owns
|
|
362
581
|
# the judgment — the mark on the node itself, the binding a bare local / ivar read resolves through, and
|
|
363
582
|
# the predicate-fold derivation of issue #313.
|
|
@@ -375,6 +594,22 @@ module Rigor
|
|
|
375
594
|
scope_after_rhs.dynamic_origins[value_node]
|
|
376
595
|
end
|
|
377
596
|
|
|
597
|
+
# The scope binding the written local with ADR-58's `:local` mark when the write copies a declaration-sourced
|
|
598
|
+
# value, or nil. Issue #1362 — a read of a global still on its declared seed counts as an ivar read does, bare
|
|
599
|
+
# or parenthesised (`sep = $/`, `sep = ($/)`), and so does a bare read of a local that copies one (`s = sep`);
|
|
600
|
+
# the local also records the global it copies, which the consumers compare against the file's own writes to it
|
|
601
|
+
# ({Analysis::CheckRules::DeclarationSourcedGuard.copied_globals}).
|
|
602
|
+
def declaration_sourced_copy(node, rhs_type, post_rhs)
|
|
603
|
+
value = node.value
|
|
604
|
+
return post_rhs.with_declaration_sourced_local(node.name, rhs_type) if
|
|
605
|
+
declaration_sourced_ivar_read?(value, post_rhs)
|
|
606
|
+
|
|
607
|
+
globals = Analysis::CheckRules::DeclarationSourcedGuard.copied_globals(value, post_rhs)
|
|
608
|
+
return nil if globals.empty?
|
|
609
|
+
|
|
610
|
+
post_rhs.with_declaration_sourced_local(node.name, rhs_type).with_global_copy_marks(node.name, globals)
|
|
611
|
+
end
|
|
612
|
+
|
|
378
613
|
# True when `value_node` is a bare instance-variable read whose binding in `scope_at_read` is currently marked
|
|
379
614
|
# declaration-sourced.
|
|
380
615
|
def declaration_sourced_ivar_read?(value_node, scope_at_read)
|
|
@@ -392,7 +627,8 @@ module Rigor
|
|
|
392
627
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
393
628
|
bound = post_rhs.with_ivar(node.name, rhs_type)
|
|
394
629
|
bound = bound.with_ivar_origin(node.name, rhs_origin(node.value, post_rhs, rhs_type))
|
|
395
|
-
|
|
630
|
+
cause = optimistic_rhs_origin(node.value, post_rhs)
|
|
631
|
+
bound = bound.with_optimistic_ivar(node.name, cause, miss: optimistic_rhs_miss(node.value, post_rhs)) if cause
|
|
396
632
|
# Issue #667 — the ivar twin of the local stamp. This is the SAME-method half; the cross-method one
|
|
397
633
|
# (`@mode = AppConfig::MODE` in `initialize`, read in a sibling) rides the class-ivar census and is
|
|
398
634
|
# stamped by {#seed_instance_ivars}.
|
|
@@ -423,15 +659,38 @@ module Rigor
|
|
|
423
659
|
# `attr_reader`, the very macro #319 silenced at every other position); inside a module, the module's own
|
|
424
660
|
# `self` — a wrong receiver for every implicit-self call in the body.
|
|
425
661
|
def eval_constant_write(node)
|
|
426
|
-
|
|
427
|
-
|
|
662
|
+
after = forget_constant_guard(forget_rebound_specials(scope, node.value), node)
|
|
663
|
+
result = [scope.type_of(node, tracer: tracer), after]
|
|
664
|
+
call_node = meta_new_block_call(node)
|
|
665
|
+
return result if call_node.nil?
|
|
666
|
+
|
|
667
|
+
context = meta_new_constant_body_context(node, call_node)
|
|
428
668
|
return result if context.nil?
|
|
429
669
|
|
|
430
|
-
call_node = node.value
|
|
431
670
|
enter_meta_class_body(call_node.block, build_block_entry_scope(call_node, call_node.block), context)
|
|
432
671
|
result
|
|
433
672
|
end
|
|
434
673
|
|
|
674
|
+
# Issue #1429 — a write to a constant ends a guard's narrowing of every spelling that may name it: `Foo::BAR =
|
|
675
|
+
# nil` inside `module Foo` writes the constant `BAR` reads, so each narrowing whose last segment is the written
|
|
676
|
+
# name is dropped, whatever its prefix.
|
|
677
|
+
def forget_constant_guard(after, node)
|
|
678
|
+
return after if after.constant_narrowings.empty?
|
|
679
|
+
|
|
680
|
+
target = node.respond_to?(:target) ? node.target : node
|
|
681
|
+
after.without_constant_narrowings_named(target.name.to_s)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# The rvalue call whose block is the class body, for every spelling of the write. Issue #963: the `.freeze`
|
|
685
|
+
# tail and the `||=` / `Const = Const || …` guard are unwrapped by {ScopeIndexer.meta_new_rvalue}, the same
|
|
686
|
+
# recognition the index walks under, so the two passes enter the same node or neither does. The recognition
|
|
687
|
+
# is still the loose one — a CallNode carrying a literal block — because the strict-argument shapes the
|
|
688
|
+
# index declines are entered under an anonymous name rather than dropped.
|
|
689
|
+
def meta_new_block_call(node)
|
|
690
|
+
rvalue = ScopeIndexer.meta_new_rvalue(node)
|
|
691
|
+
rvalue if rvalue.is_a?(Prism::CallNode) && rvalue.block.is_a?(Prism::BlockNode)
|
|
692
|
+
end
|
|
693
|
+
|
|
435
694
|
# The class context a meta-new rvalue block is entered under, or nil when the rvalue is not that shape. The
|
|
436
695
|
# KEY must be the one `ScopeIndexer` filed the body's defs and member layout under, so the two passes agree —
|
|
437
696
|
# which is why the decision is delegated to the ScopeIndexer's own recognition rather than re-spelled here. A
|
|
@@ -440,10 +699,7 @@ module Rigor
|
|
|
440
699
|
# — the path spelling included since [#703](https://github.com/rigortype/rigor/issues/703). A shape the
|
|
441
700
|
# ScopeIndexer's stricter argument check rejects (`Const = Struct.new(*names) do … end`) is registered under
|
|
442
701
|
# the call site's anonymous name and is entered under that.
|
|
443
|
-
def meta_new_constant_body_context(node)
|
|
444
|
-
call_node = node.value
|
|
445
|
-
return nil unless call_node.is_a?(Prism::CallNode) && call_node.block.is_a?(Prism::BlockNode)
|
|
446
|
-
|
|
702
|
+
def meta_new_constant_body_context(node, call_node)
|
|
447
703
|
constant = meta_new_constant_context(node)
|
|
448
704
|
return constant if constant
|
|
449
705
|
|
|
@@ -459,9 +715,9 @@ module Rigor
|
|
|
459
715
|
return nil unless ScopeIndexer.meta_new_block_body(node)
|
|
460
716
|
|
|
461
717
|
case node
|
|
462
|
-
when Prism::ConstantWriteNode
|
|
718
|
+
when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode
|
|
463
719
|
@class_context + [ClassFrame.new(name: node.name.to_s, singleton: false)]
|
|
464
|
-
when Prism::ConstantPathWriteNode
|
|
720
|
+
when Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode
|
|
465
721
|
frame = ClassFrame.new(name: Source::ConstantPath.qualified_name(node.target), singleton: false)
|
|
466
722
|
Source::ConstantPath.rooted?(node.target) ? [frame] : @class_context + [frame]
|
|
467
723
|
end
|
|
@@ -584,11 +840,15 @@ module Rigor
|
|
|
584
840
|
# The expression value is the result type, matching Ruby's semantics: `(x = params[:f] ||= []); x` observes the
|
|
585
841
|
# post-`||=` value, not the rvalue alone.
|
|
586
842
|
def eval_index_or_write(node)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
843
|
+
_rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
844
|
+
result_type = index_write_stored_type(node, scope)
|
|
845
|
+
|
|
846
|
+
# A narrowing is keyed on ONE literal slot — `a[k]` — but a multi-index `||=` reads and
|
|
847
|
+
# stores a splice REGION (`a[0, 1] ||= v`), so keying the result on the first index would
|
|
848
|
+
# record `a[0]`'s type as the region answer: `a[0, 1] ||= []` would claim `a[0]` non-nil
|
|
849
|
+
# where the store splices nothing and `a[0]` stays nil at runtime. Decline the record for
|
|
850
|
+
# any form but the single-index one.
|
|
851
|
+
key_node = single_index_argument(node)
|
|
592
852
|
address = key_node && IndexedNarrowing.stable_address(node.receiver, key_node)
|
|
593
853
|
# Issue #544 — a receiver with an untracked (Dynamic / Top) constituent can hold a caller-supplied
|
|
594
854
|
# slot value the `||=` keeps, so the recorded default would invent a fact; decline the record.
|
|
@@ -601,36 +861,106 @@ module Rigor
|
|
|
601
861
|
arg_types: index_write_arg_types(node, result_type))
|
|
602
862
|
post = post.with_indexed_narrowing(*address, result_type) if address
|
|
603
863
|
|
|
604
|
-
[result_type, post]
|
|
864
|
+
[result_type, forget_rebound_specials(post, node)]
|
|
605
865
|
end
|
|
606
866
|
|
|
607
867
|
# `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
|
|
608
868
|
# expression, scope untouched — and the receiver never widened. They store through `[]=` exactly as
|
|
609
|
-
# `eval_index_or_write` does, so they take the same widening; the value
|
|
610
|
-
#
|
|
869
|
+
# `eval_index_or_write` does, so they take the same widening; the stored value is the compound result —
|
|
870
|
+
# `falsey(h[k]) | v` for `&&=`, the dispatched `h[k] + v` for `+=` — not the rvalue alone.
|
|
611
871
|
def eval_index_write(node)
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
#
|
|
620
|
-
#
|
|
621
|
-
# `
|
|
622
|
-
# the
|
|
623
|
-
#
|
|
872
|
+
_rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
873
|
+
stored = index_write_stored_type(node, scope)
|
|
874
|
+
widened = IndexWriteWidening.widen(node: node, current_scope: post_rhs,
|
|
875
|
+
arg_types: index_write_arg_types(node, stored))
|
|
876
|
+
[stored, forget_rebound_specials(widened, node)]
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# `[index_type..., stored_value_type]` for an index-write node, shaped exactly like a `[]=`
|
|
880
|
+
# call's argument list so the widening seam can join it the same way (issue #560) — a
|
|
881
|
+
# two-index compound write (`a[0, 1] += v`) keeps BOTH index arguments ahead of the stored
|
|
882
|
+
# value, which is what lets the join read it as a splice (issue #1140). The stored value is
|
|
883
|
+
# what the write put in the slot — for a compound write {#index_write_stored_type}'s
|
|
884
|
+
# compound result (`t[0] += 5` stores the already-computed `t[0] + 5`), for an index target
|
|
885
|
+
# the value its owner stores (the slot {MultiTargetBinder} decomposed, the `for` element, the
|
|
886
|
+
# rescued exception) — which is the whole point: it
|
|
887
|
+
# is the value the retained element evidence provably no longer covers. Returns `[]` when the
|
|
888
|
+
# key is unresolvable, which reproduces the pre-join widening.
|
|
889
|
+
# The index arguments are typed, and the receiver's joinability read, in `type_scope`: the
|
|
890
|
+
# evaluator's entry scope by default; a `for` index passes its post-collection scope and a
|
|
891
|
+
# rescue reference its arm's entry scope, the nearest the engine has to where Ruby evaluates
|
|
892
|
+
# them (each iteration, the moment of the catch).
|
|
624
893
|
# There is deliberately NO `rescue` here. `Scope#type_of` is a total query over well-formed Prism input,
|
|
625
894
|
# so a raise is an engine bug, and swallowing it would silently downgrade a live seam to "no evidence" —
|
|
626
895
|
# the join would quietly stop happening with nothing to show for it. Let it reach the runner's
|
|
627
896
|
# internal-error path, where it is visible.
|
|
628
|
-
def index_write_arg_types(node, stored_type)
|
|
629
|
-
|
|
630
|
-
return MutationWidening::NO_ARG_TYPES if
|
|
631
|
-
return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver,
|
|
897
|
+
def index_write_arg_types(node, stored_type, type_scope: scope)
|
|
898
|
+
args = node.arguments
|
|
899
|
+
return MutationWidening::NO_ARG_TYPES if args.nil? || stored_type.nil?
|
|
900
|
+
return MutationWidening::NO_ARG_TYPES unless MutationWidening.joinable_receiver?(node.receiver, type_scope)
|
|
901
|
+
|
|
902
|
+
list = args.respond_to?(:arguments) ? args.arguments : args
|
|
903
|
+
# A splat argument is marked `nil` — its expansion decides the store's arity at
|
|
904
|
+
# runtime, which an untyped index type could not express (issue #1140).
|
|
905
|
+
list.map { |arg| arg.is_a?(Prism::SplatNode) ? nil : type_scope.type_of(arg, tracer: tracer) } + [stored_type]
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
# What a compound index write stores through `[]=` — `a[i] ||= v` stores `truthy(a[i]) | v`,
|
|
909
|
+
# `a[i] &&= v` stores `falsey(a[i]) | v`, and `a[i] op= v` stores the dispatched `a[i] op v`:
|
|
910
|
+
# `a[0, 1] += [2]` reads `a[0, 1] + [2]`, not `[2]` (issue #1140). It is also the node's value
|
|
911
|
+
# outside {#index_compound_write_value}'s memoizing `||=`. That method passes the `current` read
|
|
912
|
+
# and the `rhs` it already typed, so a nested `(a[i] ||= {})[j] ||= v` chain types each level's
|
|
913
|
+
# receiver once rather than doubling per level. Any other node falls back to its own type (an
|
|
914
|
+
# index target — a multi-assign slot, a `for` index, a rescue reference — keeps its untyped
|
|
915
|
+
# answer).
|
|
916
|
+
def index_write_stored_type(node, type_scope, current: nil, rhs: nil)
|
|
917
|
+
case node
|
|
918
|
+
when Prism::IndexOrWriteNode, Prism::IndexAndWriteNode
|
|
919
|
+
current ||= index_read_type(node, type_scope)
|
|
920
|
+
narrowed = if node.is_a?(Prism::IndexOrWriteNode)
|
|
921
|
+
Narrowing.narrow_truthy(current)
|
|
922
|
+
else
|
|
923
|
+
Narrowing.narrow_falsey(current)
|
|
924
|
+
end
|
|
925
|
+
Type::Combinator.union(narrowed, rhs || type_scope.type_of(node.value, tracer: tracer))
|
|
926
|
+
when Prism::IndexOperatorWriteNode
|
|
927
|
+
MethodDispatcher.dispatch(
|
|
928
|
+
receiver_type: index_read_type(node, type_scope), method_name: node.binary_operator,
|
|
929
|
+
arg_types: [type_scope.type_of(node.value, tracer: tracer)],
|
|
930
|
+
environment: type_scope.environment
|
|
931
|
+
) || Type::Combinator.untyped
|
|
932
|
+
else
|
|
933
|
+
type_scope.type_of(node, tracer: tracer)
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# True when {#index_compound_write_value} reads the `||=` `node` as the memoization idiom's rvalue: the slot
|
|
938
|
+
# reads wholly gradual, the rvalue can store something truthy, and no block-return pass marked the site.
|
|
939
|
+
def memoizing_index_read?(node, current, rhs)
|
|
940
|
+
current.is_a?(Type::Dynamic) && !Narrowing.narrow_truthy(rhs).is_a?(Type::Bot) &&
|
|
941
|
+
!scope.repeated_or_write?(node)
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
# The `receiver[i]` read a compound index write performs before storing — the `[]` read on the
|
|
945
|
+
# receiver's own type with the write's index arguments (a splat reads untyped), refined by a
|
|
946
|
+
# recorded indexed narrowing when the single-index form names a stable slot. The read takes the
|
|
947
|
+
# tiers a plain `receiver[i]` call does ({ExpressionTyper#implicit_index_read_type}), so a project
|
|
948
|
+
# `[]` with no signature answers from its body.
|
|
949
|
+
def index_read_type(node, read_scope)
|
|
950
|
+
receiver = read_scope.type_of(node.receiver, tracer: tracer)
|
|
951
|
+
args = node.arguments
|
|
952
|
+
list = args.respond_to?(:arguments) ? args.arguments : Array(args)
|
|
953
|
+
index_types = list.map do |arg|
|
|
954
|
+
arg.is_a?(Prism::SplatNode) ? Type::Combinator.untyped : read_scope.type_of(arg, tracer: tracer)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
key = single_index_argument(node)
|
|
958
|
+
address = key && IndexedNarrowing.stable_address(node.receiver, key)
|
|
959
|
+
narrowed = address && read_scope.indexed_narrowing(*address)
|
|
960
|
+
return narrowed if narrowed
|
|
632
961
|
|
|
633
|
-
|
|
962
|
+
typer = ExpressionTyper.new(scope: read_scope, tracer: tracer)
|
|
963
|
+
typer.implicit_index_read_type(node, receiver, index_types) || Type::Combinator.untyped
|
|
634
964
|
end
|
|
635
965
|
|
|
636
966
|
# Argument types for a straight-line content mutator (`arr << x`, `h[k] = v`).
|
|
@@ -659,22 +989,28 @@ module Rigor
|
|
|
659
989
|
ElementReadWidening.widen_element_read(call_node: call_node, current_scope: widened, arg_types: arg_types)
|
|
660
990
|
end
|
|
661
991
|
|
|
992
|
+
# `tr!` / `tr_s!` are typed too: whether they can empty a `non-empty-string` turns on their replacement argument.
|
|
662
993
|
def mutator_arg_types(call_node, current_scope)
|
|
663
|
-
|
|
994
|
+
unless ContentJoin::CONTENT_ADDERS.include?(call_node.name) || StringMutation::TRANSLATORS.include?(call_node.name)
|
|
995
|
+
return MutationWidening::NO_ARG_TYPES
|
|
996
|
+
end
|
|
664
997
|
unless MutationWidening.joinable_receiver?(call_node.receiver, current_scope) ||
|
|
665
998
|
ElementReadWidening.joinable_element_read?(call_node.receiver, current_scope)
|
|
666
999
|
return MutationWidening::NO_ARG_TYPES
|
|
667
1000
|
end
|
|
668
1001
|
|
|
669
|
-
content_arg_types(call_node,
|
|
1002
|
+
content_arg_types(call_node, operand_scope, @operand_types)
|
|
670
1003
|
end
|
|
671
1004
|
|
|
672
|
-
|
|
1005
|
+
# The index node of an index-write when it holds exactly one index argument — the only form
|
|
1006
|
+
# whose stored value lands on a nameable slot (`a[k]`). Multi-index forms address a splice
|
|
1007
|
+
# region and answer `nil`.
|
|
1008
|
+
def single_index_argument(node)
|
|
673
1009
|
args = node.arguments
|
|
674
1010
|
return nil if args.nil?
|
|
675
1011
|
|
|
676
1012
|
list = args.respond_to?(:arguments) ? args.arguments : args
|
|
677
|
-
list.first
|
|
1013
|
+
list.size == 1 ? list.first : nil
|
|
678
1014
|
end
|
|
679
1015
|
|
|
680
1016
|
def dispatch_operator(current, rhs, operator)
|
|
@@ -690,13 +1026,94 @@ module Rigor
|
|
|
690
1026
|
# `a, b = rhs` — Slice 5 phase 2 sub-phase 2 destructuring. Evaluates the right-hand side under the entry scope,
|
|
691
1027
|
# then decomposes its type against the multi-write target tree (Prism::MultiWriteNode#lefts/rest/rights, including
|
|
692
1028
|
# nested Prism::MultiTargetNode for the `(b, c)` form). Tuple-shaped right-hand sides produce per-slot types
|
|
693
|
-
# element-wise
|
|
694
|
-
#
|
|
1029
|
+
# element-wise, an `Array[T]` binds each fixed slot to `T` with the optimistic-nil-free mark (issue #1093), a
|
|
1030
|
+
# union distributes over its members and a value with no implicit `to_ary` binds as `[rhs]` (issue #1094), and
|
|
1031
|
+
# other carriers fall back to `Dynamic[Top]` per slot. Instance-variable targets bind by the same rules, with the
|
|
1032
|
+
# optimistic mark recorded per ivar (issue #1110). A right-hand side that is itself optimistically nil-free
|
|
1033
|
+
# (`k, v = pairs.first`, or a local bound to one) marks every name it binds, and a literal one
|
|
1034
|
+
# (`x, y = pairs.first, 1`) marks each slot by its element: a miss binds `nil` to every such slot
|
|
1035
|
+
# ({Inference::OptimisticOrigin.destructuring_marks}). The expression value is the right-hand side type
|
|
1036
|
+
# (matching Ruby's semantics: `(a, b = [1, 2])` evaluates to `[1, 2]`).
|
|
1037
|
+
#
|
|
1038
|
+
# An index target (`h[:a], z = 1, 2`, nested or splatted too) stores its slot through `[]=`, so its receiver
|
|
1039
|
+
# widens here exactly as the plain store `h[:a] = 1` widens it, joining the slot's value as content evidence
|
|
1040
|
+
# (issue #560) — otherwise the literal survives and a later `h[:a] == 0` folds on its stale `0`. The widening
|
|
1041
|
+
# runs AFTER the bindings: Ruby evaluates a target's receiver before any target is assigned, so
|
|
1042
|
+
# `h, h[:a] = h, 1` stores into the object `h` is bound to afterwards, and widening first would let the
|
|
1043
|
+
# binding of `h` restore the literal. When a target rebinds the receiver's variable to another object
|
|
1044
|
+
# instead, widening that one only loses precision.
|
|
1045
|
+
#
|
|
1046
|
+
# The stored value is the slot the binder decomposed, softened as a local in the same position is. The
|
|
1047
|
+
# ADR-57 softening that drops a slot's `nil` is honest for a local because of the optimistic mark, which a
|
|
1048
|
+
# stored value never carries — but it does not need one here: the straight-line join always adds the
|
|
1049
|
+
# `Dynamic[top]` floor ({MutationWidening#gradual_floor}), so no fold can rest on the dropped `nil`. Joining
|
|
1050
|
+
# the `nil` instead would fire `call.possible-nil-receiver` on the correlated guard the softening exists for,
|
|
1051
|
+
# `r[:k], r[:v] = h.find { … }; r[:v].upcase if r[:k]`.
|
|
1052
|
+
#
|
|
1053
|
+
# Each store then drops the indexed narrowing it overwrites, through the same
|
|
1054
|
+
# {IndexedNarrowing.invalidate_indexed_write} a `[]=` call takes (it reads only `receiver` and `arguments`,
|
|
1055
|
+
# which an index target shares): the widening carries a Nominal receiver's slot narrowings across its
|
|
1056
|
+
# rebind, so `m[:a] ||= "d"; m[:a], y = 1, 2` would otherwise keep reading `"d"`.
|
|
695
1057
|
def eval_multi_write(node)
|
|
696
1058
|
rhs_type, post_rhs = sub_eval(node.value, scope)
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1059
|
+
marks = Inference::OptimisticOrigin.destructuring_marks(node.value, post_rhs)
|
|
1060
|
+
bound = MultiTargetBinder.bind_marked(node, rhs_type, optimistic: marks, scope: post_rhs)
|
|
1061
|
+
miss = marks == false ? nil : Inference::OptimisticOrigin.destructuring_miss(node.value, post_rhs)
|
|
1062
|
+
post = widen_index_targets(bound, bound.apply_to(post_rhs, miss: miss), type_scope: scope)
|
|
1063
|
+
[rhs_type, widen_attribute_targets(node, post)]
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# `recv.attr ||= v` / `&&=` / `op=` calls the writer `attr=` on `recv`, so a writer the mutation widening
|
|
1067
|
+
# responds to widens the receiver as the plain call does: `h.default ||= 0` reopens `h` as `h.default = 0`
|
|
1068
|
+
# does ({HashLookupMutation}). The node's value is typed as before; the widening is its only scope effect.
|
|
1069
|
+
def eval_attribute_compound_write(node)
|
|
1070
|
+
widened = widen_attribute_write(node.receiver, node.write_name, scope)
|
|
1071
|
+
[scope.type_of(node, tracer: tracer), forget_rebound_specials(widened, node)]
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
# The scope effect of calling the writer `writer` on `receiver` outside a `CallNode`: the receiver widening, and
|
|
1075
|
+
# the receiver-wide drop of recorded `receiver[key]` narrowings `IndexedNarrowing` makes after a mutator call.
|
|
1076
|
+
def widen_attribute_write(receiver, writer, current_scope)
|
|
1077
|
+
widened = MutationWidening.widen_receiver_aliases(receiver, writer, current_scope)
|
|
1078
|
+
stable = IndexedNarrowing.stable_receiver(receiver)
|
|
1079
|
+
return widened unless stable && IndexedNarrowing.mutator?(writer)
|
|
1080
|
+
|
|
1081
|
+
widened.without_indexed_narrowings_for(*stable)
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# The attribute targets of a multi-write (`h.default, x = 0, 1`), nested ones included, each widening its
|
|
1085
|
+
# receiver as the plain writer call would.
|
|
1086
|
+
def widen_attribute_targets(node, post)
|
|
1087
|
+
targets = [*node.lefts, node.rest, *node.rights]
|
|
1088
|
+
targets.reduce(post) do |acc, target|
|
|
1089
|
+
target = target.expression if target.is_a?(Prism::SplatNode)
|
|
1090
|
+
case target
|
|
1091
|
+
when Prism::CallTargetNode then widen_attribute_write(target.receiver, target.name, acc)
|
|
1092
|
+
when Prism::MultiTargetNode then widen_attribute_targets(target, acc)
|
|
1093
|
+
else acc
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
# Widens the receiver of every index target a {MultiTargetBinder} result reports, over the scope its bindings
|
|
1099
|
+
# were applied to — the multi-write and the `for a, h[:k] in pairs` index share it.
|
|
1100
|
+
def widen_index_targets(bound, post, type_scope:)
|
|
1101
|
+
bound.index_targets.reduce(post) do |acc, (target, stored)|
|
|
1102
|
+
widen_index_target(target, stored, acc, type_scope: type_scope)
|
|
1103
|
+
end
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
# An index target (`Prism::IndexTargetNode`) stores `stored` through `[]=` on its receiver wherever it
|
|
1107
|
+
# appears — a multi-assign slot, a `for` index, a rescue reference — so its receiver widens exactly as the
|
|
1108
|
+
# plain store `h[:a] = v` widens it, joining `stored` as content evidence (issue #560), and drops the
|
|
1109
|
+
# `h[:a] ||= default` narrowing on the slot it overwrote, as `eval_call` drops it after a `[]=` — the
|
|
1110
|
+
# widening carries slot narrowings across the rebind, so without the drop `h[:a]` keeps reading the default.
|
|
1111
|
+
# `type_scope` types the index arguments and gates the evidence (`joinable_receiver?`); `current_scope` is
|
|
1112
|
+
# the one widened.
|
|
1113
|
+
def widen_index_target(target, stored, current_scope, type_scope:)
|
|
1114
|
+
widened = IndexWriteWidening.widen(node: target, current_scope: current_scope,
|
|
1115
|
+
arg_types: index_write_arg_types(target, stored, type_scope: type_scope))
|
|
1116
|
+
IndexedNarrowing.invalidate_indexed_write(target, widened)
|
|
700
1117
|
end
|
|
701
1118
|
|
|
702
1119
|
# `if pred; t; (elsif/else)?` runs the predicate first (its post-scope is shared by both branches), then asks
|
|
@@ -706,26 +1123,23 @@ module Rigor
|
|
|
706
1123
|
# phase 2 behaviour. The branches' result types are unioned; their post-scopes are joined with nil-injection on
|
|
707
1124
|
# half-bound names so a name set in one branch but not the other is observable as `T | nil` after the if.
|
|
708
1125
|
def eval_if(node)
|
|
709
|
-
pred_type, post_pred =
|
|
1126
|
+
pred_type, post_pred, truthy_scope, falsey_scope = eval_with_edges(node.predicate, scope)
|
|
710
1127
|
|
|
711
1128
|
# When the predicate is a known-truthy / known-falsey type (notably `Constant[true]` / `Constant[false]` after
|
|
712
1129
|
# the constant-fold tier), only the live branch contributes a type and a post-scope. The dead branch is skipped
|
|
713
1130
|
# so the result type is precise (`Constant[:even]` instead of the joined `Constant[:even] | Constant[:odd]`).
|
|
714
|
-
live = live_branch_for_if(node, pred_type, post_pred)
|
|
1131
|
+
live = live_branch_for_if(node, pred_type, post_pred, truthy_scope, falsey_scope)
|
|
715
1132
|
if live
|
|
716
1133
|
live_type, _live_scope = live
|
|
717
1134
|
# When the provably-live then-branch terminates and there is no else, apply the same falsey-scope narrowing as
|
|
718
1135
|
# the standard early-return path below. Without this, `return if @ivar.nil?` with an ivar seeded as
|
|
719
1136
|
# Constant[nil] (making nil? = Constant[true] and the then-branch "provably live") propagates the un-narrowed
|
|
720
1137
|
# nil scope past the guard instead of Bot.
|
|
721
|
-
if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
|
|
722
|
-
|
|
723
|
-
return [live_type, falsey_scope]
|
|
724
|
-
end
|
|
1138
|
+
return [live_type, falsey_scope] if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
|
|
1139
|
+
|
|
725
1140
|
return live
|
|
726
1141
|
end
|
|
727
1142
|
|
|
728
|
-
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
729
1143
|
then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
|
|
730
1144
|
else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
|
|
731
1145
|
# Slice 7 phase 14 — early-return narrowing. When the then-branch unconditionally exits (return / next / break /
|
|
@@ -754,22 +1168,19 @@ module Rigor
|
|
|
754
1168
|
# chain). The narrower's truthy/falsey edges are routed in swapped form because `unless` runs its body when the
|
|
755
1169
|
# predicate is falsey.
|
|
756
1170
|
def eval_unless(node)
|
|
757
|
-
pred_type, post_pred =
|
|
1171
|
+
pred_type, post_pred, truthy_scope, falsey_scope = eval_with_edges(node.predicate, scope)
|
|
758
1172
|
|
|
759
|
-
live = live_branch_for_unless(node, pred_type, post_pred)
|
|
1173
|
+
live = live_branch_for_unless(node, pred_type, post_pred, truthy_scope, falsey_scope)
|
|
760
1174
|
if live
|
|
761
1175
|
live_type, _live_scope = live
|
|
762
1176
|
# Mirror of the eval_if fix: when the provably-live unless-body terminates and there is no else, apply the
|
|
763
1177
|
# truthy-scope narrowing so `return unless @ivar` with a nil-seeded ivar doesn't propagate the nil scope past
|
|
764
1178
|
# the guard.
|
|
765
|
-
if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
|
|
766
|
-
|
|
767
|
-
return [live_type, truthy_scope]
|
|
768
|
-
end
|
|
1179
|
+
return [live_type, truthy_scope] if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
|
|
1180
|
+
|
|
769
1181
|
return live
|
|
770
1182
|
end
|
|
771
1183
|
|
|
772
|
-
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
|
|
773
1184
|
then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
|
|
774
1185
|
else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
|
|
775
1186
|
# Slice 7 phase 14 — same early-return narrowing as `if`: when the body unconditionally exits and there is no
|
|
@@ -792,20 +1203,29 @@ module Rigor
|
|
|
792
1203
|
# the caller falls through to the standard both-branch evaluation. Constant `true`/`false` is the obvious trigger;
|
|
793
1204
|
# non-falsey carriers like `Nominal[Integer]` (Integer is always truthy in Ruby — including 0) also collapse the
|
|
794
1205
|
# dead else.
|
|
795
|
-
def live_branch_for_if(node, pred_type, post_pred)
|
|
1206
|
+
def live_branch_for_if(node, pred_type, post_pred, truthy_scope, falsey_scope)
|
|
1207
|
+
truthy_scope, falsey_scope = live_branch_scopes(node.predicate, post_pred, truthy_scope, falsey_scope)
|
|
796
1208
|
case branch_certainty(node.predicate, pred_type, post_pred)
|
|
797
|
-
when :truthy then eval_branch_or_nil(node.statements,
|
|
798
|
-
when :falsey then eval_branch_or_nil(node.subsequent,
|
|
1209
|
+
when :truthy then eval_branch_or_nil(node.statements, truthy_scope)
|
|
1210
|
+
when :falsey then eval_branch_or_nil(node.subsequent, falsey_scope)
|
|
799
1211
|
end
|
|
800
1212
|
end
|
|
801
1213
|
|
|
802
|
-
def live_branch_for_unless(node, pred_type, post_pred)
|
|
1214
|
+
def live_branch_for_unless(node, pred_type, post_pred, truthy_scope, falsey_scope)
|
|
1215
|
+
truthy_scope, falsey_scope = live_branch_scopes(node.predicate, post_pred, truthy_scope, falsey_scope)
|
|
803
1216
|
case branch_certainty(node.predicate, pred_type, post_pred)
|
|
804
|
-
when :truthy then eval_branch_or_nil(node.else_clause,
|
|
805
|
-
when :falsey then eval_branch_or_nil(node.statements,
|
|
1217
|
+
when :truthy then eval_branch_or_nil(node.else_clause, truthy_scope)
|
|
1218
|
+
when :falsey then eval_branch_or_nil(node.statements, falsey_scope)
|
|
806
1219
|
end
|
|
807
1220
|
end
|
|
808
1221
|
|
|
1222
|
+
# A provably-live branch runs from the post-predicate scope, un-narrowed, except under an `&&` / `||` whose right
|
|
1223
|
+
# operand writes ({#eval_with_edges}): there the joined scope still reads the write as possibly `nil`, and the
|
|
1224
|
+
# live edge is the one on which the right operand ran (`if text && (w = text.size)` with `text` a String).
|
|
1225
|
+
def live_branch_scopes(predicate, post_pred, truthy_scope, falsey_scope)
|
|
1226
|
+
and_or_right_effects?(predicate) ? [truthy_scope, falsey_scope] : [post_pred, post_pred]
|
|
1227
|
+
end
|
|
1228
|
+
|
|
809
1229
|
# ADR-47 WD5 — a decidable **version guard** answers first (#627). `RUBY_VERSION >= "3.1"` and the
|
|
810
1230
|
# `Gem::Version.new(…) <cmp> Gem::Version.new(…)` spellings fold from literals the analyzer can read, so the arm
|
|
811
1231
|
# that cannot run on the Ruby being checked with is elided exactly as `if false`'s is: it is never evaluated, so
|
|
@@ -843,8 +1263,12 @@ module Rigor
|
|
|
843
1263
|
# shared with every branch (including the else); branches are evaluated independently and merged with
|
|
844
1264
|
# nil-injection so half-bound names degrade to `T | nil`.
|
|
845
1265
|
def eval_case(node)
|
|
846
|
-
post_pred = node.predicate ? sub_eval(node.predicate, scope)
|
|
847
|
-
branch_results, falsey_scope = eval_case_when_branches(node.predicate, node.conditions, post_pred)
|
|
1266
|
+
subject_type, post_pred = node.predicate ? sub_eval(node.predicate, scope) : [nil, scope]
|
|
1267
|
+
branch_results, falsey_scope = eval_case_when_branches(subject_type, node.predicate, node.conditions, post_pred)
|
|
1268
|
+
if pattern_case_matches_every_path?(node, branch_results)
|
|
1269
|
+
return unmatched_pattern_result(branch_results, node.conditions)
|
|
1270
|
+
end
|
|
1271
|
+
|
|
848
1272
|
else_result = eval_case_else(node.else_clause, falsey_scope)
|
|
849
1273
|
|
|
850
1274
|
all_results = [*branch_results, else_result]
|
|
@@ -855,6 +1279,21 @@ module Rigor
|
|
|
855
1279
|
]
|
|
856
1280
|
end
|
|
857
1281
|
|
|
1282
|
+
# Issue #1122 — a `case/in` with no `else` has no "nothing matched" path: CRuby raises
|
|
1283
|
+
# `NoMatchingPatternError` when no pattern matches, so the continuation is reached only through a
|
|
1284
|
+
# matched clause. The shared `else` arm would inject that impossible path anyway — `Constant[nil]`
|
|
1285
|
+
# for the type and the entry scope for the continuation, which nil-injects every pattern-bound name.
|
|
1286
|
+
# `case [1, "a"] in [i, s] then i end; i + 1` then read as `i + 1` on `1 | nil` and drew a false
|
|
1287
|
+
# `possible nil receiver` on a name bound on every path that reaches it. A `case/when` keeps the
|
|
1288
|
+
# arm: a subject matching no clause really does fall through as `nil`.
|
|
1289
|
+
def pattern_case_matches_every_path?(node, branch_results)
|
|
1290
|
+
node.is_a?(Prism::CaseMatchNode) && node.else_clause.nil? && !branch_results.empty?
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
def unmatched_pattern_result(branch_results, branch_nodes)
|
|
1294
|
+
[Type::Combinator.union(*branch_results.map(&:first)), join_case_branch_scopes(branch_results, branch_nodes)]
|
|
1295
|
+
end
|
|
1296
|
+
|
|
858
1297
|
# Joins the post-scopes of every `when`/`in`/`else` branch, dropping the scope of any branch that terminates
|
|
859
1298
|
# (raises / returns / throws / types to `Bot`) before the merge — control never falls through such a branch, so
|
|
860
1299
|
# its half-bound locals must not nil-inject the names a live sibling branch assigned. Mirrors the
|
|
@@ -872,22 +1311,31 @@ module Rigor
|
|
|
872
1311
|
reduce_scopes_with_nil_injection(live)
|
|
873
1312
|
end
|
|
874
1313
|
|
|
875
|
-
def eval_case_when_branches(subject, conditions, entry_scope)
|
|
1314
|
+
def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
|
|
876
1315
|
results = []
|
|
877
1316
|
falsey_scope = entry_scope
|
|
878
1317
|
conditions.each do |branch|
|
|
1318
|
+
# Issue #1359 — a clause's conditions, or its pattern's pins and guard, run before its body and before
|
|
1319
|
+
# every later clause, and the walk types them without evaluating them, so a reader there forgets `$_` here.
|
|
1320
|
+
falsey_scope = LastLine.forget_if_set(falsey_scope, *clause_tests(branch))
|
|
879
1321
|
# ADR-47 WD2 — record the scope ENTERING this clause (the subject narrowed by every earlier clause's negation)
|
|
880
1322
|
# on the clause's first condition node, so `flow.unreachable-clause` can tell a prior-exhausted subject (entry
|
|
881
1323
|
# already `bot`) from a per-clause-disjoint one (entry concrete, this clause disjoint). `on_enter`-only (no
|
|
882
1324
|
# recursion) so no condition sub-expression is newly typed; `propagate` preserves the entry because it already
|
|
883
1325
|
# keys the node.
|
|
884
1326
|
record_clause_entry_scope(branch, falsey_scope)
|
|
885
|
-
body_scope, falsey_scope = branch_body_and_falsey_scopes(subject, branch, falsey_scope)
|
|
1327
|
+
body_scope, falsey_scope = branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
|
|
886
1328
|
results << sub_eval(branch, body_scope)
|
|
887
1329
|
end
|
|
888
1330
|
[results, falsey_scope]
|
|
889
1331
|
end
|
|
890
1332
|
|
|
1333
|
+
# What a `when` / `in` clause runs to decide whether it matches, without its body: the `when` conditions, or the
|
|
1334
|
+
# `in` pattern with its pins and guard.
|
|
1335
|
+
def clause_tests(branch)
|
|
1336
|
+
branch.is_a?(Prism::WhenNode) ? branch.conditions : [branch.pattern]
|
|
1337
|
+
end
|
|
1338
|
+
|
|
891
1339
|
# ADR-47 WD2/WD3 — record the scope ENTERING a `when`/`in` clause on the node `flow.unreachable-clause` reads to
|
|
892
1340
|
# classify a dead clause (`when`: first condition; `in`: the pattern). `on_enter`-only so no sub-expression is
|
|
893
1341
|
# newly typed; `propagate` preserves it.
|
|
@@ -903,9 +1351,10 @@ module Rigor
|
|
|
903
1351
|
# Returns `[body_scope, updated_falsey_scope]` for a single branch. `WhenNode` branches narrow through
|
|
904
1352
|
# `Narrowing.case_when_scopes`. `InNode` branches narrow soundly only for a bare class pattern (`in C` / `in C =>
|
|
905
1353
|
# x`, pure `is_a?`); every other pattern keeps the conservative "body = entry + bindings, falsey unchanged" shape.
|
|
906
|
-
|
|
1354
|
+
# `subject_type` is the predicate's type, which an `in` branch's pattern decomposes to type the names it binds.
|
|
1355
|
+
def branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
|
|
907
1356
|
if branch.is_a?(Prism::InNode)
|
|
908
|
-
in_branch_body_and_falsey_scopes(subject, branch, falsey_scope)
|
|
1357
|
+
in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
|
|
909
1358
|
else
|
|
910
1359
|
when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
|
|
911
1360
|
Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
|
|
@@ -917,12 +1366,15 @@ module Rigor
|
|
|
917
1366
|
# falsey scope has `C` removed. Other patterns can fail to match even when a class test would pass (deconstruction
|
|
918
1367
|
# arity, hash keys, ...), so removing anything from the falsey scope would be unsound — they keep the conservative
|
|
919
1368
|
# shape.
|
|
920
|
-
def in_branch_body_and_falsey_scopes(subject, branch, falsey_scope)
|
|
1369
|
+
def in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
|
|
921
1370
|
class_node = bare_class_pattern_node(branch.pattern)
|
|
922
|
-
|
|
1371
|
+
unless class_node
|
|
1372
|
+
bound = apply_in_pattern_bindings(subject_type, subject, branch.pattern, falsey_scope)
|
|
1373
|
+
return [bound, falsey_scope]
|
|
1374
|
+
end
|
|
923
1375
|
|
|
924
1376
|
truthy_scope, narrowed_falsey = Narrowing.case_when_scopes(subject, [class_node], falsey_scope)
|
|
925
|
-
[apply_in_pattern_bindings(subject, branch.pattern, truthy_scope), narrowed_falsey]
|
|
1377
|
+
[apply_in_pattern_bindings(subject_type, subject, branch.pattern, truthy_scope), narrowed_falsey]
|
|
926
1378
|
end
|
|
927
1379
|
|
|
928
1380
|
# The class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is pure `is_a?`), or
|
|
@@ -949,38 +1401,73 @@ module Rigor
|
|
|
949
1401
|
sub_eval(node.statements, scope)
|
|
950
1402
|
end
|
|
951
1403
|
|
|
1404
|
+
# Issue #1360 — a `begin` with a rescue chain runs through {#eval_begin} with `$!` and `$@` restored as it
|
|
1405
|
+
# leaves ({#restoring_error_info}): Ruby restores them once the `begin` exits, however it exits, so the binding
|
|
1406
|
+
# its rescue clauses made ({#bind_rescue_reference}) never reaches the code after it. The retry edge carries
|
|
1407
|
+
# locals and instance variables only, so a retried body reads them as the `begin` found them already. A `begin`
|
|
1408
|
+
# without a rescue chain binds neither.
|
|
1409
|
+
#
|
|
1410
|
+
# A body a `retry` re-enters runs again after the exception it raised, which may have come while a subprocess
|
|
1411
|
+
# waited and so left `$?` nil, and the retry edge does not carry `$?`: such a `begin` is evaluated with `$?`
|
|
1412
|
+
# unbound.
|
|
1413
|
+
def eval_begin_node(node)
|
|
1414
|
+
return eval_begin(node) unless node.rescue_clause
|
|
1415
|
+
return evaluator_at(scope.forget_last_status).send(:eval_begin_node, node) if status_retried?(node)
|
|
1416
|
+
|
|
1417
|
+
restoring_error_info { eval_begin(node) }
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
# True when `$?` is bound and a rescue clause of `node` holds a `retry` that re-enters it.
|
|
1421
|
+
def status_retried?(node)
|
|
1422
|
+
return false unless scope.global(:$?)
|
|
1423
|
+
|
|
1424
|
+
current = node.rescue_clause
|
|
1425
|
+
current = current.subsequent until current.nil? || collect_retries(current.statements)
|
|
1426
|
+
!current.nil?
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
# The `[type, scope]` the block answers, for a `begin` or rescue modifier that starts from this evaluator's
|
|
1430
|
+
# scope, with `$!` and `$@` in that scope, and in each scope a `next` or `break` recorded into the jump sinks
|
|
1431
|
+
# while it ran, put back as this scope binds them ({ErrorInfo.restore}): the control those carry has left every
|
|
1432
|
+
# rescue clause the construct entered.
|
|
1433
|
+
def restoring_error_info
|
|
1434
|
+
marks = [@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
|
|
1435
|
+
type, after = yield
|
|
1436
|
+
[[@next_scope_sink, marks.first], [Thread.current[BREAK_SINK_KEY], marks.last]].each do |sink, mark|
|
|
1437
|
+
next if sink.nil? || mark.nil?
|
|
1438
|
+
|
|
1439
|
+
(mark...sink.size).each do |index|
|
|
1440
|
+
jump, jump_scope = sink[index]
|
|
1441
|
+
sink[index] = [jump, ErrorInfo.restore(jump_scope, scope)]
|
|
1442
|
+
end
|
|
1443
|
+
end
|
|
1444
|
+
[type, ErrorInfo.restore(after, scope)]
|
|
1445
|
+
end
|
|
1446
|
+
|
|
952
1447
|
# `begin; body; rescue ...; else; ensure; end`. The body and the rescue chain are alternative exit paths whose
|
|
953
1448
|
# scopes are joined with nil-injection. The else-clause replaces the body's value when present (matching Ruby
|
|
954
1449
|
# semantics: else runs only if the body raises no exception). The ensure-clause runs but does not contribute to
|
|
955
1450
|
# the value; its scope effects are layered on the joined exit scope so locals bound exclusively in `ensure` stay
|
|
956
1451
|
# observable.
|
|
957
1452
|
def eval_begin(node)
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
#
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
#
|
|
968
|
-
#
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
# `fail`) contribute neither a type fragment NOR a scope to the post-begin flow — control left the `begin` via
|
|
977
|
-
# that arm. Mirrors the `eval_if` / `eval_unless` / `eval_and_or` early-return narrowing. Without this filter, a
|
|
978
|
-
# `rescue ... return` on a local bound only in the primary body nil-injects that local across the join,
|
|
979
|
-
# defeating the rescue arm's whole point of guaranteeing the primary local is in scope for downstream
|
|
980
|
-
# statements.
|
|
981
|
-
live_rescues = rescue_chain.reject { |_pair, arm_node| branch_unconditionally_exits?(arm_node.statements) }
|
|
982
|
-
.map(&:first)
|
|
983
|
-
|
|
1453
|
+
jump_marks = ensure_jump_marks(node)
|
|
1454
|
+
edge = retry_edge_for(node)
|
|
1455
|
+
# Issue #1359 — `$_` is not among the bindings the retry widening below carries, so a body that runs again
|
|
1456
|
+
# after it, or a rescue clause, may have set `$_` enters with it forgotten; a rescue clause runs after any
|
|
1457
|
+
# prefix of the body, and so reads it forgotten whenever the body may set it.
|
|
1458
|
+
entry = edge ? forget_guard_if_rebinds(LastLine.forget_if_set(scope, node), node) : scope
|
|
1459
|
+
primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge)
|
|
1460
|
+
rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry), edge: edge)
|
|
1461
|
+
|
|
1462
|
+
# B2.1 — retry-edge widening. When a `retry` in the rescue chain targets this `begin`, control re-enters the
|
|
1463
|
+
# primary body carrying every rebind made before the retry: the arm's (`rescue; tries += 1; retry; end`), and
|
|
1464
|
+
# the primary body's own, since it can raise after any prefix of itself (`begin; tries += 1; raise if tries < 3;
|
|
1465
|
+
# rescue; retry; end`). Without the widening the re-entry keeps `tries: Constant[0]` and the predicate folds.
|
|
1466
|
+
# {#eval_retried_begin} re-evaluates the primary body AND the rescue chain under a widened entry.
|
|
1467
|
+
retried = edge && eval_retried_begin(node, entry, edge)
|
|
1468
|
+
primary_type, primary_scope, rescue_chain = retried if retried
|
|
1469
|
+
|
|
1470
|
+
live_rescues = live_rescue_results(rescue_chain)
|
|
984
1471
|
if live_rescues.empty?
|
|
985
1472
|
exit_type = primary_type
|
|
986
1473
|
exit_scope = primary_scope
|
|
@@ -990,6 +1477,7 @@ module Rigor
|
|
|
990
1477
|
end
|
|
991
1478
|
|
|
992
1479
|
if node.ensure_clause
|
|
1480
|
+
carry_jumps_through_ensure(node.ensure_clause, jump_marks)
|
|
993
1481
|
_ensure_type, ensure_scope = sub_eval(node.ensure_clause, exit_scope)
|
|
994
1482
|
exit_scope = ensure_scope
|
|
995
1483
|
end
|
|
@@ -997,16 +1485,67 @@ module Rigor
|
|
|
997
1485
|
[exit_type, exit_scope]
|
|
998
1486
|
end
|
|
999
1487
|
|
|
1488
|
+
# The scope a rescue clause of `node` enters with: it runs after any prefix of the body, so neither a `$_`
|
|
1489
|
+
# (issue #1359) nor a guard's narrowing of a global or constant (issue #1429) the body may rebind holds there.
|
|
1490
|
+
def rescue_entry_scope(node, entry)
|
|
1491
|
+
forget_guard_if_rebinds(LastLine.forget_if_set(entry, node.statements), node.statements)
|
|
1492
|
+
end
|
|
1493
|
+
|
|
1494
|
+
# Rescue arms that never fall through contribute neither a type fragment NOR a scope to the post-begin flow —
|
|
1495
|
+
# control left the `begin` via that arm. That is an arm ending in `return`, `next`, `break`, `raise`, `throw`,
|
|
1496
|
+
# `exit`, `abort` or `fail`, and one whose type is `bot` ({#branch_terminates?}): an arm ending in `retry`, or in
|
|
1497
|
+
# `tries < 3 ? retry : raise`, leaves back into the primary body, and joining its scope would carry the retry
|
|
1498
|
+
# edge's widened entry past the `begin`. Mirrors the `eval_if` / `eval_unless` / `eval_and_or` early-return
|
|
1499
|
+
# narrowing. Without this filter, a `rescue ... return` on a local bound only in the primary body nil-injects that
|
|
1500
|
+
# local across the join, defeating the rescue arm's whole point of guaranteeing the primary local is in scope for
|
|
1501
|
+
# downstream statements.
|
|
1502
|
+
def live_rescue_results(rescue_chain)
|
|
1503
|
+
rescue_chain.reject { |(arm_type, _), arm_node| branch_terminates?(arm_node.statements, arm_type) }
|
|
1504
|
+
.map(&:first)
|
|
1505
|
+
end
|
|
1506
|
+
|
|
1507
|
+
# The jump sinks' sizes as a `begin … ensure` starts, or nil for a `begin` without `ensure`: every `next` /
|
|
1508
|
+
# `break` scope recorded past these marks leaves through that `ensure`.
|
|
1509
|
+
def ensure_jump_marks(node)
|
|
1510
|
+
return nil unless node.ensure_clause
|
|
1511
|
+
|
|
1512
|
+
[@next_scope_sink&.size, Thread.current[BREAK_SINK_KEY]&.size]
|
|
1513
|
+
end
|
|
1514
|
+
|
|
1515
|
+
# A `next` or `break` inside `begin … ensure` leaves only once the `ensure` clause has run, so each jump scope the
|
|
1516
|
+
# clauses recorded is replaced by that scope carried through the clause: `buf = nil; next if c` under `ensure
|
|
1517
|
+
# buf = +"reset"` leaves with `"reset"`, never `nil`. The clause is evaluated without recording into the
|
|
1518
|
+
# per-node scope index, which keeps the fall-through's visit.
|
|
1519
|
+
def carry_jumps_through_ensure(ensure_clause, marks)
|
|
1520
|
+
next_mark, break_mark = marks
|
|
1521
|
+
carry_through_ensure(ensure_clause, @next_scope_sink, next_mark)
|
|
1522
|
+
carry_through_ensure(ensure_clause, Thread.current[BREAK_SINK_KEY], break_mark)
|
|
1523
|
+
end
|
|
1524
|
+
|
|
1525
|
+
def carry_through_ensure(ensure_clause, sink, mark)
|
|
1526
|
+
return if sink.nil? || mark.nil?
|
|
1527
|
+
|
|
1528
|
+
(mark...sink.size).each do |index|
|
|
1529
|
+
jump, jump_scope = sink[index]
|
|
1530
|
+
sink[index] = [jump, sub_eval(ensure_clause, jump_scope, **UNRECORDED).last]
|
|
1531
|
+
end
|
|
1532
|
+
end
|
|
1533
|
+
|
|
1000
1534
|
# `BeginNode#statements` is the primary body; when an else-clause is present, its value replaces the body's per
|
|
1001
1535
|
# Ruby semantics (the else runs only when no exception was raised), but the body's scope effects still apply
|
|
1002
1536
|
# because the body did run before the else.
|
|
1003
|
-
|
|
1537
|
+
#
|
|
1538
|
+
# `edge`, when given, collects every scope the primary body could raise from: the scope after each statement of
|
|
1539
|
+
# its frame ({#record_raise_points}), and the scope it ends with. The else-clause is not among them: what it
|
|
1540
|
+
# raises is not rescued here.
|
|
1541
|
+
def eval_begin_primary_under(node, entry_scope, edge: nil)
|
|
1004
1542
|
body_type, body_scope =
|
|
1005
1543
|
if node.statements
|
|
1006
|
-
sub_eval(node.statements, entry_scope)
|
|
1544
|
+
with_raise_frame(edge) { sub_eval(node.statements, entry_scope) }
|
|
1007
1545
|
else
|
|
1008
1546
|
[Type::Combinator.constant_of(nil), entry_scope]
|
|
1009
1547
|
end
|
|
1548
|
+
edge.raise_scopes << body_scope if edge
|
|
1010
1549
|
|
|
1011
1550
|
if node.else_clause
|
|
1012
1551
|
else_type, else_scope = sub_eval(node.else_clause, body_scope)
|
|
@@ -1016,92 +1555,293 @@ module Rigor
|
|
|
1016
1555
|
end
|
|
1017
1556
|
end
|
|
1018
1557
|
|
|
1019
|
-
# B2.1 —
|
|
1020
|
-
#
|
|
1021
|
-
#
|
|
1022
|
-
#
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
# widened form against the engine's current carrier set.
|
|
1026
|
-
def widen_entry_for_retry(entry_scope, rescue_chain)
|
|
1027
|
-
widened = nil
|
|
1028
|
-
rescue_chain.each do |(_arm_type, arm_post_scope), arm_node|
|
|
1029
|
-
next unless arm_contains_retry?(arm_node)
|
|
1558
|
+
# B2.1 — what one pass over a `begin` whose rescue chain retries collects: the `retry` nodes that target it, the
|
|
1559
|
+
# arms holding them, the nodes of its primary body's frame and the names that frame writes, and the scopes control
|
|
1560
|
+
# carries back into the primary body — at each point the body can raise from (`raise_scopes`), and at each of
|
|
1561
|
+
# those `retry`s together with the post-scope of each arm holding one (`retry_scopes`).
|
|
1562
|
+
RetryEdge = Data.define(:retries, :retrying_arms, :frame, :body_writes, :raise_scopes, :retry_scopes) do
|
|
1563
|
+
def fresh = with(raise_scopes: [], retry_scopes: [])
|
|
1030
1564
|
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
widened = accumulator
|
|
1565
|
+
def merge(other)
|
|
1566
|
+
with(raise_scopes: raise_scopes + other.raise_scopes, retry_scopes: retry_scopes + other.retry_scopes)
|
|
1034
1567
|
end
|
|
1035
|
-
|
|
1568
|
+
end
|
|
1569
|
+
private_constant :RetryEdge
|
|
1036
1570
|
|
|
1037
|
-
|
|
1571
|
+
# The entry and edge one widening reads, whether it widens to the Nominal envelope or keeps literals, and, per
|
|
1572
|
+
# name, the rebinds it has already weighed: a statement-by-statement body shares one binding object across every
|
|
1573
|
+
# scope until the name is rebound, and weighing each copy again is what made a long body quadratic.
|
|
1574
|
+
RetryWidening = Data.define(:entry, :edge, :envelope, :weighed) do
|
|
1575
|
+
def initialize(entry:, edge:, envelope:, weighed: {}) = super
|
|
1038
1576
|
end
|
|
1577
|
+
private_constant :RetryWidening
|
|
1039
1578
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
# Don't descend into nested blocks / defs / classes / modules — a `retry` inside a nested method body or block
|
|
1044
|
-
# targets its own enclosing `begin`, not this one.
|
|
1045
|
-
return false if node.is_a?(Prism::DefNode) ||
|
|
1046
|
-
node.is_a?(Prism::ClassNode) ||
|
|
1047
|
-
node.is_a?(Prism::ModuleNode) ||
|
|
1048
|
-
node.is_a?(Prism::BlockNode)
|
|
1579
|
+
# The thread-local stack of the {RetryEdge}s whose primary body is being evaluated, innermost last, or nil.
|
|
1580
|
+
RETRY_FRAMES_KEY = :rigor_retry_frames
|
|
1581
|
+
private_constant :RETRY_FRAMES_KEY
|
|
1049
1582
|
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1583
|
+
# The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
|
|
1584
|
+
# no `retry` targets.
|
|
1585
|
+
def retry_edge_for(node)
|
|
1586
|
+
retries = nil
|
|
1587
|
+
retrying_arms = nil
|
|
1588
|
+
current = node.rescue_clause
|
|
1589
|
+
while current
|
|
1590
|
+
found = collect_retries(current.statements)
|
|
1591
|
+
if found
|
|
1592
|
+
(retries ||= Set.new.compare_by_identity).merge(found)
|
|
1593
|
+
(retrying_arms ||= Set.new.compare_by_identity) << current
|
|
1594
|
+
end
|
|
1595
|
+
current = current.subsequent
|
|
1596
|
+
end
|
|
1597
|
+
return nil unless retries
|
|
1598
|
+
|
|
1599
|
+
frame = Set.new.compare_by_identity
|
|
1600
|
+
body_writes = Set.new
|
|
1601
|
+
walk_primary_frame(node.statements, true, frame, body_writes) if node.statements
|
|
1602
|
+
RetryEdge.new(retries: retries, retrying_arms: retrying_arms, frame: frame, body_writes: body_writes,
|
|
1603
|
+
raise_scopes: [], retry_scopes: [])
|
|
1604
|
+
end
|
|
1605
|
+
|
|
1606
|
+
# The `retry` nodes under `node` that re-enter the `begin` whose rescue arm holds it, or nil for none. A nested
|
|
1607
|
+
# `rescue` clause, or a rescue modifier's fallback, owns the `retry`s inside it (Ruby 4.0.5 retries the modifier's
|
|
1608
|
+
# own expression), and a nested block, lambda, `def` or class body cannot hold one for this `begin`.
|
|
1609
|
+
def collect_retries(node, found = nil)
|
|
1610
|
+
case node
|
|
1611
|
+
when nil, Prism::RescueNode then found
|
|
1612
|
+
when Prism::RetryNode then (found || []) << node
|
|
1613
|
+
when Prism::RescueModifierNode then collect_retries(node.expression, found)
|
|
1614
|
+
else
|
|
1615
|
+
return found if scope_boundary?(node)
|
|
1053
1616
|
|
|
1054
|
-
found =
|
|
1055
|
-
|
|
1617
|
+
node.rigor_each_child { |child| found = collect_retries(child, found) }
|
|
1618
|
+
found
|
|
1056
1619
|
end
|
|
1057
|
-
found
|
|
1058
1620
|
end
|
|
1059
1621
|
|
|
1060
|
-
def
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1622
|
+
def scope_boundary?(node)
|
|
1623
|
+
SCOPE_NESTING_NODES.any? { |klass| node.is_a?(klass) } || SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
|
|
1624
|
+
end
|
|
1625
|
+
|
|
1626
|
+
RETRY_WRITE_NODES = (CapturedLocals::LOCAL_WRITE_NODES | CapturedLocals::NON_LOCAL_WRITE_NODES).freeze
|
|
1627
|
+
private_constant :RETRY_WRITE_NODES
|
|
1628
|
+
|
|
1629
|
+
# Collects into `frame` the nodes of the primary body that run in its own frame — a nested block or lambda keeps
|
|
1630
|
+
# its own locals (a block parameter can shadow the counter) — and into `writes` every variable name the body
|
|
1631
|
+
# writes, a block's included (it may write an outer local). A `def` or class body runs nothing here.
|
|
1632
|
+
def walk_primary_frame(node, in_frame, frame, writes)
|
|
1633
|
+
return if SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
|
|
1634
|
+
|
|
1635
|
+
in_frame &&= SCOPE_NESTING_NODES.none? { |klass| node.is_a?(klass) }
|
|
1636
|
+
frame << node if in_frame
|
|
1637
|
+
writes << node.name if RETRY_WRITE_NODES.include?(node.class)
|
|
1638
|
+
node.rigor_each_child { |child| walk_primary_frame(child, in_frame, frame, writes) }
|
|
1639
|
+
end
|
|
1640
|
+
|
|
1641
|
+
# B2.1 — the primary path's `[type, scope]` and the rescue chain's results re-evaluated under an entry the retry
|
|
1642
|
+
# edge widens, or nil when nothing crosses the edge. The widening first keeps literals, the entry joined with each
|
|
1643
|
+
# rebind, and holds when one re-evaluation under it puts nothing new on the edge: `st = :ok; …; st = :retrying`
|
|
1644
|
+
# stays `:ok | :retrying`. A counter moves again (`0 | 1` meets `2`), so the entry is then widened to the Nominal
|
|
1645
|
+
# envelope of everything both passes saw, and evaluated once more; that pass is taken as converged.
|
|
1646
|
+
def eval_retried_begin(node, entry, edge)
|
|
1647
|
+
literal = widen_entry_for_retry(RetryWidening.new(entry: entry, edge: edge, envelope: false))
|
|
1648
|
+
return nil unless literal
|
|
1649
|
+
|
|
1650
|
+
closing = edge.fresh
|
|
1651
|
+
result = eval_begin_paths(node, literal, closing)
|
|
1652
|
+
return result unless widen_entry_for_retry(RetryWidening.new(entry: literal, edge: closing, envelope: false))
|
|
1653
|
+
|
|
1654
|
+
widened = widen_entry_for_retry(RetryWidening.new(entry: entry, edge: edge.merge(closing), envelope: true))
|
|
1655
|
+
eval_begin_paths(node, widened || literal, nil)
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1658
|
+
def eval_begin_paths(node, entry, edge)
|
|
1659
|
+
primary = eval_begin_primary_under(node, entry, edge: edge)
|
|
1660
|
+
[*primary, collect_rescue_chain_results(node.rescue_clause, entry, edge: edge)]
|
|
1661
|
+
end
|
|
1662
|
+
|
|
1663
|
+
# B2.1 — the entry scope widened by what crosses the retry edge, or nil when nothing does. A local or ivar bound
|
|
1664
|
+
# in the entry widens when a scope on the edge binds it to a type the accumulated binding does not already
|
|
1665
|
+
# accept ({#retry_binding_accepted?}): inside `log if m == :fast` the scope holds `m: :fast`, which the entry's
|
|
1666
|
+
# `:fast | :slow` accepts, and widening it would turn a declared `:fast | :slow` return into `Symbol`.
|
|
1667
|
+
#
|
|
1668
|
+
# A name the entry does not bind joins the edge only from a retrying arm, and only when the primary body never
|
|
1669
|
+
# writes it. One the body writes reads as `Dynamic[top]` on the first entry, as it does on this pass; binding a
|
|
1670
|
+
# type onto the edge would claim it set even when the body raised before assigning it — the arm's `conn.close if
|
|
1671
|
+
# conn` would fold to always-truthy for the body's connection, or to always-falsey for the arm's own `conn = nil`.
|
|
1672
|
+
def widen_entry_for_retry(widening)
|
|
1673
|
+
widened = widening.entry
|
|
1674
|
+
widening.edge.retry_scopes.each do |retry_scope|
|
|
1675
|
+
widened = absorb_retry_rebinds(widened, retry_scope, widening)
|
|
1676
|
+
end
|
|
1677
|
+
widening.edge.raise_scopes.each do |raise_scope|
|
|
1678
|
+
widened = absorb_retry_rebinds(widened, raise_scope, widening, bound_on_entry: true)
|
|
1679
|
+
end
|
|
1680
|
+
return nil if widened == widening.entry
|
|
1681
|
+
|
|
1682
|
+
widened
|
|
1683
|
+
end
|
|
1684
|
+
|
|
1685
|
+
# Runs `block` with `edge` on top of the thread-local stack {#record_raise_points} reads.
|
|
1686
|
+
def with_raise_frame(edge)
|
|
1687
|
+
return yield unless edge
|
|
1068
1688
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1689
|
+
previous = Thread.current[RETRY_FRAMES_KEY]
|
|
1690
|
+
Thread.current[RETRY_FRAMES_KEY] = previous ? [*previous, edge] : [edge]
|
|
1691
|
+
begin
|
|
1692
|
+
yield
|
|
1693
|
+
ensure
|
|
1694
|
+
Thread.current[RETRY_FRAMES_KEY] = previous
|
|
1695
|
+
end
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1698
|
+
# The scope after each statement of a retrying primary body's frame is a point the body can raise from, the next
|
|
1699
|
+
# statement's being the one after. Together they carry every rebind a retry can re-enter with, including one on a
|
|
1700
|
+
# branch that then raises and so never reaches the body's exit scope (`if bad; tries += 1; raise; end`), and a
|
|
1701
|
+
# write threaded into the raising call's own operands (`raise Retry.new(tries += 1) if flaky?`). A statement of a
|
|
1702
|
+
# nested block or lambda body is not in the frame (a block parameter can shadow the counter); the block's effect
|
|
1703
|
+
# on this frame shows in the post-scope of the statement holding it. Recording from the evaluator rather than
|
|
1704
|
+
# `on_enter` keeps a statement reached with the index recorder off (a threaded operand, a loop fixpoint pass).
|
|
1705
|
+
def record_raise_points(frames, stmt, stmt_scope)
|
|
1706
|
+
frames.each { |edge| edge.raise_scopes << stmt_scope if edge.frame.include?(stmt) }
|
|
1707
|
+
end
|
|
1708
|
+
|
|
1709
|
+
# An `on_enter` that records, besides forwarding to the installed one, the entry scope of each `retry` of `edge`
|
|
1710
|
+
# the rescue chain reaches: the scope control carries back into the primary body.
|
|
1711
|
+
def retry_scope_recorder(edge)
|
|
1712
|
+
forward = @on_enter
|
|
1713
|
+
lambda do |node, node_scope|
|
|
1714
|
+
edge.retry_scopes << node_scope if edge.retries.include?(node)
|
|
1715
|
+
forward&.call(node, node_scope)
|
|
1071
1716
|
end
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
# Widens against the accumulator's binding rather than the entry's, so a name rebound differently by two arms, or
|
|
1720
|
+
# at two points of the primary body, keeps every rebind instead of only the last one absorbed.
|
|
1721
|
+
def absorb_retry_rebinds(accumulator, post_scope, widening, bound_on_entry: false)
|
|
1722
|
+
scope_acc = absorb_retry_kind_rebinds(accumulator, post_scope, widening, :local, bound_on_entry)
|
|
1723
|
+
absorb_retry_kind_rebinds(scope_acc, post_scope, widening, :ivar, bound_on_entry)
|
|
1724
|
+
end
|
|
1077
1725
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1726
|
+
RETRY_KIND_TABLES = { local: :locals, ivar: :ivars }.freeze
|
|
1727
|
+
private_constant :RETRY_KIND_TABLES
|
|
1728
|
+
|
|
1729
|
+
# Walk every name of `kind` visible on either side (only the entry's under `bound_on_entry`), and widen a binding
|
|
1730
|
+
# the accumulated one does not already accept.
|
|
1731
|
+
def absorb_retry_kind_rebinds(scope_acc, post_scope, widening, kind, bound_on_entry)
|
|
1732
|
+
getter = VAR_KIND_GETTERS.fetch(kind)
|
|
1733
|
+
table = RETRY_KIND_TABLES.fetch(kind)
|
|
1734
|
+
names = widening.entry.public_send(table).keys
|
|
1735
|
+
names |= post_scope.public_send(table).keys unless bound_on_entry
|
|
1736
|
+
names.each do |name|
|
|
1737
|
+
post = post_scope.public_send(getter, name)
|
|
1738
|
+
next if post.nil? || retry_rebind_settled?(widening, name, widening.entry.public_send(getter, name), post)
|
|
1739
|
+
|
|
1740
|
+
current = scope_acc.public_send(getter, name)
|
|
1741
|
+
if current ? retry_binding_accepted?(current, post) : widening.edge.body_writes.include?(name)
|
|
1742
|
+
scope_acc = forget_diverged_copy(scope_acc, post_scope, kind, name)
|
|
1743
|
+
next
|
|
1744
|
+
end
|
|
1745
|
+
|
|
1746
|
+
scope_acc = rebind_retried(scope_acc, post_scope, kind, name,
|
|
1747
|
+
retry_widened_type(current, post, kind, widening.envelope))
|
|
1080
1748
|
end
|
|
1081
1749
|
scope_acc
|
|
1082
1750
|
end
|
|
1083
1751
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1752
|
+
# The rebind joins the binding a retry re-enters with into the accumulated one, so ADR-58's local mark stays only
|
|
1753
|
+
# when both scopes carry it, as `Scope#join` keeps it (issue #1287): `up(r)` in the body floors `r` in place and
|
|
1754
|
+
# keeps the mark, while `r = other` in the rescue arm is a write and drops it. Issue #1362 — as in `Scope#join`, a
|
|
1755
|
+
# copy of global seeds keeps its mark with the union of both scopes' globals, and only when both copy some.
|
|
1756
|
+
def rebind_retried(scope_acc, post_scope, kind, name, type)
|
|
1757
|
+
rebound = rebind_variable(scope_acc, kind, name, type)
|
|
1758
|
+
return rebound unless kind == :local && scope_acc.declaration_sourced?(:local, name) &&
|
|
1759
|
+
post_scope.declaration_sourced?(:local, name)
|
|
1760
|
+
|
|
1761
|
+
copies = scope_acc.declaration_sourced_global_copies(name)
|
|
1762
|
+
post_copies = post_scope.declaration_sourced_global_copies(name)
|
|
1763
|
+
return rebound unless copies.empty? == post_copies.empty?
|
|
1764
|
+
|
|
1765
|
+
rebound.with_local_declaration_mark(name).with_global_copy_marks(name, copies | post_copies)
|
|
1766
|
+
end
|
|
1767
|
+
|
|
1768
|
+
# Issue #1362 — a re-entered binding the accumulated one already accepts is not rebound, so ADR-58's mark would
|
|
1769
|
+
# stand on a local that copies a global's seed while the retried pass holds something else. As at a join, a copy
|
|
1770
|
+
# of other global seeds adds their globals to the record, and any other value makes the local flow-live.
|
|
1771
|
+
def forget_diverged_copy(scope_acc, post_scope, kind, name)
|
|
1772
|
+
return scope_acc unless kind == :local
|
|
1089
1773
|
|
|
1090
|
-
|
|
1774
|
+
copies = scope_acc.declaration_sourced_global_copies(name)
|
|
1775
|
+
return scope_acc if copies.empty?
|
|
1776
|
+
|
|
1777
|
+
post_copies = post_scope.declaration_sourced_global_copies(name)
|
|
1778
|
+
return scope_acc.without_local_declaration_marks(name) if post_copies.empty?
|
|
1779
|
+
|
|
1780
|
+
scope_acc.with_global_copy_marks(name, post_copies)
|
|
1781
|
+
end
|
|
1782
|
+
|
|
1783
|
+
# Whether `post` needs no weighing: this widening has weighed it for `name` already, or it is the entry's binding.
|
|
1784
|
+
def retry_rebind_settled?(widening, name, pre, post)
|
|
1785
|
+
return true unless (widening.weighed[name] ||= Set.new.compare_by_identity).add?(post)
|
|
1786
|
+
|
|
1787
|
+
pre.equal?(post) || pre == post
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
# Whether the binding `current` already covers `post`. A `post` carrying `Dynamic` anywhere could be any value,
|
|
1791
|
+
# which only a `current` with a `Dynamic` member covers: `Array[Integer]` gradually accepts `Array[untyped]`, but
|
|
1792
|
+
# keeping it would claim the elements are still Integers.
|
|
1793
|
+
def retry_binding_accepted?(current, post)
|
|
1794
|
+
return ContentJoin.union_members(current).any?(Type::Dynamic) if carries_dynamic?(post)
|
|
1795
|
+
|
|
1796
|
+
Acceptance.accepts(current, post).yes?
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
def carries_dynamic?(type)
|
|
1800
|
+
case type
|
|
1801
|
+
when Type::Dynamic then true
|
|
1802
|
+
when Type::Union then type.members.any? { |member| carries_dynamic?(member) }
|
|
1803
|
+
when Type::Nominal then type.type_args.any? { |arg| carries_dynamic?(arg) }
|
|
1804
|
+
when Type::Tuple then type.elements.any? { |element| carries_dynamic?(element) }
|
|
1805
|
+
when Type::HashShape then type.pairs.each_value.any? { |value| carries_dynamic?(value) }
|
|
1806
|
+
when Type::Difference, Type::Refined then carries_dynamic?(type.base)
|
|
1807
|
+
else false
|
|
1808
|
+
end
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
# The accumulated binding joined with a rebind, widened to the Nominal envelope when `envelope` is set.
|
|
1812
|
+
#
|
|
1813
|
+
# `current` is nil when the name was introduced inside a retrying arm. Such a local is nil on the first entry, and
|
|
1814
|
+
# stays nil past a `begin` whose body never raised, which reaches the exit only through the primary path (a
|
|
1815
|
+
# retrying arm does not join it), so the edge carries `nil` along with the rebind. An instance variable's
|
|
1816
|
+
# first-entry value is unknown rather than nil (another method may set it), so it takes the rebind alone.
|
|
1817
|
+
def retry_widened_type(current, post, kind, envelope)
|
|
1818
|
+
rebind = envelope ? nominal_envelope_for(post) : post
|
|
1819
|
+
return rebind if current.nil? && kind == :ivar
|
|
1820
|
+
return Type::Combinator.union(Type::Combinator.constant_of(nil), rebind) if current.nil?
|
|
1821
|
+
|
|
1822
|
+
joined = Type::Combinator.union(current, rebind)
|
|
1823
|
+
envelope ? nominal_envelope_for(joined) : joined
|
|
1091
1824
|
end
|
|
1092
1825
|
|
|
1093
1826
|
# Nominal envelope of a value type: widens Constant / Tuple / HashShape carriers to the underlying class's
|
|
1094
1827
|
# `Nominal`, preserving everything else (`Nominal`, `Union` of non-shape members, `Top`, `Dynamic`, `Bot`). Union
|
|
1095
|
-
# members are walked individually.
|
|
1828
|
+
# members are walked individually. `nil`, `true` and `false` are the only values of their classes, so their
|
|
1829
|
+
# Constant already is the envelope; `Nominal[FalseClass] | Nominal[TrueClass]` would not be accepted where `bool`
|
|
1830
|
+
# is declared.
|
|
1096
1831
|
def nominal_envelope_for(type)
|
|
1097
1832
|
members = type.is_a?(Type::Union) ? type.members : [type]
|
|
1098
1833
|
widened = members.map { |m| nominal_envelope_member(m) }
|
|
1099
1834
|
Type::Combinator.union(*widened)
|
|
1100
1835
|
end
|
|
1101
1836
|
|
|
1837
|
+
SINGLE_VALUE_CONSTANTS = [nil, true, false].freeze
|
|
1838
|
+
private_constant :SINGLE_VALUE_CONSTANTS
|
|
1839
|
+
|
|
1102
1840
|
def nominal_envelope_member(member)
|
|
1103
1841
|
case member
|
|
1104
1842
|
when Type::Constant
|
|
1843
|
+
return member if SINGLE_VALUE_CONSTANTS.include?(member.value)
|
|
1844
|
+
|
|
1105
1845
|
Type::Combinator.nominal_of(member.value.class.name)
|
|
1106
1846
|
when Type::Tuple
|
|
1107
1847
|
MutationWidening.widen_tuple(member)
|
|
@@ -1112,12 +1852,19 @@ module Rigor
|
|
|
1112
1852
|
end
|
|
1113
1853
|
end
|
|
1114
1854
|
|
|
1115
|
-
|
|
1855
|
+
# `edge`, when given, collects the scope at each of its `retry`s ({#retry_scope_recorder}) and the post-scope of
|
|
1856
|
+
# each arm holding one. The latter still carries a rebind the `retry` itself does not see: a write an `ensure`
|
|
1857
|
+
# runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
|
|
1858
|
+
# types (`log(tries < 5 ? retry : :gave_up)`).
|
|
1859
|
+
def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil)
|
|
1860
|
+
on_enter = edge ? retry_scope_recorder(edge) : @on_enter
|
|
1116
1861
|
results = []
|
|
1117
1862
|
current = rescue_node
|
|
1118
1863
|
while current
|
|
1119
1864
|
rescue_scope = bind_rescue_reference(current, entry_scope)
|
|
1120
|
-
|
|
1865
|
+
arm = eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
|
|
1866
|
+
edge.retry_scopes << arm.last if edge&.retrying_arms&.include?(current)
|
|
1867
|
+
results << [arm, current]
|
|
1121
1868
|
current = current.subsequent
|
|
1122
1869
|
end
|
|
1123
1870
|
results
|
|
@@ -1127,25 +1874,54 @@ module Rigor
|
|
|
1127
1874
|
eval_branch_or_nil(node.statements, scope)
|
|
1128
1875
|
end
|
|
1129
1876
|
|
|
1877
|
+
# Issue #1360 — an `ensure` clause runs after the body or a rescue clause finished, but also after one raised,
|
|
1878
|
+
# when `$!` is the exception in flight and a subprocess the body would have run has not set `$?`. So the clause
|
|
1879
|
+
# reads `$!`, `$@` and `$?` unbound, whatever it enters with. It cannot write them, so past the clause they are
|
|
1880
|
+
# what it entered with — the scope of a `begin` that finished, the only one the code after it runs from — unless
|
|
1881
|
+
# it ran a subprocess itself.
|
|
1882
|
+
#
|
|
1883
|
+
# Issue #1415 — the clause also runs after a `return`, `break` or `next` out of the body, from a scope other than
|
|
1884
|
+
# the one it enters with here, so a bound `$_` reads `Dynamic[top]` in it (`while gets; return $_ if …; end`
|
|
1885
|
+
# ensures with the line, not the loop's `nil`). Past the clause `$_` is what it entered with, but only where the
|
|
1886
|
+
# clause left that `Dynamic[top]` in place and cannot set `$_` itself: a call the statement rules forget `$_` for
|
|
1887
|
+
# (a closure of the frame, `binding`, a forwarded block) leaves it unbound, and the clause's scope then stands.
|
|
1130
1888
|
def eval_ensure(node)
|
|
1131
|
-
|
|
1889
|
+
entry = scope.forget_error_info.forget_last_status
|
|
1890
|
+
line = scope.global(:$_)
|
|
1891
|
+
entry = entry.untyped_last_line if line
|
|
1892
|
+
type, after = eval_branch_or_nil(node.statements, entry)
|
|
1893
|
+
after = after.with_global(:$_, line) if line && last_line_kept?(after, node)
|
|
1894
|
+
[type, LastStatus.restore_unless_set(ErrorInfo.restore(after, scope), scope)]
|
|
1895
|
+
end
|
|
1896
|
+
|
|
1897
|
+
# True when the clause's scope `after` still binds `$_` to the `Dynamic[top]` the entry gave it and the clause
|
|
1898
|
+
# holds nothing that may set it ({LastLine.may_set?}).
|
|
1899
|
+
def last_line_kept?(after, node)
|
|
1900
|
+
after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
|
|
1132
1901
|
end
|
|
1133
1902
|
|
|
1134
1903
|
# `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
|
|
1135
1904
|
# degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
|
|
1136
1905
|
# 1), reflecting the common case where no `break VALUE` is observed.
|
|
1137
1906
|
def eval_loop(node)
|
|
1138
|
-
_pred_type, post_pred = sub_eval(node.predicate,
|
|
1907
|
+
_pred_type, post_pred = sub_eval(node.predicate, loop_entry_scope(node))
|
|
1908
|
+
post_pred = widen_predicate_pins(node, post_pred)
|
|
1139
1909
|
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
|
|
1140
1910
|
|
|
1141
1911
|
# The historical single body pass joined with the pre-loop scope. This continues to carry everything the
|
|
1142
1912
|
# fixpoint does NOT track: receiver-mutation widening of non-rebound locals (`buf.push(i)` widens `buf`'s
|
|
1143
|
-
# Tuple), body-introduced locals' nil-injection, and the loop value itself. The
|
|
1144
|
-
# rebound-local bindings it corrects.
|
|
1913
|
+
# Tuple), body-introduced locals' nil-injection, an instance variable's rebind, and the loop value itself. The
|
|
1914
|
+
# fixpoint then OVERLAYS only the rebound-local bindings it corrects.
|
|
1915
|
+
#
|
|
1916
|
+
# Like every fixpoint pass, it enters the body on the predicate's loop-entry edge ({#loop_pass_entry}): the
|
|
1917
|
+
# body of `while (line = gets)` reads `line` as `String`, and the body of `while gets` reads `$_` as one
|
|
1918
|
+
# (issue #1359), also when the body rebinds no local and this is the only pass. A `begin … end while` body runs
|
|
1919
|
+
# once before the predicate is first tested, so its pass enters from the post-predicate scope unnarrowed.
|
|
1145
1920
|
#
|
|
1146
|
-
# The pass
|
|
1147
|
-
#
|
|
1148
|
-
|
|
1921
|
+
# The pass ends with its `next` exits as well as its fall-through ({#loop_iteration}). Its `break` arms are
|
|
1922
|
+
# superseded by the fixpoint's converged pass ({#loop_break_arms}) except in a `begin … end while` loop, below.
|
|
1923
|
+
jumps = loop_jumps(node.statements)
|
|
1924
|
+
body_scope, first_breaks = single_pass(node, post_pred, jumps)
|
|
1149
1925
|
base_scope = join_with_nil_injection(post_pred, body_scope)
|
|
1150
1926
|
|
|
1151
1927
|
rebound, body_first = loop_body_local_writes(node.statements, post_pred)
|
|
@@ -1159,34 +1935,97 @@ module Rigor
|
|
|
1159
1935
|
return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
|
|
1160
1936
|
end
|
|
1161
1937
|
|
|
1162
|
-
post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
|
|
1163
|
-
#
|
|
1164
|
-
#
|
|
1165
|
-
|
|
1938
|
+
post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
|
|
1939
|
+
# A `begin … end while` / `until` body runs once before the predicate is first tested, so that iteration's
|
|
1940
|
+
# entry lies outside every fixpoint pass's predicate-narrowed one, and a `break` only it can take (`if state ==
|
|
1941
|
+
# :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
|
|
1942
|
+
# un-narrowed post-predicate scope, so its arms stand in for that first iteration.
|
|
1943
|
+
post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
|
|
1166
1944
|
post_loop = narrow_loop_exit_edge(node, post_loop)
|
|
1167
1945
|
[Type::Combinator.constant_of(nil), post_loop]
|
|
1168
1946
|
end
|
|
1169
1947
|
|
|
1948
|
+
# Issue #1359 — the scope a loop's predicate first runs from: a body that may set `$_` runs again after it ran,
|
|
1949
|
+
# so neither the predicate nor any pass over the body reads a `$_` narrowing from before the loop. A `while
|
|
1950
|
+
# gets` predicate narrows it afresh.
|
|
1951
|
+
#
|
|
1952
|
+
# Issue #1429 — nor a guard's narrowing of a global or constant, when the body or the predicate, which run again
|
|
1953
|
+
# after each iteration, may rebind it by a write or a call ({#forget_guard_if_rebinds}).
|
|
1954
|
+
def loop_entry_scope(node)
|
|
1955
|
+
forget_guard_if_rebinds(LastLine.forget_if_set(scope, node.statements), node.statements, node.predicate)
|
|
1956
|
+
end
|
|
1957
|
+
|
|
1958
|
+
# Issue #1429 — `entry` with its guard narrowings restored when any of `nodes` may rebind a global or constant
|
|
1959
|
+
# ({GuardRebinding.may_rebind?}): the loop and retry back edges and a rescue clause, which runs after any prefix
|
|
1960
|
+
# of the body, reach the code again after such a node ran.
|
|
1961
|
+
def forget_guard_if_rebinds(entry, *nodes)
|
|
1962
|
+
return entry unless entry.guard_narrowed? && nodes.any? { |node| GuardRebinding.may_rebind?(node, entry) }
|
|
1963
|
+
|
|
1964
|
+
entry.forget_guard_narrowings
|
|
1965
|
+
end
|
|
1966
|
+
|
|
1967
|
+
# {#eval_loop}'s single body pass. It enters on the predicate's loop-entry edge, as every fixpoint pass does,
|
|
1968
|
+
# except a `begin … end while` body, which runs once before the predicate is tested, and a body a `redo`
|
|
1969
|
+
# targets, which runs again without the predicate being tested again.
|
|
1970
|
+
def single_pass(node, post_pred, jumps)
|
|
1971
|
+
entry =
|
|
1972
|
+
if node.begin_modifier? || jumps.redoes
|
|
1973
|
+
post_pred
|
|
1974
|
+
else
|
|
1975
|
+
loop_pass_entry(node, post_pred, NO_LOOP_BINDINGS, NO_LOOP_NAMES, jumps)
|
|
1976
|
+
end
|
|
1977
|
+
loop_iteration(node.statements, entry, jumps)
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
# A `while` / `until` predicate runs before every iteration and once more to leave, but the walk evaluates it
|
|
1981
|
+
# once, from the scope before the loop. A variable it writes therefore holds its first evaluation's value, and a
|
|
1982
|
+
# value pin there is a claim about that evaluation alone when a later evaluation can store something else: when
|
|
1983
|
+
# the predicate reads the variable it writes (`i = 0; while (i += 1) < 3; end` pinned `i` to `1`, and the exit
|
|
1984
|
+
# edge `i >= 3` contradicted the pin and left `i` as `bot`), or reads one the body rebinds (`while check(k = i *
|
|
1985
|
+
# 2); i += 1; end`). Each such binding is widened past its value pin (`Type::Combinator.widen_value_pinned`), as
|
|
1986
|
+
# the loop fixpoint widens a body's rebinds; a write that reads neither stores the same answer every time and
|
|
1987
|
+
# keeps it (`until line = (flag ? "x" : nil)` still exits on `"x"`). Issue #1223 made the shape common: a write
|
|
1988
|
+
# nested in a predicate's call operand was not threaded at all before it.
|
|
1989
|
+
def widen_predicate_pins(node, post_pred)
|
|
1990
|
+
written = OperandEffects.written_variables(node.predicate)
|
|
1991
|
+
return post_pred if written.empty?
|
|
1992
|
+
|
|
1993
|
+
reads = OperandEffects.read_variables(node.predicate)
|
|
1994
|
+
body_writes = OperandEffects.written_variables(node.statements)
|
|
1995
|
+
varying = reads.intersect?(body_writes) ? written : written & reads
|
|
1996
|
+
varying.reduce(post_pred) do |acc, name|
|
|
1997
|
+
current = CapturedLocals.bound_type(acc, name)
|
|
1998
|
+
next acc if current.nil?
|
|
1999
|
+
|
|
2000
|
+
widened = Type::Combinator.widen_value_pinned(current)
|
|
2001
|
+
widened == current ? acc : CapturedLocals.bind(acc, name, widened)
|
|
2002
|
+
end
|
|
2003
|
+
end
|
|
2004
|
+
|
|
1170
2005
|
# The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
|
|
1171
|
-
# `base_scope`, then the slice-C receiver-content writeback
|
|
1172
|
-
|
|
2006
|
+
# `base_scope`, then the slice-C receiver-content writeback, then the `break`-path bindings the fall-through
|
|
2007
|
+
# dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`).
|
|
2008
|
+
def converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
|
|
1173
2009
|
# ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
|
|
1174
2010
|
# single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end` →
|
|
1175
2011
|
# `1 | 2`, never reaching `4, 8`). Fold each body-written local's continuation binding through the same capped
|
|
1176
2012
|
# fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
|
|
1177
2013
|
# post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
|
|
1178
2014
|
# degrades it to `T | nil`, matching the nil-injection treatment.
|
|
1179
|
-
|
|
2015
|
+
break_pass = jumps.breaks && { entry: nil, arms: [] }
|
|
2016
|
+
result = loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
|
|
1180
2017
|
# Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
|
|
1181
2018
|
# assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
|
|
1182
2019
|
# constants. One extra pass from the converged bindings (result discarded) re-records the body's entry scopes.
|
|
1183
|
-
record_converged_loop_body(node, post_pred, result, names, body_first)
|
|
2020
|
+
record_converged_loop_body(node, post_pred, result, names, body_first, jumps, break_pass)
|
|
1184
2021
|
post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
|
|
1185
2022
|
# ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
|
|
1186
2023
|
# << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
|
|
1187
2024
|
# the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
|
|
1188
2025
|
# from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
|
|
1189
|
-
loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
|
|
2026
|
+
post_loop = loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
|
|
2027
|
+
arms = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
|
|
2028
|
+
join_break_scopes(post_loop, arms, names)
|
|
1190
2029
|
end
|
|
1191
2030
|
|
|
1192
2031
|
# Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred` loop exits PRECISELY on the predicate's
|
|
@@ -1220,33 +2059,35 @@ module Rigor
|
|
|
1220
2059
|
found
|
|
1221
2060
|
end
|
|
1222
2061
|
|
|
1223
|
-
#
|
|
1224
|
-
#
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
private_constant :BREAK_BOUNDARY_NODES
|
|
1231
|
-
|
|
1232
|
-
# The `BreakNode`s that lexically target THIS loop — reachable from the body without crossing a nested loop /
|
|
1233
|
-
# block / def boundary. An identity-keyed Hash used as a membership set to filter the collected break scopes (the
|
|
1234
|
-
# thread-local sink also collects breaks from nested blocks that did not install their own sink).
|
|
1235
|
-
def directly_targeting_breaks(statements)
|
|
1236
|
-
found = {}.compare_by_identity
|
|
1237
|
-
collect_direct_breaks(statements, found)
|
|
1238
|
-
found
|
|
1239
|
-
end
|
|
2062
|
+
# The jumps that target a loop body ({JumpTargets}): its `next`s and its `break`s, each an identity-keyed Hash
|
|
2063
|
+
# used as a membership set, or nil when the body has none, and whether a `redo` does (issue #1359: the body then
|
|
2064
|
+
# runs again without the predicate being tested, {#single_pass}). The sinks also collect jumps that belong to a
|
|
2065
|
+
# construct evaluated under the loop's collection without installing its own (a `->` body), and the consumers
|
|
2066
|
+
# filter against these sets.
|
|
2067
|
+
LoopJumps = Data.define(:nexts, :breaks, :redoes)
|
|
2068
|
+
private_constant :LoopJumps
|
|
1240
2069
|
|
|
1241
|
-
|
|
1242
|
-
|
|
2070
|
+
NO_LOOP_JUMPS = LoopJumps.new(nexts: nil, breaks: nil, redoes: false)
|
|
2071
|
+
# In {JumpTargets.kinds}' bit order: `next` 0b001, `break` 0b010, `redo` 0b100.
|
|
2072
|
+
LOOP_JUMP_CLASSES = [Prism::NextNode, Prism::BreakNode, Prism::RedoNode].freeze
|
|
2073
|
+
private_constant :NO_LOOP_JUMPS, :LOOP_JUMP_CLASSES
|
|
1243
2074
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
next if BREAK_BOUNDARY_NODES.any? { |klass| child.is_a?(klass) }
|
|
2075
|
+
NO_BREAK_ARMS = [].freeze
|
|
2076
|
+
private_constant :NO_BREAK_ARMS
|
|
1247
2077
|
|
|
1248
|
-
|
|
1249
|
-
|
|
2078
|
+
# The rebind assumptions and body-first names of the single body pass, which overlays none ({#eval_loop}).
|
|
2079
|
+
NO_LOOP_BINDINGS = {}.freeze
|
|
2080
|
+
NO_LOOP_NAMES = [].freeze
|
|
2081
|
+
private_constant :NO_LOOP_BINDINGS, :NO_LOOP_NAMES
|
|
2082
|
+
|
|
2083
|
+
# A body with no targeting jump pays two allocation-free scans.
|
|
2084
|
+
def loop_jumps(statements)
|
|
2085
|
+
kinds = JumpTargets.kinds(statements, LOOP_JUMP_CLASSES)
|
|
2086
|
+
return NO_LOOP_JUMPS if kinds.zero?
|
|
2087
|
+
|
|
2088
|
+
nexts = JumpTargets.of(statements, Prism::NextNode) if kinds.anybits?(0b001)
|
|
2089
|
+
breaks = JumpTargets.of(statements, Prism::BreakNode) if kinds.anybits?(0b010)
|
|
2090
|
+
LoopJumps.new(nexts: nexts, breaks: breaks, redoes: kinds.anybits?(0b100))
|
|
1250
2091
|
end
|
|
1251
2092
|
|
|
1252
2093
|
# Installs a fresh thread-local break sink around `yield` (a loop-body evaluation), returning `[collected,
|
|
@@ -1264,31 +2105,81 @@ module Rigor
|
|
|
1264
2105
|
[sink, result]
|
|
1265
2106
|
end
|
|
1266
2107
|
|
|
1267
|
-
#
|
|
1268
|
-
# scopes
|
|
1269
|
-
#
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
2108
|
+
# One evaluation of a loop body from `entry`. Returns `[exit, breaks]`: the scope the iteration ends with, and the
|
|
2109
|
+
# scopes at the `break`s that target the loop ({LoopJumps}). Every reader of a loop body goes through here — the
|
|
2110
|
+
# single pass `eval_loop` joins with the pre-loop scope, each pass of its rebind fixpoint, and `eval_for`'s only
|
|
2111
|
+
# pass.
|
|
2112
|
+
#
|
|
2113
|
+
# A `next` returns to the predicate as surely as falling off the end does, so `exit` is the fall-through joined
|
|
2114
|
+
# with the scope at every `next` that targets the loop. Without that join a rebind on a jumping branch (`if
|
|
2115
|
+
# i.odd?; w = i; next; end`) vanished — `eval_if` carries only the arm that falls through — and `w` kept its
|
|
2116
|
+
# pre-loop binding. The join nil-injects: a local first bound on a `next` path is unbound on the fall-through, and
|
|
2117
|
+
# a plain `Scope#join` would drop it and leave the fixpoint only its `nil` seed.
|
|
2118
|
+
#
|
|
2119
|
+
# The `next` scopes are collected into a sink threaded through `sub_eval` ({#evaluate_invocation} does the same
|
|
2120
|
+
# for a block), and the `break` scopes into a thread-local one, each installed only when the body has such a
|
|
2121
|
+
# jump. `recorded: false` evaluates without recording into the per-node scope index.
|
|
2122
|
+
def loop_iteration(statements, entry, jumps, recorded: true)
|
|
2123
|
+
next_sink = jumps.nexts && []
|
|
2124
|
+
recording = recorded ? {} : UNRECORDED
|
|
2125
|
+
evaluate = -> { sub_eval(statements, entry, next_scope_sink: next_sink, **recording).last }
|
|
2126
|
+
if jumps.breaks
|
|
2127
|
+
break_sink, fall_through = collect_break_scopes(&evaluate)
|
|
2128
|
+
breaks = targeted_scopes(break_sink, jumps.breaks)
|
|
2129
|
+
else
|
|
2130
|
+
fall_through = evaluate.call
|
|
2131
|
+
breaks = NO_BREAK_ARMS
|
|
2132
|
+
end
|
|
2133
|
+
return [fall_through, breaks] if next_sink.nil?
|
|
2134
|
+
|
|
2135
|
+
exit_scope = targeted_scopes(next_sink, jumps.nexts).reduce(fall_through) do |acc, next_scope|
|
|
2136
|
+
join_with_nil_injection(acc, next_scope)
|
|
2137
|
+
end
|
|
2138
|
+
[exit_scope, breaks]
|
|
2139
|
+
end
|
|
2140
|
+
|
|
2141
|
+
# The `break` scopes the continuation joins. A `break` leaves the loop, so its binding starts no further iteration
|
|
2142
|
+
# and is no input to the rebind fixpoint; it IS the continuation's binding on that path. The arms must come from a
|
|
2143
|
+
# pass whose entry is the CONVERGED binding, which contains every iteration's entry: read from the first pass
|
|
2144
|
+
# alone, a `break` whose branch is dead before any loop-carried rebind has moved (`break(flag = true) if i == 2`
|
|
2145
|
+
# while `i` is still `1`) was never reached and `flag` stayed `false`.
|
|
2146
|
+
#
|
|
2147
|
+
# The fixpoint's last pass is usually one — a fixpoint that stabilised ran it from the binding it returns — so
|
|
2148
|
+
# its arms are reused ({#loop_body_exit_bindings}). A capped fixpoint's widened binding was never evaluated, so
|
|
2149
|
+
# only then does one more pass run, without recording into the per-node scope index: that index keeps the
|
|
2150
|
+
# fixpoint's own last pass, which the check path's diagnostics read. On the display path the re-record pass
|
|
2151
|
+
# ({#record_converged_loop_body}) already ran from the converged binding and leaves its arms here, so no
|
|
2152
|
+
# unrecorded pass follows it. The block write-back reads its `break` arms the same way
|
|
2153
|
+
# ({#join_block_break_bindings}).
|
|
2154
|
+
#
|
|
2155
|
+
# The unrecorded pass is otherwise an ordinary evaluation: a `return` it reaches joins the enclosing method's
|
|
2156
|
+
# inferred return type, as one any other pass reaches does. That only widens the return, toward values a capped
|
|
2157
|
+
# fixpoint's own passes never evaluated.
|
|
2158
|
+
def loop_break_arms(node, post_pred, converged, body_first, jumps, break_pass)
|
|
2159
|
+
return NO_BREAK_ARMS if break_pass.nil?
|
|
2160
|
+
return break_pass[:arms] if break_pass[:entry] == converged.except(*body_first)
|
|
2161
|
+
|
|
2162
|
+
entry = loop_pass_entry(node, post_pred, converged, body_first, jumps)
|
|
2163
|
+
loop_iteration(node.statements, entry, jumps, recorded: false).last
|
|
1274
2164
|
end
|
|
1275
2165
|
|
|
1276
|
-
# Joins each
|
|
1277
|
-
#
|
|
2166
|
+
# Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
|
|
2167
|
+
# fall-through dropped is recovered (`flag = true; break` -> `flag` becomes `false | true`). Only
|
|
1278
2168
|
# loop-body-written names are joined — an unchanged local unions to itself; a break-only-written local is already
|
|
1279
|
-
# present via the fixpoint / nil-injection seed, so the union reflects its break value.
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
2169
|
+
# present via the fixpoint / nil-injection seed, so the union reflects its break value. A name whose continuation
|
|
2170
|
+
# binding is `Dynamic[top]` — the fixpoint's floor on non-convergence, or a local that was untyped already —
|
|
2171
|
+
# keeps it: a precise arm unioned into it would read as knowledge the analysis does not have.
|
|
2172
|
+
def join_break_scopes(continuation, breaks, names)
|
|
2173
|
+
return continuation if breaks.empty? || names.empty?
|
|
2174
|
+
|
|
2175
|
+
floor = Type::Combinator.untyped
|
|
2176
|
+
breaks.reduce(continuation) do |cont, break_scope|
|
|
1285
2177
|
names.reduce(cont) do |acc, name|
|
|
1286
2178
|
break_value = break_scope.local(name)
|
|
1287
|
-
next acc if break_value.nil?
|
|
1288
|
-
|
|
1289
2179
|
current = acc.local(name)
|
|
1290
|
-
|
|
1291
|
-
|
|
2180
|
+
next acc if break_value.nil? || current == floor
|
|
2181
|
+
|
|
2182
|
+
acc.with_local(name, current ? Type::Combinator.union(current, break_value) : break_value)
|
|
1292
2183
|
end
|
|
1293
2184
|
end
|
|
1294
2185
|
end
|
|
@@ -1324,9 +2215,14 @@ module Rigor
|
|
|
1324
2215
|
end
|
|
1325
2216
|
return post_loop if mutations.empty?
|
|
1326
2217
|
|
|
2218
|
+
rewrites = local_rewrites(statements) { true }
|
|
1327
2219
|
mutations.reduce(post_loop) do |acc, (name, calls)|
|
|
1328
|
-
|
|
1329
|
-
|
|
2220
|
+
seed_scope = content_seed_scope(name, acc, pre_body, rebound)
|
|
2221
|
+
seed = lookup_mutated_seed(statements, name, seed_scope.local(name)) { |depth, nesting| depth == nesting }
|
|
2222
|
+
joined = join_content_for_param(calls, seed, post_loop)
|
|
2223
|
+
next acc if joined.nil?
|
|
2224
|
+
|
|
2225
|
+
acc.with_mutated_local(name, rewritten_capture(joined, seed, rewrites.fetch(name, NO_REWRITES)))
|
|
1330
2226
|
end
|
|
1331
2227
|
end
|
|
1332
2228
|
|
|
@@ -1341,26 +2237,30 @@ module Rigor
|
|
|
1341
2237
|
|
|
1342
2238
|
# Re-evaluates the loop body once from the converged fixpoint bindings, solely for the `on_enter` side effect of
|
|
1343
2239
|
# re-recording the body's per-node entry scopes. Gated behind the display-path-only `converged_loop_recording`
|
|
1344
|
-
# flag so the check path neither pays the extra body evaluation nor risks any diagnostic drift.
|
|
1345
|
-
|
|
2240
|
+
# flag so the check path neither pays the extra body evaluation nor risks any diagnostic drift. The pass leaves
|
|
2241
|
+
# its `break` arms in `break_pass`, which {#loop_break_arms} then reuses instead of running a pass of its own.
|
|
2242
|
+
def record_converged_loop_body(node, post_pred, bindings, names, body_first, jumps, break_pass)
|
|
1346
2243
|
return unless @converged_loop_recording && @on_enter
|
|
1347
2244
|
|
|
1348
|
-
loop_body_exit_bindings(node, post_pred, bindings, names, body_first)
|
|
2245
|
+
loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
|
|
1349
2246
|
nil
|
|
1350
2247
|
end
|
|
1351
2248
|
|
|
1352
2249
|
# Runs the slice-B loop-body rebind fixpoint, returning the per-name continuation binding. Seed: a pre-existing
|
|
1353
2250
|
# local seeds with its post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the
|
|
1354
2251
|
# 0-iteration path (the body may never run) degrades it to `T | nil`, matching the historical nil-injection
|
|
1355
|
-
# treatment.
|
|
1356
|
-
def loop_rebind_fixpoint(node, post_pred, names, body_first)
|
|
2252
|
+
# treatment. `break_pass` rides along so every pass leaves its `break` arms for {#loop_break_arms}.
|
|
2253
|
+
def loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
|
|
1357
2254
|
nil_const = Type::Combinator.constant_of(nil)
|
|
1358
2255
|
seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
|
|
2256
|
+
evaluate_body = lambda do |bindings|
|
|
2257
|
+
loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
|
|
2258
|
+
end
|
|
1359
2259
|
BodyFixpoint.converge(
|
|
1360
2260
|
names: names,
|
|
1361
2261
|
seed_bindings: seed,
|
|
1362
2262
|
widen: Type::Combinator.method(:widen_value_pinned),
|
|
1363
|
-
evaluate_body:
|
|
2263
|
+
evaluate_body: evaluate_body
|
|
1364
2264
|
)
|
|
1365
2265
|
end
|
|
1366
2266
|
|
|
@@ -1400,40 +2300,102 @@ module Rigor
|
|
|
1400
2300
|
# exists only to model the 0-iteration path and is kept as a join constituent by {BodyFixpoint#converge}; feeding
|
|
1401
2301
|
# that `nil` back into the body re-evaluation would leak it past a condition-form assignment the engine does not
|
|
1402
2302
|
# thread into the branch (`if exps.size > (count = 3)`), false-firing `+`/nil-receiver on the guarded use.
|
|
1403
|
-
|
|
2303
|
+
#
|
|
2304
|
+
# The exit joins the pass's `next` exits ({#loop_iteration}), so a `next`-path rebind feeds the next iteration.
|
|
2305
|
+
# With a `break_pass` record the pass also leaves its entry and its `break` arms there for {#loop_break_arms};
|
|
2306
|
+
# `BodyFixpoint` hands every pass the same mutable assumption, and `except` copies it before the fixpoint moves
|
|
2307
|
+
# it.
|
|
2308
|
+
def loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass = nil)
|
|
2309
|
+
entry = loop_pass_entry(node, post_pred, bindings, body_first, jumps)
|
|
2310
|
+
exit_scope, breaks = loop_iteration(node.statements, entry, jumps)
|
|
2311
|
+
if break_pass
|
|
2312
|
+
break_pass[:entry] = bindings.except(*body_first)
|
|
2313
|
+
break_pass[:arms] = breaks
|
|
2314
|
+
end
|
|
2315
|
+
names.to_h { |name| [name, exit_scope.local(name)] }
|
|
2316
|
+
end
|
|
2317
|
+
|
|
2318
|
+
# The scope one fixpoint pass enters the body with: `post_pred` overlaid with the pre-existing names' running
|
|
2319
|
+
# assumption, then narrowed by the predicate's loop-entry edge ({#loop_body_exit_bindings} carries the why).
|
|
2320
|
+
def loop_pass_entry(node, post_pred, bindings, body_first, jumps)
|
|
1404
2321
|
overlaid = bindings.except(*body_first)
|
|
1405
2322
|
entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
|
|
2323
|
+
entry = loop_content_entry(node.statements, post_pred, entry)
|
|
1406
2324
|
truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
2325
|
+
edge = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
|
|
2326
|
+
# Issue #1359 — a `redo` re-enters the body without testing the predicate again, so a `$_` the predicate
|
|
2327
|
+
# narrowed does not hold there when the body may set it.
|
|
2328
|
+
return edge unless jumps.redoes
|
|
2329
|
+
|
|
2330
|
+
LastLine.forget_if_set(edge, node.statements)
|
|
2331
|
+
end
|
|
2332
|
+
|
|
2333
|
+
# Issue #1412 — the loop-body counterpart of {#repeating_block_entry}: `entry` with every local the body
|
|
2334
|
+
# mutates in place ({CapturedLocals.loop_content_mutations}, bound in `base`) at its unknown-store widening
|
|
2335
|
+
# ({#unknown_store_binding}). A body runs again after it ran, so a later iteration reads what an earlier one
|
|
2336
|
+
# stored, and every pass over it — the single pass and each fixpoint pass ({#loop_pass_entry}), and a `for`
|
|
2337
|
+
# body's only pass — enters this way. A name the pass also rebinds is widened over its running assumption, as
|
|
2338
|
+
# the block write-back widens it ({#capture_pass_bindings}); a name `entry` does not bind is left alone. The
|
|
2339
|
+
# widening keeps the binding's issue #1287 marks, as {#block_pass_entry} keeps them.
|
|
2340
|
+
def loop_content_entry(statements, base, entry)
|
|
2341
|
+
stores = loop_content_mutations(statements, base)
|
|
2342
|
+
return entry if stores.empty?
|
|
2343
|
+
|
|
2344
|
+
stores.reduce(entry) do |acc, (name, sites)|
|
|
2345
|
+
type = acc.local(name)
|
|
2346
|
+
type.nil? ? acc : acc.with_mutated_local(name, unknown_store_binding(type, sites))
|
|
2347
|
+
end
|
|
2348
|
+
end
|
|
2349
|
+
|
|
2350
|
+
# {CapturedLocals.loop_content_mutations} of a loop body against `base`, once per pair: every pass of one
|
|
2351
|
+
# loop asks with the same body and the same post-predicate scope. The slot is keyed on both, so a body asked
|
|
2352
|
+
# against another scope is scanned again.
|
|
2353
|
+
def loop_content_mutations(statements, base)
|
|
2354
|
+
memo = (@loop_content_mutations ||= {}.compare_by_identity)[statements]
|
|
2355
|
+
return memo[1] if memo && memo[0].equal?(base)
|
|
2356
|
+
|
|
2357
|
+
stores = CapturedLocals.loop_content_mutations(statements, base)
|
|
2358
|
+
@loop_content_mutations[statements] = [base, stores]
|
|
2359
|
+
stores
|
|
1410
2360
|
end
|
|
1411
2361
|
|
|
1412
2362
|
# `for index in collection; body; end`. Unlike `each {}` blocks, `for` does NOT create a new variable scope: the
|
|
1413
2363
|
# index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
|
|
1414
2364
|
# once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
|
|
1415
2365
|
# `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
|
|
1416
|
-
# expression itself types as `Constant[nil]
|
|
1417
|
-
#
|
|
2366
|
+
# expression itself types as `Constant[nil]`, the policy `eval_loop` uses for `while` / `until` — a known gap for
|
|
2367
|
+
# `for`, whose value in Ruby is the collection it iterated (issue #1216).
|
|
1418
2368
|
def eval_for(node)
|
|
1419
2369
|
coll_type, post_coll = sub_eval(node.collection, scope)
|
|
1420
2370
|
element_type = for_iteration_element_type(coll_type)
|
|
1421
|
-
|
|
2371
|
+
# Issue #1359 — a body that may set `$_` runs again after it ran, as a `while` body does ({#eval_loop}).
|
|
2372
|
+
body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
|
|
2373
|
+
body_entry = forget_guard_for_body(node, post_coll, body_entry)
|
|
2374
|
+
body_entry = loop_content_entry(node.statements, post_coll, body_entry)
|
|
1422
2375
|
|
|
1423
2376
|
if node.statements.nil?
|
|
1424
2377
|
return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
|
|
1425
2378
|
end
|
|
1426
2379
|
|
|
1427
|
-
#
|
|
1428
|
-
# continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single
|
|
1429
|
-
# the only continuation).
|
|
1430
|
-
|
|
2380
|
+
# The body pass ends with its `next` exits as well as its fall-through, and its `break` arms are recovered into
|
|
2381
|
+
# the continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single pass is
|
|
2382
|
+
# the only continuation and the only source of `break` arms).
|
|
2383
|
+
jumps = loop_jumps(node.statements)
|
|
2384
|
+
body_scope, breaks = loop_iteration(node.statements, body_entry, jumps)
|
|
1431
2385
|
continuation = join_with_nil_injection(post_coll, body_scope)
|
|
1432
2386
|
pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
|
|
1433
|
-
continuation = join_break_scopes(continuation,
|
|
2387
|
+
continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
|
|
1434
2388
|
[Type::Combinator.constant_of(nil), continuation]
|
|
1435
2389
|
end
|
|
1436
2390
|
|
|
2391
|
+
# Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
|
|
2392
|
+
def forget_guard_for_body(node, post_coll, body_entry)
|
|
2393
|
+
return body_entry unless body_entry.guard_narrowed?
|
|
2394
|
+
return body_entry.forget_guard_narrowings if GuardRebinding.implicit_call_may_rebind?(node, post_coll)
|
|
2395
|
+
|
|
2396
|
+
forget_guard_if_rebinds(body_entry, node.statements)
|
|
2397
|
+
end
|
|
2398
|
+
|
|
1437
2399
|
# `for x in coll` is semantically `coll.each { |x| ... }`. We ask the method dispatcher for `coll.each`'s expected
|
|
1438
2400
|
# block parameter types — that path consults RBS and the iterator dispatch table, which is more precise than the
|
|
1439
2401
|
# structural `collection_element_type` fallback (it knows, e.g., that `Hash[K, V]#each` yields `[K, V]` even when
|
|
@@ -1447,7 +2409,8 @@ module Rigor
|
|
|
1447
2409
|
receiver_type: coll_type,
|
|
1448
2410
|
method_name: :each,
|
|
1449
2411
|
arg_types: [],
|
|
1450
|
-
environment: scope.environment
|
|
2412
|
+
environment: scope.environment,
|
|
2413
|
+
scope: scope
|
|
1451
2414
|
)
|
|
1452
2415
|
return structural if block_params.nil? || block_params.empty?
|
|
1453
2416
|
|
|
@@ -1459,18 +2422,38 @@ module Rigor
|
|
|
1459
2422
|
# Binds the `for` index variable(s) into `scope`. A single `LocalVariableTargetNode` is bound to `element_type`
|
|
1460
2423
|
# (the per-iteration value the collection yields). A `MultiTargetNode` (`for a, b in pairs`) delegates to
|
|
1461
2424
|
# {MultiTargetBinder}, which decomposes a tuple-shaped element into the inner slots.
|
|
2425
|
+
#
|
|
2426
|
+
# An index target — the whole index (`for h[:a] in xs`) or a slot of a multi-target one (`for h[:a], w in
|
|
2427
|
+
# pairs`) — stores the element / its slot through `[]=` at the top of every iteration, so its receiver widens
|
|
2428
|
+
# here, before the body, exactly as a multi-assign target's does; the body then reads the widened receiver and
|
|
2429
|
+
# the post-loop join keeps it beside the zero-iteration literal, as it keeps a body store's `h[:a] = x`.
|
|
1462
2430
|
def bind_for_index(index_node, element_type, scope)
|
|
1463
2431
|
case index_node
|
|
1464
2432
|
when Prism::LocalVariableTargetNode
|
|
1465
2433
|
scope.with_local(index_node.name, element_type)
|
|
2434
|
+
when Prism::IndexTargetNode
|
|
2435
|
+
widen_index_target(index_node, element_type, scope, type_scope: scope)
|
|
1466
2436
|
when Prism::MultiTargetNode
|
|
1467
|
-
MultiTargetBinder.
|
|
1468
|
-
|
|
2437
|
+
bound = MultiTargetBinder.bind_marked(index_node, element_type, scope: scope)
|
|
2438
|
+
widen_index_targets(bound, bound.apply_to(scope), type_scope: scope)
|
|
2439
|
+
when Prism::SplatNode
|
|
2440
|
+
bind_for_splat_index(index_node, scope)
|
|
1469
2441
|
else
|
|
1470
2442
|
scope
|
|
1471
2443
|
end
|
|
1472
2444
|
end
|
|
1473
2445
|
|
|
2446
|
+
# `for *h[:a] in pairs` — Prism gives a bare splat index as a `SplatNode`, not a `MultiTargetNode`, so the
|
|
2447
|
+
# binder never sees it. The store is `*h[:a] = element`, an array of the element's `to_ary` parts; the receiver
|
|
2448
|
+
# widens with the binder's floor for a rest it cannot decompose, `Dynamic[top]`. A bare `*name` target stays
|
|
2449
|
+
# unbound here, as before.
|
|
2450
|
+
def bind_for_splat_index(splat, scope)
|
|
2451
|
+
target = splat.expression
|
|
2452
|
+
return scope unless target.is_a?(Prism::IndexTargetNode)
|
|
2453
|
+
|
|
2454
|
+
widen_index_target(target, Type::Combinator.untyped, scope, type_scope: scope)
|
|
2455
|
+
end
|
|
2456
|
+
|
|
1474
2457
|
# Extracts the per-iteration element type from a collection carrier. `Tuple[T1..Tn]` yields the union of its
|
|
1475
2458
|
# elements; `Nominal[Array, [T]]` and `Nominal[Range, [T]]` yield `T`; `Nominal[Hash, [K, V]]` yields `Tuple[K,
|
|
1476
2459
|
# V]` (Hash#each yields `[key, value]` pairs); `IntegerRange` yields `Integer`; `Constant<Range>` reads the
|
|
@@ -1531,45 +2514,178 @@ module Rigor
|
|
|
1531
2514
|
# `branch_terminates?`) — the post-OR / post-AND scope is the LHS-skipped edge alone: `a or raise` only survives
|
|
1532
2515
|
# when `a` was truthy, so subsequent statements observe `a` narrowed to its truthy fragment; the symmetric `a and
|
|
1533
2516
|
# raise` survives only when `a` was falsey. Same shape as the `eval_if` / `eval_unless` early-return narrowing.
|
|
2517
|
+
#
|
|
2518
|
+
# This is the only and/or typer: `ExpressionTyper` reads a value-position `&&` / `||` from here (issue #1016),
|
|
2519
|
+
# so the RHS narrowing and the constant short-circuit below cannot differ between a statement and a value.
|
|
1534
2520
|
def eval_and_or(node)
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
2521
|
+
and_or_with_edges(node, edges: false)
|
|
2522
|
+
end
|
|
2523
|
+
|
|
2524
|
+
# {#eval_and_or}, and with `edges:` also the operator's truthy and falsey edge scopes as a predicate
|
|
2525
|
+
# ({#eval_with_edges}), read off the scopes its operands left rather than off the joined result.
|
|
2526
|
+
def and_or_with_edges(node, edges:)
|
|
2527
|
+
and_node = node.is_a?(Prism::AndNode)
|
|
2528
|
+
left_type, left_scope, truthy_left, falsey_left = eval_with_edges(node.left, scope)
|
|
2529
|
+
right_entry = and_node ? truthy_left : falsey_left
|
|
2530
|
+
right_type, right_scope, truthy_right, falsey_right =
|
|
2531
|
+
edges ? eval_with_edges(node.right, right_entry) : sub_eval(node.right, right_entry)
|
|
2532
|
+
skipped_type = and_node ? Narrowing.narrow_falsey(left_type) : Narrowing.narrow_truthy(left_type)
|
|
1539
2533
|
|
|
2534
|
+
# Control never reaches any statement after `a or raise` via the RHS edge — the RHS scope is discarded.
|
|
1540
2535
|
if branch_terminates?(node.right, right_type)
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
if node.is_a?(Prism::AndNode)
|
|
1544
|
-
Narrowing.narrow_falsey(left_type)
|
|
1545
|
-
else
|
|
1546
|
-
Narrowing.narrow_truthy(left_type)
|
|
1547
|
-
end
|
|
1548
|
-
surviving_scope = node.is_a?(Prism::AndNode) ? falsey_left : truthy_left
|
|
1549
|
-
return [surviving_type, surviving_scope]
|
|
2536
|
+
skipped_scope = and_node ? falsey_left : truthy_left
|
|
2537
|
+
return [skipped_type, skipped_scope, *(Narrowing.predicate_scopes(node, skipped_scope) if edges)]
|
|
1550
2538
|
end
|
|
1551
2539
|
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
2540
|
+
# A dead RHS is still evaluated and its scope still joins, so a write inside it nil-injects exactly as
|
|
2541
|
+
# before; only its value is dropped, because it cannot be the value of the expression.
|
|
2542
|
+
joined_scope = join_with_nil_injection(left_scope, right_scope)
|
|
2543
|
+
type = skipped_type
|
|
2544
|
+
type = Type::Combinator.union(skipped_type, right_type) unless right_operand_dead?(node, left_type, left_scope)
|
|
2545
|
+
return [type, joined_scope] unless edges
|
|
2546
|
+
|
|
2547
|
+
ran = ran_edge(node, right_scope, and_node ? truthy_right : falsey_right, and_node)
|
|
2548
|
+
return [type, joined_scope, ran, join_with_nil_injection(falsey_left, falsey_right)] if and_node
|
|
2549
|
+
|
|
2550
|
+
[type, joined_scope, join_with_nil_injection(truthy_left, truthy_right), ran]
|
|
2551
|
+
end
|
|
2552
|
+
|
|
2553
|
+
# The edge on which the right operand certainly ran — `&&`'s truthy one, `||`'s falsey one. It is the right
|
|
2554
|
+
# operand's own edge, which keeps every binding and provenance mark the operands' writes made, with the
|
|
2555
|
+
# instance variables and globals of the whole operator narrowed over the scope the right operand left laid over
|
|
2556
|
+
# it. A call in the right operand resets the instance variables and regex globals the left operand narrowed
|
|
2557
|
+
# (`@parent && (node = find_node)` read `@parent` as nilable, `$1` after `line =~ re && (k = Integer($2))` as
|
|
2558
|
+
# nil), and narrowing afresh puts that back as the joined-scope narrowing always did. No call resets a local, so
|
|
2559
|
+
# locals keep the right operand's edge: re-narrowing one the operands write can contradict its new value
|
|
2560
|
+
# (`x.nil? && log(x = "d") && ok` read `x` as `bot`) or drop the marks its write stamped (a published-constant
|
|
2561
|
+
# copy `m = AppConfig::MODE`). A variable the operands write is never overlaid, for the same reason.
|
|
2562
|
+
def ran_edge(node, right_scope, right_edge, and_node)
|
|
2563
|
+
truthy, falsey = Narrowing.predicate_scopes(node, right_scope)
|
|
2564
|
+
renarrowed = and_node ? truthy : falsey
|
|
2565
|
+
written = OperandEffects.written_variables(node)
|
|
2566
|
+
edge = renarrowed.ivars.reduce(right_edge) do |acc, (name, type)|
|
|
2567
|
+
written.include?(name) || acc.ivar(name) == type ? acc : acc.with_ivar(name, type)
|
|
2568
|
+
end
|
|
2569
|
+
renarrowed.globals.reduce(edge) do |acc, (name, type)|
|
|
2570
|
+
written.include?(name) || acc.global(name) == type ? acc : acc.with_global(name, type)
|
|
2571
|
+
end
|
|
1562
2572
|
end
|
|
1563
2573
|
|
|
1564
|
-
# `
|
|
1565
|
-
|
|
1566
|
-
|
|
2574
|
+
# `[type, scope, truthy_edge, falsey_edge]` for `node` evaluated from `entry` as a predicate. The edges are
|
|
2575
|
+
# `Narrowing.predicate_scopes` of the scope `node` leaves, except for an `&&` / `||` whose right operand, or
|
|
2576
|
+
# the right operand of an `&&` / `||` on its left, holds a write or jump ({OperandEffects}). The scope such an
|
|
2577
|
+
# operator leaves joins the path where that operand ran with the path that skipped it, so a local the operand
|
|
2578
|
+
# binds reads `nil` there as well, and narrowing that scope cannot tell that the operand certainly ran on the
|
|
2579
|
+
# truthy edge of an `&&` and on the falsey edge of an `||`: `if a && xs.size > (n = f)` read `n` as `nil |
|
|
2580
|
+
# Integer` in the body and reported `n + 1`. Those edges are built from the scopes the operands left instead —
|
|
2581
|
+
# the right operand's edge under the left operand's, and the join where either path reaches the edge. Issue
|
|
2582
|
+
# #1223 threads such a write for every operand, not only for a statement-position one.
|
|
2583
|
+
def eval_with_edges(node, entry)
|
|
2584
|
+
unless and_or_right_effects?(node)
|
|
2585
|
+
type, post = sub_eval(node, entry)
|
|
2586
|
+
return [type, post, *Narrowing.predicate_scopes(node, post)]
|
|
2587
|
+
end
|
|
1567
2588
|
|
|
1568
|
-
|
|
2589
|
+
@on_enter&.call(node, entry)
|
|
2590
|
+
evaluator_at(entry).send(:and_or_with_edges, node, edges: true)
|
|
1569
2591
|
end
|
|
1570
2592
|
|
|
1571
|
-
|
|
1572
|
-
|
|
2593
|
+
def and_or_right_effects?(node)
|
|
2594
|
+
return false unless node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
|
|
2595
|
+
|
|
2596
|
+
OperandEffects.any?(node.right) || and_or_right_effects?(node.left)
|
|
2597
|
+
end
|
|
2598
|
+
|
|
2599
|
+
# Whether a genuine `Constant` left operand proves the RHS never supplies the value: `false && b` and
|
|
2600
|
+
# `1 || b`. The gate is `Constant`-only (issue #152 evaluated a wider one and declined it).
|
|
2601
|
+
#
|
|
2602
|
+
# Issue #313 — it MUST decline an optimistically nil-free operand. A uniform-valued literal hash reads as a lone
|
|
2603
|
+
# `Constant` (`UNIFORM[key]` → `1`), so without the mark `UNIFORM[key] || key` would discard the author's
|
|
2604
|
+
# fallback, and `MAP[key].nil? && b` would drop `b` the program runs when the lookup misses. The mark is
|
|
2605
|
+
# resolved against the LEFT operand's post-scope, as `branch_certainty` does for `if`, so a write in the left
|
|
2606
|
+
# operand (`(v = MAP[key]) || key`) is judged by the binding it just made rather than by an older one.
|
|
2607
|
+
def right_operand_dead?(node, left_type, left_scope)
|
|
2608
|
+
return false unless left_type.is_a?(Type::Constant)
|
|
2609
|
+
return false unless optimistic_origin_for(node.left, left_scope).nil?
|
|
2610
|
+
|
|
2611
|
+
truthy = left_type.value ? true : false
|
|
2612
|
+
node.is_a?(Prism::AndNode) ? !truthy : truthy
|
|
2613
|
+
end
|
|
2614
|
+
|
|
2615
|
+
# `(body)`. Threads scope through the inner expression so `(x = 1; x + 2)` binds `x` and produces `Constant[3]`.
|
|
2616
|
+
def eval_parentheses(node)
|
|
2617
|
+
return [Type::Combinator.constant_of(nil), scope] if node.body.nil?
|
|
2618
|
+
|
|
2619
|
+
sub_eval(node.body, scope)
|
|
2620
|
+
end
|
|
2621
|
+
|
|
2622
|
+
# An array or hash literal, an interpolation or a range: its value is the literal's, typed where it starts, and
|
|
2623
|
+
# its scope is the one its children leave in order ({#thread_operand}). Issue #1223 — `[s = 1]`, `x = [:a, s +=
|
|
2624
|
+
# 1]` and `"#{s = 1}"` left `s` on its pre-write binding. A literal holding no write or jump keeps the entry
|
|
2625
|
+
# scope and costs one scan. Issue #1256 — an element after one that wrote is typed from the scope the elements
|
|
2626
|
+
# before it left ({OperandWalk}), so `[n += 1, n += 1]` is `[1, 2]`.
|
|
2627
|
+
def eval_value_container(node)
|
|
2628
|
+
unless OperandEffects.any?(node)
|
|
2629
|
+
return [scope.type_of(node, tracer: tracer), forget_rebound_specials(scope, node)]
|
|
2630
|
+
end
|
|
2631
|
+
|
|
2632
|
+
walk = OperandWalk.new(walk_recorder)
|
|
2633
|
+
after = thread_operand_children(node, scope, walk, scope)
|
|
2634
|
+
[OperandWalk.type_of(scope, node, tracer, walk.types(tracer)), forget_rebound_specials(after, node)]
|
|
2635
|
+
end
|
|
2636
|
+
|
|
2637
|
+
# Issue #1360 — a backtick or `%x` command runs its interpolations, then the subprocess, which leaves `$?` a
|
|
2638
|
+
# `Process::Status` ({LastStatus.after}).
|
|
2639
|
+
def eval_xstring(node)
|
|
2640
|
+
type, after =
|
|
2641
|
+
if node.is_a?(Prism::InterpolatedXStringNode)
|
|
2642
|
+
eval_value_container(node)
|
|
2643
|
+
else
|
|
2644
|
+
[scope.type_of(node, tracer: tracer), forget_rebound_specials(scope, node)]
|
|
2645
|
+
end
|
|
2646
|
+
[type, LastStatus.after(node, after, scope)]
|
|
2647
|
+
end
|
|
2648
|
+
|
|
2649
|
+
# `expr rescue alt`. The rescue arm runs only when `expr` raised, possibly after some of its writes, so the arm
|
|
2650
|
+
# starts from the entry scope joined with the scope `expr` leaves, and the result joins the arm's scope with
|
|
2651
|
+
# the one `expr` leaves; an arm that always exits (`rescue next`) contributes no scope. Issue #1223 — `x = foo
|
|
2652
|
+
# rescue (s = 1)` left `s` on its pre-write binding. The value is the modifier's own, and one holding no write
|
|
2653
|
+
# or jump keeps the entry scope.
|
|
2654
|
+
#
|
|
2655
|
+
# Issue #1360 — the arm runs with `$!` bound to the `StandardError` it rescued, `$@` to its backtrace and `$?`
|
|
2656
|
+
# unbound ({ErrorInfo.modifier_entry}), and the modifier leaves, through its end or a jump in the arm, with `$!`
|
|
2657
|
+
# and `$@` restored to what it found. `$?` is unbound past a modifier whose arm may fall through: `expr` may have
|
|
2658
|
+
# raised while a subprocess waited, which leaves it nil. The threaded path gets that from the join with the arm.
|
|
2659
|
+
def eval_rescue_modifier(node)
|
|
2660
|
+
unless OperandEffects.any?(node)
|
|
2661
|
+
return [scope.type_of(node, tracer: tracer), forget_rebound_specials(scope, node)]
|
|
2662
|
+
end
|
|
2663
|
+
|
|
2664
|
+
type, after = restoring_error_info { thread_rescue_modifier(node) }
|
|
2665
|
+
[type, forget_rebound_specials(after, node)]
|
|
2666
|
+
end
|
|
2667
|
+
|
|
2668
|
+
def thread_rescue_modifier(node)
|
|
2669
|
+
walk = OperandWalk.new(walk_recorder)
|
|
2670
|
+
after_expression = thread_operand(node.expression, scope, walk, scope)
|
|
2671
|
+
# The arm is threaded outside the walk: its entry nil-injects a local `expr` first binds, which is the
|
|
2672
|
+
# sound join (the raise may come before the write) but reads `u` as `String?` in `Float(u = s) rescue
|
|
2673
|
+
# u.strip`, where the raise almost always comes from `Float` after it. Neither the arm nor anything in it
|
|
2674
|
+
# is recorded or typed from there, which keeps it where it was before #1256: an ADR-5 trade of the rare
|
|
2675
|
+
# raise-before-write path for no false positive on the common one.
|
|
2676
|
+
arm_entry = ErrorInfo.modifier_entry(join_with_nil_injection(scope, after_expression), node.rescue_expression)
|
|
2677
|
+
after_rescue = thread_operand(node.rescue_expression, arm_entry, OperandWalk.new(nil), arm_entry)
|
|
2678
|
+
type = OperandWalk.type_of(scope, node, tracer, walk.types(tracer))
|
|
2679
|
+
after = if branch_unconditionally_exits?(node.rescue_expression)
|
|
2680
|
+
after_expression
|
|
2681
|
+
else
|
|
2682
|
+
join_with_nil_injection(after_expression, after_rescue)
|
|
2683
|
+
end
|
|
2684
|
+
[type, after]
|
|
2685
|
+
end
|
|
2686
|
+
|
|
2687
|
+
# `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
|
|
2688
|
+
# does not see the outer locals), and the StatementEvaluator pushes a new `ClassFrame` so nested `def`s know their
|
|
1573
2689
|
# lexical owner. The outer scope is unchanged on exit because Ruby's class definition does not bind any local in
|
|
1574
2690
|
# the enclosing scope. The class body's value is the value of its last statement (`Constant[nil]` for an empty
|
|
1575
2691
|
# body); we discard the body's post-scope.
|
|
@@ -1639,14 +2755,155 @@ module Rigor
|
|
|
1639
2755
|
#
|
|
1640
2756
|
# The handler still re-evaluates the block under its entry scope so the per-node scope index sees the bindings on
|
|
1641
2757
|
# the `on_enter` callback path. Block effects do NOT leak into the post-call scope: a block-local write is
|
|
1642
|
-
# observed only inside the block body.
|
|
1643
|
-
#
|
|
2758
|
+
# observed only inside the block body.
|
|
2759
|
+
#
|
|
2760
|
+
# Issue #1223 — Ruby evaluates the receiver, then the arguments, and only then runs the method, so a write in
|
|
2761
|
+
# an operand (`out << (n += 1)`, `puts(g = e)`, `(seen += 1) == 2`) is visible to the block and to everything
|
|
2762
|
+
# after the call. {#call_operand_scope} threads each operand that holds one; when none does it answers the
|
|
2763
|
+
# entry scope itself and the call is handled exactly as before. Otherwise the rest of the call — the block's
|
|
2764
|
+
# entry, its write-back and every post-call widening — runs from the scope the operands left
|
|
2765
|
+
# ({#invoke_call}), while the call's value is still typed where the operands started ({#operand_scope}), and
|
|
2766
|
+
# each operand where it was entered: `out << (n += 1)` appends `1`, not the `2` a re-typing after the write
|
|
2767
|
+
# reads. Issue #1256 — an operand after one that wrote is entered from the scope the operands before it left,
|
|
2768
|
+
# and {OperandWalk} records it into the per-node scope index and types it from there: `puts(b.unshift("s"),
|
|
2769
|
+
# b.first.upcase)` reads `b` as the `unshift` left it. The operands are threaded first and the call is typed
|
|
2770
|
+
# after, with those later operands' values in hand, so no operand is typed twice.
|
|
1644
2771
|
def eval_call(node)
|
|
1645
|
-
|
|
2772
|
+
walk = OperandWalk.new(walk_recorder)
|
|
2773
|
+
invoked = call_operand_scope(node, walk, scope)
|
|
2774
|
+
invoked = forget_operand_specials(node, invoked)
|
|
2775
|
+
operand_types = walk.types(tracer)
|
|
2776
|
+
call_type = OperandWalk.type_of(scope, node, tracer, operand_types)
|
|
1646
2777
|
# ADR-56 slice C (B3) — `each_with_object(memo) { |x, acc| acc << … }` returns the memo; the engine otherwise
|
|
1647
2778
|
# types the call `Dynamic[top]`. Compute the joined memo type from the block's content mutations of the memo
|
|
1648
2779
|
# block-param and adopt it as the call's return type.
|
|
1649
2780
|
call_type = each_with_object_return(node, call_type)
|
|
2781
|
+
[call_type, invoke_from(node, invoked, call_type, operand_types)]
|
|
2782
|
+
end
|
|
2783
|
+
|
|
2784
|
+
# The scope after `node` runs as another expression's operand ({#thread_operand}), from the receiver scope.
|
|
2785
|
+
# Such a call is not typed, since its value is discarded and typing it re-types the whole subtree the
|
|
2786
|
+
# enclosing root types — once per level of an operator chain whose every operand writes (`f(a = 1) + f(a =
|
|
2787
|
+
# 2) + …`), and each through the callee's return inference. For the same reason it applies no post-return
|
|
2788
|
+
# narrowing ({#invoke_call}): each of those resolves the method by typing the receiver, the same subtree
|
|
2789
|
+
# again. They only narrow, and a call in an operand never applied them before #1223, so leaving them out is
|
|
2790
|
+
# the sound side. Its own later operands join `walk` and are typed as soon as they are all taken, so its
|
|
2791
|
+
# invocation reads them ({#type_operand}) and the enclosing root does not type them again.
|
|
2792
|
+
def call_effects(node, walk, typed_from)
|
|
2793
|
+
mark = walk.mark
|
|
2794
|
+
invoked = call_operand_scope(node, walk, typed_from)
|
|
2795
|
+
invoke_from(node, invoked, nil, walk.types(tracer, since: mark))
|
|
2796
|
+
end
|
|
2797
|
+
|
|
2798
|
+
# The rest of the call from `invoked`, the scope its operands left, with each later operand's own value in
|
|
2799
|
+
# `operand_types` for the helpers that type the call's operands ({#type_operand}).
|
|
2800
|
+
def invoke_from(node, invoked, call_type, operand_types)
|
|
2801
|
+
return invoke_call(node, call_type) if invoked.equal?(scope) && operand_types.nil?
|
|
2802
|
+
|
|
2803
|
+
evaluator_at(invoked, operand_scope: scope, operand_types: operand_types).send(:invoke_call, node, call_type)
|
|
2804
|
+
end
|
|
2805
|
+
|
|
2806
|
+
# The scope Ruby runs `node`'s method from: the receiver, then the arguments, then a block-pass argument, each
|
|
2807
|
+
# threaded in turn ({#thread_operand}). A safe-navigation call skips its arguments when the receiver is nil,
|
|
2808
|
+
# so their scope joins with the receiver's. Issue #1468 — and runs them only when it is not, so they are
|
|
2809
|
+
# entered with the receiver narrowed to its non-nil fragment ({Narrowing.safe_navigation_scope}). That entry
|
|
2810
|
+
# is not the scope the call is typed from, so {OperandWalk} records and types each of them from it. The
|
|
2811
|
+
# narrowing ends with the operands: when they wrote nothing the scope after them is the receiver's.
|
|
2812
|
+
def call_operand_scope(node, walk, typed_from)
|
|
2813
|
+
after_receiver = thread_operand(node.receiver, scope, walk, typed_from)
|
|
2814
|
+
entered = Narrowing.safe_navigation_scope(node, after_receiver)
|
|
2815
|
+
after_arguments = thread_operand(node.arguments, entered, walk, typed_from)
|
|
2816
|
+
block_pass = node.block
|
|
2817
|
+
if block_pass.is_a?(Prism::BlockArgumentNode)
|
|
2818
|
+
after_arguments = thread_operand(block_pass, after_arguments, walk, typed_from)
|
|
2819
|
+
end
|
|
2820
|
+
return after_arguments if after_arguments.equal?(after_receiver) || !node.safe_navigation?
|
|
2821
|
+
return after_receiver if after_arguments.equal?(entered)
|
|
2822
|
+
|
|
2823
|
+
join_with_nil_injection(after_receiver, after_arguments)
|
|
2824
|
+
end
|
|
2825
|
+
|
|
2826
|
+
# The scope after `node` from `entry`, for an expression otherwise typed as a pure value. One that holds no
|
|
2827
|
+
# write or jump ({OperandEffects}) answers `entry` itself. A call contributes its effects without its value
|
|
2828
|
+
# ({#call_effects}), any other node with a handler is evaluated through it, and an {OPERAND_CONTAINERS} node
|
|
2829
|
+
# threads its children in order.
|
|
2830
|
+
#
|
|
2831
|
+
# `typed_from` is the scope the root types `node` from: its own entry, or that of the nearest enclosing operand
|
|
2832
|
+
# {OperandWalk} took as a later one. When `entry` is not that scope, an earlier operand has moved it, and `walk`
|
|
2833
|
+
# takes `node` ({OperandWalk#later}) before anything below it, so its descendants are typed and recorded against
|
|
2834
|
+
# `entry` in turn. Nothing else is recorded into the per-node scope index.
|
|
2835
|
+
#
|
|
2836
|
+
# A call threaded this way is an operand, so it also leaves out the two resets a statement-position call
|
|
2837
|
+
# applies because it might have done anything ({#invoke_call}): the class's narrowed instance variables and the
|
|
2838
|
+
# regex globals. A call in an operand never applied them before #1223, and applying them only when the operand
|
|
2839
|
+
# happens to write made `$stdout.puts(Integer(v = $2)); $1.upcase` report where `$stdout.puts(Integer($2))`
|
|
2840
|
+
# does not. The same holds for every call under an operand evaluated through its handler, so the evaluator
|
|
2841
|
+
# carries `in_operand` into everything it opens: an in-place mutation counts as an effect, and threading
|
|
2842
|
+
# `opts[:k] = strict? ? queue.shift : nil` must not let the typed `strict?` inside the ternary reset
|
|
2843
|
+
# the regex globals or the narrowed ivars that the same line without the `shift` leaves alone. Issue #1365 —
|
|
2844
|
+
# the statement that holds the operand forgets the regex globals for every call in it instead, threaded or not,
|
|
2845
|
+
# when that call is known to match ({#forget_operand_specials}), so an operand's answer still does not
|
|
2846
|
+
# depend on whether it writes.
|
|
2847
|
+
def thread_operand(node, entry, walk, typed_from)
|
|
2848
|
+
return entry unless node.is_a?(Prism::Node)
|
|
2849
|
+
|
|
2850
|
+
taken = !entry.equal?(typed_from)
|
|
2851
|
+
slot = walk.later(node, entry) if taken
|
|
2852
|
+
typed_from = entry if slot
|
|
2853
|
+
unless OperandEffects.any?(node)
|
|
2854
|
+
# A taken position with no value of its own leaves its children to be taken: nothing types it whole.
|
|
2855
|
+
thread_operand_children(node, entry, walk, typed_from) if taken && slot.nil?
|
|
2856
|
+
return entry
|
|
2857
|
+
end
|
|
2858
|
+
|
|
2859
|
+
operand = evaluator_at(entry, on_enter: nil, in_operand: true, operand_recorder: walk.recorder)
|
|
2860
|
+
return operand.send(:call_effects, node, walk, typed_from) if node.is_a?(Prism::CallNode)
|
|
2861
|
+
# A container is threaded child by child even when it has a handler: the handler types the whole literal,
|
|
2862
|
+
# which a nested literal would repeat once per level of nesting. A statement list or `(…)` inside an operand
|
|
2863
|
+
# runs its statements in order just the same, and evaluating it would type each statement it holds.
|
|
2864
|
+
if OPERAND_CONTAINERS.include?(node.class) || OPERAND_SEQUENCES.include?(node.class)
|
|
2865
|
+
return thread_operand_children(node, entry, walk, typed_from)
|
|
2866
|
+
end
|
|
2867
|
+
return entry unless HANDLERS.key?(node.class)
|
|
2868
|
+
|
|
2869
|
+
# The handler types the operand from `entry`, which is the value a later operand takes.
|
|
2870
|
+
type, after = operand.evaluate(node)
|
|
2871
|
+
walk.resolve(slot, type) if slot
|
|
2872
|
+
after
|
|
2873
|
+
end
|
|
2874
|
+
|
|
2875
|
+
def thread_operand_children(node, entry, walk, typed_from)
|
|
2876
|
+
threaded = entry
|
|
2877
|
+
node.rigor_each_child { |child| threaded = thread_operand(child, threaded, walk, typed_from) }
|
|
2878
|
+
threaded
|
|
2879
|
+
end
|
|
2880
|
+
|
|
2881
|
+
# The recorder an {OperandWalk} rooted here records later operands with: the per-node scope index's, whether
|
|
2882
|
+
# this evaluator records into it itself or is an operand evaluator threading a recording one's operands. An
|
|
2883
|
+
# unrecorded pass ({UNRECORDED}) has neither.
|
|
2884
|
+
def walk_recorder
|
|
2885
|
+
@operand_recorder || @on_enter
|
|
2886
|
+
end
|
|
2887
|
+
|
|
2888
|
+
# A call operand's type, read from where the call's operands were typed ({#operand_scope}), with each later
|
|
2889
|
+
# operand's own value ({OperandWalk}) where the call has one.
|
|
2890
|
+
def type_operand(node)
|
|
2891
|
+
OperandWalk.type_of(operand_scope, node, tracer, @operand_types)
|
|
2892
|
+
end
|
|
2893
|
+
|
|
2894
|
+
# The scope this evaluator types a call's receiver and arguments under: where they were evaluated, which
|
|
2895
|
+
# {#invoke_call} under {#call_effects}' rebase is not the receiver scope. Every helper below that types the
|
|
2896
|
+
# current call's own operands reads this rather than `scope`, so it reads exactly what it read before the
|
|
2897
|
+
# rebase existed.
|
|
2898
|
+
def operand_scope
|
|
2899
|
+
@operand_scope || scope
|
|
2900
|
+
end
|
|
2901
|
+
|
|
2902
|
+
# The rest of {#eval_call}: the call's block and every effect the call leaves on the scope, from the receiver
|
|
2903
|
+
# scope, which is the scope its operands left. Returns the post-call scope. `call_type` is nil for a threaded
|
|
2904
|
+
# operand, which applies no post-return narrowing ({#call_effects}); nor does a call an operand evaluator
|
|
2905
|
+
# types through a handler ({#thread_operand}).
|
|
2906
|
+
def invoke_call(node, call_type)
|
|
1650
2907
|
evaluate_block_if_present(node)
|
|
1651
2908
|
# `ruby2_keywords def foo(...)` (and similar wrappers like `private def`, `public def`, `module_function def`)
|
|
1652
2909
|
# parse the def as the call's positional argument; the ExpressionTyper#type_of_def handler types it as
|
|
@@ -1662,9 +2919,8 @@ module Rigor
|
|
|
1662
2919
|
# scope; the spec MUST in § "Fact stability and mutation" names captured locals a first-class invalidation
|
|
1663
2920
|
# category. (The escaping / unknown path already widened to Dynamic[top] via `record_closure_escape_if_any`.)
|
|
1664
2921
|
post_scope = write_back_block_captures(node, post_scope)
|
|
1665
|
-
|
|
1666
|
-
post_scope =
|
|
1667
|
-
post_scope = apply_rspec_matcher_narrowing(node, post_scope)
|
|
2922
|
+
statement_call = !call_type.nil? && !@in_operand
|
|
2923
|
+
post_scope = apply_post_return_narrowing(node, post_scope) if statement_call
|
|
1668
2924
|
# Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
|
|
1669
2925
|
# (e.g. `arms << x`, `@tags << hashtag`). Stops a literal-shape carrier (`Tuple` / `HashShape`) from outliving
|
|
1670
2926
|
# its justification when the value is mutated. Always-safe (loses precision, never invents facts).
|
|
@@ -1674,7 +2930,7 @@ module Rigor
|
|
|
1674
2930
|
# sibling `s.y` stays precise. `call_type` is the setter's own result (the assigned value type). Sound only for
|
|
1675
2931
|
# a fold-safe local (never aliased / escaped, straight-line setters) — the fold-safe scan is the gate.
|
|
1676
2932
|
post_scope = MethodDispatcher::StructFolding.apply_setter_writeback(
|
|
1677
|
-
call_node: node, assigned_type: call_type, scope: post_scope
|
|
2933
|
+
call_node: node, assigned_type: call_type || local_attribute_write_value(node), scope: post_scope
|
|
1678
2934
|
)
|
|
1679
2935
|
# ADR-57 slice 3 work-item 1 (cross-method-boundary variant). When a self-call resolves to a user method that
|
|
1680
2936
|
# CONTENT-mutates one of its parameters inside an escaping block (the `build_option_parser(opts)` idiom — the
|
|
@@ -1710,41 +2966,148 @@ module Rigor
|
|
|
1710
2966
|
# binding has narrowed below the class-ivar seed back to the seed itself, so a subsequent `if @flag` predicate
|
|
1711
2967
|
# observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
|
|
1712
2968
|
# [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
|
|
1713
|
-
post_scope = invalidate_ivars_for_intervening_call(node, post_scope)
|
|
2969
|
+
post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
|
|
1714
2970
|
# C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
|
|
1715
2971
|
# later call that itself runs a regex match rebinds them, so the narrowed facts must be dropped. We forget them
|
|
1716
|
-
# only when the call
|
|
1717
|
-
#
|
|
1718
|
-
#
|
|
1719
|
-
# while a genuinely interposed match still invalidates.
|
|
2972
|
+
# only when the call may run a match in this frame ({#rebinds_match_globals?}). A call provably match-free on a
|
|
2973
|
+
# known receiver — `$3.to_i`, `year < 50` — does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)`
|
|
2974
|
+
# stdlib idiom keeps its precision while a genuinely interposed match still invalidates.
|
|
1720
2975
|
# The chain above is Scope-total by construction (every helper returns its input scope or a
|
|
1721
2976
|
# combinator result); the `||=` is a runtime no-op that pins the INFERRED type back to Scope for
|
|
1722
2977
|
# the negative rules when a helper's return widens to `Scope?` under call-site binding (#524).
|
|
1723
2978
|
post_scope ||= scope
|
|
1724
|
-
|
|
1725
|
-
|
|
2979
|
+
statement_call ? rebind_statement_specials(node, post_scope) : post_scope
|
|
2980
|
+
end
|
|
2981
|
+
|
|
2982
|
+
# `post_scope`, past a statement call, with the specials the call rebinds: the match globals and `$_` forgotten
|
|
2983
|
+
# when it may rebind them in this frame, and `$?` bound when it, or an operand, certainly ran a subprocess, since
|
|
2984
|
+
# `$?` is the thread's (issue #1360, {LastStatus.after}).
|
|
2985
|
+
# Issue #1429 — and a guard's narrowing of a global or constant restored when the call may run code that rebinds
|
|
2986
|
+
# it ({GuardRebinding.call_may_rebind?}).
|
|
2987
|
+
def rebind_statement_specials(node, post_scope)
|
|
2988
|
+
post_scope = post_scope.forget_match_globals if rebinds_match_globals?(node, post_scope)
|
|
2989
|
+
post_scope = post_scope.forget_last_line if rebinds_last_line?(node, post_scope)
|
|
2990
|
+
if post_scope.guard_narrowed? && GuardRebinding.call_may_rebind?(node, operand_scope)
|
|
2991
|
+
post_scope = post_scope.forget_guard_narrowings
|
|
2992
|
+
end
|
|
2993
|
+
forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
|
|
2994
|
+
end
|
|
2995
|
+
|
|
2996
|
+
# True when the call may rebind this frame's match globals: it is match-capable itself ({#match_capable_call?}),
|
|
2997
|
+
# its own block may run a match ({MatchRebinding.block_may_match?} — issue #1358: the block runs in this frame,
|
|
2998
|
+
# so `items.each { |i| i =~ re }` rebinds the enclosing method's `$~`, while a match inside a called Ruby method
|
|
2999
|
+
# rebinds that method's own), or the frame has made a closure that may run one whenever it is called
|
|
3000
|
+
# ({Scope#match_rebinding_closure?}). The receiver chain and arguments ran before the call, and
|
|
3001
|
+
# {#forget_operand_specials} answered for them. The scans run only while a match global is narrowed, the
|
|
3002
|
+
# one state a forget can drop.
|
|
3003
|
+
def rebinds_match_globals?(node, post_scope)
|
|
3004
|
+
return false unless post_scope.match_globals_bound?
|
|
3005
|
+
return true if match_capable_call?(node)
|
|
3006
|
+
|
|
3007
|
+
MatchRebinding.block_may_match?(node, scope) || post_scope.match_rebinding_closure?
|
|
3008
|
+
end
|
|
3009
|
+
|
|
3010
|
+
# Issue #1359 — whether the call rebinds `$_`, which shares the match globals' frame slot and so their call
|
|
3011
|
+
# rules ({LastLine.call_rebinds?}): a reader, or a block or closure of this frame that may run one, does; a
|
|
3012
|
+
# call into a method defined in Ruby does not. The receiver chain and arguments ran before the call, and
|
|
3013
|
+
# {#forget_operand_specials} answered for them. The scans run only while `$_` is narrowed.
|
|
3014
|
+
def rebinds_last_line?(node, post_scope)
|
|
3015
|
+
post_scope.last_line_bound? && LastLine.call_rebinds?(node, scope)
|
|
3016
|
+
end
|
|
3017
|
+
|
|
3018
|
+
# Issue #1365 — the scope a statement call runs its method from, with the match globals forgotten when its
|
|
3019
|
+
# receiver chain or arguments may rebind them ({MatchRebinding.operands_may_rebind?}): Ruby runs those first,
|
|
3020
|
+
# so the call's own block already reads the rebound globals (`s.sub(re, "").each_char { $1 }`). No call in them
|
|
3021
|
+
# forgot before, so each forgets here only when it is known to match ({MatchRebinding::Calls.rebinds?}), and
|
|
3022
|
+
# none resets by itself ({#invoke_call}), so an operand's answer does not depend on whether the evaluator
|
|
3023
|
+
# threads it: `$stdout.puts(Integer(v = $2))` keeps `$1` narrowed as `$stdout.puts(Integer($2))` does. `$_`
|
|
3024
|
+
# is forgotten the same way when they may set it (`line = gets.chomp`, issue #1359).
|
|
3025
|
+
def forget_operand_specials(node, invoked)
|
|
3026
|
+
return invoked if @in_operand
|
|
3027
|
+
|
|
3028
|
+
if invoked.match_globals_bound? && MatchRebinding.operands_may_rebind?(node, scope)
|
|
3029
|
+
invoked = invoked.forget_match_globals
|
|
3030
|
+
end
|
|
3031
|
+
# Issue #1429 — a guard narrowing the receiver chain or an argument may rebind.
|
|
3032
|
+
if invoked.guard_narrowed? && GuardRebinding.operands_may_rebind?(node, scope)
|
|
3033
|
+
invoked = invoked.forget_guard_narrowings
|
|
3034
|
+
end
|
|
3035
|
+
return invoked unless invoked.last_line_bound? && LastLine.operands_may_set?(node, scope)
|
|
3036
|
+
|
|
3037
|
+
invoked.forget_last_line
|
|
1726
3038
|
end
|
|
1727
3039
|
|
|
1728
|
-
#
|
|
1729
|
-
#
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
3040
|
+
# Issue #1365 — `after` with the match globals forgotten when `node`, a value this statement types without
|
|
3041
|
+
# evaluating the calls in it as statements (an array, hash or interpolation literal, a `rescue` modifier, a
|
|
3042
|
+
# constant's value, a `super` or `yield`), may rebind them ({MatchRebinding.value_may_rebind?}), and `$_` when
|
|
3043
|
+
# it may set it ({LastLine.may_set?}, issue #1359). Inside an operand the statement that holds it answers
|
|
3044
|
+
# instead.
|
|
3045
|
+
def forget_rebound_specials(after, node)
|
|
3046
|
+
return after if @in_operand
|
|
3047
|
+
|
|
3048
|
+
if after.match_globals_bound? && MatchRebinding.value_may_rebind?(node, scope)
|
|
3049
|
+
after = after.forget_match_globals
|
|
3050
|
+
end
|
|
3051
|
+
after = after.forget_last_line if after.last_line_bound? && LastLine.may_set?(node, scope)
|
|
3052
|
+
# Issue #1429 — and a guard's narrowing of a global or constant, when a call in it may rebind one.
|
|
3053
|
+
after = after.forget_guard_narrowings if after.guard_narrowed? && GuardRebinding.may_rebind?(node, scope)
|
|
3054
|
+
forget_rescued_status(after, node)
|
|
3055
|
+
end
|
|
3056
|
+
|
|
3057
|
+
# Issue #1360 — `after`, past a statement, with `$?` forgotten when the statement may fall through a rescue in its
|
|
3058
|
+
# own frame ({#rescues_through?}): the exception rescued there may have been raised while a subprocess waited,
|
|
3059
|
+
# which leaves `$?` nil, and a rescue in an operand or a block the statement passes never joins its scope back.
|
|
3060
|
+
def forget_rescued_status(after, node)
|
|
3061
|
+
return after unless after.global(:$?) && rescues_through?(node)
|
|
3062
|
+
|
|
3063
|
+
after.forget_last_status
|
|
3064
|
+
end
|
|
3065
|
+
|
|
3066
|
+
# True when running `node` may leave a rescue clause, or the fallback of a rescue modifier that may fall
|
|
3067
|
+
# through, and go on: anywhere in its operands and the blocks it passes, but not in a lambda, a block a call
|
|
3068
|
+
# keeps to run later ({StoredBlockCall.stores_block?}, which includes a thread's) or a `def`, none of which runs
|
|
3069
|
+
# there.
|
|
3070
|
+
def rescues_through?(node)
|
|
3071
|
+
case node
|
|
3072
|
+
when Prism::RescueNode then return true
|
|
3073
|
+
when Prism::RescueModifierNode then return true unless branch_unconditionally_exits?(node.rescue_expression)
|
|
3074
|
+
when Prism::DefNode, Prism::LambdaNode then return false
|
|
3075
|
+
end
|
|
3076
|
+
kept = node.block if node.is_a?(Prism::CallNode) && StoredBlockCall.stores_block?(node)
|
|
3077
|
+
found = false
|
|
3078
|
+
node.rigor_each_child { |child| found ||= !child.equal?(kept) && rescues_through?(child) }
|
|
3079
|
+
found
|
|
3080
|
+
end
|
|
3081
|
+
|
|
3082
|
+
# The value an untyped setter call on a local stores (`foo(s.x = v)`), for the Struct member write-back; nil for
|
|
3083
|
+
# any other call, which that write-back ignores.
|
|
3084
|
+
def local_attribute_write_value(node)
|
|
3085
|
+
return nil unless node.attribute_write? && node.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
3086
|
+
|
|
3087
|
+
type_operand(node)
|
|
3088
|
+
end
|
|
3089
|
+
|
|
3090
|
+
def apply_post_return_narrowing(node, post_scope)
|
|
3091
|
+
post_scope = apply_rbs_extended_assertions(node, post_scope)
|
|
3092
|
+
post_scope = apply_plugin_assertions(node, post_scope)
|
|
3093
|
+
apply_rspec_matcher_narrowing(node, post_scope)
|
|
3094
|
+
end
|
|
3095
|
+
|
|
3096
|
+
# True when `node` could rebind the regex match-data globals by itself
|
|
3097
|
+
# ({MatchRebinding::Calls.statement_rebinds?}): a method that matches on this frame's behalf, on any receiver,
|
|
3098
|
+
# read with the operands where they were typed (issue #1365: a name the old table forgot on keeps forgetting
|
|
3099
|
+
# unless its literal arguments prove it match-free, so `row[:name]`, `csv.split(",")` and `s.match?(re)` keep
|
|
3100
|
+
# the narrowing and `row[key]` does not; any other call forgets when it is known to match, as
|
|
3101
|
+
# `u.start_with?(/(q)/)` is); or an implicit-self / `self.` call that may reach this frame's slot. Issue #1364 —
|
|
3102
|
+
# a method defined in Ruby runs in a frame of its own, so a match in its body rebinds its own `$~`, never its
|
|
3103
|
+
# caller's, and `log("parsed"); key = $1` keeps `$1` narrowed; such a call forgets as every implicit-self call
|
|
3104
|
+
# did before only in a frame that hands its slot to code the analyzer does not trace, or where no body stamped
|
|
3105
|
+
# a frame. A call to a non-matching method (`$3.to_i`, `year < 50`, `buf << c`) is match-free, so the
|
|
3106
|
+
# multi-statement `m = /…/ =~ s; …; use($2)` idiom keeps the narrowed globals.
|
|
1742
3107
|
def match_capable_call?(node)
|
|
1743
3108
|
return true unless node.is_a?(Prism::CallNode)
|
|
1744
|
-
return true if MATCH_CAPABLE_METHODS.include?(node.name)
|
|
1745
3109
|
|
|
1746
|
-
|
|
1747
|
-
receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
3110
|
+
MatchRebinding::Calls.statement_rebinds?(node, operand_scope)
|
|
1748
3111
|
end
|
|
1749
3112
|
|
|
1750
3113
|
# Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
|
|
@@ -2105,6 +3468,10 @@ module Rigor
|
|
|
2105
3468
|
return nil if method_def.nil?
|
|
2106
3469
|
|
|
2107
3470
|
arguments = call_node.arguments&.arguments || []
|
|
3471
|
+
# A `(?)` overload (`RBS::Types::UntypedFunction`, #1430) names no parameter and accepts any argument list,
|
|
3472
|
+
# so a call may reach it with the target's position holding anything: no overload proves the target.
|
|
3473
|
+
return nil unless method_def.method_types.all? { |mt| mt.type.respond_to?(:required_positionals) }
|
|
3474
|
+
|
|
2108
3475
|
method_def.method_types.each do |mt|
|
|
2109
3476
|
params = mt.type.required_positionals + mt.type.optional_positionals
|
|
2110
3477
|
index = params.find_index { |param| param.name == target_name }
|
|
@@ -2113,11 +3480,23 @@ module Rigor
|
|
|
2113
3480
|
nil
|
|
2114
3481
|
end
|
|
2115
3482
|
|
|
3483
|
+
# Issue #1468 — the block of `recv&.m { … }` runs only once `recv` is non-nil, so it is entered from the scope
|
|
3484
|
+
# with the receiver narrowed ({Narrowing.safe_navigation_block_scope}), and every entry rule
|
|
3485
|
+
# {#enter_call_block} applies reads that scope as any other. The receiver's own type, which the block's
|
|
3486
|
+
# parameters and the repetition rules are read from, stays where the call's operands were typed.
|
|
2116
3487
|
def evaluate_block_if_present(node)
|
|
2117
3488
|
block = node.block
|
|
2118
3489
|
return unless block.is_a?(Prism::BlockNode)
|
|
2119
3490
|
|
|
2120
|
-
|
|
3491
|
+
narrowed = Narrowing.safe_navigation_block_scope(node, scope)
|
|
3492
|
+
return enter_call_block(node, block) if narrowed.equal?(scope)
|
|
3493
|
+
|
|
3494
|
+
evaluator_at(narrowed, operand_scope: operand_scope, operand_types: @operand_types)
|
|
3495
|
+
.send(:enter_call_block, node, block)
|
|
3496
|
+
end
|
|
3497
|
+
|
|
3498
|
+
def enter_call_block(node, block)
|
|
3499
|
+
block_entry = narrow_define_method_block_self(node, repeating_block_entry(node, block))
|
|
2121
3500
|
# #319 — `Class.new do ... end` and friends evaluate their block as a CLASS BODY (`class_eval`
|
|
2122
3501
|
# semantics): `self` is the freshly created class, so a `def` inside defines an instance method on it
|
|
2123
3502
|
# and `attr_reader` runs as a class-level macro. Enter the block under the same `self_type` /
|
|
@@ -2127,12 +3506,71 @@ module Rigor
|
|
|
2127
3506
|
# call in the body as `call.unresolved-toplevel`.
|
|
2128
3507
|
#
|
|
2129
3508
|
# Outer locals stay visible: unlike a `class` keyword body, the block is a closure.
|
|
3509
|
+
refined = refined_class_context(node)
|
|
3510
|
+
return enter_meta_class_body(block, block_entry, refined) if refined
|
|
3511
|
+
|
|
2130
3512
|
anonymous = AnonymousMetaClass.name_for(node, scope.source_path)
|
|
2131
|
-
|
|
3513
|
+
if anonymous.nil?
|
|
3514
|
+
return sub_eval(block, block_entry) unless return_barrier_block?(node)
|
|
3515
|
+
|
|
3516
|
+
return without_return_sink { sub_eval(block, block_entry) }
|
|
3517
|
+
end
|
|
2132
3518
|
|
|
2133
3519
|
enter_meta_class_body(block, block_entry, [ClassFrame.new(name: anonymous, singleton: false)])
|
|
2134
3520
|
end
|
|
2135
3521
|
|
|
3522
|
+
# Issue #1120 — `refine X do … end` in a module body. A `def` in the block defines an instance method of X (a
|
|
3523
|
+
# refined one), so its body runs with an instance of X as `self` and reads X's instance variables, exactly as
|
|
3524
|
+
# a `def` in a `class X` body does. Entering the block as X's class body gives it that through the ordinary
|
|
3525
|
+
# {#self_type_for_method_body} route. Only a constant X is modelled ({ScopeIndexer.refine_target}). One that
|
|
3526
|
+
# does not type as a class object (a gem class with no RBS) keeps the name as written: the body is still some
|
|
3527
|
+
# class's body, and leaving it on the enclosing `self` made a `refine` at the file's top level report every
|
|
3528
|
+
# implicit-self call in it as `call.unresolved-toplevel`.
|
|
3529
|
+
def refined_class_context(node)
|
|
3530
|
+
target = ScopeIndexer.refine_target(node)
|
|
3531
|
+
return nil if target.nil?
|
|
3532
|
+
|
|
3533
|
+
refined = scope.type_of(target)
|
|
3534
|
+
name = refined.is_a?(Type::Singleton) ? refined.class_name : Source::ConstantPath.qualified_name(target)
|
|
3535
|
+
return nil if name.nil?
|
|
3536
|
+
|
|
3537
|
+
[ClassFrame.new(name: name.delete_prefix("::"), singleton: false, refinement: true)]
|
|
3538
|
+
end
|
|
3539
|
+
|
|
3540
|
+
# The block calls whose body `return` leaves only the block ({ReturnBarrier.block_call?}). Like a `->` body
|
|
3541
|
+
# ({#eval_lambda}), each runs with the enclosing method's return sink suspended.
|
|
3542
|
+
def return_barrier_block?(node)
|
|
3543
|
+
ReturnBarrier.block_call?(node)
|
|
3544
|
+
end
|
|
3545
|
+
|
|
3546
|
+
# Runs the block with the method's return sink suspended, for a body whose `return` is not the method's.
|
|
3547
|
+
def without_return_sink
|
|
3548
|
+
outer_sink = Thread.current[RETURN_SINK_KEY]
|
|
3549
|
+
Thread.current[RETURN_SINK_KEY] = nil
|
|
3550
|
+
begin
|
|
3551
|
+
yield
|
|
3552
|
+
ensure
|
|
3553
|
+
Thread.current[RETURN_SINK_KEY] = outer_sink
|
|
3554
|
+
end
|
|
3555
|
+
end
|
|
3556
|
+
|
|
3557
|
+
# Issue #963 — `define_method(:name) { ... }` in a class body defines an INSTANCE method, and Ruby runs
|
|
3558
|
+
# the block with `self` bound to the receiving instance. Without this the block inherits the class body's
|
|
3559
|
+
# `Singleton[C]`, and #618's own-method veto asks the singleton side of a name the instance side answers.
|
|
3560
|
+
# {DefineMethodBlockSelf} owns the match; a non-match leaves the entry scope exactly as it was.
|
|
3561
|
+
#
|
|
3562
|
+
# The exclusion — the `class << ...` BODY, where `self` is the singleton class and the call defines a class
|
|
3563
|
+
# method — rides on `Scope#singleton_class_body?`, not on the frame stack: a `def` reached from that body
|
|
3564
|
+
# still carries the singleton frame although its `self` is the class object, and it is an instance method
|
|
3565
|
+
# the call defines there. Carrying the mark on the scope is also what lets the return-typing path apply the
|
|
3566
|
+
# same exclusion, which has no frame stack of its own.
|
|
3567
|
+
def narrow_define_method_block_self(call_node, block_entry)
|
|
3568
|
+
narrowed = DefineMethodBlockSelf.narrow_self_type_for(
|
|
3569
|
+
scope: scope, call_node: call_node
|
|
3570
|
+
)
|
|
3571
|
+
narrowed ? block_entry.with_self_type(narrowed) : block_entry
|
|
3572
|
+
end
|
|
3573
|
+
|
|
2136
3574
|
# Enters a meta-new `block` as the body of the class `class_context` names: `self_type` is that class's
|
|
2137
3575
|
# singleton, so a `def` inside binds an instance method on it through the ordinary
|
|
2138
3576
|
# {#self_type_for_method_body} route, while `block_entry` keeps the outer locals visible. Shared by the two
|
|
@@ -2146,7 +3584,7 @@ module Rigor
|
|
|
2146
3584
|
# frame is still pushed — it is what a nested `def` registers its method under — which is exactly the
|
|
2147
3585
|
# divergence that makes the chain a separate record rather than a view of the frame stack.
|
|
2148
3586
|
def enter_meta_class_body(block, block_entry, class_context)
|
|
2149
|
-
entry = block_entry.with_self_type(self_type_for_class_body(class_context))
|
|
3587
|
+
entry = block_entry.with_self_type(self_type_for_class_body(class_context)).with_singleton_class_body(false)
|
|
2150
3588
|
sub_eval(block, stamp_nesting(entry, @lexical_nesting), class_context: class_context)
|
|
2151
3589
|
end
|
|
2152
3590
|
|
|
@@ -2209,19 +3647,18 @@ module Rigor
|
|
|
2209
3647
|
acc
|
|
2210
3648
|
end
|
|
2211
3649
|
|
|
3650
|
+
# Runs for every call of every body it walks, and nearly every call reports no argument, so that case returns
|
|
3651
|
+
# before `reduce`: `Enumerable#inject` allocates its iteration state even over an empty array.
|
|
2212
3652
|
def floor_callee_escaped_args_for_call(node, base_scope)
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
# floor anything. Skip the def resolution + body scan entirely (the overwhelming common case).
|
|
2216
|
-
return base_scope unless call_passes_local_argument?(node)
|
|
2217
|
-
|
|
2218
|
-
def_node = resolve_self_callee_def(node)
|
|
2219
|
-
return base_scope if def_node.nil?
|
|
3653
|
+
arguments = content_mutated_arguments(node)
|
|
3654
|
+
return base_scope if arguments.empty?
|
|
2220
3655
|
|
|
2221
|
-
|
|
2222
|
-
|
|
3656
|
+
arguments.reduce(base_scope) do |acc, argument|
|
|
3657
|
+
next acc unless acc.locals.key?(argument.name)
|
|
2223
3658
|
|
|
2224
|
-
|
|
3659
|
+
floored = content_floor_for(acc.local(argument.name))
|
|
3660
|
+
floored.nil? ? acc : acc.with_mutated_local(argument.name, floored)
|
|
3661
|
+
end
|
|
2225
3662
|
end
|
|
2226
3663
|
|
|
2227
3664
|
# The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
|
|
@@ -2367,20 +3804,6 @@ module Rigor
|
|
|
2367
3804
|
end
|
|
2368
3805
|
end
|
|
2369
3806
|
|
|
2370
|
-
def floor_arguments_at_positions(node, positions, base_scope)
|
|
2371
|
-
args = node.arguments
|
|
2372
|
-
return base_scope unless args.respond_to?(:arguments)
|
|
2373
|
-
|
|
2374
|
-
argument_nodes = args.arguments
|
|
2375
|
-
positions.values.uniq.reduce(base_scope) do |acc, index|
|
|
2376
|
-
arg = argument_nodes[index]
|
|
2377
|
-
next acc unless arg.is_a?(Prism::LocalVariableReadNode) && acc.locals.key?(arg.name)
|
|
2378
|
-
|
|
2379
|
-
floored = content_floor_for(acc.local(arg.name))
|
|
2380
|
-
floored.nil? ? acc : acc.with_local(arg.name, floored)
|
|
2381
|
-
end
|
|
2382
|
-
end
|
|
2383
|
-
|
|
2384
3807
|
# Walk the receiver chain of `node` and fold the escaping-content widening of every block-bearing, escaping
|
|
2385
3808
|
# receiver call into `base_scope`. Only receiver calls are walked — `node` itself is handled by the caller. A
|
|
2386
3809
|
# `:non_escaping` receiver block is left to slice C's non-escaping write-back (which the receiver expression
|
|
@@ -2422,7 +3845,7 @@ module Rigor
|
|
|
2422
3845
|
|
|
2423
3846
|
mutations.keys.reduce(post_scope) do |acc, name|
|
|
2424
3847
|
floored = content_floor_for(acc.local(name))
|
|
2425
|
-
floored.nil? ? acc : acc.
|
|
3848
|
+
floored.nil? ? acc : acc.with_mutated_local(name, floored)
|
|
2426
3849
|
end
|
|
2427
3850
|
end
|
|
2428
3851
|
|
|
@@ -2445,10 +3868,17 @@ module Rigor
|
|
|
2445
3868
|
|
|
2446
3869
|
# The Dynamic-floor carrier for a content-mutated escaping capture, or nil when the pre-state is not a recognised
|
|
2447
3870
|
# mutable collection (leave it alone — e.g. an already-`Dynamic` binding or an unknown shape).
|
|
3871
|
+
#
|
|
3872
|
+
# A String counts in any refined form (`non-empty-string`, `decimal-int-string`), which `stringish?` does not
|
|
3873
|
+
# accept: the mutation can empty or rewrite it as it can a plain `String`.
|
|
2448
3874
|
def content_floor_for(type)
|
|
2449
3875
|
return nil if type.nil?
|
|
3876
|
+
# A union with a String member floors member by member, so neither carrier swallows the other and a member no
|
|
3877
|
+
# mutation can fill (`nil`) stays: taken whole, `Array | String` floored to `Array[untyped]` and `String?` to
|
|
3878
|
+
# nothing at all.
|
|
3879
|
+
return UnknownStoreWidening.content_floor(type) if string_union?(type)
|
|
2450
3880
|
|
|
2451
|
-
if
|
|
3881
|
+
if UnknownStoreWidening.carrier_class(type) == "String"
|
|
2452
3882
|
Type::Combinator.nominal_of("String")
|
|
2453
3883
|
elsif hashish?(type)
|
|
2454
3884
|
Type::Combinator.nominal_of("Hash",
|
|
@@ -2459,12 +3889,26 @@ module Rigor
|
|
|
2459
3889
|
end
|
|
2460
3890
|
end
|
|
2461
3891
|
|
|
3892
|
+
# The type of `call_node`'s explicit receiver where its operands were typed ({#type_operand}), computed once per
|
|
3893
|
+
# call: the block's entry (its parameter types, a DSL `self`, the #1412 repeat gate), its escape class and
|
|
3894
|
+
# every write-back pass all ask, and {#type_operand} types the receiver afresh each time. The one slot is
|
|
3895
|
+
# keyed on the node, and an evaluator types every operand from the same scope.
|
|
3896
|
+
def explicit_receiver_type(call_node)
|
|
3897
|
+
memo = @explicit_receiver_type
|
|
3898
|
+
return memo[1] if memo && memo[0].equal?(call_node)
|
|
3899
|
+
|
|
3900
|
+
type = type_operand(call_node.receiver)
|
|
3901
|
+
@explicit_receiver_type = [call_node, type]
|
|
3902
|
+
type
|
|
3903
|
+
end
|
|
3904
|
+
|
|
2462
3905
|
def classify_closure_escape(call_node)
|
|
2463
|
-
receiver_type = call_node.receiver ?
|
|
3906
|
+
receiver_type = call_node.receiver ? explicit_receiver_type(call_node) : nil
|
|
2464
3907
|
ClosureEscapeAnalyzer.classify(
|
|
2465
3908
|
receiver_type: receiver_type,
|
|
2466
3909
|
method_name: call_node.name,
|
|
2467
|
-
environment: scope.environment
|
|
3910
|
+
environment: scope.environment,
|
|
3911
|
+
scope: scope
|
|
2468
3912
|
)
|
|
2469
3913
|
rescue StandardError
|
|
2470
3914
|
:unknown
|
|
@@ -2474,11 +3918,16 @@ module Rigor
|
|
|
2474
3918
|
# drop matches the spec line "facts about locals it can write become unstable after the escape point": rather than
|
|
2475
3919
|
# synthesise the union of the block's write types (which the current pass does not yet expose), we discard the
|
|
2476
3920
|
# narrowed binding altogether. A future sub-phase MAY refine this to the union of the block's actual writes.
|
|
3921
|
+
#
|
|
3922
|
+
# An instance variable the body rebinds is dropped the same way: the block shares the caller's `self`, so a
|
|
3923
|
+
# callback that runs later writes the very ivar the continuation reads (`@clicked = false; button.on_click {
|
|
3924
|
+
# @clicked = true }` left `if @clicked` folding always-falsey). One still on its ADR-58 declaration seed is
|
|
3925
|
+
# left alone ({CapturedLocals.writes}): that seed already holds whatever the callback stores.
|
|
2477
3926
|
def drop_captured_narrowing(block_node, base_scope)
|
|
2478
|
-
names = CapturedLocals.writes(block_node, base_scope)
|
|
3927
|
+
names = CapturedLocals.writes(block_node, base_scope, ivars: true)
|
|
2479
3928
|
return base_scope if names.empty?
|
|
2480
3929
|
|
|
2481
|
-
names.reduce(base_scope) { |acc, name| acc
|
|
3930
|
+
names.reduce(base_scope) { |acc, name| bind_capture(acc, name, Type::Combinator.untyped) }
|
|
2482
3931
|
end
|
|
2483
3932
|
|
|
2484
3933
|
# ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
|
|
@@ -2487,25 +3936,154 @@ module Rigor
|
|
|
2487
3936
|
# — `[].each { … }` — stays sound), value-pinned- widened on the final permitted iteration, and floored to
|
|
2488
3937
|
# `Dynamic[top]` on non-convergence (matching `drop_captured_narrowing`).
|
|
2489
3938
|
#
|
|
2490
|
-
#
|
|
2491
|
-
#
|
|
3939
|
+
# The instance variables the body rebinds (`CapturedLocals.writes` with `ivars: true`) outlive the call the same
|
|
3940
|
+
# way — `@n = 0; [1, 2].each { @n += 1 }` left `@n == 0` folding always-truthy — so they join the name set,
|
|
3941
|
+
# seeded from their pre-call binding. See {#converge_captures_by_kind} for how a block that rebinds both kinds is
|
|
3942
|
+
# answered.
|
|
3943
|
+
#
|
|
3944
|
+
# Fast path: a block writing no outer local and no rebindable ivar leaves `post_scope` byte-identical (the
|
|
3945
|
+
# overwhelming majority of blocks), so this costs one `CapturedLocals.writes` walk and nothing else.
|
|
3946
|
+
#
|
|
3947
|
+
# Every pass reads a captured local the body mutates IN PLACE at its unknown-store widening
|
|
3948
|
+
# ({#capture_pass_bindings}), never at the contents the collection held before the call: only the rebound names
|
|
3949
|
+
# move between passes, so a rebind read from such a collection (`last = a.last; a << x`) would otherwise record
|
|
3950
|
+
# the first iteration's answer on every pass and the fixpoint would close over it (ADR-56 WD2.13, second
|
|
3951
|
+
# residue).
|
|
2492
3952
|
def write_back_block_captures(call_node, post_scope)
|
|
2493
3953
|
block = call_node.block
|
|
2494
3954
|
return post_scope unless block.is_a?(Prism::BlockNode)
|
|
2495
3955
|
return post_scope unless classify_closure_escape(call_node) == :non_escaping
|
|
2496
3956
|
|
|
2497
|
-
names = CapturedLocals.writes(block, scope)
|
|
3957
|
+
names = CapturedLocals.writes(block, scope, ivars: true)
|
|
2498
3958
|
return post_scope if names.empty?
|
|
2499
3959
|
|
|
2500
|
-
|
|
2501
|
-
result =
|
|
3960
|
+
break_pass = block_break_pass(block)
|
|
3961
|
+
result = converge_captures_by_kind(call_node, block, names, break_pass)
|
|
3962
|
+
result = join_block_break_bindings(call_node, block, result, break_pass)
|
|
3963
|
+
result.reduce(post_scope) { |acc, (name, type)| bind_capture(acc, name, type) }
|
|
3964
|
+
end
|
|
3965
|
+
|
|
3966
|
+
# The {BodyFixpoint} continuation of `names`, seeded from their pre-call bindings.
|
|
3967
|
+
def converge_block_captures(call_node, block, names, break_pass)
|
|
3968
|
+
BodyFixpoint.converge(
|
|
2502
3969
|
names: names,
|
|
2503
|
-
seed_bindings:
|
|
3970
|
+
seed_bindings: names.to_h { |name| [name, CapturedLocals.bound_type(scope, name)] },
|
|
2504
3971
|
widen: Type::Combinator.method(:widen_value_pinned),
|
|
2505
|
-
evaluate_body: ->(bindings) {
|
|
3972
|
+
evaluate_body: ->(bindings) { block_pass_exit_bindings(call_node, block, bindings, names, break_pass) }
|
|
2506
3973
|
)
|
|
3974
|
+
end
|
|
3975
|
+
|
|
3976
|
+
# The continuation of a block's rebound names. One {BodyFixpoint} over locals and ivars together is the wrong
|
|
3977
|
+
# answer for most of them: its final pass widens every name while any name still moves, so an ivar counter beside
|
|
3978
|
+
# `mode = :b` turned `mode`'s converged `:a | :b` into `Symbol` — and a local counter beside `@mode = :b` did the
|
|
3979
|
+
# same to `@mode` — and `take(mode)` against `(:a | :b) -> void` fired on code each kind's own fixpoint accepts.
|
|
3980
|
+
#
|
|
3981
|
+
# So each kind converges on its own first, with the other at its pre-call binding; for a block that rebinds one
|
|
3982
|
+
# kind that is the only fixpoint, and for the locals it is exactly the one a block rebinding no ivar has always
|
|
3983
|
+
# had. A name converged that way is wrong when it reads the other kind (`last = @n; @n += 1`, `@last = count`),
|
|
3984
|
+
# so one more pass under the settled bindings checks every name, and one whose exit leaves its settled binding
|
|
3985
|
+
# takes its answer from a joint fixpoint over both kinds, computed only then. The check repeats, because a name
|
|
3986
|
+
# read off a moved one (`first = last`) may move in turn; every round moves a name to its joint answer or stops.
|
|
3987
|
+
#
|
|
3988
|
+
# The body's last evaluation is therefore a pass under the settled bindings, so the scopes recorded inside the
|
|
3989
|
+
# block read those rather than a pass that pinned one kind to its pre-call value.
|
|
3990
|
+
#
|
|
3991
|
+
# `break_pass` ({#block_break_pass}) rides along so every pass — a fixpoint's or a check's — leaves its `break`
|
|
3992
|
+
# scopes for {#join_block_break_bindings}.
|
|
3993
|
+
def converge_captures_by_kind(call_node, block, names, break_pass)
|
|
3994
|
+
kinds = names.partition { |name| !CapturedLocals.ivar_name?(name) }
|
|
3995
|
+
return converge_block_captures(call_node, block, names, break_pass) if kinds.any?(&:empty?)
|
|
3996
|
+
|
|
3997
|
+
settled = kinds.map { |kind| converge_block_captures(call_node, block, kind, break_pass) }.reduce(:merge)
|
|
3998
|
+
joint = nil
|
|
3999
|
+
loop do
|
|
4000
|
+
exits = block_pass_exit_bindings(call_node, block, settled, names, break_pass)
|
|
4001
|
+
moved = escaped_captures(settled, exits, joint)
|
|
4002
|
+
return settled if moved.empty?
|
|
4003
|
+
|
|
4004
|
+
joint ||= converge_block_captures(call_node, block, names, break_pass)
|
|
4005
|
+
moved.each { |name| settled[name] = joint[name] }
|
|
4006
|
+
end
|
|
4007
|
+
end
|
|
4008
|
+
|
|
4009
|
+
# The names whose `exits` leave their `settled` binding, less those already on their `joint` answer.
|
|
4010
|
+
def escaped_captures(settled, exits, joint)
|
|
4011
|
+
settled.keys.select do |name|
|
|
4012
|
+
exit_type = exits[name]
|
|
4013
|
+
next false if exit_type.nil? || (joint && settled[name] == joint[name])
|
|
2507
4014
|
|
|
2508
|
-
|
|
4015
|
+
Type::Combinator.union(settled[name], exit_type) != settled[name]
|
|
4016
|
+
end
|
|
4017
|
+
end
|
|
4018
|
+
|
|
4019
|
+
# Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
|
|
4020
|
+
# #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
|
|
4021
|
+
# rebinds no ivar leaves the locals exactly as before.
|
|
4022
|
+
def bind_capture(scope, name, type)
|
|
4023
|
+
CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
|
|
4024
|
+
end
|
|
4025
|
+
|
|
4026
|
+
# A block-level `break` ends the CALL, so the binding it leaves with starts no further iteration and is no input
|
|
4027
|
+
# to the write-back fixpoint — feeding it back would type the next pass's body under a value the body never sees
|
|
4028
|
+
# (`acc = "s"; break` reaching the next pass's `acc`). It IS the continuation's binding on that path, though, and
|
|
4029
|
+
# without this join `found = nil; xs.each { |x| if x > 1; found = x; break; end }` left `found` on `nil` and
|
|
4030
|
+
# folded `if found` always-falsey.
|
|
4031
|
+
#
|
|
4032
|
+
# The arms must come from a pass whose entry is the CONVERGED binding, which contains every iteration's entry.
|
|
4033
|
+
# The write-back's last pass usually is one — a fixpoint that stabilised, and every by-kind check pass
|
|
4034
|
+
# ({#converge_captures_by_kind}), ran from the binding it returns — so its arms are reused
|
|
4035
|
+
# ({#block_pass_exit_bindings}). A capped fixpoint's widened binding was never evaluated, so only then does one
|
|
4036
|
+
# more pass run, without recording into the per-node scope index: that index keeps the write-back's own last
|
|
4037
|
+
# pass, which the check path's diagnostics read, exactly as the loop fixpoint's converged re-record is
|
|
4038
|
+
# display-only ({#record_converged_loop_body}). A name the fixpoint floored to `Dynamic[top]` keeps the floor; a
|
|
4039
|
+
# precise arm unioned into it would read as knowledge the analysis does not have.
|
|
4040
|
+
def join_block_break_bindings(call_node, block, converged, break_pass)
|
|
4041
|
+
return converged if break_pass.nil?
|
|
4042
|
+
|
|
4043
|
+
arms =
|
|
4044
|
+
if break_pass[:entry] == converged
|
|
4045
|
+
break_pass[:arms]
|
|
4046
|
+
else
|
|
4047
|
+
converged_break_arms(call_node, block, converged, break_pass[:targets])
|
|
4048
|
+
end
|
|
4049
|
+
return converged if arms.empty?
|
|
4050
|
+
|
|
4051
|
+
floor = Type::Combinator.untyped
|
|
4052
|
+
converged.to_h do |name, type|
|
|
4053
|
+
next [name, type] if type == floor
|
|
4054
|
+
|
|
4055
|
+
[name, Type::Combinator.union(type, *arms.filter_map { |arm| CapturedLocals.bound_type(arm, name) })]
|
|
4056
|
+
end
|
|
4057
|
+
end
|
|
4058
|
+
|
|
4059
|
+
# nil for a body with no block-level `break` — one allocation-free scan, and the write-back runs exactly as
|
|
4060
|
+
# before. Otherwise the record {#block_pass_exit_bindings} fills: the targeting `break`s, and the entry
|
|
4061
|
+
# bindings and `break` scopes of the most recent fixpoint pass.
|
|
4062
|
+
def block_break_pass(block)
|
|
4063
|
+
body = block.body
|
|
4064
|
+
return nil unless JumpTargets.any?(body, Prism::BreakNode)
|
|
4065
|
+
|
|
4066
|
+
{ targets: JumpTargets.of(body, Prism::BreakNode), entry: nil, arms: [] }
|
|
4067
|
+
end
|
|
4068
|
+
|
|
4069
|
+
# One write-back fixpoint pass ({#block_exit_bindings}), collecting the scopes at the block-level `break`s it
|
|
4070
|
+
# reaches when there are any. `BodyFixpoint` hands every pass the same mutable assumption, so the entry is
|
|
4071
|
+
# copied before the fixpoint moves it.
|
|
4072
|
+
def block_pass_exit_bindings(call_node, block, bindings, names, break_pass)
|
|
4073
|
+
return block_exit_bindings(call_node, block, bindings, names) if break_pass.nil?
|
|
4074
|
+
|
|
4075
|
+
sink, exits = collect_break_scopes { block_exit_bindings(call_node, block, bindings, names) }
|
|
4076
|
+
break_pass[:entry] = bindings.dup
|
|
4077
|
+
break_pass[:arms] = targeted_scopes(sink, break_pass[:targets])
|
|
4078
|
+
exits
|
|
4079
|
+
end
|
|
4080
|
+
|
|
4081
|
+
# The `break` scopes of one unrecorded pass from `converged` — for a capped fixpoint, whose widened binding no
|
|
4082
|
+
# pass has run from.
|
|
4083
|
+
def converged_break_arms(call_node, block, converged, targets)
|
|
4084
|
+
entry = block_pass_entry(call_node, block, converged)
|
|
4085
|
+
sink, = collect_break_scopes { sub_eval(block, entry, **UNRECORDED) }
|
|
4086
|
+
targeted_scopes(sink, targets)
|
|
2509
4087
|
end
|
|
2510
4088
|
|
|
2511
4089
|
# ADR-56 slice C — receiver-content element-type join. After the rebind write-back and
|
|
@@ -2525,8 +4103,8 @@ module Rigor
|
|
|
2525
4103
|
# post-call effect applied ahead of the widening; the pre-CALL `scope` would carry neither. The loop seam makes
|
|
2526
4104
|
# the same choice with `pre_body`; see {#loop_content_writeback}.
|
|
2527
4105
|
#
|
|
2528
|
-
# The
|
|
2529
|
-
#
|
|
4106
|
+
# The stored evidence is typed in the block-entry scope and iterated to a fixpoint when a store reads a
|
|
4107
|
+
# collection the join moves — see {#join_content_to_fixpoint}. Always sound — only ever widens.
|
|
2530
4108
|
def content_writeback_block_captures(call_node, post_scope, seed_scope:)
|
|
2531
4109
|
block = call_node.block
|
|
2532
4110
|
return post_scope unless block.is_a?(Prism::BlockNode)
|
|
@@ -2535,14 +4113,424 @@ module Rigor
|
|
|
2535
4113
|
body = block.body
|
|
2536
4114
|
return post_scope if body.nil?
|
|
2537
4115
|
|
|
2538
|
-
|
|
4116
|
+
shadows = {}.compare_by_identity
|
|
4117
|
+
mutations = captured_content_mutations(block, shadows)
|
|
2539
4118
|
return post_scope if mutations.empty?
|
|
2540
4119
|
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
4120
|
+
seeds = mutations.to_h do |name, _calls|
|
|
4121
|
+
[name, lookup_mutated_seed(body, name, seed_scope.local(name)) { |depth, nesting| depth > nesting }]
|
|
4122
|
+
end
|
|
4123
|
+
shadow_rebound_reads(block, mutations, seeds, shadows)
|
|
4124
|
+
joined = join_content_to_fixpoint(mutations, seeds, build_block_entry_scope(call_node, block), shadows)
|
|
4125
|
+
rewrites = local_rewrites(block.body) { |receiver, ancestors| receiver.depth > scope_nesting(ancestors) }
|
|
4126
|
+
joined.reduce(post_scope) do |acc, (name, type)|
|
|
4127
|
+
acc.with_mutated_local(name, rewritten_capture(type, seeds[name], rewrites.fetch(name, NO_REWRITES)))
|
|
4128
|
+
end
|
|
4129
|
+
end
|
|
4130
|
+
|
|
4131
|
+
NO_REWRITES = [].freeze
|
|
4132
|
+
private_constant :NO_REWRITES
|
|
4133
|
+
|
|
4134
|
+
# The {RewriteMutation} names `root` calls on each local its block admits — `a.map!(&:to_s)` beside an `a << x`.
|
|
4135
|
+
# The receiver test is the one the caller's content-mutation walk applies.
|
|
4136
|
+
def local_rewrites(root)
|
|
4137
|
+
rewrites = {}
|
|
4138
|
+
Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
|
|
4139
|
+
next unless node.is_a?(Prism::CallNode) && RewriteMutation.rewriter?(node.name)
|
|
4140
|
+
|
|
4141
|
+
receiver = node.receiver
|
|
4142
|
+
next unless receiver.is_a?(Prism::LocalVariableReadNode) && yield(receiver, ancestors)
|
|
4143
|
+
|
|
4144
|
+
(rewrites[receiver.name] ||= []) << node.name
|
|
4145
|
+
end
|
|
4146
|
+
rewrites
|
|
4147
|
+
end
|
|
4148
|
+
|
|
4149
|
+
# The slice-C join (and the loop seam's) rebuilds a collection from its SEED, so the rewrite the body's own
|
|
4150
|
+
# widening applied is gone from it: `a = [1]; [0].each { a.map!(&:to_s); a << "x" }` read `Array["x" | 1]`, and
|
|
4151
|
+
# `a[0] == "1"` folded always-falsey. Each rewrite the body makes on the local is re-applied here, on the seam's
|
|
4152
|
+
# terms: a seed the straight-line widening may not grow (a precise nominal, #561) is left as the join answered it.
|
|
4153
|
+
def rewritten_capture(type, seed, method_names)
|
|
4154
|
+
return type if method_names.empty? || !MutationWidening.shape_carrier?(seed)
|
|
4155
|
+
|
|
4156
|
+
method_names.uniq.reduce(type) { |acc, method_name| RewriteMutation.arm_through(acc, method_name) }
|
|
4157
|
+
end
|
|
4158
|
+
|
|
4159
|
+
# Adds to each store's `shadows` every local it reads that the block body writes and the block-entry scope binds:
|
|
4160
|
+
# an outer local the body rebinds, or a block parameter or `;`-local it reassigns. A local the body introduces
|
|
4161
|
+
# already reads `Dynamic[top]` there. A joined collection the body also rebinds is included: the join's seed
|
|
4162
|
+
# carries slice A's continuation, which misses a value written between two rebinds.
|
|
4163
|
+
#
|
|
4164
|
+
# The block-entry scope binds such a local where the call found it, so a store reading one recorded the first
|
|
4165
|
+
# iteration's value: `total = 0; out = []; [1, 2].each { |x| total += x; out << total }` stored `0` as far as the
|
|
4166
|
+
# join could tell, `out` read `Array[0]`, and `out.last == 3` folded always-falsey on a program whose `out` is
|
|
4167
|
+
# `[1, 3]`. Typed as `Dynamic[top]`, the store is `out`'s one unknown member and nothing folds.
|
|
4168
|
+
#
|
|
4169
|
+
# Every precise reading tried reported on correct code instead:
|
|
4170
|
+
#
|
|
4171
|
+
# - slice A's continuation misses a value written between two rebinds;
|
|
4172
|
+
# - joined with the block-entry typing, it still stores exit values no store reads;
|
|
4173
|
+
# - one more walk of the body to the store inherits every gap in the engine's in-body flow.
|
|
4174
|
+
#
|
|
4175
|
+
# See ADR-56 WD2.13.
|
|
4176
|
+
def shadow_rebound_reads(block, sites, seeds, shadows)
|
|
4177
|
+
entry_names = scope.locals.keys | CapturedLocals.introduced_locals(block).to_a
|
|
4178
|
+
written = scope_local_writes(block) & entry_names
|
|
4179
|
+
return if written.empty?
|
|
4180
|
+
|
|
4181
|
+
sites.each do |name, nodes|
|
|
4182
|
+
# A mixed `Array | Hash` seed reads as a Hash here: its key arguments are the Hash side's evidence, and a
|
|
4183
|
+
# `Dynamic` index only makes the Array side read the store as both forms.
|
|
4184
|
+
array = content_kind(seeds[name]) == :array
|
|
4185
|
+
nodes.each do |site|
|
|
4186
|
+
names = store_value_reads(site, array) & written
|
|
4187
|
+
shadows[site] = shadows.fetch(site, []) | names unless names.empty?
|
|
4188
|
+
end
|
|
4189
|
+
end
|
|
4190
|
+
end
|
|
4191
|
+
|
|
4192
|
+
# The locals a store reads to build what it stores. An Array index write's index arguments are left out, and so
|
|
4193
|
+
# is any name they read: the join classifies the store as an element or a splice from the index's type, and a
|
|
4194
|
+
# `Dynamic` index reads as both, so `grid[i] = [x, x]` would join `x` itself as a member of `grid` beside the
|
|
4195
|
+
# pair. Such an index keeps its block-entry binding, which is master's reading, and so does a stored value that
|
|
4196
|
+
# reads the same name: `ids[n] = n; n += 1` still stores `n`'s first-iteration value.
|
|
4197
|
+
def store_value_reads(site, array)
|
|
4198
|
+
return local_reads(site) unless array
|
|
4199
|
+
|
|
4200
|
+
if site.is_a?(Prism::CallNode) && site.name == :[]=
|
|
4201
|
+
*index, value = site.arguments&.arguments || []
|
|
4202
|
+
[value, site.receiver].compact.flat_map { |n| local_reads(n) } - index.flat_map { |n| local_reads(n) }
|
|
4203
|
+
elsif IndexWriteWidening.index_write?(site)
|
|
4204
|
+
value = site.respond_to?(:value) ? site.value : nil
|
|
4205
|
+
[value, site.receiver].compact.flat_map { |n| local_reads(n) } - local_reads(site.arguments)
|
|
4206
|
+
else
|
|
4207
|
+
local_reads(site)
|
|
4208
|
+
end
|
|
4209
|
+
end
|
|
4210
|
+
|
|
4211
|
+
# Every local the block writes in its own scope or an outer one, in its body or in a parameter's default. A
|
|
4212
|
+
# write inside an inner block or lambda to a name that block introduces is a different variable: its `depth`
|
|
4213
|
+
# climbs fewer scopes than it is nested in. A method, class or module body inside the block is a scope of its own.
|
|
4214
|
+
def scope_local_writes(block)
|
|
4215
|
+
names = []
|
|
4216
|
+
[block.parameters, block.body].compact.each do |root|
|
|
4217
|
+
Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
|
|
4218
|
+
next unless CapturedLocals::LOCAL_WRITE_NODES.any? { |klass| node.is_a?(klass) }
|
|
4219
|
+
|
|
4220
|
+
names << node.name if same_scope_local?(node, ancestors)
|
|
4221
|
+
end
|
|
4222
|
+
end
|
|
4223
|
+
names.uniq
|
|
4224
|
+
end
|
|
4225
|
+
|
|
4226
|
+
# The bodies that open a scope of their own, where a local's `depth` starts again from zero.
|
|
4227
|
+
SCOPE_BODY_NODES = [Prism::DefNode, Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
4228
|
+
private_constant :SCOPE_BODY_NODES
|
|
4229
|
+
|
|
4230
|
+
# True when the local `node` names lives in the scope the walk started in or an outer one.
|
|
4231
|
+
def same_scope_local?(node, ancestors)
|
|
4232
|
+
return false if ancestors.any? { |ancestor| SCOPE_BODY_NODES.any? { |klass| ancestor.is_a?(klass) } }
|
|
4233
|
+
|
|
4234
|
+
node.depth >= scope_nesting(ancestors)
|
|
4235
|
+
end
|
|
4236
|
+
|
|
4237
|
+
# The nodes that read a local's current value: a plain read, and the compound writes that read before they store.
|
|
4238
|
+
LOCAL_READ_NODES = [
|
|
4239
|
+
Prism::LocalVariableReadNode,
|
|
4240
|
+
Prism::LocalVariableOperatorWriteNode,
|
|
4241
|
+
Prism::LocalVariableOrWriteNode,
|
|
4242
|
+
Prism::LocalVariableAndWriteNode
|
|
4243
|
+
].freeze
|
|
4244
|
+
private_constant :LOCAL_READ_NODES
|
|
4245
|
+
|
|
4246
|
+
# Every local `node` reads from the scope it sits in: a read inside an inner block of a name that block
|
|
4247
|
+
# introduces is a different variable.
|
|
4248
|
+
def local_reads(node)
|
|
4249
|
+
return [] if node.nil?
|
|
4250
|
+
|
|
4251
|
+
names = []
|
|
4252
|
+
Source::NodeWalker.each_with_ancestors(node) do |n, ancestors|
|
|
4253
|
+
next unless LOCAL_READ_NODES.any? { |klass| n.is_a?(klass) }
|
|
4254
|
+
|
|
4255
|
+
names << n.name if same_scope_local?(n, ancestors)
|
|
4256
|
+
end
|
|
4257
|
+
names.uniq
|
|
4258
|
+
end
|
|
4259
|
+
|
|
4260
|
+
# The evidence a content join reads, per collection kind: one element union for an Array, a key union and a
|
|
4261
|
+
# value union for a Hash, all three for a seed carrying both ({ContentJoin.join_mixed_content}), and none for a
|
|
4262
|
+
# String, which widens to `String` whatever it stored.
|
|
4263
|
+
CONTENT_EVIDENCE_SLOTS = {
|
|
4264
|
+
array: %i[element].freeze, hash: %i[key value].freeze, mixed: %i[key value element].freeze, string: [].freeze
|
|
4265
|
+
}.freeze
|
|
4266
|
+
private_constant :CONTENT_EVIDENCE_SLOTS
|
|
4267
|
+
|
|
4268
|
+
# The joined continuation carrier of each content-mutated name, shared by the block seam and
|
|
4269
|
+
# {#each_with_object_return}. `sites` maps each name to its mutation nodes, `seeds` to its pre-state; the answer
|
|
4270
|
+
# omits a name whose pre-state is no collection.
|
|
4271
|
+
#
|
|
4272
|
+
# The evidence is typed in the block-entry scope, where each mutated collection still holds its PRE-CALL
|
|
4273
|
+
# contents. A store whose evidence reads one of them therefore records the FIRST iteration's answer: `h = { a:
|
|
4274
|
+
# 0 }; [:a, :a, :a].each { |k| h[k] = h[k] + 1 }` stored `1` as far as a single pass could tell, the join read
|
|
4275
|
+
# `Hash[:a | Symbol, 0 | 1]`, and `h[:a] == 3` folded always-falsey on a program that prints.
|
|
4276
|
+
#
|
|
4277
|
+
# So each collection a store reads is bound to what it holds at ANY iteration's entry. A name none of whose
|
|
4278
|
+
# stores reads a mutated Array or Hash is FIXED: its evidence is the same on every iteration, so it is typed
|
|
4279
|
+
# once and the name is bound to its own join — a String to `String`, whatever it stored, so `lens << buf.length`
|
|
4280
|
+
# after `buf << w` reads `Integer`, not the length of `buf`'s pre-call value. Every other name MOVES, and its
|
|
4281
|
+
# evidence is iterated to a fixpoint through {BodyFixpoint}: each of its evidence slots is one fixpoint name,
|
|
4282
|
+
# and each pass re-types its stores with every moving collection bound to its seed joined with the evidence so
|
|
4283
|
+
# far. The join above widens to `Hash[Symbol, 0 | Integer]` on the final pass, and evidence that keeps growing
|
|
4284
|
+
# structurally floors to `Dynamic[top]`, the slot's one-unknown-store answer. Only moving slots are ever
|
|
4285
|
+
# widened, so `acc << 1` beside such a store keeps `Array[1]`.
|
|
4286
|
+
#
|
|
4287
|
+
# That is what keeps this seam's claim to complete evidence ({MutationWidening#gradual_floor} rests on it): the
|
|
4288
|
+
# scan sees every store in the body, and no store's evidence is read off a first-iteration binding. The final
|
|
4289
|
+
# pass trusts its widening without re-checking it, exactly as slice A's fixpoint does (ADR-56 WD3). A gradual
|
|
4290
|
+
# arm on every self-reading store would be sound too, but its `Dynamic` would quiet every later read of the
|
|
4291
|
+
# collection, where the converged `Integer` still reports `h[:a].upcase`. With no moving name — the `acc = [];
|
|
4292
|
+
# xs.each { |x| acc.push(x) }` accumulator — this is the single pass it always was.
|
|
4293
|
+
#
|
|
4294
|
+
# `shadows` maps a site to the names its evidence is typed with bound to `Dynamic[top]`. A site nested in an inner
|
|
4295
|
+
# block or lambda lists the names that block binds itself (parameters, `;`-locals): the entry scope is the seam
|
|
4296
|
+
# block's, where such a name resolves to the OUTER local it shadows, and `|y| out << y.first` inside the block
|
|
4297
|
+
# must not read an outer `y = [0]`. Every site also lists the locals it reads that the body writes
|
|
4298
|
+
# ({#shadow_rebound_reads}).
|
|
4299
|
+
def join_content_to_fixpoint(sites, seeds, entry, shadows = NO_SHADOWS)
|
|
4300
|
+
kinds = seeds.filter_map { |name, seed| (kind = content_kind(seed)) && [name, kind] }.to_h
|
|
4301
|
+
return {} if kinds.empty?
|
|
4302
|
+
|
|
4303
|
+
moving = moving_content_names(sites, kinds)
|
|
4304
|
+
fixed = kinds.except(*moving)
|
|
4305
|
+
strings, settled = fixed.partition { |_name, kind| kind == :string }.map(&:to_h)
|
|
4306
|
+
fixed_entry = bind_content_joins(entry, strings, seeds, {})
|
|
4307
|
+
evidence = content_evidence(sites, fixed, fixed_entry, shadows)
|
|
4308
|
+
unless moving.empty?
|
|
4309
|
+
base = bind_content_joins(fixed_entry, settled, seeds, evidence)
|
|
4310
|
+
evidence = evidence.merge(converge_content_evidence(sites, seeds, kinds.slice(*moving), base, shadows))
|
|
4311
|
+
end
|
|
4312
|
+
kinds.to_h { |name, kind| [name, join_content_evidence(seeds[name], kind, name, evidence)] }
|
|
4313
|
+
end
|
|
4314
|
+
|
|
4315
|
+
# The pre-state's collection kind, or nil when the join has no carrier to rederive — the dispatch
|
|
4316
|
+
# {#join_content_for_param} makes, and the reason it answers nil for the same pre-states. A seed carrying both
|
|
4317
|
+
# an Array and a Hash member is `:mixed`, and each side joins with its own class's evidence.
|
|
4318
|
+
def content_kind(pre_state)
|
|
4319
|
+
return nil if pre_state.nil?
|
|
4320
|
+
return :string if stringish?(pre_state)
|
|
4321
|
+
return (arrayish?(pre_state) ? :mixed : :hash) if hashish?(pre_state)
|
|
4322
|
+
|
|
4323
|
+
:array if arrayish?(pre_state)
|
|
4324
|
+
end
|
|
4325
|
+
|
|
4326
|
+
# The names whose evidence can differ between iterations: a store that reads a mutated Array or Hash among its
|
|
4327
|
+
# arguments (a `[]=` call's stored value is one), or an Array-side compound index write (`a[i] += v`), whose
|
|
4328
|
+
# stored value is computed from the slot it overwrites. A String never moves — its join is `String` whatever it
|
|
4329
|
+
# stored — and the Hash side floors an index write's value, so there only the key arguments are typed.
|
|
4330
|
+
def moving_content_names(sites, kinds)
|
|
4331
|
+
movable = kinds.reject { |_name, kind| kind == :string }.keys
|
|
4332
|
+
movable.select do |name|
|
|
4333
|
+
sites[name].any? do |node|
|
|
4334
|
+
(kinds[name] == :array && IndexWriteWidening.index_write?(node)) || store_arguments_read?(node, movable)
|
|
4335
|
+
end
|
|
4336
|
+
end
|
|
4337
|
+
end
|
|
4338
|
+
|
|
4339
|
+
def bind_content_joins(scope, kinds, seeds, evidence)
|
|
4340
|
+
kinds.reduce(scope) do |acc, (name, kind)|
|
|
4341
|
+
acc.with_local(name, content_entry_binding(seeds[name], kind, name, evidence))
|
|
4342
|
+
end
|
|
4343
|
+
end
|
|
4344
|
+
|
|
4345
|
+
# What a collection holds at any iteration's entry, given the evidence so far: its join, plus the seed members
|
|
4346
|
+
# that join refutes. The join already covers every other seed value — a `Tuple`, `HashShape` or `Difference`
|
|
4347
|
+
# is absorbed into the rederived carrier and any other member survives beside it — but it drops a seed's
|
|
4348
|
+
# `nil` ({ContentJoin::NON_SURVIVING_CLASSES}). The first iteration's entry still holds that `nil`, and it can
|
|
4349
|
+
# outlive another collection's growth: without it `out << a.nil?; a ||= []; a << v` read `Array[false]`.
|
|
4350
|
+
# Unioning the whole seed back would re-add its literal shape as well, and dispatch over `[] | Array[2]` is
|
|
4351
|
+
# wider than over `Array[2]`, so `a[0, 1] ||= [2]` stopped converging.
|
|
4352
|
+
def content_entry_binding(seed, kind, name, evidence)
|
|
4353
|
+
join = join_content_evidence(seed, kind, name, evidence)
|
|
4354
|
+
members = seed.is_a?(Type::Union) ? seed.members : [seed]
|
|
4355
|
+
refuted = members.select do |member|
|
|
4356
|
+
ContentJoin::NON_SURVIVING_CLASSES.include?(ContentJoin.evidence_class(member))
|
|
4357
|
+
end
|
|
4358
|
+
refuted.empty? ? join : Type::Combinator.union(join, *refuted)
|
|
4359
|
+
end
|
|
4360
|
+
|
|
4361
|
+
# The nodes that open a local scope a read's `depth` counts.
|
|
4362
|
+
SCOPE_NESTING_NODES = [Prism::BlockNode, Prism::LambdaNode].freeze
|
|
4363
|
+
private_constant :SCOPE_NESTING_NODES
|
|
4364
|
+
|
|
4365
|
+
# The block's captured content mutations: `{ name => [node, ...] }` for every content mutator whose receiver is
|
|
4366
|
+
# a local from OUTSIDE the block. A read's `depth` counts the scopes it climbs, so it reaches past the seam's
|
|
4367
|
+
# block only when it climbs more scopes than the blocks and lambdas nested between it and the block's body.
|
|
4368
|
+
# `collect_content_mutations` tests `depth >= 1`, which is that rule only directly in the body: it took a block
|
|
4369
|
+
# PARAMETER mutated inside a nested block (`|y| [9].each { y << 9 }`), or a nested block's own parameter one
|
|
4370
|
+
# level deeper, for the outer local it shadows, and the join rebound the parameter to that local's contents.
|
|
4371
|
+
#
|
|
4372
|
+
# Each site nested in an inner block or lambda is recorded in `shadows` with the names those blocks bind
|
|
4373
|
+
# themselves (see {#join_content_to_fixpoint}).
|
|
4374
|
+
def captured_content_mutations(block, shadows)
|
|
4375
|
+
mutations = Hash.new { |h, k| h[k] = [] }
|
|
4376
|
+
Source::NodeWalker.each_with_ancestors(block.body) do |node, ancestors|
|
|
4377
|
+
name, site = content_mutation_target(node) { |receiver| receiver.depth > scope_nesting(ancestors) }
|
|
4378
|
+
next if name.nil?
|
|
4379
|
+
|
|
4380
|
+
mutations[name] << site
|
|
4381
|
+
record_shadows(shadows, site, ancestors)
|
|
4382
|
+
end
|
|
4383
|
+
mutations
|
|
4384
|
+
end
|
|
4385
|
+
|
|
4386
|
+
def scope_nesting(ancestors)
|
|
4387
|
+
ancestors.count { |ancestor| scope_nesting_node?(ancestor) }
|
|
4388
|
+
end
|
|
4389
|
+
|
|
4390
|
+
def scope_nesting_node?(node)
|
|
4391
|
+
SCOPE_NESTING_NODES.any? { |klass| node.is_a?(klass) }
|
|
4392
|
+
end
|
|
4393
|
+
|
|
4394
|
+
def record_shadows(shadows, site, ancestors)
|
|
4395
|
+
names = ancestors.select { |a| scope_nesting_node?(a) }
|
|
4396
|
+
.flat_map { |a| CapturedLocals.introduced_locals(a).to_a }
|
|
4397
|
+
shadows[site] = names unless names.empty?
|
|
4398
|
+
end
|
|
4399
|
+
|
|
4400
|
+
# `scope` with each name a site's enclosing inner blocks bind bound to `Dynamic[top]`: the seam's scope cannot
|
|
4401
|
+
# see those bindings, and the name would otherwise resolve to the outer local it shadows.
|
|
4402
|
+
def site_evidence_scope(scope, site, shadows)
|
|
4403
|
+
names = shadows[site]
|
|
4404
|
+
return scope if names.nil?
|
|
4405
|
+
|
|
4406
|
+
names.reduce(scope) { |acc, name| acc.with_local(name, Type::Combinator.untyped) }
|
|
4407
|
+
end
|
|
4408
|
+
|
|
4409
|
+
# `base` binds every fixed name to its join; the moving names are rebound on each pass.
|
|
4410
|
+
def converge_content_evidence(sites, seeds, kinds, base, shadows)
|
|
4411
|
+
slots = kinds.flat_map { |name, kind| CONTENT_EVIDENCE_SLOTS.fetch(kind).map { |slot| [name, slot] } }
|
|
4412
|
+
BodyFixpoint.converge(
|
|
4413
|
+
names: slots,
|
|
4414
|
+
seed_bindings: slots.to_h { |slot| [slot, Type::Combinator.bot] },
|
|
4415
|
+
widen: Type::Combinator.method(:widen_value_pinned),
|
|
4416
|
+
evaluate_body: lambda do |assumption|
|
|
4417
|
+
pass_entry = kinds.reduce(base) do |acc, (name, kind)|
|
|
4418
|
+
acc.with_local(name, content_carrier_under(seeds[name], kind, name, assumption))
|
|
4419
|
+
end
|
|
4420
|
+
content_evidence(sites, kinds, pass_entry, shadows)
|
|
4421
|
+
end
|
|
4422
|
+
)
|
|
4423
|
+
end
|
|
4424
|
+
|
|
4425
|
+
# The binding a fixpoint pass reads a moving collection at: its seed until any evidence exists, then — as for a
|
|
4426
|
+
# fixed name — {#content_entry_binding} over the evidence so far.
|
|
4427
|
+
def content_carrier_under(seed, kind, name, evidence)
|
|
4428
|
+
no_evidence = CONTENT_EVIDENCE_SLOTS.fetch(kind).all? { |slot| present_evidence(evidence[[name, slot]]).empty? }
|
|
4429
|
+
no_evidence ? seed : content_entry_binding(seed, kind, name, evidence)
|
|
4430
|
+
end
|
|
4431
|
+
|
|
4432
|
+
# `{ [name, slot] => union }` for every collection name, typed in `evidence_scope`; a slot no store contributes
|
|
4433
|
+
# to reads `bot`.
|
|
4434
|
+
def content_evidence(sites, kinds, evidence_scope, shadows)
|
|
4435
|
+
kinds.each_with_object({}) do |(name, kind), evidence|
|
|
4436
|
+
case kind
|
|
4437
|
+
when :hash
|
|
4438
|
+
record_pair_evidence(evidence, name, hash_pair_evidence(sites[name], evidence_scope, shadows))
|
|
4439
|
+
when :array
|
|
4440
|
+
evidence[[name, :element]] =
|
|
4441
|
+
Type::Combinator.union(*array_element_evidence(sites[name], evidence_scope, shadows).compact)
|
|
4442
|
+
when :mixed
|
|
4443
|
+
pairs, elements = mixed_content_evidence(sites[name], evidence_scope, shadows)
|
|
4444
|
+
record_pair_evidence(evidence, name, pairs)
|
|
4445
|
+
evidence[[name, :element]] = Type::Combinator.union(*elements.compact)
|
|
4446
|
+
end
|
|
4447
|
+
end
|
|
4448
|
+
end
|
|
4449
|
+
|
|
4450
|
+
# A collection seed with a String member (`[1] | "ab"`) joins that member as `String` and the rest as the
|
|
4451
|
+
# collection it is ({#join_string_members}); joined whole, the String member survived with its value pinned
|
|
4452
|
+
# although a String mutator in the body is what put the name here.
|
|
4453
|
+
def join_content_evidence(seed, kind, name, evidence)
|
|
4454
|
+
if kind != :string && string_union?(seed)
|
|
4455
|
+
return join_string_members(seed) { |others| join_content_evidence(others, kind, name, evidence) }
|
|
4456
|
+
end
|
|
4457
|
+
|
|
4458
|
+
case kind
|
|
4459
|
+
when :string
|
|
4460
|
+
Type::Combinator.nominal_of("String")
|
|
4461
|
+
when :hash
|
|
4462
|
+
ContentJoin.join_hash_content(seed, joined_pair_evidence(name, evidence))
|
|
4463
|
+
when :mixed
|
|
4464
|
+
ContentJoin.join_mixed_content(
|
|
4465
|
+
seed, joined_pair_evidence(name, evidence), present_evidence(evidence[[name, :element]])
|
|
4466
|
+
)
|
|
4467
|
+
else
|
|
4468
|
+
ContentJoin.join_array_content(seed, present_evidence(evidence[[name, :element]]))
|
|
4469
|
+
end
|
|
4470
|
+
end
|
|
4471
|
+
|
|
4472
|
+
def record_pair_evidence(evidence, name, pairs)
|
|
4473
|
+
evidence[[name, :key]] = Type::Combinator.union(*pairs.map(&:first).compact)
|
|
4474
|
+
evidence[[name, :value]] = Type::Combinator.union(*pairs.map(&:last).compact)
|
|
4475
|
+
end
|
|
4476
|
+
|
|
4477
|
+
# The `[pairs, elements]` the `calls` on a mixed `Array | Hash` seed store, typed in `entry_scope`. The seam
|
|
4478
|
+
# cannot tell which member a store reached, so an index store (`[]=` or an index write) is routed by its index.
|
|
4479
|
+
# One no Array accepts — a Symbol, String, `nil` or boolean key, where `[1][:k] = v` raises `TypeError` — is the
|
|
4480
|
+
# Hash side's alone. One that could reach either member floors BOTH sides to `Dynamic[top]`: read precisely, its
|
|
4481
|
+
# value lands on the side it never reached, and a hand-written `-> Array[Integer] | Hash[Symbol, String]`
|
|
4482
|
+
# rejects the `Array["t" | Integer]` a guarded `x[:b] = "t" if x.is_a?(Hash)` made of the Array member. Every
|
|
4483
|
+
# other adder belongs to one class, and each side reads it as its single-class join does.
|
|
4484
|
+
def mixed_content_evidence(calls, entry_scope, shadows = NO_SHADOWS)
|
|
4485
|
+
index_stores, adders = calls.partition { |c| index_write?(c) || (c.is_a?(Prism::CallNode) && c.name == :[]=) }
|
|
4486
|
+
hash_only, either = index_stores.partition do |site|
|
|
4487
|
+
array_index_excluded?(site, site_evidence_scope(entry_scope, site, shadows))
|
|
2545
4488
|
end
|
|
4489
|
+
pairs = hash_pair_evidence(adders + hash_only, entry_scope, shadows)
|
|
4490
|
+
elements = array_element_evidence(adders, entry_scope, shadows)
|
|
4491
|
+
return [pairs, elements] if either.empty?
|
|
4492
|
+
|
|
4493
|
+
untyped = Type::Combinator.untyped
|
|
4494
|
+
[pairs + [[untyped, untyped]], elements + [untyped]]
|
|
4495
|
+
end
|
|
4496
|
+
|
|
4497
|
+
# The classes an Array index never converts from: none defines `to_int`, and none is a Range.
|
|
4498
|
+
NON_ARRAY_INDEX_CLASSES = %w[Symbol String NilClass TrueClass FalseClass].to_set.freeze
|
|
4499
|
+
private_constant :NON_ARRAY_INDEX_CLASSES
|
|
4500
|
+
|
|
4501
|
+
# True when one of the index store `site`'s index arguments provably holds no value an Array accepts as an index.
|
|
4502
|
+
# A splat, and a type with any member of another or unknown class, may hold one.
|
|
4503
|
+
def array_index_excluded?(site, scope)
|
|
4504
|
+
arguments = site.arguments
|
|
4505
|
+
list = arguments.is_a?(Prism::ArgumentsNode) ? arguments.arguments : []
|
|
4506
|
+
list = list.take(list.size - 1) if site.is_a?(Prism::CallNode)
|
|
4507
|
+
list.any? do |arg|
|
|
4508
|
+
next false if arg.is_a?(Prism::SplatNode)
|
|
4509
|
+
|
|
4510
|
+
ContentJoin.union_members(scope.type_of(arg, tracer: tracer)).all? do |member|
|
|
4511
|
+
NON_ARRAY_INDEX_CLASSES.include?(ContentJoin.evidence_class(member))
|
|
4512
|
+
end
|
|
4513
|
+
end
|
|
4514
|
+
rescue StandardError
|
|
4515
|
+
false
|
|
4516
|
+
end
|
|
4517
|
+
|
|
4518
|
+
# The Hash side's evidence as the one `[key, value]` pair its slots join to, or none.
|
|
4519
|
+
def joined_pair_evidence(name, evidence)
|
|
4520
|
+
key = present_evidence(evidence[[name, :key]]).first
|
|
4521
|
+
value = present_evidence(evidence[[name, :value]]).first
|
|
4522
|
+
key.nil? && value.nil? ? [] : [[key, value]]
|
|
4523
|
+
end
|
|
4524
|
+
|
|
4525
|
+
def present_evidence(type)
|
|
4526
|
+
type.nil? || type.is_a?(Type::Bot) ? [] : [type]
|
|
4527
|
+
end
|
|
4528
|
+
|
|
4529
|
+
def store_arguments_read?(node, names)
|
|
4530
|
+
arguments = node.arguments
|
|
4531
|
+
return false if arguments.nil?
|
|
4532
|
+
|
|
4533
|
+
Source::NodeWalker.each(arguments).any? { |n| n.is_a?(Prism::LocalVariableReadNode) && names.include?(n.name) }
|
|
2546
4534
|
end
|
|
2547
4535
|
|
|
2548
4536
|
# ADR-56 slice C (B3). For `recv.each_with_object(memo) { |x, acc| … }` the return is the memo object after the
|
|
@@ -2567,15 +4555,51 @@ module Rigor
|
|
|
2567
4555
|
|
|
2568
4556
|
# The memo alias is a block-local (depth 0) — collect content mutations on it directly rather than via the
|
|
2569
4557
|
# captured-local walk.
|
|
2570
|
-
|
|
4558
|
+
shadows = {}.compare_by_identity
|
|
4559
|
+
calls = body_content_mutations_on(body, memo_param, shadows)
|
|
2571
4560
|
return call_type if calls.empty?
|
|
2572
4561
|
|
|
2573
|
-
pre_state = scope.type_of(memo_arg, tracer: tracer)
|
|
2574
|
-
|
|
2575
|
-
|
|
4562
|
+
pre_state = lookup_mutated_seed(body, memo_param, scope.type_of(memo_arg, tracer: tracer)) do |depth, nesting|
|
|
4563
|
+
depth == nesting
|
|
4564
|
+
end
|
|
4565
|
+
joined = join_memo_content(call_node, memo_param, calls, pre_state, shadows)
|
|
2576
4566
|
joined || call_type
|
|
2577
4567
|
end
|
|
2578
4568
|
|
|
4569
|
+
# `seed` as the {HashLookupMutation} calls `body` makes on `name` leave it. They add no content, so the join
|
|
4570
|
+
# never sees them as sites, and a seed read before `widen_after_block` is still the closed shape whose known
|
|
4571
|
+
# values answer every missing key: `b = { a: 1 }; [1].each { b.default = 0; b[:c] = 2 }` read `b[:zz]` as
|
|
4572
|
+
# `1 | 2`, and so did an `each_with_object({})` memo given a default beside its stores, and a `while` body. The
|
|
4573
|
+
# block receives a read's `depth` and its enclosing block count, and says whether the read is the variable
|
|
4574
|
+
# `seed` describes. A `def` opens a scope of its own, so nothing under one is.
|
|
4575
|
+
def lookup_mutated_seed(body, name, seed)
|
|
4576
|
+
Source::NodeWalker.each_with_ancestors(body) do |node, ancestors|
|
|
4577
|
+
next unless node.is_a?(Prism::CallNode) && HashLookupMutation::MUTATORS.include?(node.name)
|
|
4578
|
+
next if ancestors.any?(Prism::DefNode)
|
|
4579
|
+
|
|
4580
|
+
receiver = node.receiver
|
|
4581
|
+
next unless receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
|
|
4582
|
+
next unless yield(receiver.depth, scope_nesting(ancestors))
|
|
4583
|
+
|
|
4584
|
+
seed = MutationWidening.widen_for_mutator(seed, node.name) || seed
|
|
4585
|
+
end
|
|
4586
|
+
seed
|
|
4587
|
+
end
|
|
4588
|
+
|
|
4589
|
+
# The memo's joined carrier. The captured collections the block content-mutates join alongside it, and only the
|
|
4590
|
+
# memo's carrier is kept: a memo store reading one of them (`buf << w; m << buf.length`) must see it as it
|
|
4591
|
+
# stands at any iteration's entry, not at its pre-call contents. Their own continuation is the block seam's to
|
|
4592
|
+
# write.
|
|
4593
|
+
def join_memo_content(call_node, memo_param, calls, pre_state, shadows)
|
|
4594
|
+
block = call_node.block
|
|
4595
|
+
captured = captured_content_mutations(block, shadows)
|
|
4596
|
+
seeds = captured.keys.to_h { |name| [name, scope.local(name)] }
|
|
4597
|
+
seeds[memo_param] = pre_state
|
|
4598
|
+
sites = captured.merge(memo_param => calls)
|
|
4599
|
+
shadow_rebound_reads(block, sites, seeds, shadows)
|
|
4600
|
+
join_content_to_fixpoint(sites, seeds, build_block_entry_scope(call_node, block), shadows)[memo_param]
|
|
4601
|
+
end
|
|
4602
|
+
|
|
2579
4603
|
# The name of the memo block parameter (the SECOND positional param of an `each_with_object` block), or nil when
|
|
2580
4604
|
# the block does not bind a second positional param.
|
|
2581
4605
|
def each_with_object_memo_param(block)
|
|
@@ -2592,18 +4616,21 @@ module Rigor
|
|
|
2592
4616
|
second.respond_to?(:name) ? second.name : nil
|
|
2593
4617
|
end
|
|
2594
4618
|
|
|
2595
|
-
# Content-mutator calls on
|
|
2596
|
-
|
|
4619
|
+
# Content-mutator calls on the block-local `var_name` within `body` — directly, or from a nested block that
|
|
4620
|
+
# reaches it by exactly the scopes it is nested in. A nested block's own parameter of the same name is a
|
|
4621
|
+
# different variable and does not count.
|
|
4622
|
+
def body_content_mutations_on(body, var_name, shadows)
|
|
2597
4623
|
calls = []
|
|
2598
|
-
Source::NodeWalker.
|
|
4624
|
+
Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
|
|
2599
4625
|
next unless descendant.is_a?(Prism::CallNode)
|
|
2600
|
-
next unless
|
|
4626
|
+
next unless CONTENT_MUTATORS.include?(descendant.name)
|
|
2601
4627
|
|
|
2602
4628
|
receiver = descendant.receiver
|
|
2603
4629
|
next unless receiver.is_a?(Prism::LocalVariableReadNode)
|
|
2604
|
-
next unless receiver.name == var_name
|
|
4630
|
+
next unless receiver.name == var_name && receiver.depth == scope_nesting(ancestors)
|
|
2605
4631
|
|
|
2606
4632
|
calls << descendant
|
|
4633
|
+
record_shadows(shadows, descendant, ancestors)
|
|
2607
4634
|
end
|
|
2608
4635
|
calls
|
|
2609
4636
|
end
|
|
@@ -2616,11 +4643,14 @@ module Rigor
|
|
|
2616
4643
|
# Dynamic out: a shapeless pre-state falls through to `join_array_param`, which declines it.
|
|
2617
4644
|
def join_content_for_param(calls, pre_state, block_entry)
|
|
2618
4645
|
return nil if pre_state.nil?
|
|
4646
|
+
return join_string_union(calls, pre_state, block_entry) if string_union?(pre_state)
|
|
2619
4647
|
|
|
2620
4648
|
if stringish?(pre_state)
|
|
2621
4649
|
# String carries no element parameter; mutating `<<`/`concat` makes the constant value unsound (`s = "a"; s <<
|
|
2622
4650
|
# x` → runtime `"a…"`), so widen to the nominal base. Sound — only widens.
|
|
2623
4651
|
Type::Combinator.nominal_of("String")
|
|
4652
|
+
elsif content_kind(pre_state) == :mixed
|
|
4653
|
+
ContentJoin.join_mixed_content(pre_state, *mixed_content_evidence(calls, block_entry))
|
|
2624
4654
|
elsif hashish?(pre_state)
|
|
2625
4655
|
join_hash_param(calls, pre_state, block_entry)
|
|
2626
4656
|
else
|
|
@@ -2628,8 +4658,35 @@ module Rigor
|
|
|
2628
4658
|
end
|
|
2629
4659
|
end
|
|
2630
4660
|
|
|
4661
|
+
# A union with a String member (`Array | String`, `String?`) joins member by member: the String members widen to
|
|
4662
|
+
# `String`, which has no element evidence to join, and the rest join as a union of their own. Joined whole, the
|
|
4663
|
+
# union reached the Array or Hash join, which dropped the String member and read a String mutator's arguments as
|
|
4664
|
+
# elements — `x.force_encoding(e)` on an `Array | String` capture typed it `Array[1 | Encoding]`.
|
|
4665
|
+
def join_string_union(calls, union, block_entry)
|
|
4666
|
+
join_string_members(union) { |others| join_content_for_param(calls, others, block_entry) }
|
|
4667
|
+
end
|
|
4668
|
+
|
|
4669
|
+
# `String` for the String members of `union`, beside what the block answers for the rest (as a union of their
|
|
4670
|
+
# own), or the rest unchanged when the block answers nil.
|
|
4671
|
+
def join_string_members(union)
|
|
4672
|
+
rest = union.members.reject { |member| string_member?(member) }
|
|
4673
|
+
string = Type::Combinator.nominal_of("String")
|
|
4674
|
+
return string if rest.empty?
|
|
4675
|
+
|
|
4676
|
+
others = Type::Combinator.union(*rest)
|
|
4677
|
+
Type::Combinator.union(string, yield(others) || others)
|
|
4678
|
+
end
|
|
4679
|
+
|
|
4680
|
+
def string_union?(type)
|
|
4681
|
+
type.is_a?(Type::Union) && type.members.any? { |member| string_member?(member) }
|
|
4682
|
+
end
|
|
4683
|
+
|
|
4684
|
+
def string_member?(type)
|
|
4685
|
+
UnknownStoreWidening.carrier_class(type) == "String"
|
|
4686
|
+
end
|
|
4687
|
+
|
|
2631
4688
|
def join_hash_param(calls, pre_state, block_entry)
|
|
2632
|
-
pairs = calls
|
|
4689
|
+
pairs = hash_pair_evidence(calls, block_entry)
|
|
2633
4690
|
return nil if pairs.empty? && !hashish?(pre_state)
|
|
2634
4691
|
|
|
2635
4692
|
ContentJoin.join_hash_content(pre_state, pairs)
|
|
@@ -2638,14 +4695,49 @@ module Rigor
|
|
|
2638
4695
|
def join_array_param(calls, pre_state, block_entry)
|
|
2639
4696
|
return nil unless arrayish?(pre_state)
|
|
2640
4697
|
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
4698
|
+
ContentJoin.join_array_content(pre_state, array_element_evidence(calls, block_entry))
|
|
4699
|
+
end
|
|
4700
|
+
|
|
4701
|
+
# No site sits under an inner block that shadows a name — the loop seam's answer, and the default.
|
|
4702
|
+
NO_SHADOWS = {}.freeze
|
|
4703
|
+
private_constant :NO_SHADOWS
|
|
4704
|
+
|
|
4705
|
+
# The `[key, value]` pairs `calls` store into a Hash, typed in `block_entry`.
|
|
4706
|
+
def hash_pair_evidence(calls, block_entry, shadows = NO_SHADOWS)
|
|
4707
|
+
calls.flat_map { |c| hash_pair_types(c, site_evidence_scope(block_entry, c, shadows)) }
|
|
4708
|
+
end
|
|
4709
|
+
|
|
4710
|
+
# The elements `calls` add to an Array, typed in `block_entry`.
|
|
4711
|
+
def array_element_evidence(calls, entry_scope, shadows = NO_SHADOWS)
|
|
4712
|
+
calls.flat_map do |c|
|
|
4713
|
+
block_entry = site_evidence_scope(entry_scope, c, shadows)
|
|
4714
|
+
# An index-write in the block (`a[i] += v`, `a[i] ||= v`, an index target) stores
|
|
4715
|
+
# through `[]=` the same way — emit its index arguments ahead of the node's own stored
|
|
4716
|
+
# type so the join classifies the same splice / element forms the straight-line path
|
|
4717
|
+
# does (issue #1140).
|
|
4718
|
+
next ContentJoin.array_added_elements(:[]=, index_write_block_arg_types(c, block_entry)) if index_write?(c)
|
|
2645
4719
|
|
|
2646
4720
|
ContentJoin.array_added_elements(c.name, content_arg_types(c, block_entry))
|
|
2647
4721
|
end
|
|
2648
|
-
|
|
4722
|
+
end
|
|
4723
|
+
|
|
4724
|
+
# `[index_type..., stored_value_type]` for an index-write node inside a block, typed in the
|
|
4725
|
+
# block-entry scope — the stored value is what the write stores through `[]=`, which for a
|
|
4726
|
+
# compound write is the dispatched compound result (`a[i] += v` stores `a[i] + v`, the same
|
|
4727
|
+
# compound result the node itself types as); an index target (a multi-assign slot, a `for`
|
|
4728
|
+
# index, a rescue reference) stays untyped.
|
|
4729
|
+
# `[]` when any type cannot be read, which reproduces the pre-join no-evidence answer.
|
|
4730
|
+
def index_write_block_arg_types(node, block_entry)
|
|
4731
|
+
args = node.arguments
|
|
4732
|
+
return [] if args.nil?
|
|
4733
|
+
|
|
4734
|
+
stored = index_write_stored_type(node, block_entry)
|
|
4735
|
+
return [] if stored.nil?
|
|
4736
|
+
|
|
4737
|
+
list = args.respond_to?(:arguments) ? args.arguments : args
|
|
4738
|
+
list.map { |a| a.is_a?(Prism::SplatNode) ? nil : block_entry.type_of(a, tracer: tracer) } + [stored]
|
|
4739
|
+
rescue StandardError
|
|
4740
|
+
[]
|
|
2649
4741
|
end
|
|
2650
4742
|
|
|
2651
4743
|
# Walks the block body for content-mutator calls (`<<`, `push`, `[]=`, …) whose receiver is a captured outer local
|
|
@@ -2660,17 +4752,24 @@ module Rigor
|
|
|
2660
4752
|
mutations
|
|
2661
4753
|
end
|
|
2662
4754
|
|
|
2663
|
-
# Index-write forms (`h[k] ||= v`, `h[k] += v`, `h[k] = v`
|
|
4755
|
+
# Index-write forms (`h[k] ||= v`, `h[k] += v`, and an index target's `h[k] = v` — a multi-assign slot, a `for`
|
|
4756
|
+
# index, a rescue reference) that mutate a collection's
|
|
2664
4757
|
# CONTENT without a `[]=` CallNode. `h[k] ||= []; h[k] << v` mutates `h` through the OrWrite even though the
|
|
2665
4758
|
# appended values land on the nested array — leaving `h` an empty `{}` is unsound (`h.empty?` folds to `true`).
|
|
2666
|
-
INDEX_WRITE_NODES =
|
|
2667
|
-
Prism::IndexOrWriteNode,
|
|
2668
|
-
Prism::IndexAndWriteNode,
|
|
2669
|
-
Prism::IndexOperatorWriteNode,
|
|
2670
|
-
Prism::IndexTargetNode
|
|
2671
|
-
].freeze
|
|
4759
|
+
INDEX_WRITE_NODES = IndexWriteWidening::CONTENT_WRITE_NODE_CLASSES
|
|
2672
4760
|
private_constant :INDEX_WRITE_NODES
|
|
2673
4761
|
|
|
4762
|
+
# Every call name a content scan counts: the adders the joins read evidence from, and the String mutators no
|
|
4763
|
+
# Array or Hash table lists. A String carries no element parameter, so a join answers a String pre-state with the
|
|
4764
|
+
# bare `String` whatever the name, and a floor floors it; without them `def strip(s) = s.delete_prefix!("a")` and
|
|
4765
|
+
# an escaping `-> { s.upcase! }` left the caller's `+"ab"` pinned. A name an Array or Hash table also lists stays
|
|
4766
|
+
# with those tables' adders: a scan cannot see the receiver's class, and the Array join reads a non-adder's
|
|
4767
|
+
# arguments as appended elements (`slice!(0)`'s index).
|
|
4768
|
+
CONTENT_MUTATORS = (ContentJoin::CONTENT_ADDERS |
|
|
4769
|
+
(StringMutation::MUTATORS - MutationWidening::ARRAY_MUTATORS -
|
|
4770
|
+
MutationWidening::HASH_MUTATORS)).freeze
|
|
4771
|
+
private_constant :CONTENT_MUTATORS
|
|
4772
|
+
|
|
2674
4773
|
# The shared "not a content mutation" answer. This predicate runs on every node of every block, loop and
|
|
2675
4774
|
# method body it censuses (~950k calls on the lib self-check) and almost always declines, so a fresh
|
|
2676
4775
|
# `[nil, nil]` per decline was one of the largest allocation sites in the evaluator.
|
|
@@ -2681,7 +4780,7 @@ module Rigor
|
|
|
2681
4780
|
# (depth predicate), else the frozen `[nil, nil]`. Covers `[]=`-style CallNode mutators and the index-write node
|
|
2682
4781
|
# forms.
|
|
2683
4782
|
def content_mutation_target(node)
|
|
2684
|
-
is_call_mutator = node.is_a?(Prism::CallNode) &&
|
|
4783
|
+
is_call_mutator = node.is_a?(Prism::CallNode) && CONTENT_MUTATORS.include?(node.name)
|
|
2685
4784
|
return NO_CONTENT_MUTATION unless is_call_mutator || index_write?(node)
|
|
2686
4785
|
|
|
2687
4786
|
receiver = node.receiver
|
|
@@ -2691,13 +4790,6 @@ module Rigor
|
|
|
2691
4790
|
[receiver.name, node]
|
|
2692
4791
|
end
|
|
2693
4792
|
|
|
2694
|
-
# Computes the joined continuation collection type for one captured local from its content-mutator calls. Returns
|
|
2695
|
-
# `nil` (no overlay) when the pre-state is neither an Array-ish nor a Hash-ish binding — e.g. a String
|
|
2696
|
-
# accumulator, whose `<<` carries no element parameter and whose binding already types as `String`.
|
|
2697
|
-
def join_content_for_local(name, calls, post_scope, block_entry)
|
|
2698
|
-
join_content_for_param(calls, post_scope.local(name), block_entry)
|
|
2699
|
-
end
|
|
2700
|
-
|
|
2701
4793
|
def index_write?(node)
|
|
2702
4794
|
INDEX_WRITE_NODES.any? { |k| node.is_a?(k) }
|
|
2703
4795
|
end
|
|
@@ -2742,10 +4834,16 @@ module Rigor
|
|
|
2742
4834
|
return [[key, Type::Combinator.untyped]]
|
|
2743
4835
|
end
|
|
2744
4836
|
|
|
4837
|
+
# Only a Hash adder stores a pair; a String mutator a content scan counted (`x.sub!("a", "b")` on a
|
|
4838
|
+
# `Hash | String` capture) stores none.
|
|
4839
|
+
return [] unless ContentJoin::HASH_CONTENT_ADDERS.include?(node.name)
|
|
4840
|
+
|
|
2745
4841
|
args = content_arg_types(node, block_entry)
|
|
2746
4842
|
return [] if args.size < 2
|
|
2747
4843
|
|
|
2748
|
-
|
|
4844
|
+
# A splat index marker (`nil`) means unknown arity only to the Array classifier;
|
|
4845
|
+
# read as a key it is an unknown value — degrade to untyped (issue #1140).
|
|
4846
|
+
[[args.first || Type::Combinator.untyped, args.last]]
|
|
2749
4847
|
end
|
|
2750
4848
|
|
|
2751
4849
|
# Type of the index expression of an index-write node (`h[k] ||= v`).
|
|
@@ -2762,33 +4860,198 @@ module Rigor
|
|
|
2762
4860
|
# Argument types for a content-mutator call, typed against the block-entry scope (block params bound). A
|
|
2763
4861
|
# sub-evaluator over `block_entry` keeps the argument typing flow-correct for params / `;`-locals without leaking
|
|
2764
4862
|
# into the outer scope.
|
|
2765
|
-
def content_arg_types(call_node, block_entry)
|
|
4863
|
+
def content_arg_types(call_node, block_entry, operand_types = nil)
|
|
2766
4864
|
arguments = call_node.arguments
|
|
2767
4865
|
return [] if arguments.nil?
|
|
2768
4866
|
|
|
2769
|
-
arguments.arguments
|
|
4867
|
+
list = arguments.arguments
|
|
4868
|
+
list.map.with_index do |arg, i|
|
|
4869
|
+
# For `[]=` a splat in an index position leaves the store's arity open — it is
|
|
4870
|
+
# marked `nil` for {ContentJoin.array_added_elements}, which counts it as
|
|
4871
|
+
# arity-unknown rather than as the untyped index it would type as (issue #1140).
|
|
4872
|
+
next nil if call_node.name == :[]= && i < list.size - 1 && arg.is_a?(Prism::SplatNode)
|
|
4873
|
+
|
|
4874
|
+
OperandWalk.type_of(block_entry, arg, tracer, operand_types)
|
|
4875
|
+
end
|
|
2770
4876
|
rescue StandardError
|
|
2771
4877
|
[]
|
|
2772
4878
|
end
|
|
2773
4879
|
|
|
2774
|
-
# Evaluates `block`'s body once with each written outer local bound to the supplied `bindings` (block
|
|
2775
|
-
# `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the
|
|
2776
|
-
# body-evaluator.
|
|
4880
|
+
# Evaluates `block`'s body once with each written outer local or ivar bound to the supplied `bindings` (block
|
|
4881
|
+
# params / `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the
|
|
4882
|
+
# `BodyFixpoint` body-evaluator.
|
|
2777
4883
|
def block_exit_bindings(call_node, block, bindings, names)
|
|
4884
|
+
_type, exit_scope = sub_eval(block, block_pass_entry(call_node, block, bindings))
|
|
4885
|
+
names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
|
|
4886
|
+
end
|
|
4887
|
+
|
|
4888
|
+
# The entry scope of one write-back pass: the block's entry with each written outer local or ivar bound to
|
|
4889
|
+
# `bindings`, and each captured local the body only mutates in place at its widening ({#capture_pass_bindings}).
|
|
4890
|
+
# That widening describes the same object, so it keeps the marks a source-level write drops (issue #1287,
|
|
4891
|
+
# `Scope#with_mutated_local`): a local copied from a declaration-seeded ivar stays exempt from nil-receiver
|
|
4892
|
+
# reports, as it does after straight-line `r << x`.
|
|
4893
|
+
def block_pass_entry(call_node, block, bindings)
|
|
2778
4894
|
entry = build_block_entry_scope(call_node, block)
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
4895
|
+
capture_pass_bindings(block, bindings).reduce(entry) do |acc, (name, type)|
|
|
4896
|
+
bindings.key?(name) ? bind_capture(acc, name, type) : acc.with_mutated_local(name, type)
|
|
4897
|
+
end
|
|
4898
|
+
end
|
|
4899
|
+
|
|
4900
|
+
NO_CAPTURE_BINDINGS = {}.freeze
|
|
4901
|
+
private_constant :NO_CAPTURE_BINDINGS
|
|
4902
|
+
|
|
4903
|
+
# Issue #1412 — the entry of the statement pass over a block ({#evaluate_block_if_present}). A block its call
|
|
4904
|
+
# may run more than once ({BlockRepetition.may_repeat?}, the gate the block-return pass lays the #587 (b)
|
|
4905
|
+
# binding under) enters with every captured local it mutates in place at its unknown-store widening
|
|
4906
|
+
# ({#capture_pass_bindings}), as each ADR-56 write-back pass enters. The write-back runs only for a
|
|
4907
|
+
# non-escaping body that also REBINDS a capture, so a body that only mutates one (`depth = []; lines.each {
|
|
4908
|
+
# |tl| puts depth.last.length if depth.last; depth << tl }`) was typed from `[]` on every pass: `depth.last`
|
|
4909
|
+
# read `nil`, and the guarded read reported `undefined method` for nil on correct code. A later pass reads
|
|
4910
|
+
# what an earlier one stored, so the widening's gradual arm is the honest entry. The price is that a read
|
|
4911
|
+
# only the first pass makes is gradual too — a false negative, the same one the write-back's passes take.
|
|
4912
|
+
#
|
|
4913
|
+
# A repeating call the escape analysis leaves `:unknown` (an iterator name on an untyped receiver) gets no
|
|
4914
|
+
# write-back at all, so a capture it rebinds from a `nil` placeholder read `nil` on every pass (`names = nil;
|
|
4915
|
+
# io.each_line { … names = {} … names[l] = true }`). Such a capture enters at {#unproven_rebind_bindings}.
|
|
4916
|
+
#
|
|
4917
|
+
# This costs no body pass. A body that can neither rebind nor mutate a captured binding
|
|
4918
|
+
# ({CapturedLocals.may_touch_capture?}, an allocation-free scan) — most of them — skips the gate, and the
|
|
4919
|
+
# gate reads the one receiver type and escape class the rest of {#eval_call} reads.
|
|
4920
|
+
def repeating_block_entry(call_node, block)
|
|
4921
|
+
body = block.body
|
|
4922
|
+
return build_block_entry_scope(call_node, block) if body.nil? || !CapturedLocals.may_touch_capture?(body, scope)
|
|
4923
|
+
|
|
4924
|
+
classification = repeating_block_class(call_node)
|
|
4925
|
+
return build_block_entry_scope(call_node, block) if classification.nil?
|
|
4926
|
+
|
|
4927
|
+
bindings = unproven_rebind_bindings(call_node, block, classification)
|
|
4928
|
+
return build_block_entry_scope(call_node, block) if bindings.empty? && block_content_mutations(block).empty?
|
|
4929
|
+
|
|
4930
|
+
block_pass_entry(call_node, block, bindings)
|
|
4931
|
+
end
|
|
4932
|
+
|
|
4933
|
+
# The call's {ClosureEscapeAnalyzer} class when {BlockRepetition.may_repeat?} holds for it, else nil. The
|
|
4934
|
+
# receiver is the one the block-return pass asks about: the explicit receiver, or the implicit `self`
|
|
4935
|
+
# (`Object` at the top level).
|
|
4936
|
+
def repeating_block_class(call_node)
|
|
4937
|
+
receiver_type =
|
|
4938
|
+
if call_node.receiver
|
|
4939
|
+
explicit_receiver_type(call_node)
|
|
4940
|
+
else
|
|
4941
|
+
operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
|
|
4942
|
+
end
|
|
4943
|
+
return nil if receiver_type.nil?
|
|
4944
|
+
|
|
4945
|
+
method_name = call_node.name
|
|
4946
|
+
classification = ClosureEscapeAnalyzer.classify(receiver_type: receiver_type, method_name: method_name,
|
|
4947
|
+
scope: scope)
|
|
4948
|
+
repeats = BlockRepetition.may_repeat?(
|
|
4949
|
+
method_name: method_name, receiver_type: receiver_type, scope: scope, classification: classification
|
|
4950
|
+
)
|
|
4951
|
+
repeats ? classification : nil
|
|
4952
|
+
rescue StandardError
|
|
4953
|
+
nil
|
|
4954
|
+
end
|
|
4955
|
+
|
|
4956
|
+
# The binding each outer local or ivar the body rebinds ({CapturedLocals.writes}) from a sentinel seed — `nil` or
|
|
4957
|
+
# `false`, alone or as a union of the two — enters a repeating call's body with when the write-back will not run
|
|
4958
|
+
# its passes for it ({#write_back_block_captures} reaches only an explicit receiver classified
|
|
4959
|
+
# `:non_escaping`): the seed joined with `Dynamic[top]`. A sentinel is a placeholder the body replaces before
|
|
4960
|
+
# the reads it guards (`names = nil; io.each_line { |l| if state == :start; names = {}; else names[l] = true;
|
|
4961
|
+
# end }`), so a later pass reads whatever replaced it, and no pass types that here. Any other seed keeps its
|
|
4962
|
+
# call-site binding: the first pass reads it, and a later pass's binding is left unmodelled, as it was before.
|
|
4963
|
+
# A call the write-back reaches answers none: its fixpoint enters every pass it records at the converged
|
|
4964
|
+
# binding.
|
|
4965
|
+
def unproven_rebind_bindings(call_node, block, classification)
|
|
4966
|
+
return NO_CAPTURE_BINDINGS if call_node.receiver && classification == :non_escaping
|
|
4967
|
+
|
|
4968
|
+
names = CapturedLocals.writes(block, scope, ivars: true)
|
|
4969
|
+
return NO_CAPTURE_BINDINGS if names.empty?
|
|
4970
|
+
|
|
4971
|
+
untyped = Type::Combinator.untyped
|
|
4972
|
+
names.each_with_object({}) do |name, acc|
|
|
4973
|
+
seed = CapturedLocals.bound_type(scope, name)
|
|
4974
|
+
acc[name] = Type::Combinator.union(seed, untyped) if seed && sentinel_seed?(seed)
|
|
4975
|
+
end
|
|
4976
|
+
end
|
|
4977
|
+
|
|
4978
|
+
def sentinel_seed?(type)
|
|
4979
|
+
case type
|
|
4980
|
+
when Type::Constant then type.value.nil? || type.value == false
|
|
4981
|
+
when Type::Union then type.members.all? { |member| sentinel_seed?(member) }
|
|
4982
|
+
else false
|
|
4983
|
+
end
|
|
4984
|
+
end
|
|
4985
|
+
|
|
4986
|
+
# `bindings` plus the pass binding of every captured local the body mutates in place
|
|
4987
|
+
# ({CapturedLocals.content_mutations}): its binding widened for a store of UNKNOWN values at every mutation site
|
|
4988
|
+
# ({#unknown_store_binding}), so it holds whatever any earlier iteration stored. A name the pass does not move
|
|
4989
|
+
# is widened from its call-site binding; a name it moves (the body both rebinds and mutates it) is widened over
|
|
4990
|
+
# the running assumption, as the per-element fold widens the same name (#587 (b)) — that assumption carries the
|
|
4991
|
+
# exits of the body's straight-line seam, which can close the collection without a gradual arm
|
|
4992
|
+
# (`stack ||= [0]; top = stack.pop; stack.push(x)` kept `top` at `0?`). The stored values are not typed: one
|
|
4993
|
+
# computed from the collection's own entry contents is the same first-iteration answer. `bindings` itself
|
|
4994
|
+
# comes back for the common body that mutates nothing captured.
|
|
4995
|
+
#
|
|
4996
|
+
# The price is the gradual arm on a rebind that reads such a collection — `last = a.last; a << x` over `a =
|
|
4997
|
+
# [0]` reads `0 | Dynamic[top] | nil`, not `0 | 1 | 2 | nil`. Precise evidence would mean iterating this
|
|
4998
|
+
# fixpoint jointly with slice C's content join; ADR-56 WD2.13 records why that was not taken.
|
|
4999
|
+
def capture_pass_bindings(block, bindings)
|
|
5000
|
+
stores = block_content_mutations(block)
|
|
5001
|
+
return bindings if stores.empty?
|
|
5002
|
+
|
|
5003
|
+
widened = stores.each_with_object({}) do |(name, sites), acc|
|
|
5004
|
+
next if bindings.key?(name)
|
|
5005
|
+
|
|
5006
|
+
seed = scope.local(name)
|
|
5007
|
+
acc[name] = unknown_store_binding(seed, sites) unless seed.nil?
|
|
5008
|
+
end
|
|
5009
|
+
widened.merge(bindings.to_h do |name, type|
|
|
5010
|
+
sites = stores[name]
|
|
5011
|
+
[name, sites.nil? || type.nil? ? type : unknown_store_binding(type, sites)]
|
|
5012
|
+
end)
|
|
5013
|
+
end
|
|
5014
|
+
|
|
5015
|
+
# {CapturedLocals.content_mutations} of `block` against this evaluator's scope, once per block: every write-back
|
|
5016
|
+
# pass asks, and neither input changes between them.
|
|
5017
|
+
def block_content_mutations(block)
|
|
5018
|
+
(@block_content_mutations ||= {}.compare_by_identity)[block] ||= CapturedLocals.content_mutations(block, scope)
|
|
5019
|
+
end
|
|
5020
|
+
|
|
5021
|
+
# `type` widened through `sites` for a store of unknown values, with one more step: when the result is still a
|
|
5022
|
+
# collection whose contents are value-pinned, those pins are the first-iteration answer and the contents take
|
|
5023
|
+
# the gradual arm. A widening that DECLINES leaves such a binding — `s = [0, 9]; s.pop` leaves `Array[0 | 9]`,
|
|
5024
|
+
# a nominal the `push` in the body then declines — and so does one that only changes a refinement: under `if
|
|
5025
|
+
# s.any?` the `pop` drops `non-empty-array[0 | 9]` to that same pinned `Array[0 | 9]` before the `push` declines
|
|
5026
|
+
# it. Either way `top = s.last; s.push(x)` would keep `top` at `0 | 9`. A result that already carries the arm
|
|
5027
|
+
# is unchanged by it.
|
|
5028
|
+
def unknown_store_binding(type, sites)
|
|
5029
|
+
widened = UnknownStoreWidening.widen(type, sites)
|
|
5030
|
+
return widened unless UnknownStoreWidening.value_pinned_collection?(widened)
|
|
5031
|
+
|
|
5032
|
+
UnknownStoreWidening.gradual_content(widened)
|
|
2782
5033
|
end
|
|
2783
5034
|
|
|
2784
5035
|
# `Prism::BlockNode` is reached through {#eval_call}; the handler runs the body under `scope`, which the caller
|
|
2785
5036
|
# has already augmented with the block's parameter bindings. Effects do not leak past the block (the outer
|
|
2786
5037
|
# eval_call returns the caller's scope unchanged), but the body's local writes are threaded through subsequent
|
|
2787
5038
|
# statements *inside* the block so `each { |x| sum = x; sum.succ }` types `sum.succ` under the `sum: x` binding.
|
|
5039
|
+
# The scope returned is the one the invocation ENDS with — the fall-through joined with every `next` that
|
|
5040
|
+
# leaves it ({#evaluate_invocation}).
|
|
2788
5041
|
def eval_block(node)
|
|
2789
|
-
|
|
5042
|
+
type, _fall_through, exit_scope = evaluate_invocation(node)
|
|
5043
|
+
[type, exit_scope]
|
|
5044
|
+
end
|
|
2790
5045
|
|
|
2791
|
-
|
|
5046
|
+
# `base` joined with every collected jump scope whose node is in `targets` — a jump belonging to a nested
|
|
5047
|
+
# construct lands in the same sink and is dropped by identity.
|
|
5048
|
+
def join_jump_scopes(base, sink, targets)
|
|
5049
|
+
targeted_scopes(sink, targets).reduce(base) { |acc, jump_scope| acc.join(jump_scope) }
|
|
5050
|
+
end
|
|
5051
|
+
|
|
5052
|
+
# The scopes a sink collected at the jumps in `targets`.
|
|
5053
|
+
def targeted_scopes(sink, targets)
|
|
5054
|
+
sink.filter_map { |node, jump_scope| jump_scope if targets.key?(node) }
|
|
2792
5055
|
end
|
|
2793
5056
|
|
|
2794
5057
|
# Issue #878 — `->() { }` and `lambda { }` build the same object, so they MUST type the same. The `lambda`
|
|
@@ -2803,9 +5066,13 @@ module Rigor
|
|
|
2803
5066
|
# literal is a value that outlives the expression, exactly like the Proc `lambda` returns, so the outer locals it
|
|
2804
5067
|
# can rebind lose their narrowing rather than being written back through ADR-56's non-escaping fixpoint. Both
|
|
2805
5068
|
# halves are widenings — the two spellings now agree in both directions instead of `->` being the precise one.
|
|
5069
|
+
# A lambda is a return barrier: `return` inside it returns from the lambda, so its body runs with the method's
|
|
5070
|
+
# return sink suspended, as a nested `def` body does ({#eval_def}). Issue #1223 made the barrier matter more
|
|
5071
|
+
# often: a lambda passed as an argument (`register(-> { return :skip if … })`) is now evaluated with its
|
|
5072
|
+
# enclosing call's operands, where it used to be typed as a value only.
|
|
2806
5073
|
def eval_lambda(node)
|
|
2807
5074
|
lambda_type = scope.type_of(node, tracer: tracer)
|
|
2808
|
-
sub_eval(node.body, build_block_entry_scope(nil, node)) unless node.body.nil?
|
|
5075
|
+
without_return_sink { sub_eval(node.body, build_block_entry_scope(nil, node)) } unless node.body.nil?
|
|
2809
5076
|
|
|
2810
5077
|
[lambda_type, escaping_closure_captures(node, scope)]
|
|
2811
5078
|
end
|
|
@@ -2827,17 +5094,46 @@ module Rigor
|
|
|
2827
5094
|
# when the runtime would actually `NoMethodError` on `nil`.
|
|
2828
5095
|
def build_block_entry_scope(call_node, block_node)
|
|
2829
5096
|
expected = expected_block_param_types_for(call_node)
|
|
2830
|
-
bindings = BlockParameterBinder.new(expected_param_types: expected).bind(block_node)
|
|
2831
5097
|
# Issue #316 — every block body enters with `self` unmodelled (`Scope#entering_opaque_block`); the
|
|
2832
5098
|
# yielding method, not the lexical context, decides what `self` is, and Rigor does not track it.
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
5099
|
+
# Issue #1358 — a body that may run a match reads the match globals an earlier iteration may have rebound
|
|
5100
|
+
# ({MatchRebinding.block_entry}).
|
|
5101
|
+
entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
|
|
5102
|
+
scope_with_params = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
|
|
5103
|
+
# ADR-16 Tier A — a plugin `block_as_methods:` entry that matches `(receiver, name)` narrows the
|
|
5104
|
+
# body's `self` to the object the DSL `instance_eval`s the block on (`params` on
|
|
5105
|
+
# `Grape::Validations::ParamsScope`, `namespace` on the `Grape::API::Instance` class object, verb
|
|
5106
|
+
# bodies on `Grape::Endpoint`). The expression-side narrowing
|
|
5107
|
+
# ({ExpressionTyper#block_body_self_narrowing}) already applies the same contract to block-return
|
|
5108
|
+
# typing; without it here the recorded per-node scopes — what `dump_type`/`assert_type` and the
|
|
5109
|
+
# survey read — keep the enclosing `self_type` and every DSL call inside stays `Dynamic[top]`.
|
|
5110
|
+
narrowed = call_node && narrow_macro_block_self(call_node)
|
|
5111
|
+
scope_with_params = scope_with_params.with_self_type(narrowed) if narrowed
|
|
2836
5112
|
block_local_names(block_node).reduce(scope_with_params) do |acc, name|
|
|
2837
5113
|
acc.with_local(name, Type::Combinator.constant_of(nil))
|
|
2838
5114
|
end
|
|
2839
5115
|
end
|
|
2840
5116
|
|
|
5117
|
+
# The receiver an ADR-16 `block_as_methods:` match is keyed on: the explicit receiver's type, or the
|
|
5118
|
+
# current `self_type` for an implicit-self DSL call (the `params do` / `namespace do` shapes, whose
|
|
5119
|
+
# receiver is the enclosing `Singleton[X]`). A miss leaves the entry scope as built — the false-
|
|
5120
|
+
# positive-safe direction.
|
|
5121
|
+
def narrow_macro_block_self(call_node)
|
|
5122
|
+
receiver_type =
|
|
5123
|
+
if call_node.receiver
|
|
5124
|
+
explicit_receiver_type(call_node)
|
|
5125
|
+
else
|
|
5126
|
+
scope.self_type
|
|
5127
|
+
end
|
|
5128
|
+
return nil if receiver_type.nil?
|
|
5129
|
+
|
|
5130
|
+
MacroBlockSelfType.narrow_self_type_for(
|
|
5131
|
+
scope: scope, call_node: call_node, receiver_type: receiver_type
|
|
5132
|
+
)
|
|
5133
|
+
rescue StandardError
|
|
5134
|
+
nil
|
|
5135
|
+
end
|
|
5136
|
+
|
|
2841
5137
|
def block_local_names(block_node)
|
|
2842
5138
|
params_root = block_node.parameters
|
|
2843
5139
|
return [] unless params_root.is_a?(Prism::BlockParametersNode)
|
|
@@ -2850,7 +5146,12 @@ module Rigor
|
|
|
2850
5146
|
def expected_block_param_types_for(call_node)
|
|
2851
5147
|
return [] if call_node.nil?
|
|
2852
5148
|
|
|
2853
|
-
receiver_type =
|
|
5149
|
+
receiver_type =
|
|
5150
|
+
if call_node.receiver
|
|
5151
|
+
explicit_receiver_type(call_node)
|
|
5152
|
+
else
|
|
5153
|
+
scope.self_type || scope.environment.nominal_for_name("Object")
|
|
5154
|
+
end
|
|
2854
5155
|
return [] if receiver_type.nil?
|
|
2855
5156
|
|
|
2856
5157
|
arg_types = call_arg_types_for(call_node)
|
|
@@ -2858,7 +5159,8 @@ module Rigor
|
|
|
2858
5159
|
receiver_type: receiver_type,
|
|
2859
5160
|
method_name: call_node.name,
|
|
2860
5161
|
arg_types: arg_types,
|
|
2861
|
-
environment: scope.environment
|
|
5162
|
+
environment: scope.environment,
|
|
5163
|
+
scope: scope
|
|
2862
5164
|
)
|
|
2863
5165
|
rescue StandardError
|
|
2864
5166
|
[]
|
|
@@ -2868,7 +5170,7 @@ module Rigor
|
|
|
2868
5170
|
arguments = call_node.arguments
|
|
2869
5171
|
return [] if arguments.nil?
|
|
2870
5172
|
|
|
2871
|
-
arguments.arguments.map { |arg|
|
|
5173
|
+
arguments.arguments.map { |arg| type_operand(arg) }
|
|
2872
5174
|
end
|
|
2873
5175
|
|
|
2874
5176
|
# ----- def/class helpers -----
|
|
@@ -2883,6 +5185,11 @@ module Rigor
|
|
|
2883
5185
|
fresh = build_fresh_body_scope
|
|
2884
5186
|
body_self = self_type_for_class_body(new_context)
|
|
2885
5187
|
fresh = fresh.with_self_type(body_self) if body_self
|
|
5188
|
+
# Issue #963 — `self` in a `class << ...` body is the SINGLETON class, which shares the `Singleton[X]`
|
|
5189
|
+
# carrier a `class X` body gets. The mark is the only thing that tells the two apart downstream, and a
|
|
5190
|
+
# `def` reached from this body clears it by starting from a fresh scope.
|
|
5191
|
+
fresh = fresh.with_singleton_class_body(node.is_a?(Prism::SingletonClassNode))
|
|
5192
|
+
fresh = fresh.with_match_frame(node.body)
|
|
2886
5193
|
fresh = stamp_nesting(fresh, new_nesting)
|
|
2887
5194
|
sub_eval(node.body, fresh, class_context: new_context, lexical_nesting: new_nesting)
|
|
2888
5195
|
end
|
|
@@ -2891,7 +5198,9 @@ module Rigor
|
|
|
2891
5198
|
singleton = singleton_def?(def_node)
|
|
2892
5199
|
binder = MethodParameterBinder.new(
|
|
2893
5200
|
environment: scope.environment,
|
|
2894
|
-
|
|
5201
|
+
# Issue #1120 — a refinement exists to redefine, so X's declared parameters for the name are not this
|
|
5202
|
+
# def's contract; it binds its parameters as an undeclared method does.
|
|
5203
|
+
class_path: @class_context.last&.refinement ? nil : current_class_path,
|
|
2895
5204
|
singleton: singleton,
|
|
2896
5205
|
source_path: scope.source_path
|
|
2897
5206
|
)
|
|
@@ -2917,6 +5226,9 @@ module Rigor
|
|
|
2917
5226
|
fresh = seed_instance_ivars(fresh, singleton: singleton)
|
|
2918
5227
|
fresh = seed_class_cvars(fresh)
|
|
2919
5228
|
fresh = seed_program_globals(fresh)
|
|
5229
|
+
# Issue #1358 — the body runs in a frame of its own, whose match globals its blocks and closures share, and
|
|
5230
|
+
# so do its parameters' default expressions.
|
|
5231
|
+
fresh = fresh.with_match_frame(def_node.body, def_node.parameters)
|
|
2920
5232
|
# ADR-48 Struct slice 3 — install the method body's fold-safe-local set so a member read off a mutation-free
|
|
2921
5233
|
# local folds during the in-body walk (the call-return inference path is seeded separately).
|
|
2922
5234
|
fresh = fresh.with_struct_fold_safe(
|
|
@@ -3016,14 +5328,21 @@ module Rigor
|
|
|
3016
5328
|
seeded.reduce(body_scope) { |acc, (name, type)| acc.with_cvar(name, type) }
|
|
3017
5329
|
end
|
|
3018
5330
|
|
|
3019
|
-
# Globals are process-wide. The body scope already inherited the program-
|
|
3020
|
-
#
|
|
3021
|
-
#
|
|
5331
|
+
# Globals are process-wide. The body scope already inherited the program-global tables through its discovery
|
|
5332
|
+
# index; seeding here just materialises each entry into the body's `globals` map so reads observe a precise type
|
|
5333
|
+
# without consulting the index on every lookup. The frame-local `$_` and `$~` are not in it (issue #1359): a
|
|
5334
|
+
# method body starts with a slot of its own. A global Ruby's own signatures declare is seeded with its declared
|
|
5335
|
+
# type joined with the file's writes, under the ADR-58 `:global` mark (issue #1362,
|
|
5336
|
+
# `ScopeIndexer#join_declared_globals`, `Scope#seed_declaration_sourced_global`).
|
|
3022
5337
|
def seed_program_globals(body_scope)
|
|
3023
|
-
|
|
3024
|
-
return body_scope if
|
|
5338
|
+
written = scope.program_globals
|
|
5339
|
+
return body_scope if written.empty?
|
|
3025
5340
|
|
|
3026
|
-
|
|
5341
|
+
seeds = scope.discovery.program_global_seeds
|
|
5342
|
+
written.reduce(body_scope) do |acc, (name, type)|
|
|
5343
|
+
seed = seeds[name]
|
|
5344
|
+
seed ? acc.seed_declaration_sourced_global(name, seed) : acc.with_global(name, type)
|
|
5345
|
+
end
|
|
3027
5346
|
end
|
|
3028
5347
|
|
|
3029
5348
|
# Slice A-declarations. Class- and method-bodies start from a fresh local-empty scope, but they MUST keep the
|
|
@@ -3183,21 +5502,33 @@ module Rigor
|
|
|
3183
5502
|
def eval_next(node)
|
|
3184
5503
|
sink = Thread.current[NEXT_SINK_KEY]
|
|
3185
5504
|
sink << [node, jump_value_type(node)] if sink
|
|
5505
|
+
@next_scope_sink << [node, jump_scope(node)] if @next_scope_sink
|
|
3186
5506
|
[Type::Combinator.bot, scope]
|
|
3187
5507
|
end
|
|
3188
5508
|
|
|
3189
|
-
#
|
|
3190
|
-
#
|
|
3191
|
-
#
|
|
3192
|
-
#
|
|
3193
|
-
|
|
5509
|
+
# The scope control leaves with at a `next` / `break`: the entry scope threaded through the jump's arguments, so a
|
|
5510
|
+
# write inside one (`next(n = :odd)`, `break(flag = true)`) is part of the path that leaves. The arguments are
|
|
5511
|
+
# evaluated without recording into the per-node scope index: whether a sink is collecting depends on unrelated
|
|
5512
|
+
# context (a captured write elsewhere in the block), and the index must not change with it.
|
|
5513
|
+
def jump_scope(node)
|
|
5514
|
+
args = node.arguments&.arguments || []
|
|
5515
|
+
args.reduce(scope) { |acc, arg| sub_eval(arg, acc, **UNRECORDED).last }
|
|
5516
|
+
end
|
|
5517
|
+
|
|
5518
|
+
# A `break` transfers control to the loop exit (its flow value is `Bot`, like `return`). It records the scope it
|
|
5519
|
+
# leaves with ({#jump_scope}) into the active break sink so the loop join can recover a `break`-path binding the
|
|
5520
|
+
# fall-through would drop (`flag = true; break` -> `flag` is `false | true` after the loop); ADR-56's block
|
|
5521
|
+
# write-back reads the same sink for a `break` that ends a yielding call ({#join_block_break_bindings}), and an
|
|
5522
|
+
# enclosing `begin … ensure` carries the recorded scope through its clause ({#carry_jumps_through_ensure}). nil
|
|
5523
|
+
# sink = a `break` reached outside either collection (top level, or an escaping block) — left to the existing
|
|
5524
|
+
# escaping-block / no-op handling.
|
|
3194
5525
|
#
|
|
3195
5526
|
# Issue #853: the value it carries out belongs to the yielding CALL, so it is recorded into the separate
|
|
3196
5527
|
# break-value sink for `ExpressionTyper#call_dispatch_type_for` to union in. Both sinks are optional and
|
|
3197
5528
|
# independent — a loop body collects scopes while an enclosing call collects values from the same walk.
|
|
3198
5529
|
def eval_break(node)
|
|
3199
5530
|
sink = Thread.current[BREAK_SINK_KEY]
|
|
3200
|
-
sink << [node,
|
|
5531
|
+
sink << [node, jump_scope(node)] if sink
|
|
3201
5532
|
value_sink = Thread.current[BREAK_VALUE_SINK_KEY]
|
|
3202
5533
|
value_sink << [node, jump_value_type(node)] if value_sink
|
|
3203
5534
|
[Type::Combinator.bot, scope]
|
|
@@ -3215,15 +5546,34 @@ module Rigor
|
|
|
3215
5546
|
Type::Combinator.tuple_of(*args.map { |arg| sub_eval(arg, scope).first })
|
|
3216
5547
|
end
|
|
3217
5548
|
|
|
3218
|
-
|
|
5549
|
+
# `on_enter: nil` evaluates without recording into the per-node scope index — for a pass whose scopes are not
|
|
5550
|
+
# the ones the index should keep. `next_scope_sink:` is replaced only by {#evaluate_invocation} and
|
|
5551
|
+
# {#loop_iteration}.
|
|
5552
|
+
def sub_eval(node, with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting,
|
|
5553
|
+
on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_recorder: @operand_recorder)
|
|
5554
|
+
evaluator_at(with_scope, class_context: class_context, lexical_nesting: lexical_nesting, on_enter: on_enter,
|
|
5555
|
+
next_scope_sink: next_scope_sink, operand_recorder: operand_recorder).evaluate(node)
|
|
5556
|
+
end
|
|
5557
|
+
|
|
5558
|
+
# An evaluator over `with_scope` that inherits everything else from this one. `operand_scope:` and
|
|
5559
|
+
# `operand_types:` are set only by {#invoke_from}, for the evaluator that runs a call from the scope its
|
|
5560
|
+
# operands left.
|
|
5561
|
+
def evaluator_at(with_scope, class_context: @class_context, lexical_nesting: @lexical_nesting, # rubocop:disable Metrics/ParameterLists
|
|
5562
|
+
on_enter: @on_enter, next_scope_sink: @next_scope_sink, operand_scope: nil,
|
|
5563
|
+
in_operand: @in_operand, operand_recorder: @operand_recorder, operand_types: nil)
|
|
3219
5564
|
StatementEvaluator.new(
|
|
3220
5565
|
scope: with_scope,
|
|
3221
5566
|
tracer: tracer,
|
|
3222
|
-
on_enter:
|
|
5567
|
+
on_enter: on_enter,
|
|
3223
5568
|
class_context: class_context,
|
|
3224
5569
|
lexical_nesting: lexical_nesting,
|
|
3225
|
-
converged_loop_recording: @converged_loop_recording
|
|
3226
|
-
|
|
5570
|
+
converged_loop_recording: @converged_loop_recording,
|
|
5571
|
+
next_scope_sink: next_scope_sink,
|
|
5572
|
+
operand_scope: operand_scope,
|
|
5573
|
+
in_operand: in_operand,
|
|
5574
|
+
operand_recorder: operand_recorder,
|
|
5575
|
+
operand_types: operand_types
|
|
5576
|
+
)
|
|
3227
5577
|
end
|
|
3228
5578
|
|
|
3229
5579
|
# Slice 7 phase 14 — branch exit detection. Returns true when the branch's body unconditionally exits the
|
|
@@ -3279,10 +5629,10 @@ module Rigor
|
|
|
3279
5629
|
branch_type.is_a?(Type::Bot)
|
|
3280
5630
|
end
|
|
3281
5631
|
|
|
3282
|
-
def eval_branch_or_nil(branch_node, branch_scope)
|
|
5632
|
+
def eval_branch_or_nil(branch_node, branch_scope, on_enter: @on_enter)
|
|
3283
5633
|
return [Type::Combinator.constant_of(nil), branch_scope] if branch_node.nil?
|
|
3284
5634
|
|
|
3285
|
-
sub_eval(branch_node, branch_scope)
|
|
5635
|
+
sub_eval(branch_node, branch_scope, on_enter: on_enter)
|
|
3286
5636
|
end
|
|
3287
5637
|
|
|
3288
5638
|
# Joins two branch scopes at a control-flow merge point. Names bound in only one branch are nil-injected into the
|
|
@@ -3310,17 +5660,31 @@ module Rigor
|
|
|
3310
5660
|
# ---------------------------------------------------------------
|
|
3311
5661
|
|
|
3312
5662
|
# Returns `scope` extended with the rescue reference variable bound to the exception instance type. Leaves scope
|
|
3313
|
-
# unchanged when the node carries no reference (bare `rescue` without `=> var`).
|
|
5663
|
+
# unchanged when the node carries no reference (bare `rescue` without `=> var`). An index-target reference
|
|
5664
|
+
# (`rescue => h[:e]`) stores the exception through `[]=` instead, so its receiver widens with the exception
|
|
5665
|
+
# instance type as the stored value, exactly as `rescue => e; h[:e] = e` widens it.
|
|
5666
|
+
#
|
|
5667
|
+
# Issue #1360 — with or without a reference, the clause runs with `$!` bound to the exception it rescued, `$@` to
|
|
5668
|
+
# its backtrace and `$?` unbound ({ErrorInfo.rescue_entry}); {#eval_begin_node} restores `$!` and `$@` once the
|
|
5669
|
+
# `begin` exits.
|
|
3314
5670
|
def bind_rescue_reference(rescue_node, scope)
|
|
5671
|
+
exception_type = rescue_exception_type(rescue_node, scope)
|
|
3315
5672
|
ref = rescue_node.reference
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
5673
|
+
reference = ref.name if ref.is_a?(Prism::LocalVariableTargetNode)
|
|
5674
|
+
scope = ErrorInfo.rescue_entry(scope, exception_type, rescue_node.statements, reference)
|
|
5675
|
+
case ref
|
|
5676
|
+
when Prism::LocalVariableTargetNode
|
|
5677
|
+
scope.with_local(ref.name, exception_type)
|
|
5678
|
+
when Prism::IndexTargetNode
|
|
5679
|
+
widen_index_target(ref, exception_type, scope, type_scope: scope)
|
|
5680
|
+
else
|
|
5681
|
+
scope
|
|
5682
|
+
end
|
|
3319
5683
|
end
|
|
3320
5684
|
|
|
3321
5685
|
# Derives the exception instance type for a `RescueNode`. When the exceptions list is empty (bare `rescue`) the
|
|
3322
|
-
# type is `StandardError`. When one or more exception classes are named the types are unioned.
|
|
3323
|
-
#
|
|
5686
|
+
# type is `StandardError`. When one or more exception classes are named the types are unioned. A class that
|
|
5687
|
+
# cannot be resolved to a `Singleton` type contributes `Dynamic[top]`.
|
|
3324
5688
|
def rescue_exception_type(rescue_node, scope)
|
|
3325
5689
|
exceptions = rescue_node.exceptions
|
|
3326
5690
|
if exceptions.empty?
|
|
@@ -3338,87 +5702,392 @@ module Rigor
|
|
|
3338
5702
|
# ---------------------------------------------------------------
|
|
3339
5703
|
|
|
3340
5704
|
# Builds the entry scope for an `in` branch by injecting every variable captured by the pattern as a local
|
|
3341
|
-
# binding.
|
|
3342
|
-
|
|
3343
|
-
|
|
5705
|
+
# binding. `subject_type` is the type of the `case` subject — nil when the `case` carries no predicate, which
|
|
5706
|
+
# leaves every binding at the `Dynamic[top]` floor — and `subject_node` the subject expression, which the
|
|
5707
|
+
# `deconstruct` / `deconstruct_keys` dispatch reads for its freshness gate.
|
|
5708
|
+
def apply_in_pattern_bindings(subject_type, subject_node, pattern, scope)
|
|
5709
|
+
bindings = collect_in_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
|
|
3344
5710
|
bindings.reduce(scope) { |s, (name, type)| s.with_local(name, type) }
|
|
3345
5711
|
end
|
|
3346
5712
|
|
|
3347
|
-
# Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern
|
|
3348
|
-
# nodes contribute no bindings (fail-soft).
|
|
3349
|
-
|
|
5713
|
+
# Returns an array of `[Symbol, Rigor::Type]` pairs for every variable captured by `pattern`, typed against the
|
|
5714
|
+
# subject. Unrecognised pattern nodes contribute no bindings (fail-soft).
|
|
5715
|
+
#
|
|
5716
|
+
# A union subject distributes first (see {#collect_union_pattern_bindings}); every other subject walks the
|
|
5717
|
+
# pattern once, and each node kind decides how much of the subject it can name:
|
|
5718
|
+
#
|
|
5719
|
+
# - a bare target (`in [i, s]`, `in x`) binds the slot the enclosing pattern hands it,
|
|
5720
|
+
# - a capture (`Integer => i`, `[a, b] => whole`) binds its own constraint, and recurses into the captured
|
|
5721
|
+
# pattern,
|
|
5722
|
+
# - an array / find / hash pattern decomposes the subject (see the three collectors below).
|
|
5723
|
+
def collect_in_pattern_bindings(subject_type, pattern, scope, subject_node: nil)
|
|
5724
|
+
if subject_type.is_a?(Type::Union)
|
|
5725
|
+
return collect_union_pattern_bindings(subject_type.members, pattern, scope, subject_node: subject_node)
|
|
5726
|
+
end
|
|
5727
|
+
|
|
3350
5728
|
case pattern
|
|
3351
5729
|
when Prism::CapturePatternNode
|
|
3352
|
-
|
|
5730
|
+
collect_capture_pattern_bindings(subject_type, pattern, scope)
|
|
3353
5731
|
when Prism::LocalVariableTargetNode
|
|
3354
|
-
subject_type = subject.is_a?(Prism::LocalVariableReadNode) ? scope.local(subject.name) : nil
|
|
3355
5732
|
[[pattern.name, subject_type || Type::Combinator.untyped]]
|
|
3356
5733
|
when Prism::ImplicitNode
|
|
3357
|
-
collect_in_pattern_bindings(
|
|
5734
|
+
collect_in_pattern_bindings(subject_type, pattern.value, scope)
|
|
3358
5735
|
when Prism::ArrayPatternNode
|
|
3359
|
-
collect_array_pattern_bindings(pattern, scope)
|
|
5736
|
+
collect_array_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
|
|
3360
5737
|
when Prism::FindPatternNode
|
|
3361
|
-
collect_find_pattern_bindings(pattern, scope)
|
|
5738
|
+
collect_find_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
|
|
3362
5739
|
when Prism::HashPatternNode
|
|
3363
|
-
collect_hash_pattern_bindings(pattern, scope)
|
|
5740
|
+
collect_hash_pattern_bindings(subject_type, pattern, scope, subject_node: subject_node)
|
|
3364
5741
|
when Prism::AlternationPatternNode
|
|
3365
|
-
collect_alternation_pattern_bindings(
|
|
5742
|
+
collect_alternation_pattern_bindings(subject_type, pattern, scope)
|
|
3366
5743
|
else
|
|
3367
5744
|
[]
|
|
3368
5745
|
end
|
|
3369
5746
|
end
|
|
3370
5747
|
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
5748
|
+
# `pattern => target` binds `target` to what the pattern matched AND every name the pattern itself binds:
|
|
5749
|
+
# `in [a, b] => whole` binds `a`, `b` and `whole`. The target's own type is the pattern's constraint when it
|
|
5750
|
+
# names a class (`Integer => i`), and the subject otherwise — a capture over any other pattern IS the subject
|
|
5751
|
+
# the pattern matched.
|
|
5752
|
+
def collect_capture_pattern_bindings(subject_type, pattern, scope)
|
|
5753
|
+
target = pattern.target
|
|
5754
|
+
inner = collect_in_pattern_bindings(subject_type, pattern.value, scope)
|
|
5755
|
+
return inner unless target.is_a?(Prism::LocalVariableTargetNode)
|
|
5756
|
+
|
|
5757
|
+
[[target.name, capture_pattern_type(subject_type, pattern.value, scope)]] + inner
|
|
5758
|
+
end
|
|
5759
|
+
|
|
5760
|
+
# `in [i, s]` / `in [a, *rest, z]` / `in Foo[a, b]`.
|
|
5761
|
+
def collect_array_pattern_bindings(subject_type, pattern, scope, subject_node:)
|
|
5762
|
+
subject_type = pattern_class_constraint(subject_type, pattern.constant, scope)
|
|
5763
|
+
fronts, rest_type, backs = pattern_slot_types(subject_type, pattern, scope, subject_node)
|
|
5764
|
+
bindings = pattern.requireds.each_with_index.flat_map do |elem, i|
|
|
5765
|
+
collect_in_pattern_bindings(fronts[i], elem, scope)
|
|
5766
|
+
end
|
|
5767
|
+
bindings += pattern.posts.each_with_index.flat_map do |elem, i|
|
|
5768
|
+
collect_in_pattern_bindings(backs[i], elem, scope)
|
|
5769
|
+
end
|
|
5770
|
+
append_array_splat_binding(bindings, pattern.rest, rest_type)
|
|
5771
|
+
bindings
|
|
5772
|
+
end
|
|
5773
|
+
|
|
5774
|
+
# The per-slot types a positional pattern reads: the subject's own decomposition when it is a carrier
|
|
5775
|
+
# {MultiTargetBinder.decompose_slots} accepts (`Tuple`, `Array[T]`), else the `deconstruct` projection of a
|
|
5776
|
+
# subject that defines one (`Struct#deconstruct`, a `Data` instance, a class whose `deconstruct` names the
|
|
5777
|
+
# parts), else the `Dynamic[top]` floor per slot.
|
|
5778
|
+
def pattern_slot_types(subject_type, pattern, scope, subject_node)
|
|
5779
|
+
view = positional_pattern_view(subject_type, scope, subject_node)
|
|
5780
|
+
return floor_pattern_slots(pattern.requireds.size, pattern.posts.size, !pattern.rest.nil?) if view.nil?
|
|
5781
|
+
|
|
5782
|
+
MultiTargetBinder.decompose_slots(
|
|
5783
|
+
view, front_count: pattern.requireds.size, back_count: pattern.posts.size,
|
|
5784
|
+
rest_present: !pattern.rest.nil?, scope: scope
|
|
5785
|
+
)
|
|
5786
|
+
end
|
|
5787
|
+
|
|
5788
|
+
# The carrier a positional pattern decomposes: the subject itself when {MultiTargetBinder} already accepts it,
|
|
5789
|
+
# else what the subject's `deconstruct` answers with (a `Tuple` / `Array[T]` carrier), else nil.
|
|
5790
|
+
#
|
|
5791
|
+
# `subject_node` rides along as the dispatch's call node so the `Struct` fold's freshness gate can see the
|
|
5792
|
+
# receiver: `case Point.new(1, 2); in [x, y]` is a freshly materialised instance and folds, while a stored
|
|
5793
|
+
# binding that may have been mutated since does not (ADR-48).
|
|
5794
|
+
def positional_pattern_view(subject_type, scope, subject_node)
|
|
5795
|
+
return subject_type if subject_type.is_a?(Type::Tuple)
|
|
5796
|
+
return subject_type if MultiTargetBinder.array_element_type(subject_type)
|
|
5797
|
+
|
|
5798
|
+
deconstruct_projection(subject_type, scope, subject_node)
|
|
5799
|
+
end
|
|
5800
|
+
|
|
5801
|
+
# What `subject.deconstruct` answers with, when that is a carrier this binder decomposes; nil otherwise —
|
|
5802
|
+
# an absent method, or `Struct#deconstruct`'s RBS `Array[untyped]` for a class whose members are not known.
|
|
5803
|
+
def deconstruct_projection(subject_type, scope, subject_node)
|
|
5804
|
+
result = struct_instance_projection(subject_type, :deconstruct, scope, subject_node) ||
|
|
5805
|
+
pattern_decomposition_dispatch(subject_type, :deconstruct, [], scope) ||
|
|
5806
|
+
source_decomposition_projection(subject_type, :deconstruct, [], scope)
|
|
5807
|
+
return nil if result.nil?
|
|
5808
|
+
return result if result.is_a?(Type::Tuple) || MultiTargetBinder.array_element_type(result)
|
|
5809
|
+
|
|
5810
|
+
nil
|
|
5811
|
+
end
|
|
5812
|
+
|
|
5813
|
+
# The inferred return type of a project-defined `deconstruct` / `deconstruct_keys` (issue #1122). The
|
|
5814
|
+
# dispatcher answers nil for a class no RBS describes: the body-inference tier that would type
|
|
5815
|
+
# `subject.deconstruct` lives on `ExpressionTyper` and needs the call NODE a pattern does not have, so
|
|
5816
|
+
# this asks the scope's own entry point for the same answer a resolved call site gets (ADR-84 memo
|
|
5817
|
+
# included). nil when the project defines no such method, or when the body's answer is the gradual floor.
|
|
5818
|
+
def source_decomposition_projection(subject_type, method_name, arg_types, scope)
|
|
5819
|
+
return nil unless subject_type.is_a?(Type::Nominal)
|
|
5820
|
+
|
|
5821
|
+
def_node = scope.discovered_def_nodes[subject_type.class_name]&.[](method_name)
|
|
5822
|
+
return nil if def_node.nil?
|
|
5823
|
+
|
|
5824
|
+
result = scope.user_method_return(def_node, subject_type, arg_types)
|
|
5825
|
+
return nil if result.nil? || result.is_a?(Type::Dynamic) || result.is_a?(Type::Top)
|
|
5826
|
+
|
|
5827
|
+
result
|
|
5828
|
+
end
|
|
5829
|
+
|
|
5830
|
+
# A `StructInstance`'s own projection — `Tuple` of its member values for `deconstruct`, `HashShape` of
|
|
5831
|
+
# its members for `deconstruct_keys` — or nil when the subject is another carrier or the projection would
|
|
5832
|
+
# be unsound.
|
|
5833
|
+
#
|
|
5834
|
+
# The `Struct` fold's freshness gate cannot answer for a pattern through the dispatcher: it asks whether
|
|
5835
|
+
# the CALL's receiver was freshly materialised (`Point.new(1, 2).x`), while a pattern's subject node IS
|
|
5836
|
+
# that materialisation (`case Point.new(1, 2); in [x, y]`). The gate still applies — a `Struct` is
|
|
5837
|
+
# mutable, so a stored binding's member map may be stale (ADR-48) — so this asks it the pattern's own
|
|
5838
|
+
# question, with the subject expression as the materialisation. A `Data` instance needs none of this:
|
|
5839
|
+
# it is frozen, and the dispatcher already projects it (see `DataFolding`).
|
|
5840
|
+
def struct_instance_projection(subject_type, method_name, scope, subject_node)
|
|
5841
|
+
return nil unless subject_type.is_a?(Type::StructInstance)
|
|
5842
|
+
return nil unless MethodDispatcher::StructMaterialization.materialization_call?(subject_node, subject_type,
|
|
5843
|
+
scope)
|
|
5844
|
+
|
|
5845
|
+
case method_name
|
|
5846
|
+
when :deconstruct then Type::Combinator.tuple_of(*subject_type.members.values)
|
|
5847
|
+
when :deconstruct_keys then Type::Combinator.hash_shape_of(subject_type.members.dup)
|
|
5848
|
+
end
|
|
5849
|
+
end
|
|
5850
|
+
|
|
5851
|
+
# `deconstruct` / `deconstruct_keys` on a subject carrier. A `Dynamic` / `Top` answer is the gradual floor
|
|
5852
|
+
# rather than a method's result, so it reads as "cannot ask" to every caller here.
|
|
5853
|
+
#
|
|
5854
|
+
# The subject node is deliberately NOT passed as the dispatch's `call_node`: the tiers read a call node's
|
|
5855
|
+
# receiver and arguments, and a pattern's subject is any expression at all — `StructFolding`'s freshness
|
|
5856
|
+
# gate dereferences `call_node.receiver`, which a local read or a literal does not answer. The one
|
|
5857
|
+
# freshness question a pattern needs (`case Point.new(1, 2)`) is asked by
|
|
5858
|
+
# {#struct_instance_projection} instead.
|
|
5859
|
+
def pattern_decomposition_dispatch(subject_type, method_name, args, scope)
|
|
5860
|
+
return nil if subject_type.nil?
|
|
5861
|
+
|
|
5862
|
+
result = MethodDispatcher.dispatch(
|
|
5863
|
+
receiver_type: subject_type, method_name: method_name, arg_types: args,
|
|
5864
|
+
environment: scope.environment, scope: scope
|
|
5865
|
+
)
|
|
5866
|
+
return nil if result.is_a?(Type::Dynamic) || result.is_a?(Type::Top)
|
|
5867
|
+
|
|
5868
|
+
result
|
|
5869
|
+
end
|
|
5870
|
+
|
|
5871
|
+
# `in [*pre, m, *post]`. Ruby matches a find pattern's required elements at the EARLIEST position the
|
|
5872
|
+
# surrounding splats allow (the pre-splat is non-greedy: `[1, 2, 3] in [*pre, x, *post]` binds `pre = []`, `x =
|
|
5873
|
+
# 1`), but a required that does not match there slides right, so each required binds the union of every
|
|
5874
|
+
# position it could occupy and the surrounding splats bind an `Array` of the subject's element type.
|
|
5875
|
+
def collect_find_pattern_bindings(subject_type, pattern, scope, subject_node:)
|
|
5876
|
+
subject_type = pattern_class_constraint(subject_type, pattern.constant, scope)
|
|
5877
|
+
view = positional_pattern_view(subject_type, scope, subject_node)
|
|
5878
|
+
slots = find_pattern_slots(view, pattern.requireds.size)
|
|
5879
|
+
bindings = pattern.requireds.each_with_index.flat_map do |elem, i|
|
|
5880
|
+
collect_in_pattern_bindings(slots ? slots[i] : Type::Combinator.untyped, elem, scope)
|
|
3374
5881
|
end
|
|
3375
|
-
|
|
5882
|
+
surround = find_pattern_surround_type(view)
|
|
5883
|
+
[pattern.left, pattern.right].each { |splat| append_array_splat_binding(bindings, splat, surround) }
|
|
3376
5884
|
bindings
|
|
3377
5885
|
end
|
|
3378
5886
|
|
|
3379
|
-
|
|
5887
|
+
# The type each of a find pattern's `count` requireds can see, or nil when the subject cannot supply that many
|
|
5888
|
+
# elements (the pattern cannot match).
|
|
5889
|
+
def find_pattern_slots(view, count)
|
|
5890
|
+
if view.is_a?(Type::Tuple)
|
|
5891
|
+
elements = view.elements
|
|
5892
|
+
return nil if elements.size < count
|
|
5893
|
+
|
|
5894
|
+
return Array.new(count) { |i| Type::Combinator.union(*elements[i..(elements.size - count + i)]) }
|
|
5895
|
+
end
|
|
5896
|
+
|
|
5897
|
+
element = view && MultiTargetBinder.array_element_type(view)
|
|
5898
|
+
element && Array.new(count) { element }
|
|
5899
|
+
end
|
|
5900
|
+
|
|
5901
|
+
# `*pre` / `*post` capture the elements the requireds did not: `Array[T]` for an `Array[T]` subject, an `Array`
|
|
5902
|
+
# of the element union for a `Tuple`, `Array[untyped]` when the subject could not be decomposed.
|
|
5903
|
+
def find_pattern_surround_type(view)
|
|
5904
|
+
element = case view
|
|
5905
|
+
when Type::Tuple then union_of_types(view.elements)
|
|
5906
|
+
else view && MultiTargetBinder.array_element_type(view)
|
|
5907
|
+
end
|
|
5908
|
+
Type::Combinator.nominal_of("Array", type_args: [element || Type::Combinator.untyped])
|
|
5909
|
+
end
|
|
5910
|
+
|
|
5911
|
+
# `in {name: String => n}` / `in {name:, **rest}`. Each element reads the subject's value at its key through
|
|
5912
|
+
# the subject's `deconstruct_keys` projection; `**rest` binds the remaining entries.
|
|
5913
|
+
def collect_hash_pattern_bindings(subject_type, pattern, scope, subject_node:)
|
|
5914
|
+
subject_type = pattern_class_constraint(subject_type, pattern.constant, scope)
|
|
5915
|
+
view = hash_pattern_view(subject_type, scope, subject_node)
|
|
3380
5916
|
bindings = pattern.elements.flat_map do |assoc|
|
|
3381
5917
|
next [] unless assoc.is_a?(Prism::AssocNode) && assoc.value
|
|
3382
5918
|
|
|
3383
|
-
collect_in_pattern_bindings(
|
|
5919
|
+
collect_in_pattern_bindings(hash_pattern_value_type(view, assoc.key), assoc.value, scope)
|
|
3384
5920
|
end
|
|
3385
5921
|
rest = pattern.rest
|
|
3386
|
-
if rest.is_a?(Prism::AssocSplatNode)
|
|
3387
|
-
|
|
3388
|
-
bindings << [val.name, hash_pattern_rest_type] if val.is_a?(Prism::LocalVariableTargetNode)
|
|
5922
|
+
if rest.is_a?(Prism::AssocSplatNode) && rest.value.is_a?(Prism::LocalVariableTargetNode)
|
|
5923
|
+
bindings << [rest.value.name, hash_pattern_rest_type(view)]
|
|
3389
5924
|
end
|
|
3390
5925
|
bindings
|
|
3391
5926
|
end
|
|
3392
5927
|
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
[
|
|
3398
|
-
|
|
5928
|
+
# The `Hash`-shaped view a hash pattern reads: the subject's `deconstruct_keys` answer, which is a `HashShape`
|
|
5929
|
+
# for a shape carrier or a `deconstruct_keys` body and `Hash[K, V]` for the RBS answer of a plain `Hash` — or
|
|
5930
|
+
# nil when the subject cannot be asked.
|
|
5931
|
+
def hash_pattern_view(subject_type, scope, subject_node)
|
|
5932
|
+
args = [Type::Combinator.constant_of(nil)]
|
|
5933
|
+
struct_instance_projection(subject_type, :deconstruct_keys, scope, subject_node) ||
|
|
5934
|
+
pattern_decomposition_dispatch(subject_type, :deconstruct_keys, args, scope) ||
|
|
5935
|
+
source_decomposition_projection(subject_type, :deconstruct_keys, args, scope)
|
|
3399
5936
|
end
|
|
3400
5937
|
|
|
3401
|
-
#
|
|
3402
|
-
# `
|
|
3403
|
-
#
|
|
3404
|
-
def
|
|
3405
|
-
|
|
5938
|
+
# The value type the pattern's key reads out of the view: a `HashShape` answers per key, a `Hash[K, V]`
|
|
5939
|
+
# answers `V` for every key, and anything else — a key the AST does not pin (`in {"#{k}": v}`), a shape that
|
|
5940
|
+
# does not carry the key — answers the floor.
|
|
5941
|
+
def hash_pattern_value_type(view, key_node)
|
|
5942
|
+
key = hash_pattern_key(key_node)
|
|
5943
|
+
return Type::Combinator.untyped if key.nil?
|
|
5944
|
+
return view.pairs[key] || Type::Combinator.untyped if view.is_a?(Type::HashShape)
|
|
3406
5945
|
|
|
3407
|
-
|
|
3408
|
-
|
|
5946
|
+
hash_value_type(view) || Type::Combinator.untyped
|
|
5947
|
+
end
|
|
3409
5948
|
|
|
3410
|
-
|
|
5949
|
+
# The key a hash pattern element names, or nil when the AST does not pin one (an interpolated or computed key).
|
|
5950
|
+
def hash_pattern_key(key_node)
|
|
5951
|
+
case key_node
|
|
5952
|
+
when Prism::SymbolNode then key_node.unescaped.to_sym
|
|
5953
|
+
when Prism::StringNode then key_node.unescaped
|
|
5954
|
+
end
|
|
3411
5955
|
end
|
|
3412
5956
|
|
|
3413
5957
|
# `{ key:, **rest }` binds `rest` to a Hash whose keys are Symbols (the only legal key shape for a hash pattern)
|
|
3414
|
-
# and whose values are
|
|
3415
|
-
def hash_pattern_rest_type
|
|
5958
|
+
# and whose values are the view's own value type — the entries the pattern named are a subset of it.
|
|
5959
|
+
def hash_pattern_rest_type(view)
|
|
5960
|
+
value = view.is_a?(Type::HashShape) ? union_of_types(view.pairs.values) : hash_value_type(view)
|
|
3416
5961
|
Type::Combinator.nominal_of(
|
|
3417
5962
|
"Hash",
|
|
3418
|
-
type_args: [Type::Combinator.nominal_of("Symbol"), Type::Combinator.untyped]
|
|
5963
|
+
type_args: [Type::Combinator.nominal_of("Symbol"), value || Type::Combinator.untyped]
|
|
3419
5964
|
)
|
|
3420
5965
|
end
|
|
3421
5966
|
|
|
5967
|
+
# The `V` of a `Hash[K, V]`, or nil for a raw `Hash`, a non-`Hash` nominal, or a `Dynamic` / `Top` value.
|
|
5968
|
+
def hash_value_type(type)
|
|
5969
|
+
return nil unless type.is_a?(Type::Nominal) && type.class_name == "Hash" && type.type_args.size == 2
|
|
5970
|
+
|
|
5971
|
+
value = type.type_args.last
|
|
5972
|
+
return nil if value.is_a?(Type::Dynamic) || value.is_a?(Type::Top)
|
|
5973
|
+
|
|
5974
|
+
value
|
|
5975
|
+
end
|
|
5976
|
+
|
|
5977
|
+
# The union of a list of types, ignoring the gradual floor (`Dynamic[top]` / `Top` carries nothing to union)
|
|
5978
|
+
# and answering nil when nothing is left.
|
|
5979
|
+
def union_of_types(types)
|
|
5980
|
+
known = types.reject { |type| type.is_a?(Type::Dynamic) || type.is_a?(Type::Top) }
|
|
5981
|
+
known.empty? ? nil : Type::Combinator.union(*known)
|
|
5982
|
+
end
|
|
5983
|
+
|
|
5984
|
+
# `[..., *rest, ...]` / `[*pre, x, *post]` capture an Array of the unmatched elements. `rest_type` is the
|
|
5985
|
+
# enclosing decomposition's own rest (a `Tuple` of the middle elements for a tuple subject, `Array[T]` for an
|
|
5986
|
+
# `Array[T]`); without one — a subject that did not decompose — the rest is `Array[untyped]`.
|
|
5987
|
+
def append_array_splat_binding(bindings, splat, rest_type)
|
|
5988
|
+
return unless splat.is_a?(Prism::SplatNode)
|
|
5989
|
+
|
|
5990
|
+
target = splat.expression
|
|
5991
|
+
return unless target.is_a?(Prism::LocalVariableTargetNode)
|
|
5992
|
+
|
|
5993
|
+
bindings << [target.name, rest_type || Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped])]
|
|
5994
|
+
end
|
|
5995
|
+
|
|
5996
|
+
# The `Dynamic[top]` floor per slot, for a subject no rule decomposes.
|
|
5997
|
+
def floor_pattern_slots(front_count, back_count, rest_present)
|
|
5998
|
+
[
|
|
5999
|
+
Array.new(front_count) { Type::Combinator.untyped },
|
|
6000
|
+
rest_present ? Type::Combinator.nominal_of("Array", type_args: [Type::Combinator.untyped]) : nil,
|
|
6001
|
+
Array.new(back_count) { Type::Combinator.untyped }
|
|
6002
|
+
]
|
|
6003
|
+
end
|
|
6004
|
+
|
|
6005
|
+
# The class a pattern's own constant asserts (`in Point[x, y]`, `in Foo{...}`), applied to the subject. An
|
|
6006
|
+
# opaque subject — `Dynamic` / `Top`, which no `is_a?`-style narrowing can refine — becomes `Nominal[C]`: the
|
|
6007
|
+
# pattern's `C === subject` test has just established the class, which is what lets a constrained pattern bind
|
|
6008
|
+
# off a subject whose own type named nothing. A subject whose class is already known keeps it.
|
|
6009
|
+
def pattern_class_constraint(subject_type, constant_node, scope)
|
|
6010
|
+
return subject_type if constant_node.nil?
|
|
6011
|
+
|
|
6012
|
+
nominal = singleton_to_nominal(sub_eval(constant_node, scope).first)
|
|
6013
|
+
return subject_type unless opaque_pattern_subject?(subject_type)
|
|
6014
|
+
return subject_type if nominal.is_a?(Type::Dynamic) || nominal.is_a?(Type::Top)
|
|
6015
|
+
|
|
6016
|
+
nominal
|
|
6017
|
+
end
|
|
6018
|
+
|
|
6019
|
+
def opaque_pattern_subject?(subject_type)
|
|
6020
|
+
subject_type.nil? || subject_type.is_a?(Type::Dynamic) || subject_type.is_a?(Type::Top)
|
|
6021
|
+
end
|
|
6022
|
+
|
|
6023
|
+
# Distributes a union subject over the pattern, the rule {MultiTargetBinder} applies to `a, b = union`: every
|
|
6024
|
+
# member walks the same pattern and each name binds the join of its per-member types, while a member that binds
|
|
6025
|
+
# `Dynamic[top]` floors the name for the whole union — a precise member must not stand for one nothing is known
|
|
6026
|
+
# about. A member that PROVABLY cannot match the pattern contributes nothing at all instead of flooring:
|
|
6027
|
+
# `case maybe; in [a, b]` over `Tuple[1, "a"] | nil` binds `1` / `"a"`, because a `nil` subject raises
|
|
6028
|
+
# `NoMatchingPatternError` rather than reaching the body.
|
|
6029
|
+
def collect_union_pattern_bindings(members, pattern, scope, subject_node: nil)
|
|
6030
|
+
reachable = members.reject { |member| pattern_match_impossible?(member, pattern, scope) }
|
|
6031
|
+
walks = (reachable.empty? ? [Type::Combinator.untyped] : reachable).map do |member|
|
|
6032
|
+
collect_in_pattern_bindings(member, pattern, scope, subject_node: subject_node)
|
|
6033
|
+
end
|
|
6034
|
+
merge_pattern_bindings(walks)
|
|
6035
|
+
end
|
|
6036
|
+
|
|
6037
|
+
# Whether `type` provably cannot match `pattern`, so its arm contributes no binding. Only the two
|
|
6038
|
+
# decompositions a pattern asks for are checked — `deconstruct` (array / find patterns) and `deconstruct_keys`
|
|
6039
|
+
# (hash patterns) — and both only for a class the RBS environment knows, whose method set is closed, the same
|
|
6040
|
+
# rule `array_conversion_free?` applies to the multi-assign `to_ary` question (issue #1094). A carrier the
|
|
6041
|
+
# check cannot prove negative about (Dynamic, a source class, an unresolved constant) answers false, which
|
|
6042
|
+
# keeps the union at the conservative floor.
|
|
6043
|
+
def pattern_match_impossible?(type, pattern, scope)
|
|
6044
|
+
case pattern
|
|
6045
|
+
when Prism::ArrayPatternNode, Prism::FindPatternNode then !decomposable_as_array?(type, scope)
|
|
6046
|
+
when Prism::HashPatternNode then !decomposable_as_hash?(type, scope)
|
|
6047
|
+
when Prism::CapturePatternNode then pattern_match_impossible?(type, pattern.value, scope)
|
|
6048
|
+
else false
|
|
6049
|
+
end
|
|
6050
|
+
end
|
|
6051
|
+
|
|
6052
|
+
def decomposable_as_array?(type, scope)
|
|
6053
|
+
return true unless pattern_decomposition_dispatch(type, :deconstruct, [], scope).nil?
|
|
6054
|
+
|
|
6055
|
+
class_name = MultiTargetBinder.conversion_class_name(type)
|
|
6056
|
+
class_name.nil? || !MethodDispatcher::RbsDispatch.array_conversion_free?(class_name, scope)
|
|
6057
|
+
end
|
|
6058
|
+
|
|
6059
|
+
def decomposable_as_hash?(type, scope)
|
|
6060
|
+
args = [Type::Combinator.constant_of(nil)]
|
|
6061
|
+
return true unless pattern_decomposition_dispatch(type, :deconstruct_keys, args, scope).nil?
|
|
6062
|
+
|
|
6063
|
+
class_name = MultiTargetBinder.conversion_class_name(type)
|
|
6064
|
+
return true if class_name.nil? || scope.environment.nil?
|
|
6065
|
+
|
|
6066
|
+
!Reflection.rbs_class_known?(class_name, environment: scope.environment)
|
|
6067
|
+
end
|
|
6068
|
+
|
|
6069
|
+
# Joins per-member binding lists by name: `Dynamic[top]` from any walk — or a name a walk does not bind —
|
|
6070
|
+
# floors the name, otherwise the members union. The first walk's order is the declaration order.
|
|
6071
|
+
def merge_pattern_bindings(walks)
|
|
6072
|
+
floor = Type::Combinator.untyped
|
|
6073
|
+
walks.first.map do |name, _type|
|
|
6074
|
+
types = walks.map { |walk| walk.assoc(name)&.last }
|
|
6075
|
+
[name, types.any? { |type| type.nil? || type == floor } ? floor : Type::Combinator.union(*types)]
|
|
6076
|
+
end
|
|
6077
|
+
end
|
|
6078
|
+
|
|
6079
|
+
# `expr in pattern` (a `MatchPredicateNode`, evaluating to a boolean) and `expr => pattern` (a
|
|
6080
|
+
# `MatchRequiredNode`, evaluating to `nil` and raising `NoMatchingPatternError` on a mismatch) — the one-line
|
|
6081
|
+
# pattern matches. Both bind every name the pattern captures into the post-scope, decomposed exactly as an `in`
|
|
6082
|
+
# branch of a `case` is, and WITHOUT the nil-injection a surrounding join would add: a name is read on the
|
|
6083
|
+
# truthy side only after the pattern matched it, which is the shape `if config in {timeout: Integer => t}`
|
|
6084
|
+
# depends on.
|
|
6085
|
+
def eval_match_pattern(node)
|
|
6086
|
+
subject_type, post_value = sub_eval(node.value, scope)
|
|
6087
|
+
bound = apply_in_pattern_bindings(subject_type, node.value, node.pattern, post_value)
|
|
6088
|
+
[scope.type_of(node, tracer: tracer), bound]
|
|
6089
|
+
end
|
|
6090
|
+
|
|
3422
6091
|
# --------------------------------------------------------------- named-capture regex binding (`MatchWriteNode`)
|
|
3423
6092
|
# ---------------------------------------------------------------
|
|
3424
6093
|
|
|
@@ -3449,26 +6118,43 @@ module Rigor
|
|
|
3449
6118
|
type.is_a?(Type::Singleton) ? Type::Combinator.nominal_of(type.class_name) : Type::Combinator.untyped
|
|
3450
6119
|
end
|
|
3451
6120
|
|
|
3452
|
-
# Returns the type to bind for a `CapturePatternNode`'s target.
|
|
3453
|
-
#
|
|
3454
|
-
#
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
else
|
|
6121
|
+
# Returns the type to bind for a `CapturePatternNode`'s target. A class reference (`Integer => x`, and every
|
|
6122
|
+
# alternate of `Integer | String => x`) answers the constraint's `Nominal[T]` — the pattern's own `T ===
|
|
6123
|
+
# subject` test is what licenses the binding even when the subject's type is opaque. A value pattern (`1 => x`)
|
|
6124
|
+
# answers the literal's own type. A capture over any other pattern (`[a, b] => whole`, `^(x) => y`) answers the
|
|
6125
|
+
# subject, which is what that pattern matched.
|
|
6126
|
+
def capture_pattern_type(subject_type, value_node, scope)
|
|
6127
|
+
case value_node
|
|
6128
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
3461
6129
|
singleton_to_nominal(sub_eval(value_node, scope).first)
|
|
6130
|
+
when Prism::AlternationPatternNode
|
|
6131
|
+
Type::Combinator.union(
|
|
6132
|
+
capture_pattern_type(subject_type, value_node.left, scope),
|
|
6133
|
+
capture_pattern_type(subject_type, value_node.right, scope)
|
|
6134
|
+
)
|
|
6135
|
+
when Prism::ArrayPatternNode, Prism::FindPatternNode, Prism::HashPatternNode,
|
|
6136
|
+
Prism::PinnedVariableNode, Prism::PinnedExpressionNode, Prism::ImplicitNode
|
|
6137
|
+
subject_type || Type::Combinator.untyped
|
|
6138
|
+
else
|
|
6139
|
+
literal_pattern_type(value_node, scope)
|
|
3462
6140
|
end
|
|
3463
6141
|
end
|
|
3464
6142
|
|
|
3465
|
-
#
|
|
3466
|
-
#
|
|
6143
|
+
# The type a non-class pattern node evaluates to: `Constant[1]` for `1 => x`, the nominal for `/re/ => x`,
|
|
6144
|
+
# the range for `1..5 => x`. A class reference is the one carrier that must convert (`Singleton[C]` is the
|
|
6145
|
+
# class object; the binding holds an instance), which the caller's constant arm does.
|
|
6146
|
+
def literal_pattern_type(value_node, scope)
|
|
6147
|
+
type = sub_eval(value_node, scope).first
|
|
6148
|
+
type.is_a?(Type::Singleton) ? singleton_to_nominal(type) : type
|
|
6149
|
+
end
|
|
6150
|
+
|
|
6151
|
+
# `in PatternA | PatternB` — Ruby requires both alternates to bind the same names, but the binder runs against
|
|
6152
|
+
# the AST and cannot enforce that. We collect bindings from each side and merge by name, unioning types when both
|
|
3467
6153
|
# alternates contribute. Names that only one alternate contributes still surface (the parser would have rejected
|
|
3468
6154
|
# the case at compile time, so by the time we see it the user's intent is the merged set).
|
|
3469
|
-
def collect_alternation_pattern_bindings(
|
|
3470
|
-
left = collect_in_pattern_bindings(
|
|
3471
|
-
right = collect_in_pattern_bindings(
|
|
6155
|
+
def collect_alternation_pattern_bindings(subject_type, pattern, scope)
|
|
6156
|
+
left = collect_in_pattern_bindings(subject_type, pattern.left, scope)
|
|
6157
|
+
right = collect_in_pattern_bindings(subject_type, pattern.right, scope)
|
|
3472
6158
|
merged = {}
|
|
3473
6159
|
(left + right).each do |name, type|
|
|
3474
6160
|
merged[name] = merged.key?(name) ? Type::Combinator.union(merged[name], type) : type
|