rigortype 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/runner.rb +67 -9
  4. data/lib/rigor/analysis/worker_session.rb +13 -4
  5. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  6. data/lib/rigor/cache/rbs_environment.rb +2 -1
  7. data/lib/rigor/cli/annotate_command.rb +57 -7
  8. data/lib/rigor/cli/coverage_command.rb +126 -0
  9. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  10. data/lib/rigor/cli/coverage_report.rb +75 -0
  11. data/lib/rigor/cli/mcp_command.rb +70 -0
  12. data/lib/rigor/cli.rb +73 -3
  13. data/lib/rigor/environment/rbs_loader.rb +46 -5
  14. data/lib/rigor/environment/reporters.rb +3 -2
  15. data/lib/rigor/environment.rb +159 -4
  16. data/lib/rigor/inference/def_return_typer.rb +98 -0
  17. data/lib/rigor/inference/expression_typer.rb +143 -12
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  19. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  20. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  21. data/lib/rigor/inference/precision_scanner.rb +131 -0
  22. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  23. data/lib/rigor/mcp/loop.rb +43 -0
  24. data/lib/rigor/mcp/server.rb +263 -0
  25. data/lib/rigor/mcp.rb +16 -0
  26. data/lib/rigor/plugin/base.rb +28 -5
  27. data/lib/rigor/plugin/manifest.rb +33 -5
  28. data/lib/rigor/plugin/registry.rb +21 -0
  29. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  30. data/lib/rigor/sig_gen/generator.rb +150 -75
  31. data/lib/rigor/type/combinator.rb +57 -0
  32. data/lib/rigor/version.rb +1 -1
  33. data/sig/rigor/analysis/baseline.rbs +39 -0
  34. data/sig/rigor/environment.rbs +3 -2
  35. data/sig/rigor/type.rbs +4 -0
  36. data/sig/rigor.rbs +2 -0
  37. metadata +32 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  require_relative "environment/class_registry"
4
6
  require_relative "environment/rbs_loader"
5
7
  require_relative "environment/reflection"
@@ -24,7 +26,7 @@ module Rigor
24
26
  # constant-folding tiers cannot answer.
25
27
  #
26
28
  # See docs/internal-spec/inference-engine.md for the binding contract.
27
- class Environment
29
+ class Environment # rubocop:disable Metrics/ClassLength
28
30
  DEFAULT_PROJECT_SIG_DIR = "sig"
29
31
  private_constant :DEFAULT_PROJECT_SIG_DIR
30
32
 
@@ -87,6 +89,7 @@ module Rigor
87
89
  def initialize(class_registry: ClassRegistry.default, rbs_loader: nil, # rubocop:disable Metrics/ParameterLists
88
90
  plugin_registry: nil, dependency_source_index: nil,
89
91
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
92
+ source_rbs_synthesis_reporter: nil,
90
93
  synthetic_method_index: nil, project_patched_methods: nil,
91
94
  hkt_registry: nil)
92
95
  @class_registry = class_registry
@@ -100,7 +103,8 @@ module Rigor
100
103
  # accessors below preserve the public lookup shape.
101
104
  @reporters = Reporters.new(
102
105
  rbs_extended: rbs_extended_reporter,
103
- boundary_cross: boundary_cross_reporter
106
+ boundary_cross: boundary_cross_reporter,
107
+ source_rbs_synthesis: source_rbs_synthesis_reporter
104
108
  )
105
109
  @synthetic_method_index = synthetic_method_index || Inference::SyntheticMethodIndex::EMPTY
106
110
  @project_patched_methods = project_patched_methods || Inference::ProjectPatchedMethods::EMPTY
@@ -159,6 +163,17 @@ module Rigor
159
163
  @reporters.boundary_cross
160
164
  end
161
165
 
166
+ # ADR-32 WD6 — the per-run accumulator for synthesizer
167
+ # failure events. `Environment.for_project` records
168
+ # `[:error, message]` returns from a plugin's
169
+ # `source_rbs_synthesizer` here so the Runner can emit
170
+ # `source-rbs-synthesis-failed` `:info` diagnostics after
171
+ # analysis completes. Nil when no plugin contributes a
172
+ # synthesizer.
173
+ def source_rbs_synthesis_reporter
174
+ @reporters.source_rbs_synthesis
175
+ end
176
+
162
177
  # Replaces the env's per-run reporter slots. Intended for
