Skip to content

Commit 349cf6d

Browse files
committed
Merge castwide#1266: structurally verify RBS interface-typed expectations
Two conflicts resolved: lib/solargraph/complex_type/conformance.rb: HEAD's intersection-type check (from castwide#1231, `conforms_to_intersection_expectation?`) and castwide#1266's new `interface_bypass_verdict` mechanism both needed to run, in that order — an expectation of `A & B` where either conjunct is an RBS interface must still resolve the interface question per-conjunct, not skip it. `interface_bypass_verdict` replaces the old blanket `:allow_unmatched_interface` short-circuit with a 3-way verdict (true/false/nil) based on `structural_interface_verdict`, deferring to the old blanket bypass only when no structural verdict can be reached. spec/complex_type/conforms_to_spec.rb: - Dropped a `pending 'nil does not yet simplify to NilClass (issue castwide#1196, fixed by PR castwide#1223)'` marker after confirming directly (`inf.conforms_to?(api_map, exp, :method_call)` => true) that castwide#1223, already merged into this branch, fixes it. - Combined HEAD's `context 'with intersection types'` (castwide#1231) and castwide#1266's `context 'with RBS interface types'` as sibling contexts rather than choosing one; kept castwide#1266's two `pending` markers for issue castwide#1267 (structural interface checks don't yet verify return types/arity) as-is since those are castwide#1266's own honest, still-open limitations. Verified: spec/complex_type/conforms_to_spec.rb + spec/complex_type (56 examples, 0 failures, 4 pending), and a broader safety net — spec/type_checker, spec/source_map/clip_spec.rb, spec/parser/flow_sensitive_typing_spec.rb (539 examples, 0 failures, 17 pending) — all passing locally.
2 parents 434e792 + b5cdb3f commit 349cf6d

2 files changed

Lines changed: 133 additions & 4 deletions

File tree

lib/solargraph/complex_type/conformance.rb

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ def conforms_to_unique_type?
4747
# satisfying both).
4848
return conforms_to_intersection_expectation? if expected.is_a?(UniqueType::Intersection)
4949

50-
return true if ignore_interface?
50+
interface_verdict = interface_bypass_verdict
51+
return interface_verdict unless interface_verdict.nil?
52+
5153
return true if conforms_via_reverse_match?
5254

5355
downcast_inferred = inferred.downcast_to_literal_if_possible
@@ -107,9 +109,34 @@ def conforms_via_stripped_expected_parameters?
107109
with_new_types(inferred, expected.erase_parameters).conforms_to_unique_type?
108110
end
109111

110-
def ignore_interface?
111-
(expected.any?(&:interface?) && rules.include?(:allow_unmatched_interface)) ||
112-
(inferred.interface? && rules.include?(:allow_unmatched_interface))
112+
# Resolves interface-typed conformance before any parameter/subtype
113+
# comparisons run, the same way the old blanket
114+
# :allow_unmatched_interface bypass did. That's deliberate: RBS
115+
# interfaces can have their own type parameters (e.g. `_Each[Elem]`),
116+
# and this doesn't verify those (see
117+
# https://github.com/castwide/solargraph/issues/1267), so once the
118+
# interface question is settled, comparing `expected`'s subtypes
119+
# against `inferred`'s would either be meaningless or wrong.
120+
#
121+
# There's no structural check when the *inferred* type is itself an
122+
# abstract interface (e.g., a method returns `_ToAry`), since
123+
# Solargraph doesn't know which concrete type will show up at
124+
# runtime, so :allow_unmatched_interface remains a blanket escape
125+
# hatch for that direction.
126+
#
127+
# @return [Boolean, nil] true/false if the interface question
128+
# settles conformance outright, nil if there's no interface
129+
# involved (or no verdict could be reached and no fallback rule
130+
# applies), meaning normal conformance checking should proceed
131+
def interface_bypass_verdict
132+
return true if inferred.interface? && rules.include?(:allow_unmatched_interface)
133+
return nil unless expected.interface?
134+
135+
verdict = structural_interface_verdict
136+
return verdict unless verdict.nil?
137+
return true if rules.include?(:allow_unmatched_interface)
138+
139+
nil
113140
end
114141

115142
def can_strip_expected_parameters?
@@ -148,6 +175,26 @@ def erased_type_conforms?
148175
true
149176
end
150177

