diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 7186c4b83..09cf0d780 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -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] # @param visibility [Enumerable] # @return [Array] @@ -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`. + # + # 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 diff --git a/lib/solargraph/complex_type/conformance.rb b/lib/solargraph/complex_type/conformance.rb index c2a48b255..388240406 100644 --- a/lib/solargraph/complex_type/conformance.rb +++ b/lib/solargraph/complex_type/conformance.rb @@ -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 -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 @@ -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 @@ -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? diff --git a/spec/api_map_spec.rb b/spec/api_map_spec.rb index 6f367d229..08eee4d18 100755 --- a/spec/api_map_spec.rb +++ b/spec/api_map_spec.rb @@ -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') + 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 diff --git a/spec/complex_type/conforms_to_spec.rb b/spec/complex_type/conforms_to_spec.rb index 27e9356af..01d8e261a 100644 --- a/spec/complex_type/conforms_to_spec.rb +++ b/spec/complex_type/conforms_to_spec.rb @@ -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') + 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') + 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 generic) 2-arity type into a tuple ' \ + 'to conform to a lower-arity Enumerable ancestor' do + # `Pair` uses ordinary `` 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] + # @param b [generic] + def initialize(a, b) + @a = a + @b = b + end + + # @yieldparam [Array(generic, generic)] + def each + yield [@a, @b] + end + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('Pair') + 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] + # @param b [generic] + def initialize(a, b) + @a = a + @b = b + end + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('NotEnumerablePair') + 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] + # @param b [generic] + # @param c [generic] + def initialize(a, b, c) + @a = a + @b = b + @c = c + end + + # @yieldparam [Array(generic, generic, generic)] + def each + yield [@a, @b, @c] + end + end + )) + api_map.map source + exp = described_class.parse('Enumerable') + inf = described_class.parse('Triple') + match = inf.conforms_to?(api_map, exp, :method_call) + expect(match).to be(true) + + exp2 = described_class.parse('Enumerable') + 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] + # @param b [generic] + def initialize(a, b) + @a = a + @b = b + end + end + )) + api_map.map source + exp = described_class.parse('Taggable') + inf = described_class.parse('Pair') + 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] + # @param b [generic] + # @param c [generic] + def initialize(a, b, c) + @a = a + @b = b + @c = c + end + end + )) + api_map.map source + exp = described_class.parse('Labeled') + inf = described_class.parse('Triple2') + 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') + 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')