163
178
  # long-lived integrations (LSP `ProjectContext`) that share one
164
179
  # Environment instance across many `Runner.run` calls: each call
@@ -210,10 +225,12 @@ module Rigor
210
225
  def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
211
226
  plugin_registry: nil, dependency_source_index: nil,
212
227
  rbs_extended_reporter: nil, boundary_cross_reporter: nil,
228
+ source_rbs_synthesis_reporter: nil,
213
229
  bundler_bundle_path: nil, bundler_auto_detect: false,
214
230
  bundler_lockfile: nil,
215
231
  rbs_collection_lockfile: nil, rbs_collection_auto_detect: false,
216
- synthetic_method_index: nil, project_patched_methods: nil)
232
+ synthetic_method_index: nil, project_patched_methods: nil,
233
+ source_files: [])
217
234
  resolved_paths = signature_paths || default_signature_paths(root)
218
235
  # O4 MVP — append per-gem `sig/` directories discovered
219
236
  # under the target project's bundler install root. Empty
@@ -262,10 +279,29 @@ module Rigor
262
279
  plugin_sig_paths = plugin_registry ? plugin_registry.signature_paths.map(&:to_s) : []
263
280
  loader_signature_paths = resolved_paths + plugin_sig_paths + gem_sig_paths + collection_paths
264
281
  merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
282
+ # ADR-32 WD4 + WD5 — invoke each loaded plugin's
283
+ # `source_rbs_synthesizer` once per project source file
284
+ # and collect non-nil `[filename, rbs_source]` pairs.
285
+ # The synthesizer-emitting plugin (currently only
286
+ # `rigor-rbs-inline`) is responsible for its own
287
+ # fail-soft on parse errors per WD6; this loop only
288
+ # filters `nil` returns.
289
+ #
290
+ # When a `cache_store` is supplied, each synthesizer
291
+ # invocation is memoised per
292
+ # `(file path + content SHA, plugin id + version + config_hash)`
293
+ # — WD5's cache key — so a second run with unchanged
294
+ # source skips the rbs-inline parse cost. The empty
295
+ # string is the sentinel for "no contribution" so the
296
+ # Store (which treats `nil` as cache miss) can persist
297
+ # the no-contribution decision too.
298
+ virtual_rbs = collect_virtual_rbs(plugin_registry, source_files, cache_store,
299
+ source_rbs_synthesis_reporter)
265
300
  loader = RbsLoader.new(
266
301
  libraries: merged_libraries,
267
302
  signature_paths: loader_signature_paths,
268
- cache_store: cache_store
303
+ cache_store: cache_store,
304
+ virtual_rbs: virtual_rbs
269
305
  )
270
306
  # ADR-20 slice 2c + 2e — seed hkt_registry with the
271
307
  # bundled builtins. The Environment's `#hkt_registry`
@@ -282,6 +318,7 @@ module Rigor
282
318
  dependency_source_index: dependency_source_index,
283
319
  rbs_extended_reporter: rbs_extended_reporter,
284
320
  boundary_cross_reporter: boundary_cross_reporter,
321
+ source_rbs_synthesis_reporter: source_rbs_synthesis_reporter,
285
322
  synthetic_method_index: synthetic_method_index,
286
323
  project_patched_methods: project_patched_methods,
287
324
  hkt_registry: Builtins::HktBuiltins.registry
@@ -295,6 +332,124 @@ module Rigor
295
332
  sig = Pathname(root) / DEFAULT_PROJECT_SIG_DIR
296
333
  sig.directory? ? [sig] : []
297
334
  end