178+
# The methods `expected` declares directly on itself, excluding ones
179+
# inherited from `Object` and other ancestors.
180+
#
181+
# @return [Array<Pin::Method>]
182+
def required_interface_methods
183+
api_map.get_methods(expected.name, scope: :instance)
184+
.select { |pin| pin.closure&.path == expected.name }
185+
end
186+
187+
# @return [Boolean, nil] true or false if `expected`'s directly
188+
# declared methods could be checked against `inferred`'s method
189+
# stack, or nil if no verdict could be reached (e.g., the interface
190+
# has no pin, or declares no methods of its own)
191+
def structural_interface_verdict
192+
required = required_interface_methods
193+
return nil if required.empty?
194+
195+
required.all? { |pin| !api_map.get_method_stack(inferred.name, pin.name, scope: :instance).empty? }
196+
end
197+
151198
def key_types_conform?
152199
return true if expected.key_types.empty?
153200

spec/complex_type/conforms_to_spec.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,88 @@ def to_str
355355
end
356356
end
357357

358+
context 'with RBS interface types' do
359+
it 'structurally validates a type that satisfies the interface, without any rule' do
360+
exp = described_class.parse('Hash::_Key')
361+
inf = described_class.parse('Symbol')
362+
match = inf.conforms_to?(api_map, exp, :method_call)
363+
expect(match).to be(true)
364+
end
365+
366+
it 'structurally invalidates a type that does not satisfy the interface, even with allow_unmatched_interface' do
367+
exp = described_class.parse('_ToAry')
368+
inf = described_class.parse('Integer')
369+
match = inf.conforms_to?(api_map, exp, :method_call, [:allow_unmatched_interface])
370+
expect(match).to be(false)
371+
end
372+
373+
it 'rejects a type that does not satisfy the interface when the rule is absent' do
374+
exp = described_class.parse('_ToAry')
375+
inf = described_class.parse('Integer')
376+
match = inf.conforms_to?(api_map, exp, :method_call)
377+
expect(match).to be(false)
378+
end
379+
380+
it 'validates a type that satisfies the interface via a core fill include' do
381+
exp = described_class.parse('_ToAry')
382+
inf = described_class.parse('Array')
383+
match = inf.conforms_to?(api_map, exp, :method_call)
384+
expect(match).to be(true)
385+
end
386+
387+
it 'falls back to allow_unmatched_interface when the interface pin cannot be found' do
388+
exp = described_class::UniqueType.new('_NoSuchInterface', rooted: true)
389+
inf = described_class.parse('Integer')
390+
expect(inf.conforms_to?(api_map, exp, :method_call, [:allow_unmatched_interface])).to be(true)
391+
expect(inf.conforms_to?(api_map, exp, :method_call)).to be(false)
392+
end
393+
394+
it 'rejects a same-named method with the wrong return type' do
395+
# https://github.com/castwide/solargraph/issues/1267
396+
#
397+
# The structural check only confirms a `to_ary` method exists; it
398+
# doesn't verify it actually returns an Array.
399+
pending 'structural interface conformance does not yet check method return types (issue #1267)'
400+
source = Solargraph::Source.load_string(%(
401+
class BadToAry
402+
# @return [String]
403+
def to_ary
404+
'not an array'
405+
end
406+
end
407+
))
408+
api_map.map source
409+
exp = described_class.parse('_ToAry')
410+
inf = described_class.parse('BadToAry')
411+
match = inf.conforms_to?(api_map, exp, :method_call)
412+
expect(match).to be(false)
413+
end
414+
415+
it 'rejects a same-named method with the wrong arity' do
416+
# https://github.com/castwide/solargraph/issues/1267
417+
#
418+
# The structural check only confirms an `eql?` method exists; it
419+
# doesn't verify it accepts the argument Hash::_Key#eql? requires.
420+
pending 'structural interface conformance does not yet check method parameters (issue #1267)'
421+
source = Solargraph::Source.load_string(%(
422+
class BadKey
423+
def eql?
424+
true
425+
end
426+
427+
def hash
428+
1
429+
end
430+
end
431+
))
432+
api_map.map source
433+
exp = described_class.parse('Hash::_Key')
434+
inf = described_class.parse('BadKey')
435+
match = inf.conforms_to?(api_map, exp, :method_call)
436+
expect(match).to be(false)
437+
end
438+
end
439+
358440
context 'with inheritance relationship in allow_reverse_match mode' do
359441
let(:api_map) { Solargraph::ApiMap.new }
360442
let(:sup) { described_class.parse('String') }

0 commit comments

Comments
 (0)