From ee8462dc388f7b8acb8e92a756d0160f043ce4d7 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 5 Sep 2026 22:25:03 -0400 Subject: [PATCH 1/2] Resolve a duck type param's own declared method Chain::Call#resolve converted a duck-typed receiver to Object and searched Object's method stack, so a call to a method the duck type itself declares was reported as an unresolved call at strict level and above: # @param thing [#read_body] def fetch(thing) thing.read_body end # => Unresolved call to read_body on #read_body ApiMap#get_complex_type_methods already handles this correctly for completion, so the same call resolved for autocomplete while failing typecheck. Special-case a duck-type receiver whose name matches the called method and synthesize a Pin::DuckMethod, mirroring get_complex_type_methods. The pin is explicit: false so arity checking is skipped -- a duck type declares that the method exists, not what signature it has, so checking arity against it would turn "unresolved call" into a false "too many arguments". Co-Authored-By: Claude Opus 5 --- lib/solargraph/source/chain/call.rb | 12 +++++++++--- spec/type_checker/levels/alpha_spec.rb | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/solargraph/source/chain/call.rb b/lib/solargraph/source/chain/call.rb index 940e7737a..11bcfacfc 100644 --- a/lib/solargraph/source/chain/call.rb +++ b/lib/solargraph/source/chain/call.rb @@ -59,9 +59,15 @@ def resolve api_map, name_pin, locals binder = binder.without_nil if nullable? # @sg-ignore Need to handle duck-typed method calls on union types pin_groups = binder.each_unique_type.map do |context| - ns_tag = context.namespace == '' ? '' : context.namespace_type.tag - stack = api_map.get_method_stack(ns_tag, word, scope: context.scope) - [stack.first].compact + if context.duck_type? && context.name[1..] == word + # explicit: false skips arity checking; the duck type + # only tells us the method exists, not its signature + [Pin::DuckMethod.new(name: word, source: :chain, explicit: false)] + else + ns_tag = context.namespace == '' ? '' : context.namespace_type.tag + stack = api_map.get_method_stack(ns_tag, word, scope: context.scope) + [stack.first].compact + end end pin_groups = [] if !api_map.loose_unions && pin_groups.any?(&:empty?) pins = pin_groups.flatten.uniq(&:path) diff --git a/spec/type_checker/levels/alpha_spec.rb b/spec/type_checker/levels/alpha_spec.rb index aca95b9c3..b2c7ac67e 100644 --- a/spec/type_checker/levels/alpha_spec.rb +++ b/spec/type_checker/levels/alpha_spec.rb @@ -193,6 +193,19 @@ def bing expect(checker.problems.map(&:message)).to eq([]) end + it 'resolves calls to a duck type param\'s own declared method' do + checker = type_checker(%( + class Foo + # @param baz [#read_body] + # @return [void] + def bar(baz) + baz.read_body + end + end + )) + expect(checker.problems).to be_empty + end + it 'resolves self correctly in arguments (second case)' do checker = type_checker(%( class Blah From 5d5f6f2beddb2f4f3bbf5bc53049e0e1a404267f Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 16:05:40 -0400 Subject: [PATCH 2/2] Give DuckMethod a signature and no ancestor stack 1280 dispatches a duck call to Pin::DuckMethod for the first time. That pin has no closure, so typify_from_super walks nil and raises NoMethodError on any duck tag Object also answers, such as #to_s. It also synthesized a zero-arg signature, which no real call matches. Ported from apiology/solargraph#72, which carries the same two changes on the intersection branch. Neither references intersections. --- lib/solargraph/pin/duck_method.rb | 30 ++++++++++++++++++---- spec/pin/duck_method_spec.rb | 42 +++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 spec/pin/duck_method_spec.rb diff --git a/lib/solargraph/pin/duck_method.rb b/lib/solargraph/pin/duck_method.rb index eea180b6a..695783425 100644 --- a/lib/solargraph/pin/duck_method.rb +++ b/lib/solargraph/pin/duck_method.rb @@ -6,11 +6,31 @@ module Pin # use duck typing, e.g., `@param file [#read]`. # class DuckMethod < Pin::Method - # @param location [Solargraph::Location] - # @param name [String] - # def initialize location, name - # # super(location, '', name, nil, :instance, :public, []) - # end + # A duck-type tag has no syntax for arguments, so the default empty + # #parameters would synthesize a zero-arg signature no real call matches. + # + # @param splat [Hash{Symbol => Object}] + def initialize **splat + super(parameters: accepts_any_arguments, **splat) + end + + # @return [::Array] + def accepts_any_arguments + [ + Pin::Parameter.new(decl: :restarg, name: 'args', closure: self, source: :api_map), + Pin::Parameter.new(decl: :kwrestarg, name: 'kwargs', closure: self, source: :api_map) + ] + end + + # A synthetic pin sits in no ancestor chain and has no closure. Letting + # the inherited walk find a type sends #typify on to `closure.gates`, + # which raises. + # + # @param _api_map [ApiMap] + # @return [Array] + def rest_of_stack _api_map + [] + end end end end diff --git a/spec/pin/duck_method_spec.rb b/spec/pin/duck_method_spec.rb new file mode 100644 index 000000000..0f8113e9f --- /dev/null +++ b/spec/pin/duck_method_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +describe Solargraph::Pin::DuckMethod do + let(:api_map) do + Solargraph::ApiMap.new.tap do |map| + map.map Solargraph::Source.load_string(%( + class ClassTest + # @param clazz [#new] + def create_object(clazz) + clazz.new + end + end + )) + end + end + + # The one place these are built: a duck-type tag reaches ApiMap via its + # parsed ComplexType, not via the source that declared it. + let(:duck_new) do + api_map.get_complex_type_methods(Solargraph::ComplexType.parse('#new')) + .grep(described_class).first + end + + # ApiMap leaves #closure nil; the call site is what gives an ancestor + # walk somewhere to go, since ClassTest inherits its own Class#new. + let(:duck_new_at_call_site) do + described_class.new(name: 'new', source: :api_map, + closure: api_map.get_path_pins('ClassTest#create_object').first) + end + + it 'synthesizes a signature that accepts any arguments' do + expect(duck_new.signatures.first.parameters.map(&:decl)).to eq(%i[restarg kwrestarg]) + end + + it 'has no ancestor chain of its own to walk' do + expect(duck_new_at_call_site.rest_of_stack(api_map)).to be_empty + end + + it "infers nothing rather than the call site's own inherited #new" do + expect(duck_new_at_call_site.typify(api_map)).to be_undefined + end +end