335
+
336
+ # ADR-32 WD4 + WD5 — for each project source file, invoke
337
+ # every plugin-registered synthesizer once and collect
338
+ # non-nil returns. The returned array is `[[virtual_filename,
339
+ # rbs_source], ...]`; the loader threads it through to
340
+ # `RbsLoader.new(virtual_rbs: ...)`.
341
+ #
342
+ # `virtual_filename` is the source file path prefixed with
343
+ # the plugin id so RBS parse errors point back to the
344
+ # contributing plugin in their diagnostic location string.
345
+ #
346
+ # When no plugin declares a synthesizer (the common case),
347
+ # the registry's `source_rbs_synthesizers` is empty and
348
+ # this method short-circuits to `[]` without walking the
349
+ # file list.
350
+ #
351
+ # WD5 — when `cache_store` is supplied, each (file, plugin)
352
+ # synthesizer call is memoised through `Cache::Store`. The
353
+ # cache key composes the file's content SHA with the
354
+ # plugin's `PluginEntry` (id + version + config_hash) so a
355
+ # config change or content change invalidates the entry
356
+ # automatically.
357
+ def collect_virtual_rbs(plugin_registry, source_files, cache_store, reporter)
358
+ return [] if plugin_registry.nil?
359
+
360
+ synthesizers = plugin_registry.source_rbs_synthesizers
361
+ return [] if synthesizers.empty?
362
+ return [] if source_files.nil? || source_files.empty?
363
+
364
+ result = []
365
+ source_files.each do |path|
366
+ synthesizers.each do |plugin, callable|
367
+ outcome = synthesizer_output_for(plugin, callable, path, cache_store)
368
+ outcome = interpret_synthesizer_outcome(outcome, plugin, path, reporter)
369
+ next if outcome.nil? || outcome.empty?
370
+
371
+ virtual_name = "virtual:#{plugin.manifest.id}:#{path}"
372
+ result << [virtual_name, outcome]
373
+ end
374
+ end
375
+ result
376
+ end
377
+
378
+ # ADR-32 WD5 — cache wrapper around a single (plugin,
379
+ # file) invocation. The cache stores the empty string
380
+ # `""` as the "no contribution" sentinel because
381
+ # `Cache::Store` treats `nil` as a cache miss. Error
382
+ # tuples are stored as the canonical
383
+ # `[:error, message_string]` Array so the same wrapper
384
+ # short-circuits subsequent runs against unchanged broken
385
+ # input.
386
+ def synthesizer_output_for(plugin, callable, path, cache_store)
387
+ return invoke_synthesizer_safely(callable, path) if cache_store.nil?
388
+ return invoke_synthesizer_safely(callable, path) unless File.file?(path)
389
+
390
+ descriptor = build_synthesizer_cache_descriptor(plugin, path)
391
+ cache_store.fetch_or_compute(
392
+ producer_id: SYNTHESIZER_CACHE_PRODUCER_ID,
393
+ params: {},
394
+ descriptor: descriptor
395
+ ) { invoke_synthesizer_safely(callable, path) || "" }
396
+ end
397
+
398
+ # ADR-32 WD6 — route a synthesizer return value through
399
+ # the per-run failure reporter. The synthesizer's contract
400
+ # (declared in `plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb`)
401
+ # admits three return shapes:
402
+ # - `String` (non-empty) → successful RBS source
403
+ # - `nil` / `""` → no contribution
404
+ # - `[:error, message]` → parse failed
405
+ # The error tuple is converted into a reporter entry +
406
+ # treated as "no contribution" so the analysis pipeline
407
+ # continues. Reporter is `nil` for callers that don't care
408
+ # (legacy Environment.new, tests).
409
+ def interpret_synthesizer_outcome(outcome, plugin, path, reporter)
410
+ return outcome unless outcome.is_a?(Array) && outcome[0] == :error
411
+
412
+ reporter&.record(
413
+ plugin_id: plugin.manifest.id,
414
+ path: path,
415
+ message: outcome[1].to_s
416
+ )
417
+ nil
418
+ end
419
+
420
+ SYNTHESIZER_CACHE_PRODUCER_ID = "plugin.source_rbs_synthesizer"
421
+ private_constant :SYNTHESIZER_CACHE_PRODUCER_ID
422
+
423
+ def build_synthesizer_cache_descriptor(plugin, path)
424
+ Cache::Descriptor.new(
425
+ files: [Cache::Descriptor::FileEntry.new(
426
+ path: path.to_s,
427
+ comparator: :digest,
428
+ value: synthesizer_input_digest(path)
429
+ )],
430
+ plugins: [plugin.plugin_entry]
431
+ )
432
+ end
433
+
434
+ def synthesizer_input_digest(path)
435
+ Digest::SHA256.hexdigest(File.binread(path))
436
+ rescue ::SystemCallError
437
+ # Unreadable file → key on the path alone; the
438
+ # synthesizer's File.file?/File.read will see the same
439
+ # failure and return nil.
440
+ Digest::SHA256.hexdigest(path.to_s)
441
+ end
442
+
443
+ def invoke_synthesizer_safely(callable, path)
444
+ callable.call(path.to_s)
445
+ rescue StandardError
446
+ # WD6 fail-soft — a synthesizer that raises does NOT
447
+ # crash analysis. Slice 2b will turn this into a
448
+ # `source-rbs-synthesis-failed` info diagnostic; for now
449
+ # the contract is "no analysis crash on a misbehaving
450
+ # synthesizer".
451
+ nil
452
+ end
298
453
  end
