Skip to content
Draft
65 changes: 65 additions & 0 deletions lib/solargraph/api_map.rb
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,29 @@ def type_include? host_ns, module_ns
store.get_includes(host_ns).map { |inc_tag| inc_tag.type.name }.include?(module_ns)
end

# @return [Hash{String => Boolean}]
def cached_yields_type_parameter
@cached_yields_type_parameter ||= {}
end

# @param ancestor_name [String]
# @return [Boolean]
def uncached_yields_type_parameter? ancestor_name
namespace_pin = store.get_path_pins(ancestor_name).select { |pin| pin.is_a?(Pin::Namespace) }.first
return false unless namespace_pin.is_a?(Pin::Namespace)
return false unless namespace_pin.generics.length == 1

generic_tag = "generic<#{namespace_pin.generics.first}>"
store.get_methods(ancestor_name).any? do |method_pin|
method_pin.signatures.any? do |signature|
block = signature.block
next false if block.nil?

block.parameters.any? { |param| param.return_type.to_s == generic_tag }
end
end
end

# @param pins [Enumerable<Pin::Base>]
# @param visibility [Enumerable<Symbol>]
# @return [Array<Pin::Base>]
Expand Down Expand Up @@ -793,6 +816,48 @@ def inner_get_methods_from_reference fq_reference_tag, namespace_pin, type, scop
methods
end

# Express `type` as the ancestor named `ancestor_name`, resolving that
# ancestor's own type arguments from `type`'s parameters - given Hash's
# `include Enumerable[[K, V]]`, `Hash{String => Integer}` becomes
# `Enumerable<Array(String, Integer)>`.
#
# A bare `include Enumerable` declares no arguments, so it resolves to a
# parameterless `Enumerable`: the ancestry says nothing about how the
# includer's params relate to the ancestor's.
#
# @param type [ComplexType::UniqueType]
# @param ancestor_name [String] unrooted name, as ComplexType#name reports
# @return [ComplexType::UniqueType, nil] nil unless an ancestor matches
def type_as_ancestor type, ancestor_name
namespace_pin = store.get_path_pins(type.name).select { |pin| pin.is_a?(Pin::Namespace) }.first
return nil if namespace_pin.nil?

context = ComplexType.new([type])
store.get_ancestor_references(type.name).each do |ref|
tag = store.constants.dereference(ref)
next if tag.nil?
resolved = ComplexType.parse(tag).force_rooted.resolve_generics(namespace_pin, context).first
return resolved if resolved.name == ancestor_name
end
nil
end

# Whether `ancestor_name`'s single type parameter is bound to what its
# methods yield, as `Enumerable[E]` binds E through `map`'s block. Such a
# parameter describes one yielded element, so an includer conforms by the
# shape it yields rather than by matching parameters one for one.
#
# A module yielding nothing - `Taggable[X]` - says nothing about its
# includers' parameters, and gets no such treatment.
#
# @param ancestor_name [String]
# @return [Boolean]
def yields_type_parameter? ancestor_name
cached_yields_type_parameter.fetch(ancestor_name) do
cached_yields_type_parameter[ancestor_name] = uncached_yields_type_parameter?(ancestor_name)
end
end

# @param fq_sub_tag [String]
# @return [String, nil]
def qualify_superclass fq_sub_tag
Expand Down
50 changes: 50 additions & 0 deletions lib/solargraph/complex_type/conformance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ def conforms_to_unique_type?

return false unless erased_type_conforms?

# Where the ancestry declares how the ancestor's params derive from
# ours - Hash's `include Enumerable[[K, V]]` - resolve them and
# compare against that rather than guessing a shape.
declared = declared_ancestor_view
return with_new_types(declared, expected).conforms_to_unique_type? if declared

# Hash{K=>V} and <A,B>-generic types yield their params together as
# one tuple via #each, not one at a time - compare against that
# tuple shape, not raw per-param types, for a lower-arity ancestor.
return with_new_types(pair_shaped_as_pairs, expected).conforms_to_unique_type? if pair_shaped_viewed_as_pairs?

return true if inferred.all_params.empty? && rules.include?(:allow_empty_params)

# at this point we know the erased type is fine - time to look at parameters
Expand All @@ -78,6 +89,20 @@ def conforms_to_unique_type?

