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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +73 -3
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- metadata +32 -1
data/lib/rigor/environment.rb
CHANGED
|
@@ -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.
|
|
664
|
-
#
|
|
665
|
-
#
|
|
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
|
|
675
|
+
return nil if type.nil? || type.is_a?(Type::Bot)
|
|
671
676
|
|
|
672
|
-
type.
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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, :
|
|
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(
|
|
115
|
-
return nil unless Type::Combinator.literal_string_compatible?(
|
|
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(
|
|
121
|
-
return nil unless integer_typed?(
|
|
122
|
-
return nil if known_negative_integer?(
|
|
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
|
-
|
|
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?,
|
|
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
|