299
454
 
300
455
  # Resolves a constant name to a Rigor::Type::Nominal (the *instance*
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../type"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Returns the inferred return type of a `Prism::DefNode`, or nil
10
+ # when no type can be derived (empty body, scope-lookup miss,
11
+ # or any failure during inference — caller surfaces "no
12
+ # annotation" on a nil).
13
+ #
14
+ # The inferred type is the union of:
15
+ #
16
+ # - the body's last-statement type, and
17
+ # - the type of every explicit `return value` reachable in the
18
+ # body. Nested `def` / lambda / block bodies are return
19
+ # barriers — their `return`s do not bubble up to the enclosing
20
+ # method.
21
+ #
22
+ # Extracted from `Rigor::SigGen::Generator#infer_return_type` so
23
+ # `LineTypeCollector` (`rigor annotate`'s def-line annotator)
24
+ # and the sig-generator share one source of truth.
25
+ module DefReturnTyper
26
+ RETURN_BARRIER_NODES = [Prism::DefNode, Prism::LambdaNode, Prism::BlockNode].freeze
27
+ private_constant :RETURN_BARRIER_NODES
28
+
29
+ module_function
30
+
31
+ def call(def_node, scope_index)
32
+ body = def_node.body
33
+ return nil if body.nil?
34
+
35
+ last = body_last_expression(body)
36
+ return nil if last.nil?
37
+
38
+ inner_scope = scope_index[last] || scope_index[body] || scope_index[def_node]
39
+ return nil if inner_scope.nil?
40
+
41
+ last_type = safe_type_of(inner_scope, last)
42
+ return nil if last_type.nil?
43
+
44
+ union_with_explicit_returns(body, last_type, scope_index)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ def body_last_expression(body)
50
+ case body
51
+ when Prism::StatementsNode then body.body.last
52
+ when Prism::BeginNode then body_last_expression(body.statements)
53
+ else body
54
+ end
55
+ end
56
+
57
+ def union_with_explicit_returns(body, last_type, scope_index)
58
+ return_types = []
59
+ collect_return_types(body, scope_index, return_types)
60
+ return last_type if return_types.empty?
61
+
62
+ Type::Combinator.union(last_type, *return_types)
63
+ end
64
+
65
+ def collect_return_types(node, scope_index, out)
66
+ return unless node.is_a?(Prism::Node)
67
+ return if RETURN_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
68
+
69
+ type_return_node(node, scope_index, out) if node.is_a?(Prism::ReturnNode)
70
+ node.compact_child_nodes.each { |c| collect_return_types(c, scope_index, out) }
71
+ end
72
+
73
+ def type_return_node(return_node, scope_index, out)
74
+ args = return_node.arguments&.arguments || []
75
+ if args.empty?
76
+ out << Type::Combinator.constant_of(nil)
77
+ return
78
+ end
79
+
80
+ scope = scope_index[return_node] || scope_index[args.first]
81
+ return if scope.nil?
82
+ # `return a, b` packs into a Tuple at runtime; the MVP only
83
+ # handles the single-value form. Multi-arg returns
84
+ # contribute no type to keep the implementation focused.
85
+ return unless args.size == 1
86
+
87
+ type = safe_type_of(scope, args.first)
88
+ out << type unless type.nil?
89
+ end
90
+
91
+ def safe_type_of(scope, node)
92
+ scope.type_of(node)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+ end
97
+ end
98
+ end
@@ -8,6 +8,7 @@ require_relative "block_parameter_binder"
8
8
  require_relative "fallback"