private

# @return [UniqueType, nil] `inferred` expressed as `expected`'s
# ancestor, when that ancestor is declared with arguments derived
# from `inferred`'s own params. nil when the ancestry declares none
# (a bare include), leaving nothing to resolve.
def declared_ancestor_view
return nil if inferred.name == expected.name
return nil if inferred.all_params.empty? || expected.all_params.empty?

view = api_map.type_as_ancestor(inferred, expected.name)
return nil if view.nil? || view.all_params.empty?

view
end

def only_inferred_parameters?
!expected.parameters? && inferred.parameters?
end
Expand Down Expand Up @@ -127,6 +152,31 @@ def erased_type_conforms?
true
end

# @return [Boolean] true if `inferred`'s 2+ ordered params need to be
# viewed as one tuple, not compared one-for-one, against a
# mismatched-arity Enumerable/_Each expectation
def pair_shaped_viewed_as_pairs?
return false unless inferred.all_params.size >= 2
return false unless api_map.yields_type_parameter?(expected.name)

return expected.parameters_type != :hash if inferred.parameters_type == :hash

inferred.parameters_type == :list && inferred.all_params.size != expected.all_params.size
end

# @return [UniqueType] `inferred` reshaped as `expected`'s name,
# parametrized with a single tuple of its own params - [key, value]
# for hash-shaped, or its ordered params for a list-generic type
def pair_shaped_as_pairs
ordered_params = if inferred.parameters_type == :hash
[ComplexType.new(inferred.key_types), ComplexType.new(inferred.subtypes)]
else
inferred.all_params
end
pair = UniqueType.new('Array', [], ordered_params, rooted: true, parameters_type: :fixed)
UniqueType.new(expected.name, [], [pair], rooted: inferred.rooted?, parameters_type: :list)
end

def key_types_conform?
return true if expected.key_types.empty?

Expand Down
14 changes: 14 additions & 0 deletions spec/api_map_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1005,4 +1005,18 @@ def self.property(name, default, type:, comment:)
# @todo Undefined because the return tag expands to `type: String`
expect(pins.map(&:return_type).map(&:tag)).to eq(%w[undefined])
end

it 'resolves an ancestor\'s declared type arguments against the type' do
api_map = described_class.new
hash = Solargraph::ComplexType.parse('Hash{String => Integer}').first
# Hash's core signature declares `include Enumerable[[K, V]]`, so K and V
# bind to String and Integer.
expect(api_map.type_as_ancestor(hash, 'Enumerable').to_s).to eq('Enumerable<Array(String, Integer)>')
end

it 'returns nil for a namespace that is not an ancestor' do
api_map = described_class.new
hash = Solargraph::ComplexType.parse('Hash{String => Integer}').first
expect(api_map.type_as_ancestor(hash, 'Comparable')).to be_nil
end
end
190 changes: 190 additions & 0 deletions spec/complex_type/conforms_to_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,196 @@ class Sub < Sup; end
expect(match).to be(true)
end

it 'reshapes a Hash into key/value pairs to conform to a lower-arity Enumerable ancestor' do
exp = described_class.parse('Enumerable<Array(Symbol, String)>')
inf = described_class.parse('Hash{Symbol => String}')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(true)
end

it 'reshapes any hash-shaped (parameters_type == :hash) type into pairs, not just Hash' do
# YARD's `{K => V}` tag syntax produces parameters_type == :hash for
# any class name, not just Hash - PairBag proves the reshape is
# structural, not name-based.
source = Solargraph::Source.load_string(%(
class PairBag
include Enumerable
end
))
api_map.map source
exp = described_class.parse('Enumerable<Array(Symbol, String)>')
inf = described_class.parse('PairBag{Symbol => String}')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(true)
end

it 'reshapes a :list-parameterized (ordinary <A, B> generic) 2-arity type into a tuple ' \
'to conform to a lower-arity Enumerable ancestor' do
# `Pair` uses ordinary `<A, B>` generic syntax (parameters_type ==
# :list), not YARD's `{K => V}` hash tag - the same reshape must
# apply to both param shapes.
source = Solargraph::Source.load_string(%(
# @generic A
# @generic B
class Pair
include Enumerable

# @param a [generic<A>]
# @param b [generic<B>]
def initialize(a, b)
@a = a
@b = b
end

# @yieldparam [Array(generic<A>, generic<B>)]
def each
yield [@a, @b]
end
end
))
api_map.map source
exp = described_class.parse('Enumerable<Array(Symbol, String)>')
inf = described_class.parse('Pair<Symbol, String>')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(true)
end

