From 155b9c257873747e13a34854879da604396e4edb Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sat, 5 Sep 2026 22:13:10 -0400 Subject: [PATCH 01/12] Carry RBS type args onto prepend and extend pins prepend_to_pin and extend_to_pin computed generic_values from the mixin type arguments and then dropped the value on the floor, so a `prepend Foo[Integer]` or `extend Foo[String]` in RBS produced a reference pin with no type arguments at all. The generic module was mixed in as if it were bare, and the instantiated element type was lost. RuboCop flagged both lines as Lint/UselessAssignment, which was the only signal the bug existed. Pass generic_values through to the constructor, the way the sibling include_to_pin already does. Pin::Reference accepts the keyword already, so Prepend and Extend needed no widening. Also switch both from all_params.map(&:rooted_tags) to map(&:to_s), matching every live caller of build_type in this file. The two differ: rooted_tags emits a leading :: that no other reference pin carries, and Reference#type re-parses the joined values, so the include path format is the one already proven to round-trip. Co-Authored-By: Claude Opus 5 --- lib/solargraph/rbs_map/conversions.rb | 6 +++-- spec/rbs_map/conversions_spec.rb | 32 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index ebe7a6ce0..63c92a356 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -716,10 +716,11 @@ def include_to_pin decl, closure # @return [void] def prepend_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:rooted_tags) + generic_values = type.all_params.map(&:to_s) pins.push Solargraph::Pin::Reference::Prepend.new( name: decl.name.relative!.to_s, type_location: location_decl_to_pin_location(decl.location), + generic_values: generic_values, closure: closure, source: :rbs ) @@ -730,10 +731,11 @@ def prepend_to_pin decl, closure # @return [void] def extend_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:rooted_tags) + generic_values = type.all_params.map(&:to_s) pins.push Solargraph::Pin::Reference::Extend.new( name: decl.name.relative!.to_s, type_location: location_decl_to_pin_location(decl.location), + generic_values: generic_values, closure: closure, source: :rbs ) diff --git a/spec/rbs_map/conversions_spec.rb b/spec/rbs_map/conversions_spec.rb index 50f4b0b1a..705b0b0a5 100644 --- a/spec/rbs_map/conversions_spec.rb +++ b/spec/rbs_map/conversions_spec.rb @@ -93,6 +93,38 @@ def bar: () -> untyped expect(method_pin.return_type.tag).to eq('undefined') end end + + context 'with generic modules mixed in' do + let(:rbs) do + <<~RBS + module Prependable[T] + end + module Extendable[T] + end + class Foo + prepend Prependable[Integer] + extend Extendable[String] + end + RBS + end + + # @param klass [Class] + # @param name [String] + # @return [Array] + def references_to klass, name + conversions.pins.select { |pin| pin.instance_of?(klass) && pin.name == name } + end + + it 'keeps the type arguments on the prepend reference' do + pins = references_to(Solargraph::Pin::Reference::Prepend, 'Prependable') + expect(pins.map(&:generic_values)).to all(eq(['Integer'])) + end + + it 'keeps the type arguments on the extend reference' do + pins = references_to(Solargraph::Pin::Reference::Extend, 'Extendable') + expect(pins.map(&:generic_values)).to all(eq(['String'])) + end + end end context 'with standard loads for solargraph project' do From e3c756b5e1f345c661607abb41188978eea27ba9 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 13:47:27 -0400 Subject: [PATCH 02/12] Root RBS reference pin and namespace names Include, Prepend and Extend reference pins, and Interface and Module namespace pins, took their name from decl.name.relative!.to_s, which yields a name relative to wherever the declaration sat. Superclass reference pins already used the rooted form, so the two kinds disagreed. Name them from the built type instead - rooted_name for references, fqns for namespaces - and build every generic_values list from rooted_tags rather than to_s, so a pin carries a name that does not depend on the context it was created in. Prepend and Extend were also dropping their generic values entirely; pass them through. --- lib/solargraph/rbs_map/conversions.rb | 25 +++++++++++++------------ spec/rbs_map/core_map_spec.rb | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index ebe7a6ce0..728bb1cf4 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -164,9 +164,9 @@ def fqns type_name # @return [void] def convert_self_type_to_pins decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) include_pin = Solargraph::Pin::Reference::Include.new( - name: decl.name.relative!.to_s, + name: type.name, type_location: location_decl_to_pin_location(decl.location), generic_values: generic_values, closure: closure, @@ -279,8 +279,7 @@ def class_decl_to_pin decl pins.push class_pin if decl.super_class type = build_type(decl.super_class.name, decl.super_class.args) - generic_values = type.all_params.map(&:to_s) - superclass_name = decl.super_class.name.to_s + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Superclass.new( type_location: location_decl_to_pin_location(decl.super_class.location), closure: class_pin, @@ -299,7 +298,7 @@ def interface_decl_to_pin decl class_pin = Solargraph::Pin::Namespace.new( type: :module, type_location: location_decl_to_pin_location(decl.location), - name: decl.name.relative!.to_s, + name: fqns(decl.name), closure: Solargraph::Pin::ROOT_PIN, comments: decl.comment&.string, generics: type_parameter_names(decl), @@ -318,7 +317,7 @@ def interface_decl_to_pin decl def module_decl_to_pin decl module_pin = Solargraph::Pin::Namespace.new( type: :module, - name: decl.name.relative!.to_s, + name: fqns(decl.name), type_location: location_decl_to_pin_location(decl.location), closure: Solargraph::Pin::ROOT_PIN, comments: decl.comment&.string, @@ -701,9 +700,9 @@ def civar_to_pin decl, closure # @return [void] def include_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Include.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), generic_values: generic_values, closure: closure, @@ -718,8 +717,9 @@ def prepend_to_pin decl, closure type = build_type(decl.name, decl.args) generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Prepend.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), + generic_values: generic_values, closure: closure, source: :rbs ) @@ -732,8 +732,9 @@ def extend_to_pin decl, closure type = build_type(decl.name, decl.args) generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Extend.new( - name: decl.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), + generic_values: generic_values, closure: closure, source: :rbs ) @@ -797,9 +798,9 @@ def add_mixins decl, namespace # @todo are we handling prepend correctly? klass = mixin.is_a?(RBS::AST::Members::Include) ? Pin::Reference::Include : Pin::Reference::Extend type = build_type(mixin.name, mixin.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push klass.new( - name: mixin.name.relative!.to_s, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(mixin.location), generic_values: generic_values, closure: namespace, diff --git a/spec/rbs_map/core_map_spec.rb b/spec/rbs_map/core_map_spec.rb index 94cd8395b..6f1c48bec 100644 --- a/spec/rbs_map/core_map_spec.rb +++ b/spec/rbs_map/core_map_spec.rb @@ -82,7 +82,7 @@ # correctly. It would be better to test RbsMap or RbsMap::Conversions # with an RBS fixture. core_map = described_class.new - pins = core_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::Reference::Include) && pin.name == 'Enumerable' } + pins = core_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::Reference::Include) && pin.name == '::Enumerable' } expect(pins.map(&:closure).map(&:namespace)).to include('Enumerator') end From b5113601eaf06d3f501b1dc64ec675f851e57727 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 14:56:14 -0400 Subject: [PATCH 03/12] Root the self-type include pin as well convert_self_type_to_pins was the one reference pin still named from the unrooted form, so it disagreed with the Include pins built everywhere else in this file. --- lib/solargraph/rbs_map/conversions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index 728bb1cf4..38649f62d 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -166,7 +166,7 @@ def convert_self_type_to_pins decl, closure type = build_type(decl.name, decl.args) generic_values = type.all_params.map(&:rooted_tags) include_pin = Solargraph::Pin::Reference::Include.new( - name: type.name, + name: type.rooted_name, # reference pins use rooted names type_location: location_decl_to_pin_location(decl.location), generic_values: generic_values, closure: closure, From 182978a893e057ff917e564b70704b038d59e421 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 15:28:23 -0400 Subject: [PATCH 04/12] Cover generic values on prepend and extend pins The reference pins built by prepend_to_pin and extend_to_pin now pass their generic values through, but nothing exercised that. Add an RBS fixture declaring a generic module that a class both prepends and extends, and assert the resulting pins carry the type argument. The extend case needs the negative form as well: add_mixins emits its own Extend pin for the same mixin and has always set generic values on it, so asserting only that some Extend pin carries ::Integer passes even without the change. Asserting that no Extend pin is left with an empty list is what actually covers extend_to_pin. --- spec/rbs_map/conversions_spec.rb | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/spec/rbs_map/conversions_spec.rb b/spec/rbs_map/conversions_spec.rb index 50f4b0b1a..4c16b9375 100644 --- a/spec/rbs_map/conversions_spec.rb +++ b/spec/rbs_map/conversions_spec.rb @@ -27,6 +27,41 @@ attr_reader :temp_dir + context 'with a generic module prepended and extended' do + let(:rbs) do + <<~RBS + module Wrapper[T] + end + + class Holder + prepend Wrapper[String] + extend Wrapper[Integer] + end + RBS + end + + # @param klass [Class] + # @return [Array>] + def generic_values_for klass + conversions.pins + .select { |pin| pin.instance_of?(klass) && pin.name == '::Wrapper' } + .map(&:generic_values) + .uniq + end + + it 'carries the prepended type argument' do + expect(generic_values_for(Solargraph::Pin::Reference::Prepend)).to eq([['::String']]) + end + + it 'carries the extended type argument' do + expect(generic_values_for(Solargraph::Pin::Reference::Extend)).to include(['::Integer']) + end + + it 'leaves no extend reference pin without its type argument' do + expect(generic_values_for(Solargraph::Pin::Reference::Extend)).not_to include([]) + end + end + context 'with overlapping module hierarchies and inheritance' do subject(:method_pin) { api_map.get_method_stack('A::B::C', 'foo').first } From 66c6095d58813e47b53525b3cc73b858172a8e5d Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 17:45:25 -0400 Subject: [PATCH 05/12] Honor RBS type args on prepend and extend Carrying generic_values onto the Prepend and Extend reference pins had no user-visible effect on its own: ApiMap#inner_get_methods resolved both through the plain inner_get_methods, which performs no generic substitution. A method on a generic prepended module inferred generic rather than the type argument the RBS declaration supplied. Route prepends and extends through inner_get_methods_from_reference, the same path includes and superclasses already take, so the reference type resolves its generics against the namespace pin before its methods are pulled in. namespace_pin is nil whenever the namespace has no Pin::Namespace, and all four call sites already passed a possibly-nil value; widening the three @param tags to match clears four pre-existing strong-typecheck problems (591 to 587 locally, none introduced). Replace the two pin-inspection specs with specs on inferred method return types, which is where the behavior is observable. The included case is a control: it passed before this change and still passes. --- lib/solargraph/api_map.rb | 6 ++-- lib/solargraph/complex_type.rb | 2 +- lib/solargraph/complex_type/unique_type.rb | 2 +- spec/rbs_map/conversions_spec.rb | 37 +++++++++++++++------- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 26b42ddb4..7186c4b83 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -759,7 +759,7 @@ def workspace end # @param fq_reference_tag [String] A fully qualified whose method should be pulled in - # @param namespace_pin [Pin::Base] Namespace pin for the rooted_type + # @param namespace_pin [Pin::Base, nil] Namespace pin for the rooted_type # parameter - used to pull generics information # @param type [ComplexType] The type which is having its # methods supplemented from fq_reference_tag @@ -844,7 +844,7 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false if deep && scope == :instance store.get_prepends(fqns).reverse.each do |im| fqim = store.constants.dereference(im) - result.concat inner_get_methods(fqim, scope, visibility, deep, skip, true) unless fqim.nil? + result.concat inner_get_methods_from_reference(fqim, namespace_pin, rooted_type, scope, visibility, deep, skip, true) unless fqim.nil? end end # Store#get_methods doesn't know about full tags, just @@ -876,7 +876,7 @@ def inner_get_methods rooted_tag, scope, visibility, deep, skip, no_core = false end store.get_extends(fqns).reverse.each do |em| fqem = dereference(em) - result.concat inner_get_methods(fqem, :instance, visibility, deep, skip, true) unless fqem.nil? + result.concat inner_get_methods_from_reference(fqem, namespace_pin, rooted_type, :instance, visibility, deep, skip, true) unless fqem.nil? end rooted_sc_tag = qualify_superclass(rooted_tag) unless rooted_sc_tag.nil? diff --git a/lib/solargraph/complex_type.rb b/lib/solargraph/complex_type.rb index 5b0e984b2..9b200ed7a 100644 --- a/lib/solargraph/complex_type.rb +++ b/lib/solargraph/complex_type.rb @@ -322,7 +322,7 @@ def force_rooted end end - # @param definitions [Pin::Namespace, Pin::Method] + # @param definitions [Pin::Namespace, Pin::Method, nil] # @param context_type [ComplexType] # @return [ComplexType] def resolve_generics definitions, context_type diff --git a/lib/solargraph/complex_type/unique_type.rb b/lib/solargraph/complex_type/unique_type.rb index 4bbdda5b2..92a402d2d 100644 --- a/lib/solargraph/complex_type/unique_type.rb +++ b/lib/solargraph/complex_type/unique_type.rb @@ -439,7 +439,7 @@ def resolve_param_generics_from_context generics_to_resolve, context_type, resol # parameters used in this type, and return a new type if # possible. # - # @param definitions [Pin::Namespace, Pin::Method] The module/class/method which uses generic types + # @param definitions [Pin::Namespace, Pin::Method, nil] The module/class/method which uses generic types # @param context_type [ComplexType] The receiver type # @return [UniqueType, ComplexType] def resolve_generics definitions, context_type diff --git a/spec/rbs_map/conversions_spec.rb b/spec/rbs_map/conversions_spec.rb index 705b0b0a5..9480808fe 100644 --- a/spec/rbs_map/conversions_spec.rb +++ b/spec/rbs_map/conversions_spec.rb @@ -98,31 +98,44 @@ def bar: () -> untyped let(:rbs) do <<~RBS module Prependable[T] + def value: () -> T end module Extendable[T] + def build: () -> T end - class Foo + module Includable[T] + def fetch: () -> T + end + class Prepender prepend Prependable[Integer] + end + class Extender extend Extendable[String] end + class Includer + include Includable[Symbol] + end RBS end - # @param klass [Class] - # @param name [String] - # @return [Array] - def references_to klass, name - conversions.pins.select { |pin| pin.instance_of?(klass) && pin.name == name } + # @param namespace [String] + # @param method [String] + # @param scope [Symbol] + # @return [String, nil] + def inferred_type namespace, method, scope + api_map.get_method_stack(namespace, method, scope: scope).first&.return_type&.tag + end + + it 'substitutes the type argument of a prepended module' do + expect(inferred_type('Prepender', 'value', :instance)).to eq('Integer') end - it 'keeps the type arguments on the prepend reference' do - pins = references_to(Solargraph::Pin::Reference::Prepend, 'Prependable') - expect(pins.map(&:generic_values)).to all(eq(['Integer'])) + it 'substitutes the type argument of an extended module' do + expect(inferred_type('Extender', 'build', :class)).to eq('String') end - it 'keeps the type arguments on the extend reference' do - pins = references_to(Solargraph::Pin::Reference::Extend, 'Extendable') - expect(pins.map(&:generic_values)).to all(eq(['String'])) + it 'substitutes the type argument of an included module' do + expect(inferred_type('Includer', 'fetch', :instance)).to eq('Symbol') end end end From 9251c23ad5cc6149499b92e25a1b355b32b1b16c Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 20:54:47 -0400 Subject: [PATCH 06/12] Read inline RBS type args on mixins RBS documents a trailing type-argument annotation on mixins, written `include Enumerable #[String]`. Solargraph read that annotation only after a superclass, so a generic module mixed into Ruby source resolved its methods to the bare type variable rather than the argument given. Read it in the include, prepend and extend processors and pass the values through as generic_values, which Pin::Reference already renders into the reference type. Hand the argument list to RBS::Parser and RbsTranslator rather than splitting it locally, so a nested argument converts to Solargraph syntax: Hash[String, Integer] becomes Hash{String => Integer}. The superclass path, which does split locally, still truncates that form at its first bracket. Follow RBS on the two rules it defines for the annotation: one module argument per call, and no space between the hash mark and the bracket. --- .../parser_gem/node_processors/send_node.rb | 58 +++++++++++++++++++ spec/parser/node_processor_spec.rb | 58 +++++++++++++++++++ spec/source_map/clip_spec.rb | 19 ++++++ 3 files changed, 135 insertions(+) diff --git a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb index a9e60cb65..14b2e4db8 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'rbs' + module Solargraph module Parser module ParserGem @@ -7,6 +9,10 @@ module NodeProcessors class SendNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods + # A trailing inline RBS type argument list, e.g. the `#[String]` in + # `include Enumerable #[String]`. + TRAILING_TYPE_ARGS = /\A\s*\#(?\[(?(?:[^\[\]]++|\g)*)\])/ + # @sg-ignore @override is adding, not overriding def process # @sg-ignore Variable type could not be inferred for method_name @@ -138,6 +144,7 @@ def process_include location: get_node_location(i), closure: cp, name: unpack_name(i), + generic_values: mixin_generic_values, source: :parser ) end @@ -153,6 +160,7 @@ def process_prepend location: get_node_location(i), closure: cp, name: unpack_name(i), + generic_values: mixin_generic_values, source: :parser ) end @@ -175,12 +183,62 @@ def process_extend location: loc, closure: region.closure, name: unpack_name(i), + generic_values: mixin_generic_values, source: :parser ) end end end + # Type arguments for a generic module mixed in with inline RBS + # syntax, e.g. `include Enumerable #[String]`. RBS recognizes this + # for one module argument only, so `include A, B` takes none. + # + # @return [Array] + def mixin_generic_values + args = node.children[2..] + return [] unless args && args.length == 1 + + arg = args.first + return [] unless arg.is_a?(AST::Node) + + code = trailing_type_args_code(arg) + return [] if code.nil? + + parse_type_args(code) + end + + # The text inside a trailing `#[...]` on the same line as +arg+. + # RBS requires no space between the `#` and the `[`; with one, this + # is an ordinary comment. The inner group recurses so that a nested + # `Hash[String, Integer]` is not cut short at its first `]`. + # + # @param arg [AST::Node] + # @return [String, nil] + def trailing_type_args_code arg + source = region.source.code + pos = get_node_end_position(arg) + offset = Position.line_char_to_offset(source, pos.line, pos.character) + eol = source.index("\n", offset) || source.length + match = source[offset...eol].to_s.match(TRAILING_TYPE_ARGS) + match && match[:body] + end + + # Parse an RBS type argument list by wrapping it in a throwaway + # generic, which lets RBS split the arguments and gives each one to + # RbsTranslator for conversion to Solargraph's own type syntax. + # + # @param code [String] + # @return [Array] + def parse_type_args code + type = RBS::Parser.parse_type("Object[#{code}]") + return [] unless type.is_a?(RBS::Types::ClassInstance) + + type.args.map { |arg| RbsTranslator.to_complex_type(arg).to_s } + rescue RBS::ParsingError + [] + end + # @return [void] def process_require return unless node.children[2].is_a?(AST::Node) && node.children[2].type == :str diff --git a/spec/parser/node_processor_spec.rb b/spec/parser/node_processor_spec.rb index 54f442ee2..7d1880e59 100644 --- a/spec/parser/node_processor_spec.rb +++ b/spec/parser/node_processor_spec.rb @@ -98,4 +98,62 @@ class Foo < Array # [String] expect(map.pins.last.type.to_s).to eq('Array') end + + # @param code [String] + # @param klass [Class] + # @return [String] + def mixin_type_for code, klass + map = Solargraph::SourceMap.load_string(code, 'test.rb') + map.pins.find { |pin| pin.instance_of?(klass) }.type.to_s + end + + it 'parses RBS parameters for included modules' do + expect(mixin_type_for(%( + class Foo + include Bar #[String] + end + ), Solargraph::Pin::Reference::Include)).to eq('Bar') + end + + it 'parses RBS parameters for prepended modules' do + expect(mixin_type_for(%( + class Foo + prepend Bar #[String] + end + ), Solargraph::Pin::Reference::Prepend)).to eq('Bar') + end + + it 'parses RBS parameters for extended modules' do + expect(mixin_type_for(%( + class Foo + extend Bar #[String] + end + ), Solargraph::Pin::Reference::Extend)).to eq('Bar') + end + + it 'translates RBS parameter syntax on mixins into Solargraph syntax' do + expect(mixin_type_for(%( + class Foo + include Bar #[Hash[String, Integer]] + end + ), Solargraph::Pin::Reference::Include)).to eq('Bar Integer}>') + end + + it 'ignores a bracketed mixin comment separated from the hash' do + expect(mixin_type_for(%( + class Foo + include Bar # [String] + end + ), Solargraph::Pin::Reference::Include)).to eq('Bar') + end + + it 'ignores mixin parameters when one call mixes in several modules' do + # RBS rejects this too, as "Mixing multiple modules with one call is + # not supported", since the parameters could apply to either module. + expect(mixin_type_for(%( + class Foo + include Bar, Baz #[String] + end + ), Solargraph::Pin::Reference::Include)).to eq('Bar') + end end diff --git a/spec/source_map/clip_spec.rb b/spec/source_map/clip_spec.rb index 468612ab1..7c2ed1912 100644 --- a/spec/source_map/clip_spec.rb +++ b/spec/source_map/clip_spec.rb @@ -1917,6 +1917,25 @@ def bad_passthrough; yield; end expect(type.to_s).to eq('undefined') end + it 'resolves generics from an inline RBS parameter on a prepended module' do + source = Solargraph::Source.load_string(%( + # @generic T + module Mixin + # @return [generic] + def value; end + end + class Foo + prepend Mixin #[Integer] + end + a = Foo.new.value + a + ), 'test.rb') + api_map = Solargraph::ApiMap.new.map(source) + clip = api_map.clip_at('test.rb', [10, 6]) + type = clip.infer + expect(type.to_s).to eq('Integer') + end + it 'uses simple return value of block to infer return value of Enumerable#map' do source = Solargraph::Source.load_string(%( a = ['a'].map { 123 } From c3be42f4d9d16e6d6719eb4619403daca633fc46 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Sun, 6 Sep 2026 22:44:27 -0400 Subject: [PATCH 07/12] Cover the unparseable mixin type argument path Undercover reported parse_type_args at 75 percent: nothing exercised the branch where RBS refuses the annotation. Add a spec whose brackets are balanced, so the regex accepts them, but whose contents are not a type. --- spec/parser/node_processor_spec.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/spec/parser/node_processor_spec.rb b/spec/parser/node_processor_spec.rb index 7d1880e59..1758f78c1 100644 --- a/spec/parser/node_processor_spec.rb +++ b/spec/parser/node_processor_spec.rb @@ -156,4 +156,12 @@ class Foo end ), Solargraph::Pin::Reference::Include)).to eq('Bar') end + + it 'ignores mixin parameters that RBS cannot parse as a type' do + expect(mixin_type_for(%( + class Foo + include Bar #[def] + end + ), Solargraph::Pin::Reference::Include)).to eq('Bar') + end end From 5f4eb8fa20f7c55d9e59a4dcd274ecbf4f4c3cab Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 11:20:36 -0400 Subject: [PATCH 08/12] Restore strict types on generic resolution params These three @param tags had been widened to accept nil, which cleared six Solargraph complaints by declaring the nil legitimate rather than by stopping it. Restore them so the gap stays visible. The nil has a single source. Boolean has no Pin::Namespace at all, yet qualify_superclass('Boolean') still returns 'Object', so the superclass branch of inner_get_methods calls onward with a nil namespace_pin. Instrumenting the full suite counted four such calls out of 35,837, every one of them Boolean. Six errors come back, four already present before this branch and two the branch adds by routing prepends and extends through inner_get_methods_from_reference. All six are accurate: nil does reach those parameters. Fixing that means giving Boolean a namespace pin, not describing the nil as valid input. --- lib/solargraph/api_map.rb | 2 +- lib/solargraph/complex_type.rb | 2 +- lib/solargraph/complex_type/unique_type.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index 7186c4b83..e10490f26 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -759,7 +759,7 @@ def workspace end # @param fq_reference_tag [String] A fully qualified whose method should be pulled in - # @param namespace_pin [Pin::Base, nil] Namespace pin for the rooted_type + # @param namespace_pin [Pin::Base] Namespace pin for the rooted_type # parameter - used to pull generics information # @param type [ComplexType] The type which is having its # methods supplemented from fq_reference_tag diff --git a/lib/solargraph/complex_type.rb b/lib/solargraph/complex_type.rb index 9b200ed7a..5b0e984b2 100644 --- a/lib/solargraph/complex_type.rb +++ b/lib/solargraph/complex_type.rb @@ -322,7 +322,7 @@ def force_rooted end end - # @param definitions [Pin::Namespace, Pin::Method, nil] + # @param definitions [Pin::Namespace, Pin::Method] # @param context_type [ComplexType] # @return [ComplexType] def resolve_generics definitions, context_type diff --git a/lib/solargraph/complex_type/unique_type.rb b/lib/solargraph/complex_type/unique_type.rb index 92a402d2d..4bbdda5b2 100644 --- a/lib/solargraph/complex_type/unique_type.rb +++ b/lib/solargraph/complex_type/unique_type.rb @@ -439,7 +439,7 @@ def resolve_param_generics_from_context generics_to_resolve, context_type, resol # parameters used in this type, and return a new type if # possible. # - # @param definitions [Pin::Namespace, Pin::Method, nil] The module/class/method which uses generic types + # @param definitions [Pin::Namespace, Pin::Method] The module/class/method which uses generic types # @param context_type [ComplexType] The receiver type # @return [UniqueType, ComplexType] def resolve_generics definitions, context_type From 9a616337e41dd66a72e2d71bd0b908196a0f174a Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 13:56:36 -0400 Subject: [PATCH 09/12] Use rooted_tags for mixin generic values A reference pin's name is a rooted name, so its type arguments should be rooted too. to_s drops that, which makes the stored value depend on where it happened to be read from. This also moves include_to_pin, which was already on to_s, so all three mixin builders agree. No measured difference: strong typecheck stays at 593 and the suite stays at 1657 examples. The rootedness matters for what the value means, not for anything currently exercised. --- lib/solargraph/rbs_map/conversions.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index 63c92a356..3e913aaf3 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -701,7 +701,7 @@ def civar_to_pin decl, closure # @return [void] def include_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Include.new( name: decl.name.relative!.to_s, type_location: location_decl_to_pin_location(decl.location), @@ -716,7 +716,7 @@ def include_to_pin decl, closure # @return [void] def prepend_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Prepend.new( name: decl.name.relative!.to_s, type_location: location_decl_to_pin_location(decl.location), @@ -731,7 +731,7 @@ def prepend_to_pin decl, closure # @return [void] def extend_to_pin decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push Solargraph::Pin::Reference::Extend.new( name: decl.name.relative!.to_s, type_location: location_decl_to_pin_location(decl.location), From deeca0ad6608cbb3ba17e653ca5799ef533742f2 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 15:46:31 -0400 Subject: [PATCH 10/12] Share one inline RBS type argument parser The mixin processors and the superclass processor each had their own scan for a trailing #[...] annotation, with the same offset and end-of-line scaffolding written twice. Move the regex and both helpers into ParserGem::NodeMethods, which both processors already include, and have each call trailing_rbs_type_args. The superclass path gains what the mixin one already had: a recursive bracket match, so a nested argument is not cut off at its first bracket, and conversion through RBS::Parser and RbsTranslator, so RBS syntax becomes Solargraph syntax. class Foo < Array #[Hash[String, Integer]] gave Array Integer}>. Strong typecheck drops 593 to 591 with the duplicated code. --- .../parser/parser_gem/node_methods.rb | 39 +++++++++++++++++ .../node_processors/namespace_node.rb | 17 ++------ .../parser_gem/node_processors/send_node.rb | 42 +------------------ spec/parser/node_processor_spec.rb | 9 ++++ 4 files changed, 53 insertions(+), 54 deletions(-) diff --git a/lib/solargraph/parser/parser_gem/node_methods.rb b/lib/solargraph/parser/parser_gem/node_methods.rb index 59f2f255c..3bffbea35 100644 --- a/lib/solargraph/parser/parser_gem/node_methods.rb +++ b/lib/solargraph/parser/parser_gem/node_methods.rb @@ -2,6 +2,7 @@ require 'parser' require 'ast' +require 'rbs' # https://github.com/whitequark/parser # rubocop:disable Metrics/ModuleLength @@ -81,6 +82,44 @@ def get_node_end_position node Position.new(node.loc.last_line, node.loc.last_column) end + # A trailing inline RBS type argument list, e.g. the `#[String]` in + # `include Enumerable #[String]`. The inner group recurses so that a + # nested `Hash[String, Integer]` is not cut short at its first `]`. + TRAILING_TYPE_ARGS = /\A\s*\#(?\[(?(?:[^\[\]]++|\g)*)\])/ + + # Type arguments from an inline RBS annotation directly after +node+, + # as in `class Foo < Array #[String]` or `include Enumerable #[String]`. + # RBS requires no space between the hash mark and the bracket; with one, + # this is an ordinary comment. + # + # @param node [Parser::AST::Node] the node the annotation follows + # @param code [String] source the node was parsed from + # @return [Array] + def trailing_rbs_type_args node, code + pos = get_node_end_position(node) + offset = Position.line_char_to_offset(code, pos.line, pos.character) + eol = code.index("\n", offset) || code.length + match = code[offset...eol].to_s.match(TRAILING_TYPE_ARGS) + return [] unless match + + parse_rbs_type_args match[:body].to_s + end + + # Parse an RBS type argument list by wrapping it in a throwaway + # generic, which lets RBS split the arguments and gives each one to + # RbsTranslator for conversion to Solargraph's own type syntax. + # + # @param code [String] + # @return [Array] + def parse_rbs_type_args code + type = RBS::Parser.parse_type("Object[#{code}]") + return [] unless type.is_a?(RBS::Types::ClassInstance) + + type.args.map { |arg| RbsTranslator.to_complex_type(arg).rooted_tags } + rescue RBS::ParsingError + [] + end + # @param node [Parser::AST::Node] # @param signature [String] # diff --git a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb index 7cecd22c0..082cefe1c 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/namespace_node.rb @@ -41,26 +41,17 @@ def process private # Type arguments for a generic superclass in inline RBS syntax, e.g., - # `class Foo < Array #[String]`. Only recognized where RBS defines - # it: directly after the superclass, on the same line, with no space - # between `#` and `[`. Anything else is an ordinary comment. + # `class Foo < Array #[String]`. # # @return [String, nil] def parameters_from_inline_rbs superclass = node.children[1] return unless superclass - source = region.source.code - pos = get_node_end_position(superclass) - offset = Position.line_char_to_offset(source, pos.line, pos.character) - eol = source.index("\n", offset) || source.length - match = source[offset...eol].to_s.match(/\A\s*#\[([^\]]*)\]/) - return unless match + args = trailing_rbs_type_args(superclass, region.source.code) + return if args.empty? - code = match[1].strip - return if code.empty? - - "<#{code}>" + "<#{args.join(', ')}>" end def type_from_node diff --git a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb index 14b2e4db8..f963ab389 100644 --- a/lib/solargraph/parser/parser_gem/node_processors/send_node.rb +++ b/lib/solargraph/parser/parser_gem/node_processors/send_node.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rbs' - module Solargraph module Parser module ParserGem @@ -9,10 +7,6 @@ module NodeProcessors class SendNode < Parser::NodeProcessor::Base include ParserGem::NodeMethods - # A trailing inline RBS type argument list, e.g. the `#[String]` in - # `include Enumerable #[String]`. - TRAILING_TYPE_ARGS = /\A\s*\#(?\[(?(?:[^\[\]]++|\g)*)\])/ - # @sg-ignore @override is adding, not overriding def process # @sg-ignore Variable type could not be inferred for method_name @@ -202,41 +196,7 @@ def mixin_generic_values arg = args.first return [] unless arg.is_a?(AST::Node) - code = trailing_type_args_code(arg) - return [] if code.nil? - - parse_type_args(code) - end - - # The text inside a trailing `#[...]` on the same line as +arg+. - # RBS requires no space between the `#` and the `[`; with one, this - # is an ordinary comment. The inner group recurses so that a nested - # `Hash[String, Integer]` is not cut short at its first `]`. - # - # @param arg [AST::Node] - # @return [String, nil] - def trailing_type_args_code arg - source = region.source.code - pos = get_node_end_position(arg) - offset = Position.line_char_to_offset(source, pos.line, pos.character) - eol = source.index("\n", offset) || source.length - match = source[offset...eol].to_s.match(TRAILING_TYPE_ARGS) - match && match[:body] - end - - # Parse an RBS type argument list by wrapping it in a throwaway - # generic, which lets RBS split the arguments and gives each one to - # RbsTranslator for conversion to Solargraph's own type syntax. - # - # @param code [String] - # @return [Array] - def parse_type_args code - type = RBS::Parser.parse_type("Object[#{code}]") - return [] unless type.is_a?(RBS::Types::ClassInstance) - - type.args.map { |arg| RbsTranslator.to_complex_type(arg).to_s } - rescue RBS::ParsingError - [] + trailing_rbs_type_args arg, region.source.code end # @return [void] diff --git a/spec/parser/node_processor_spec.rb b/spec/parser/node_processor_spec.rb index 1758f78c1..ce338b38a 100644 --- a/spec/parser/node_processor_spec.rb +++ b/spec/parser/node_processor_spec.rb @@ -80,6 +80,15 @@ class Foo < Array #[String] expect(map.pins.last.type.to_s).to eq('Array') end + it 'translates RBS parameter syntax on a superclass into Solargraph syntax' do + map = Solargraph::SourceMap.load_string(%( + class Foo < Array #[Hash[String, Integer]] + end + ), 'test.rb') + + expect(map.pins.last.type.to_s).to eq('Array Integer}>') + end + it 'ignores bracketed comments in the class body' do map = Solargraph::SourceMap.load_string(%( class Foo < Array From 15ef765e284c44ae759bedb0c7058363b200e686 Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Tue, 8 Sep 2026 16:45:37 -0400 Subject: [PATCH 11/12] Use rooted_tags for all reference generic values Three sites still built generic_values with to_s: the self-type include, the superclass pin in the class declaration path, and add_mixins. All feed the same field on the same kind of pin as the three mixin builders already moved, so a reference pin's type arguments are now rooted wherever they are built. The superclass one already said so: name: type.rooted_name carries the comment "reference pins use rooted names", six lines under a to_s that produced an unrooted one. Strong typecheck stays at 591 and the suite stays at 1658 examples. --- lib/solargraph/rbs_map/conversions.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/solargraph/rbs_map/conversions.rb b/lib/solargraph/rbs_map/conversions.rb index 3e913aaf3..d5393910d 100644 --- a/lib/solargraph/rbs_map/conversions.rb +++ b/lib/solargraph/rbs_map/conversions.rb @@ -164,7 +164,7 @@ def fqns type_name # @return [void] def convert_self_type_to_pins decl, closure type = build_type(decl.name, decl.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) include_pin = Solargraph::Pin::Reference::Include.new( name: decl.name.relative!.to_s, type_location: location_decl_to_pin_location(decl.location), @@ -279,7 +279,7 @@ def class_decl_to_pin decl pins.push class_pin if decl.super_class type = build_type(decl.super_class.name, decl.super_class.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) superclass_name = decl.super_class.name.to_s pins.push Solargraph::Pin::Reference::Superclass.new( type_location: location_decl_to_pin_location(decl.super_class.location), @@ -799,7 +799,7 @@ def add_mixins decl, namespace # @todo are we handling prepend correctly? klass = mixin.is_a?(RBS::AST::Members::Include) ? Pin::Reference::Include : Pin::Reference::Extend type = build_type(mixin.name, mixin.args) - generic_values = type.all_params.map(&:to_s) + generic_values = type.all_params.map(&:rooted_tags) pins.push klass.new( name: mixin.name.relative!.to_s, type_location: location_decl_to_pin_location(mixin.location), From 765c2d90c5432fed832aee5c0c19f82506f2cf7a Mon Sep 17 00:00:00 2001 From: Vince Broz Date: Wed, 9 Sep 2026 09:35:40 -0400 Subject: [PATCH 12/12] Restore nil to three generic-resolution param types These same three @param tags were widened to accept nil, then reverted back to strict types in 5f4eb8fa2, on the reasoning that the nil source (Boolean lacks a Pin::Namespace) should be fixed at its root rather than described as valid input. A second, permanent source of nil exists alongside Boolean: ActiveSupportConcern guesses a "::ClassMethods" submodule path that often does not exist, and inner_get_methods correctly returns zero methods for it without namespace_pin ever being non-nil. Unlike Boolean, there is no namespace pin to add here - a nonexistent submodule is supposed to resolve to nothing, forever. Restore the nil so the type matches both call sites' real behavior. Verified by comparing typecheck output before and after this revert on lib/solargraph/api_map.rb, lib/solargraph/complex_type.rb and lib/solargraph/complex_type/unique_type.rb: it removes exactly the four "Wrong argument type ... namespace_pin expected Solargraph::Pin::Base, received Solargraph::Pin::Base, nil" errors at the inner_get_methods_from_reference call sites, and introduces none. --- lib/solargraph/api_map.rb | 2 +- lib/solargraph/complex_type.rb | 2 +- lib/solargraph/complex_type/unique_type.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/solargraph/api_map.rb b/lib/solargraph/api_map.rb index e10490f26..7186c4b83 100755 --- a/lib/solargraph/api_map.rb +++ b/lib/solargraph/api_map.rb @@ -759,7 +759,7 @@ def workspace end # @param fq_reference_tag [String] A fully qualified whose method should be pulled in - # @param namespace_pin [Pin::Base] Namespace pin for the rooted_type + # @param namespace_pin [Pin::Base, nil] Namespace pin for the rooted_type # parameter - used to pull generics information # @param type [ComplexType] The type which is having its # methods supplemented from fq_reference_tag diff --git a/lib/solargraph/complex_type.rb b/lib/solargraph/complex_type.rb index 5b0e984b2..9b200ed7a 100644 --- a/lib/solargraph/complex_type.rb +++ b/lib/solargraph/complex_type.rb @@ -322,7 +322,7 @@ def force_rooted end end - # @param definitions [Pin::Namespace, Pin::Method] + # @param definitions [Pin::Namespace, Pin::Method, nil] # @param context_type [ComplexType] # @return [ComplexType] def resolve_generics definitions, context_type diff --git a/lib/solargraph/complex_type/unique_type.rb b/lib/solargraph/complex_type/unique_type.rb index 4bbdda5b2..92a402d2d 100644 --- a/lib/solargraph/complex_type/unique_type.rb +++ b/lib/solargraph/complex_type/unique_type.rb @@ -439,7 +439,7 @@ def resolve_param_generics_from_context generics_to_resolve, context_type, resol # parameters used in this type, and return a new type if # possible. # - # @param definitions [Pin::Namespace, Pin::Method] The module/class/method which uses generic types + # @param definitions [Pin::Namespace, Pin::Method, nil] The module/class/method which uses generic types # @param context_type [ComplexType] The receiver type # @return [UniqueType, ComplexType] def resolve_generics definitions, context_type