9
9
  require_relative "macro_block_self_type"
10
10
  require_relative "method_dispatcher"
11
+ require_relative "narrowing"
11
12
 
12
13
  module Rigor
13
14
  module Inference
@@ -660,16 +661,26 @@ module Rigor
660
661
  end
661
662
 
662
663
  # Returns `:truthy`, `:falsey`, or `nil` for an arbitrary
663
- # predicate expression. Only `Type::Constant` answers
664
- # decisively `Union[true, false]`, `Nominal[bool]`, and
665
- # `Dynamic[T]` keep both branches live.
664
+ # predicate expression under three-valued logic. Uses the
665
+ # same {Narrowing} probe as `StatementEvaluator#eval_if`:
666
+ # the predicate is truthy when its falsey fragment is `Bot`,
667
+ # falsey when its truthy fragment is `Bot`. So
668
+ # `Nominal[Integer]` (always truthy in Ruby), `Constant[nil]`,
669
+ # and `Constant[false]` fold one branch; `Union[true, false]`,
670
+ # `Dynamic[T]`, and `Top` keep both branches live.
666
671
  def constant_predicate_polarity(predicate)
667
672
  return nil if predicate.nil?
668
673
 
669
674
  type = type_of(predicate)
670
- return nil unless type.is_a?(Type::Constant)
675
+ return nil if type.nil? || type.is_a?(Type::Bot)
671
676
 
672
- type.value ? :truthy : :falsey
677
+ truthy_bot = Narrowing.narrow_truthy(type).is_a?(Type::Bot)
678
+ falsey_bot = Narrowing.narrow_falsey(type).is_a?(Type::Bot)
679
+
680
+ return :falsey if truthy_bot && !falsey_bot
681
+ return :truthy if !truthy_bot && falsey_bot
682
+
683
+ nil
673
684
  end
674
685
 
675
686
  def type_of_else(node)
@@ -712,15 +723,135 @@ module Rigor
712
723
  type.value ? :truthy : :falsey
713
724
  end
714
725
 
726
+ # Three-valued evaluation of `case predicate when pattern`
727
+ # dispatch. For each `when` clause we ask: under static types,
728
+ # does `pattern === predicate` definitely match (`:yes`),
729
+ # definitely not match (`:no`), or possibly match (`:maybe`)?
730
+ # Walking in source order:
731
+ #
732
+ # - `:yes` — this branch fires, subsequent branches are
733
+ # unreachable. Result = union(prior `:maybe` branches, this
734
+ # `:yes` branch).
735
+ # - `:no` — branch dropped.
736
+ # - `:maybe` — branch is a candidate, continue.
737
+ #
738
+ # If no `:yes` was reached, the else clause (or `Constant[nil]`
739
+ # when absent) is added to the candidate set.
740
+ #
741
+ # The `case ... in` pattern-matching form (`CaseMatchNode`) and
742
+ # the predicate-less form (`case; when c1; ...`) bypass the
743
+ # `===` analysis: pattern matching has richer semantics, and a
744
+ # predicate-less `case` reduces to a `if c1; ...; elsif c2`
745
+ # chain that statement-level narrowing already handles.
715
746
  def type_of_case(node)