it 'does not reshape a 2-arity :list type that does not include Enumerable' do
source = Solargraph::Source.load_string(%(
# @generic A
# @generic B
class NotEnumerablePair
# @param a [generic<A>]
# @param b [generic<B>]
def initialize(a, b)
@a = a
@b = b
end
end
))
api_map.map source
exp = described_class.parse('Enumerable<Array(Symbol, String)>')
inf = described_class.parse('NotEnumerablePair<Symbol, String>')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(false)
end

it 'reshapes a 3-arity :list type into a 3-tuple to conform to a lower-arity Enumerable ancestor' do
source = Solargraph::Source.load_string(%(
# @generic A
# @generic B
# @generic C
class Triple
include Enumerable

# @param a [generic<A>]
# @param b [generic<B>]
# @param c [generic<C>]
def initialize(a, b, c)
@a = a
@b = b
@c = c
end

# @yieldparam [Array(generic<A>, generic<B>, generic<C>)]
def each
yield [@a, @b, @c]
end
end
))
api_map.map source
exp = described_class.parse('Enumerable<Array(Symbol, String, Integer)>')
inf = described_class.parse('Triple<Symbol, String, Integer>')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(true)

exp2 = described_class.parse('Enumerable<Array(Symbol, String)>')
expect(inf.conforms_to?(api_map, exp2, :method_call)).to be(false)
end

it 'does not reshape a :list type into a tuple for a lower-arity ancestor whose own ' \
'generic param is unrelated to the inferred type\'s params' do
# Taggable's X has no relation to Pair's A/B (unlike Enumerable, RBS-
# bound to Hash's K/V via `include Enumerable[[K, V]]`) - an arity
# mismatch alone must not wrap [A, B] into a tuple and call it X.
source = Solargraph::Source.load_string(%(
# @generic X
module Taggable
end

# @generic A
# @generic B
class Pair
include Taggable

# @param a [generic<A>]
# @param b [generic<B>]
def initialize(a, b)
@a = a
@b = b
end
end
))
api_map.map source
exp = described_class.parse('Taggable<Array(Symbol, String)>')
inf = described_class.parse('Pair<Symbol, String>')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(false)
end

it 'does not reshape a 3-arity :list type into a tuple for a 2-arity non-Enumerable ancestor' do
source = Solargraph::Source.load_string(%(
# @generic X
# @generic Y
module Labeled
end

# @generic A
# @generic B
# @generic C
class Triple2
include Labeled

# @param a [generic<A>]
# @param b [generic<B>]
# @param c [generic<C>]
def initialize(a, b, c)
@a = a
@b = b
@c = c
end
end
))
api_map.map source
exp = described_class.parse('Labeled<Array(Symbol, String, Integer), Integer>')
inf = described_class.parse('Triple2<Symbol, String, Integer>')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(false)
end

it 'does not reshape a hash-shaped type into a tuple for a lower-arity, non-Enumerable ' \
'ancestor whose own generic param is unrelated to the inferred type\'s key/value types' do
pending 'pre-existing conflation in key_types_conform?/subtypes_conform?, not introduced ' \
'by pair_shaped_viewed_as_pairs? - see comment below'
# key_types_conform?/subtypes_conform? compares a hash-shaped inferred's
# value type against a list-shaped expected's param positionally - the
# same false positive reproduces with no pair-shaping logic involved.
source = Solargraph::Source.load_string(%(
# @generic X
module Taggable
end

class PairBag2
include Taggable
end
))
api_map.map source
exp = described_class.parse('Taggable<String>')
inf = described_class.parse('PairBag2{Symbol => String}')
match = inf.conforms_to?(api_map, exp, :method_call)
expect(match).to be(false)
end

it 'matches multiple types' do
exp = described_class.parse('String, Integer')
inf = described_class.parse('String, Integer')
Expand Down
Loading