716
- branch_types = node.conditions.map { |branch| type_of(branch) }
717
- else_type =
718
- if node.else_clause
719
- type_of(node.else_clause)
720
- else
721
- Type::Combinator.constant_of(nil)
747
+ return type_of_case_simple_union(node) if node.is_a?(Prism::CaseMatchNode) || node.predicate.nil?
748
+
749
+ subject_type = type_of(node.predicate)
750
+ candidates = []
751
+ reached_yes = false
752
+
753
+ node.conditions.each do |when_node|
754
+ case case_when_branch_certainty(subject_type, when_node)
755
+ when :yes
756
+ candidates << type_of(when_node)
757
+ reached_yes = true
758
+ break
759
+ when :maybe
760
+ candidates << type_of(when_node)
761
+ # :no — drop the branch
722
762
  end
723
- Type::Combinator.union(*branch_types, else_type)
763
+ end
764
+
765
+ candidates << type_of_case_else(node) unless reached_yes
766
+ Type::Combinator.union(*candidates)
767
+ end
768
+
769
+ def type_of_case_simple_union(node)
770
+ branch_types = node.conditions.map { |branch| type_of(branch) }
771
+ Type::Combinator.union(*branch_types, type_of_case_else(node))
772
+ end
773
+
774
+ def type_of_case_else(node)
775
+ return Type::Combinator.constant_of(nil) if node.else_clause.nil?
776
+
777
+ type_of(node.else_clause)
778
+ end
779
+
780
+ # Combines per-pattern certainty across a `when` clause's
781
+ # conditions (`when a, b, c` ≡ `a === s || b === s || c === s`).
782
+ # `:yes` if any pattern is `:yes`; `:no` if all are `:no`;
783
+ # `:maybe` otherwise.
784
+ def case_when_branch_certainty(subject_type, when_node)
785
+ return :maybe unless when_node.respond_to?(:conditions)
786
+
787
+ results = when_node.conditions.map { |c| case_when_pattern_certainty(subject_type, c) }
788
+ return :maybe if results.empty?
789
+ return :yes if results.include?(:yes)
790
+ return :no if results.all?(:no)
791
+
792
+ :maybe
793
+ end
794
+
795
+ # Static three-valued certainty for `pattern === subject`.
796
+ # Specialises two pattern shapes:
797
+ #
798
+ # - **Class / Module reference** (`Integer`, `Foo::Bar`):
799
+ # reduce to `subject.is_a?(class)` via
800
+ # `Narrowing.narrow_class` / `narrow_not_class`. A Bot
801
+ # truthy fragment means no inhabitant matches (`:no`); a
802
+ # Bot falsey fragment means every inhabitant matches
803
+ # (`:yes`).
804
+ # - **Value-equality literal** (numeric / String / Symbol /
805
+ # true / false / nil) against a `Constant[c]` subject:
806
+ # the static comparison `pattern_value === c` is exact.
807
+ # Other subject carriers stay `:maybe` because the
808
+ # runtime value isn't pinned.
809
+ #
810
+ # Other pattern shapes (Range, Regexp, custom `===`) stay
811
+ # `:maybe` — the existing union fallback handles them.
812
+ def case_when_pattern_certainty(subject_type, pattern_node)
813
+ class_name = build_constant_path_name(pattern_node)
814
+ return class_pattern_certainty(subject_type, class_name) if class_name
815
+
816
+ literal = literal_pattern_value(pattern_node)
817
+ return literal_pattern_certainty(subject_type, literal[:value]) if literal
818
+
819
+ :maybe
820
+ end
821
+
822
+ def class_pattern_certainty(subject_type, class_name)
823
+ env = scope.environment
824
+ truthy_bot = Narrowing.narrow_class(subject_type, class_name, environment: env).is_a?(Type::Bot)
825
+ falsey_bot = Narrowing.narrow_not_class(subject_type, class_name, environment: env).is_a?(Type::Bot)
826
+
827
+ return :no if truthy_bot && !falsey_bot
828
+ return :yes if !truthy_bot && falsey_bot
829
+
830
+ :maybe
831
+ end
832
+
833
+ VALUE_EQUALITY_CLASSES = [Integer, Float, Rational, Complex, String, Symbol,
834
+ TrueClass, FalseClass, NilClass].freeze
835
+ private_constant :VALUE_EQUALITY_CLASSES
836
+
837
+ # Returns `{ value: v }` when `pattern_node` types to a
838
+ # `Constant[v]` of a value-equality-safe class (so `===`
839
+ # reduces to `==`), else nil. Wrapped in a hash so a literal
840
+ # `nil` / `false` value doesn't collide with the "no literal"
841
+ # signal.
842
+ def literal_pattern_value(pattern_node)
843
+ type = type_of(pattern_node)
844
+ return nil unless type.is_a?(Type::Constant)
845
+ return nil unless VALUE_EQUALITY_CLASSES.any? { |klass| type.value.is_a?(klass) }
846
+
847
+ { value: type.value }
848
+ end
849
+
850
+ def literal_pattern_certainty(subject_type, pattern_value)
851
+ return :maybe unless subject_type.is_a?(Type::Constant)
852
+ return :maybe unless VALUE_EQUALITY_CLASSES.any? { |klass| subject_type.value.is_a?(klass) }
853
+
854
+ pattern_value == subject_type.value ? :yes : :no
724
855
  end
725
856
 
726
857
  # `when` clauses for `case` and `in` clauses for `case ... in` have
@@ -988,6 +988,11 @@ module Rigor
988
988
  magnitude: :range_unary_abs,
989
989
  "-@": :range_unary_negate,
990
990
  "+@": :range_unary_identity,
991
+ # `Integer#to_i` / `Integer#to_int` are identity operations —
992
+ # they return `self` — so the IntegerRange carrier is preserved.
993
+ # `positive-int.to_i → positive-int`, etc.
994
+ to_i: :range_unary_identity,
995
+ to_int: :range_unary_identity,
991
996
  bit_length: :range_unary_bit_length
992
997
  }.freeze
993
998
  private_constant :UNARY_RANGE_DIRECT
@@ -65,6 +65,11 @@ module Rigor
65
65
  # == ""`); the carrier collapses from `non-empty-literal-string`
66
66
  # down to plain `literal-string`.
67
67
  LITERAL_PRESERVING_METHODS = %i[strip lstrip rstrip chomp chop scrub].freeze
68
+ # Methods that preserve literal-bearing AND non-empty-string-ness.
69
+ # Unlike `LITERAL_PRESERVING_METHODS` (strip/chomp/etc.) these do
70
+ # not reduce the string — they transform characters without
71
+ # removing any, so a non-empty receiver stays non-empty.
72
+ NON_EMPTY_LITERAL_PRESERVING_METHODS = %i[upcase downcase capitalize swapcase reverse].freeze
68
73
  # v0.1.1 Track 1 slice 5c — width-padding methods. `center`
69
74
  # / `ljust` / `rjust` take a `width` Integer plus an
70
75
  # optional literal padding `String`. When the receiver
@@ -72,28 +77,32 @@ module Rigor
72
77
  # literal-bearing, the result is literal-bearing too.
73
78
  WIDTH_PADDING_METHODS = %i[center ljust rjust].freeze
74
79
  private_constant :CONCAT_METHODS, :FORMAT_METHODS,
75
- :LITERAL_PRESERVING_METHODS, :WIDTH_PADDING_METHODS
80
+ :LITERAL_PRESERVING_METHODS, :NON_EMPTY_LITERAL_PRESERVING_METHODS,
81
+ :WIDTH_PADDING_METHODS
76
82
 
77
83
  def try_dispatch(receiver:, method_name:, args:, **)
78
84
  return fold_array_join(receiver, args) if method_name == :join
79
85
  return fold_format(args) if FORMAT_METHODS.include?(method_name)
80
-
81
86
  return nil unless Type::Combinator.literal_string_compatible?(receiver)
82
-
83
87
  return fold_string_percent(args) if method_name == :%
84
- if args.empty?
85
- return LITERAL_PRESERVING_METHODS.include?(method_name) ? Type::Combinator.literal_string : nil
86
- end
88
+ return fold_no_arg(receiver, method_name) if args.empty?
87
89
  return fold_width_pad(args) if WIDTH_PADDING_METHODS.include?(method_name)
88
90
  return nil unless args.size == 1
89
91
 
90
92
  if CONCAT_METHODS.include?(method_name)
91
- fold_concat(args.first)
93
+ fold_concat(receiver, args.first)
92
94
  elsif method_name == :*
93
- fold_repeat(args.first)
95
+ fold_repeat(receiver, args.first)
94
96
  end
95
97
  end
96
98
 
99
+ def fold_no_arg(receiver, method_name)
100
+ return Type::Combinator.literal_string if LITERAL_PRESERVING_METHODS.include?(method_name)
101
+ return non_empty_literal_result(receiver) if NON_EMPTY_LITERAL_PRESERVING_METHODS.include?(method_name)
102
+
103
+ nil
104
+ end
105
+
97
106
  # `String#center` / `#ljust` / `#rjust` — first argument is
98
107
  # the target width (Integer-typed), optional second
99
108
  # argument is the padding string (must be literal-bearing
@@ -111,19 +120,39 @@ module Rigor
111
120
  Type::Combinator.literal_string
112
121
  end
113
122
 
114
- def fold_concat(arg_type)
115
- return nil unless Type::Combinator.literal_string_compatible?(arg_type)
123
+ def fold_concat(receiver, arg)
124
+ return nil unless Type::Combinator.literal_string_compatible?(arg)
125
+
126
+ if Type::Combinator.non_empty_string_compatible?(receiver) ||
127
+ Type::Combinator.non_empty_string_compatible?(arg)
128
+ return Type::Combinator.non_empty_literal_string
129
+ end
116
130
 
117
131
  Type::Combinator.literal_string
118
132
  end
119
133
 
120
- def fold_repeat(arg_type)
121
- return nil unless integer_typed?(arg_type)
122
- return nil if known_negative_integer?(arg_type)
134
+ def fold_repeat(receiver, arg)
135
+ return nil unless integer_typed?(arg)
136
+ return nil if known_negative_integer?(arg)
137
+ return Type::Combinator.constant_of("") if known_zero_integer?(arg)
138
+
139
+ if Type::Combinator.non_empty_string_compatible?(receiver) && known_positive_integer?(arg)
140
+ return Type::Combinator.non_empty_literal_string
141
+ end
123
142
 
124
143
  Type::Combinator.literal_string
125
144
  end
126
145
 
146
+ # Returns `non_empty_literal_string` when the receiver is provably
147
+ # non-empty; otherwise collapses to plain `literal_string`.
148
+ def non_empty_literal_result(receiver)
149
+ if Type::Combinator.non_empty_string_compatible?(receiver)
150
+ Type::Combinator.non_empty_literal_string
151
+ else
152
+ Type::Combinator.literal_string
153
+ end
154
+ end
155
+
127
156
  # `[lit, lit].join(sep)` — receiver must be a Tuple
128
157
  # whose every element is literal-bearing; separator
129
158
  # (when given) must be literal-bearing too. Multi-arg
@@ -205,9 +234,27 @@ module Rigor
205
234
  type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.negative?
206
235
  end
207
236
 
208
- private_class_method :fold_concat, :fold_repeat, :fold_array_join,
237
+ def known_zero_integer?(type)
238
+ case type
239
+ when Type::Constant then type.value.is_a?(Integer) && type.value.zero?
240
+ when Type::IntegerRange then type.lower.zero? && type.upper.zero?
241
+ else false
242
+ end
243
+ end
244
+
245
+ def known_positive_integer?(type)
246
+ case type
247
+ when Type::Constant then type.value.is_a?(Integer) && type.value.positive?
248
+ when Type::IntegerRange then type.lower >= 1
249
+ else false
250
+ end
251
+ end
252
+
253
+ private_class_method :fold_no_arg, :fold_concat, :fold_repeat, :fold_array_join,
209
254
  :fold_format, :fold_string_percent, :fold_width_pad,
210
- :literal_or_constant?, :integer_typed?
255
+ :non_empty_literal_result, :literal_or_constant?,
256
+ :integer_typed?, :known_negative_integer?,
257
+ :known_zero_integer?, :known_positive_integer?
211
258
  end
212
259
  end
213
260
  end