From aafcbb9566b28262c09cb57ebf49db1ba2073db0 Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:06:12 -0500 Subject: [PATCH 01/10] Compile methods from anonymous superclasses `class Foo < Struct.new(:bar)` is written as `class Foo < ::Struct`, so the `bar`/`bar=` accessors defined on the anonymous superclass were dropped. Methods owned by unnamed superclasses are now compiled on the constant. --- lib/tapioca/gem/listeners/methods.rb | 4 + lib/tapioca/gem/pipeline.rb | 50 ++++ spec/tapioca/gem/pipeline_spec.rb | 411 ++++++++++++++++++++++++++- 3 files changed, 464 insertions(+), 1 deletion(-) diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 92f74aea0..3b078d1aa 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -186,6 +186,9 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public # It walks up the ancestor tree via the `super_method` method; if any of the super # methods are owned by the constant, it means that the constant declares the method, # and that super method is returned. + # + # Methods owned by the constant's anonymous superclasses also count, since those + # superclasses are not part of the RBI and their methods would otherwise be lost. #: (UnboundMethod method, Module[top] constant) -> UnboundMethod? def method_defined_by_constant(method, constant) # Widen the type of `method` to be nilable @@ -193,6 +196,7 @@ def method_defined_by_constant(method, constant) while method return method if method.owner == constant + return method if @pipeline.anonymous_superclass_owning(method, constant) method = method.super_method end diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index ebb35faee..ea4c63d0a 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -26,6 +26,7 @@ def initialize( @gem = gem @seen = Set.new #: Set[String] @alias_namespace = Set.new #: Set[String] + @anonymous_superclasses = {}.compare_by_identity #: Hash[Module[top], Array[Module[top]]] @error_handler = error_handler @events = [] #: Array[Gem::Event] @@ -189,8 +190,57 @@ def name_of(constant) name end + # Return the superclasses that `compile_superclass` skips because they can't be written to the RBI, like the + # unnamed ones created by `class Foo < Struct.new(:bar)`, `Data.define` or `Class.new`. The methods and mixins of + # these superclasses are folded into the constant. + #: (Module[top] constant) -> Array[Module[top]] + def anonymous_superclasses_of(constant) + @anonymous_superclasses[constant] ||= begin + superclasses = [] #: Array[Module[top]] + + if constant.singleton_class? + # Singleton classes have no names, so find the anonymous superclasses through the attached class. + # Since `Foo.singleton_class.superclass == Foo.superclass.singleton_class`, return the anonymous + # superclasses' singleton classes, which define class methods like `new`. + singleton_class = constant #: as Class[top] + attached_class = attached_class_of(singleton_class) + + if attached_class + superclasses = anonymous_superclasses_of(attached_class).map { |klass| singleton_class_of(klass) } + end + elsif Class === constant + superclass = superclass_of(constant) #: Class[top]? + + while superclass && fold_into_constant?(superclass) + superclasses << superclass + superclass = superclass_of(superclass) + end + end + + superclasses + end + end + + # Return the anonymous superclass of the constant that owns the method, or nil if none does + #: (UnboundMethod method, Module[top] constant) -> Module[top]? + def anonymous_superclass_owning(method, constant) + owner = method.owner + + anonymous_superclasses_of(constant).find { |superclass| are_equal?(superclass, owner) } + end + private + # Whether the superclass is missing from the RBI, so its methods and mixins should be folded into the constant + #: (Class[top] superclass) -> bool + def fold_into_constant?(superclass) + # Most superclasses are named and written to the RBI, so check the name first + name = name_of(superclass) + return false unless name.nil? + + true + end + #: (Gemfile::GemSpec gem) -> Set[String] def load_bootstrap_symbols(gem) engine_symbols = Static::SymbolLoader.engine_symbols(gem) diff --git a/spec/tapioca/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index 43a52ed94..6ba9573da 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -1678,7 +1678,19 @@ def foo; end end module M3; end - class S1 < ::Struct; end + + class S1 < ::Struct + def foo; end + def foo=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end class S2 < ::Struct def foo; end @@ -1712,6 +1724,403 @@ class S4 < ::Struct; end assert_equal(output, compile) end + it "compiles methods of Struct anonymous superclasses the same as the block form" do + add_ruby_file("ssl_options.rb", <<~RUBY) + class SSLOptions < Struct.new(:verify_hostname) + def verify?; end + end + + BlockSSLOptions = Struct.new(:verify_hostname) do + def verify?; end + end + RUBY + + output = template(<<~RBI) + class BlockSSLOptions < ::Struct + def verify?; end + def verify_hostname; end + def verify_hostname=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + + class SSLOptions < ::Struct + def verify?; end + def verify_hostname; end + def verify_hostname=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "compiles Struct anonymous superclass methods overridden or hidden by the subclass" do + add_ruby_file("point.rb", <<~RUBY) + class Point < Struct.new(:x, :y, keyword_init: true) + def x + super.to_i + end + + private :y= + end + RUBY + + output = template(<<~RBI) + class Point < ::Struct + def x; end + def x=(_); end + def y; end + + private + + def y=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "compiles Struct anonymous superclass methods overridden by a prepended module" do + add_ruby_file("point.rb", <<~RUBY) + module Rounded + def x + super.round + end + end + + class Point < Struct.new(:x) + prepend Rounded + end + RUBY + + output = template(<<~RBI) + class Point < ::Struct + include ::Rounded + + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + + module Rounded + def x; end + end + RBI + + assert_equal(output, compile) + end + + it "does not repeat Struct anonymous superclass methods on grandchild classes" do + add_ruby_file("point.rb", <<~RUBY) + class Point < Struct.new(:x) + end + + class Point3D < Point + def z; end + end + RUBY + + output = template(<<~RBI) + class Point < ::Struct + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + + class Point3D < ::Point + def z; end + end + RBI + + assert_equal(output, compile) + end + + it "compiles methods of Data anonymous superclasses the same as the block form" do + add_ruby_file("point.rb", <<~RUBY) + class Point < Data.define(:x, :y) + def sum + x + y + end + end + + BlockPoint = Data.define(:x, :y) do + def sum + x + y + end + end + RUBY + + output = template(<<~RBI) + class BlockPoint < ::Data + def sum; end + def x; end + def y; end + + class << self + def [](*_arg0); end + def inspect; end + def members; end + def new(*_arg0); end + end + end + + class Point < ::Data + def sum; end + def x; end + def y; end + + class << self + def [](*_arg0); end + def inspect; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "compiles methods of Class.new anonymous superclasses" do + add_ruby_file("foo.rb", <<~RUBY) + class Base + def base; end + end + + class Foo < Class.new { def bar; end; def self.baz; end } + end + + class Qux < Class.new(Base) { def quux; end } + end + + class Deep < Class.new(Class.new { def deep; end }) { def shallow; end } + end + RUBY + + output = template(<<~RBI) + class Base + def base; end + end + + class Deep + def deep; end + def shallow; end + end + + class Foo + def bar; end + + class << self + def baz; end + end + end + + class Qux < ::Base + def quux; end + end + RBI + + assert_equal(output, compile) + end + + it "compiles initialize methods of anonymous superclasses" do + add_ruby_file("point.rb", <<~RUBY) + class Point < Struct.new(:x) do + def initialize(x = 1) + super + end + end + end + + class Coordinate < Data.define(:x, :y) do + def initialize(x:, y: 0) = super + end + end + + class Pair < Class.new { def initialize(a, b: 1); end } + end + RUBY + + output = template(<<~RBI) + class Coordinate < ::Data + def initialize(x:, y: T.unsafe(nil)); end + + def x; end + def y; end + + class << self + def [](*_arg0); end + def inspect; end + def members; end + def new(*_arg0); end + end + end + + class Pair + def initialize(a, b: T.unsafe(nil)); end + end + + class Point < ::Struct + def initialize(x = T.unsafe(nil)); end + + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "compiles the visibility of methods defined in an anonymous superclass" do + add_ruby_file("point.rb", <<~RUBY) + class Point < Struct.new(:x) do + def pub; end + protected def prot; end + private def priv; end + + def self.build; end + private_class_method :build + end + end + RUBY + + output = template(<<~RBI) + class Point < ::Struct + def pub; end + def x; end + def x=(_); end + + protected + + def prot; end + + private + + def priv; end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + + private + + def build; end + end + end + RBI + + assert_equal(output, compile) + end + + it "does not compile methods a DelegateClass superclass delegates" do + add_ruby_file("foo.rb", <<~RUBY) + class Bar + def bar; end + end + + class Foo < DelegateClass(Bar) + def foo; end + end + RUBY + + output = template(<<~RBI) + class Bar + def bar; end + end + + class Foo + def foo; end + end + RBI + + assert_equal(output, compile) + end + + it "compiles public methods ActiveSupport defines in an anonymous superclass" do + add_ruby_file("list.rb", <<~RUBY) + require "active_support" + require "active_support/core_ext/module/delegation" + require "active_support/core_ext/class/attribute" + + class List < Struct.new(:items) do + delegate :size, to: :items + class_attribute :setting + end + end + RUBY + + output = template(<<~RBI) + class List < ::Struct + def items; end + def items=(_); end + def setting; end + def setting=(_arg0); end + def setting?; end + def size(*, **, &); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + def setting; end + def setting=(value); end + def setting?; end + end + end + RBI + + assert_equal(output, compile) + end + it "handles dynamic mixins" do add_ruby_file("foo.rb", <<~RUBY) module Foo From 610acb71128ee1db991c53b02c08a067dd35a48d Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:06:42 -0500 Subject: [PATCH 02/10] Add tests for anonymous superclass edge cases - compiles Structs, Classes, and Modules: expect methods for a named `Struct.new("Foo")` superclass - compiles named Struct superclasses like aws-sdk-core's EmptyStructure - compiles named Struct superclasses defined in other gems - does not compile Struct anonymous superclass methods in other gems that reopen the class - keeps signatures of methods defined in the block of an anonymous superclass - compiles source locations of methods from an anonymous superclass - compiles Struct anonymous superclass methods behind a module prepended into the anonymous superclass - compiles Struct anonymous superclass methods overridden by a module prepended from another gem - uses the anonymous superclass method visibility instead of the prepended method's visibility - compiles mixins of Struct and Data anonymous superclasses - does not compile mixins of anonymous superclasses performed in other gems (passes for now, since no mixins are compiled yet) - does not compile methods of abstract or generic anonymous superclasses (passes for now, since sig-wrapped methods aren't found yet) --- spec/tapioca/gem/pipeline_spec.rb | 465 +++++++++++++++++++++++++++++- 1 file changed, 464 insertions(+), 1 deletion(-) diff --git a/spec/tapioca/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index 6ba9573da..540d0ffbf 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -1718,7 +1718,18 @@ def new(*_arg0); end end end - class S4 < ::Struct; end + class S4 < ::Struct + def foo; end + def foo=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end RBI assert_equal(output, compile) @@ -1768,6 +1779,124 @@ def new(*_arg0); end assert_equal(output, compile) end + it "compiles named Struct superclasses like aws-sdk-core's EmptyStructure" do + add_ruby_file("structure.rb", <<~RUBY) + module Aws + module Structure + def initialize(values = {}) + values.each do |k, v| + self[k] = v + end + end + + def key?(member_name) + !self[member_name].nil? + end + end + + class EmptyStructure < Struct.new("AwsEmptyStructure") + include(Aws::Structure) + end + end + RUBY + + output = template(<<~RBI) + module Aws; end + + class Aws::EmptyStructure < ::Struct + include ::Aws::Structure + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + + module Aws::Structure + def initialize(values = T.unsafe(nil)); end + + def key?(member_name); end + end + RBI + + assert_equal(output, compile) + end + + it "compiles named Struct superclasses defined in other gems" do + mock_gem("aws") do + add_ruby_file("lib/aws.rb", <<~RUBY) + EmptyStructure = Struct.new("EmptyStructure") + Credentials = Struct.new("Credentials", :key) + RUBY + end + + mock_gem("foo") do + add_ruby_file("lib/foo.rb", <<~RUBY) + class EmptyA < EmptyStructure; end + + class FooCredentials < Credentials + def secret; end + end + RUBY + end + + output = <<~RBI + class EmptyA < ::Struct + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + + class FooCredentials < ::Struct + def key; end + def key=(_); end + def secret; end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile("foo")) + end + + it "does not compile Struct anonymous superclass methods in other gems that reopen the class" do + mock_gem("foo") do + add_ruby_file("lib/foo.rb", <<~RUBY) + class Foo < Struct.new(:x) + end + RUBY + end + + mock_gem("bar") do + add_ruby_file("lib/bar.rb", <<~RUBY) + class Foo + def bar; end + end + RUBY + end + + output = <<~RBI + class Foo < ::Struct + def bar; end + end + RBI + + assert_equal(output, compile("bar")) + end + it "compiles Struct anonymous superclass methods overridden or hidden by the subclass" do add_ruby_file("point.rb", <<~RUBY) class Point < Struct.new(:x, :y, keyword_init: true) @@ -1802,6 +1931,99 @@ def new(*_arg0); end assert_equal(output, compile) end + it "keeps signatures of methods defined in the block of an anonymous superclass" do + add_ruby_file("counter.rb", <<~RUBY) + class Counter < Struct.new(:x) do + extend T::Sig + + sig { returns(Integer) } + attr_accessor :count + + sig { returns(Integer) } + def total; 1; end + + sig(:final) { returns(String) } + def label; ""; end + end + end + RUBY + + output = template(<<~RBI) + class Counter < ::Struct + sig { returns(::Integer) } + def count; end + + sig { params(count: ::Integer).returns(::Integer) } + def count=(count); end + + sig(:final) { returns(::String) } + def label; end + + sig { returns(::Integer) } + def total; end + + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "compiles source locations of methods from an anonymous superclass" do + add_ruby_file("point.rb", <<~RUBY) + class Point < Struct.new(:x) do + extend T::Sig + + sig { returns(Integer) } + def double; x * 2; end + end + end + RUBY + + output = template(<<~RBI) + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:6 + class Point < ::Struct + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:5 + sig { returns(::Integer) } + def double; end + + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def x; end + + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def x=(_); end + + class << self + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def [](*_arg0); end + + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def inspect; end + + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def keyword_init?; end + + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def members; end + + # pkg:gem/#{DEFAULT_GEM_NAME}#lib/point.rb:1 + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile(include_loc: true)) + end + it "compiles Struct anonymous superclass methods overridden by a prepended module" do add_ruby_file("point.rb", <<~RUBY) module Rounded @@ -1839,6 +2061,214 @@ def x; end assert_equal(output, compile) end + it "compiles Struct anonymous superclass methods behind a module prepended into the anonymous superclass" do + add_ruby_file("point.rb", <<~RUBY) + module Rounded + def x + super.round + end + end + + class Point < Struct.new(:x) { prepend Rounded } + end + RUBY + + output = template(<<~RBI) + class Point < ::Struct + include ::Rounded + + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + + module Rounded + def x; end + end + RBI + + assert_equal(output, compile) + end + + it "compiles Struct anonymous superclass methods overridden by a module prepended from another gem" do + mock_gem("foo") do + add_ruby_file("lib/foo.rb", <<~RUBY) + class Point < Struct.new(:x) do + def double; x * 2; end + end + end + RUBY + end + + mock_gem("bar") do + add_ruby_file("lib/bar.rb", <<~RUBY) + module Patch + def double; super; end + def x; super; end + end + + Point.prepend(Patch) + RUBY + end + + output = <<~RBI + class Point < ::Struct + def double; end + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile("foo")) + end + + it "uses the anonymous superclass method visibility instead of the prepended method's visibility" do + add_ruby_file("point.rb", <<~RUBY) + module Hidden + private + + def x + super + end + end + + class Point < Struct.new(:x) + prepend Hidden + end + RUBY + + output = template(<<~RBI) + module Hidden + private + + def x; end + end + + class Point < ::Struct + include ::Hidden + + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "compiles mixins of Struct and Data anonymous superclasses" do + add_ruby_file("point.rb", <<~RUBY) + module Helpers; end + module ClassHelpers; end + + class Point < Struct.new(:x) do + include Helpers + extend ClassHelpers + end + end + + class Coordinate < Data.define(:x) { include Comparable } + end + RUBY + + output = template(<<~RBI) + module ClassHelpers; end + + class Coordinate < ::Data + include ::Comparable + + def x; end + + class << self + def [](*_arg0); end + def inspect; end + def members; end + def new(*_arg0); end + end + end + + module Helpers; end + + class Point < ::Struct + include ::Helpers + extend ::ClassHelpers + + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile) + end + + it "does not compile mixins of anonymous superclasses performed in other gems" do + mock_gem("foo") do + add_ruby_file("lib/foo.rb", <<~RUBY) + class Foo < Struct.new(:x) + end + RUBY + end + + mock_gem("bar") do + add_ruby_file("lib/bar.rb", <<~RUBY) + module Bar; end + module BarPrepend; end + module BarExtend; end + + Foo.superclass.include(Bar) + Foo.superclass.prepend(BarPrepend) + Foo.superclass.extend(BarExtend) + RUBY + end + + output = <<~RBI + class Foo < ::Struct + def x; end + def x=(_); end + + class << self + def [](*_arg0); end + def inspect; end + def keyword_init?; end + def members; end + def new(*_arg0); end + end + end + RBI + + assert_equal(output, compile("foo")) + end + it "does not repeat Struct anonymous superclass methods on grandchild classes" do add_ruby_file("point.rb", <<~RUBY) class Point < Struct.new(:x) @@ -1959,6 +2389,39 @@ def quux; end assert_equal(output, compile) end + it "does not compile methods of abstract or generic anonymous superclasses" do + add_ruby_file("foo.rb", <<~RUBY) + class Impl < Class.new { + extend T::Sig + extend T::Helpers + + abstract! + + sig { abstract.returns(Integer) } + def value; end + } + end + + class IntList < Class.new { + extend T::Sig + extend T::Generic + + Elem = type_member + + sig { returns(T.nilable(Elem)) } + def first; end + } + end + RUBY + + output = template(<<~RBI) + class Impl; end + class IntList; end + RBI + + assert_equal(output, compile) + end + it "compiles initialize methods of anonymous superclasses" do add_ruby_file("point.rb", <<~RUBY) class Point < Struct.new(:x) do From 0300c13d62bb4b545e2492f3585492659c4cb6c8 Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:07:10 -0500 Subject: [PATCH 03/10] Skip anonymous superclass methods in gems that reopen the class Only the gem that first defines the class picks its superclass, so other gems reopening it shouldn't repeat the superclass methods. Passing: does not compile Struct anonymous superclass methods in other gems that reopen the class --- lib/tapioca/gem/pipeline.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index ea4c63d0a..61d3cb763 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -193,6 +193,9 @@ def name_of(constant) # Return the superclasses that `compile_superclass` skips because they can't be written to the RBI, like the # unnamed ones created by `class Foo < Struct.new(:bar)`, `Data.define` or `Class.new`. The methods and mixins of # these superclasses are folded into the constant. + # + # Only the gem that first defines the constant picks its superclass, so other gems that reopen the constant get + # no superclasses. Otherwise, they would repeat the superclass methods in their own RBI. #: (Module[top] constant) -> Array[Module[top]] def anonymous_superclasses_of(constant) @anonymous_superclasses[constant] ||= begin @@ -215,6 +218,10 @@ def anonymous_superclasses_of(constant) superclasses << superclass superclass = superclass_of(superclass) end + + # Drop the superclasses if another gem first defined the constant and this gem only reopens it. Most classes + # have none, so check that first to skip looking up where the constant was defined. + superclasses = [] unless superclasses.empty? || superclass_picked_by_gem?(constant) end superclasses @@ -231,6 +238,17 @@ def anonymous_superclass_owning(method, constant) private + # Whether the gem first defines the constant. Ruby only sets the superclass the first time the class is defined + # (`class Foo < Bar`), so that gem picks it, and reopening the class elsewhere can't change it. + #: (Module[top] constant) -> bool + def superclass_picked_by_gem?(constant) + name = name_of(constant) + # Without a name, there's no way to tell where the constant was defined, so assume it's this gem + return true if name.nil? + + constant_in_gem?(name) + end + # Whether the superclass is missing from the RBI, so its methods and mixins should be folded into the constant #: (Class[top] superclass) -> bool def fold_into_constant?(superclass) From f90cc64772d0f80a418b1c4c8597bc5b3ed37bb1 Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 16:34:37 -0500 Subject: [PATCH 04/10] Keep anonymous superclass methods behind sig wrappers and prepends Methods from anonymous superclasses aren't tracked for the constant, so the lookup fell back to `constant.instance_method`, which can return a `sig` wrapper or a prepended module's method. Those aren't defined in the gem, so the methods were dropped. Use the anonymous superclass's own method instead. Abstract and generic superclasses are now skipped, since their sigs can't be written on the constant. Passing: - keeps signatures of methods defined in the block of an anonymous superclass - compiles Struct anonymous superclass methods overridden by a module prepended from another gem - does not compile methods of abstract or generic anonymous superclasses (fails without the abstract/generic check once these methods are kept) --- lib/tapioca/gem/listeners/methods.rb | 6 +++++- lib/tapioca/gem/pipeline.rb | 17 +++++++++++++---- .../runtime/trackers/method_definition.rb | 9 ++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 3b078d1aa..efabbe2b6 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -81,8 +81,12 @@ def compile_method(tree, symbol_name, constant, method, visibility = RBI::Public signature = signature_defined_by_constant(method, constant) signature ||= inferred_attr_writer_signature(method, constant) method = signature.method if signature #: UnboundMethod + # Methods from anonymous superclasses aren't tracked for the constant, so use the method's source location + # instead of `constant.instance_method`, which can return a prepended module's method or a `sig` wrapper. + fallback_source_method = method if @pipeline.anonymous_superclass_owning(method, constant) + definition = @pipeline.method_definition_in_gem(method.name, constant, fallback_source_method: fallback_source_method) - case @pipeline.method_definition_in_gem(method.name, constant) + case definition when Pipeline::MethodUnknown # This means that this is a C-method. Thus, we want to # skip it only if the constant is an ignored one, since diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index 61d3cb763..9649db2c8 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -151,9 +151,13 @@ def initialize(location) end end - #: (Symbol method_name, Module[top] owner) -> MethodDefinitionLookupResult - def method_definition_in_gem(method_name, owner) - definitions = Tapioca::Runtime::Trackers::MethodDefinition.method_definitions_for(method_name, owner) + #: (Symbol method_name, Module[top] owner, ?fallback_source_method: UnboundMethod?) -> MethodDefinitionLookupResult + def method_definition_in_gem(method_name, owner, fallback_source_method: nil) + definitions = Tapioca::Runtime::Trackers::MethodDefinition.method_definitions_for( + method_name, + owner, + fallback_source_method: fallback_source_method, + ) # If the source location of the method isn't available, signal that by returning nil. return MethodUnknown.new if definitions.empty? @@ -192,7 +196,8 @@ def name_of(constant) # Return the superclasses that `compile_superclass` skips because they can't be written to the RBI, like the # unnamed ones created by `class Foo < Struct.new(:bar)`, `Data.define` or `Class.new`. The methods and mixins of - # these superclasses are folded into the constant. + # these superclasses are folded into the constant. Abstract and generic superclasses are not included (see + # `fold_into_constant?`). # # Only the gem that first defines the constant picks its superclass, so other gems that reopen the constant get # no superclasses. Otherwise, they would repeat the superclass methods in their own RBI. @@ -256,6 +261,10 @@ def fold_into_constant?(superclass) name = name_of(superclass) return false unless name.nil? + # Abstract methods and type variables can't be written on the constant, so stop at these superclasses + return false if abstract_type_of(superclass) + return false if Runtime::GenericTypeRegistry.lookup_type_variables(superclass) + true end diff --git a/lib/tapioca/runtime/trackers/method_definition.rb b/lib/tapioca/runtime/trackers/method_definition.rb index a5772ae3a..95c62db92 100644 --- a/lib/tapioca/runtime/trackers/method_definition.rb +++ b/lib/tapioca/runtime/trackers/method_definition.rb @@ -24,12 +24,15 @@ def register(method_name, owner, locations) registrations_for(method_name, owner) << loc end - #: (Symbol method_name, Module[top] owner) -> Array[SourceLocation] - def method_definitions_for(method_name, owner) + # Return where `method_name` was defined on `owner`. If no definitions were recorded for `owner` (for example, + # the method was defined on an anonymous superclass), fall back to the source location of `fallback_source_method`, + # or of the method `owner` resolves to. + #: (Symbol method_name, Module[top] owner, ?fallback_source_method: UnboundMethod?) -> Array[SourceLocation] + def method_definitions_for(method_name, owner, fallback_source_method: nil) definitions = registrations_for(method_name, owner) if definitions.empty? - source_loc = owner.instance_method(method_name).source_location + source_loc = (fallback_source_method || owner.instance_method(method_name)).source_location definitions = [SourceLocation.from_loc(source_loc)].compact end From d95c2b15a9040fbaa50d22a7749596df63b41ad7 Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:08:20 -0500 Subject: [PATCH 05/10] Use the anonymous superclass method visibility behind prepends When a prepended module overrides an anonymous superclass method, take the visibility from the anonymous superclass, like we already do for methods the constant defines. Passing: uses the anonymous superclass method visibility instead of the prepended method's visibility --- lib/tapioca/gem/listeners/methods.rb | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index efabbe2b6..2c3f3dd12 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -47,8 +47,11 @@ def compile_directly_owned_methods( method_visibility = visibility if method.owner != mod - # Use the visibility of the method `mod` itself defines, ignoring any modules prepended to it - method_visibility = visibility_defined_by_constant(name, mod) || visibility + # Use the visibility of the method `mod` itself defines, ignoring any modules prepended to it. + # Methods from anonymous superclasses aren't defined by `mod`, so check those superclasses next. + method_visibility = visibility_defined_by_constant(name, mod) || + visibility_defined_by_anonymous_superclasses(name, mod) || + visibility end vis = case method_visibility @@ -254,6 +257,18 @@ def visibility_defined_by_constant(name, constant) end end + # Return the visibility of the method the constant's anonymous superclasses define, ignoring any modules + # prepended to them. + #: (Symbol name, Module[top] constant) -> Symbol? + def visibility_defined_by_anonymous_superclasses(name, constant) + @pipeline.anonymous_superclasses_of(constant).each do |superclass| + visibility = visibility_defined_by_constant(name, superclass) + return visibility if visibility + end + + nil + end + #: (UnboundMethod method, Module[top] constant) -> untyped def inferred_attr_writer_signature(method, constant) reader_method = attr_reader_for_writer(method, constant) From c880a32b473f0300964a31418408b52c8b5ba2cf Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:08:40 -0500 Subject: [PATCH 06/10] Look up source locations on the anonymous superclass Methods from anonymous superclasses are tracked under that superclass, not the constant, so look up their source locations there. Passing: compiles source locations of methods from an anonymous superclass --- lib/tapioca/gem/listeners/source_location.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/tapioca/gem/listeners/source_location.rb b/lib/tapioca/gem/listeners/source_location.rb index 88f46ca50..74ef13d2c 100644 --- a/lib/tapioca/gem/listeners/source_location.rb +++ b/lib/tapioca/gem/listeners/source_location.rb @@ -34,7 +34,12 @@ def on_scope(event) # @override #: (MethodNodeAdded event) -> void def on_method(event) - definition = @pipeline.method_definition_in_gem(event.method.name, event.constant) + anonymous_superclass = @pipeline.anonymous_superclass_owning(event.method, event.constant) + + # Methods from anonymous superclasses are tracked under the anonymous superclass, not the constant, + # so use it if it owns the method. Otherwise, fall back to the constant, which is always there. + tracked_owner = anonymous_superclass || event.constant + definition = @pipeline.method_definition_in_gem(event.method.name, tracked_owner) if Pipeline::MethodInGemWithLocation === definition loc = definition.location From ec71ac2f5d09ec3cf0a7164caf30fa785946e68c Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:09:14 -0500 Subject: [PATCH 07/10] Compile mixins from anonymous superclasses Mixins added inside `Struct.new`/`Data.define` blocks were dropped along with the anonymous superclass. Keep them on the constant, and check where they were added so mixins from other gems are still skipped. Passing: - compiles mixins of Struct and Data anonymous superclasses - compiles Struct anonymous superclass methods behind a module prepended into the anonymous superclass - does not compile mixins of anonymous superclasses performed in other gems (fails without the location check) --- lib/tapioca/gem/listeners/mixins.rb | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/tapioca/gem/listeners/mixins.rb b/lib/tapioca/gem/listeners/mixins.rb index 9dbe30313..85eeb0a49 100644 --- a/lib/tapioca/gem/listeners/mixins.rb +++ b/lib/tapioca/gem/listeners/mixins.rb @@ -60,12 +60,34 @@ def add_mixins(tree, constant, mods, mixin_type) #: (Module[top] constant, Module[top] mixin, Runtime::Trackers::Mixin::Type mixin_type) -> bool def mixed_in_by_gem?(constant, mixin, mixin_type) mixin_location = Runtime::Trackers::Mixin.mixin_location(mixin, mixin_type, constant) + mixin_location ||= anonymous_superclass_mixin_location(constant, mixin, mixin_type) return true if mixin_location.nil? @pipeline.gem.contains_path?(mixin_location) end + # Mixins added inside an anonymous superclass are tracked under that superclass, not the constant, so look + # for them there. Modules prepended into an anonymous superclass come after the constant in its ancestors + # and are listed as includes, so includes are looked up as either an include or a prepend. + #: (Module[top] constant, Module[top] mixin, Runtime::Trackers::Mixin::Type mixin_type) -> String? + def anonymous_superclass_mixin_location(constant, mixin, mixin_type) + mixin_types = if mixin_type == Runtime::Trackers::Mixin::Type::Extend + [mixin_type] + else + [Runtime::Trackers::Mixin::Type::Include, Runtime::Trackers::Mixin::Type::Prepend] + end + + @pipeline.anonymous_superclasses_of(constant).each do |superclass| + mixin_types.each do |type| + location = Runtime::Trackers::Mixin.mixin_location(mixin, type, superclass) + return location if location + end + end + + nil + end + #: (String mixin_name) -> bool def filtered_mixin?(mixin_name) # filter T:: namespace mixins that aren't T::Props @@ -75,7 +97,14 @@ def filtered_mixin?(mixin_name) #: (Module[top] constant) -> Array[Module[top]] def interesting_ancestors_of(constant) - inherited_ancestors = Set.new.compare_by_identity.merge(inherited_ancestors_of(constant)) + # Drop the ancestors the superclass in the RBI already brings in. `class Foo < Struct.new(:a)` is written as + # `class Foo < ::Struct`, so only `Struct`'s ancestors are dropped (it's the superclass of the last anonymous + # superclass), and mixins added inside `Struct.new` are kept. Also drop the anonymous superclasses themselves, + # or a named struct like `Struct::Foo` shows up as `include ::Struct::Foo`. + anonymous_superclasses = @pipeline.anonymous_superclasses_of(constant) + inherited_ancestors = Set.new.compare_by_identity.merge( + inherited_ancestors_of(anonymous_superclasses.last || constant), + ).merge(anonymous_superclasses) # TODO: There is actually a bug here where this will drop modules that # may be included twice. For example: From 3aca72a4f225539a05db73e7c7d3a411c7f05f5e Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:09:35 -0500 Subject: [PATCH 08/10] Treat named Struct superclasses like anonymous ones `Struct.new("Bar", :baz)` creates `Struct::Bar`, which is written as `Struct`, so its methods were dropped the same way. Passing: - compiles Structs, Classes, and Modules - compiles named Struct superclasses like aws-sdk-core's EmptyStructure - compiles named Struct superclasses defined in other gems --- lib/tapioca/gem/pipeline.rb | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/tapioca/gem/pipeline.rb b/lib/tapioca/gem/pipeline.rb index 9649db2c8..021f3b5c7 100644 --- a/lib/tapioca/gem/pipeline.rb +++ b/lib/tapioca/gem/pipeline.rb @@ -195,9 +195,9 @@ def name_of(constant) end # Return the superclasses that `compile_superclass` skips because they can't be written to the RBI, like the - # unnamed ones created by `class Foo < Struct.new(:bar)`, `Data.define` or `Class.new`. The methods and mixins of - # these superclasses are folded into the constant. Abstract and generic superclasses are not included (see - # `fold_into_constant?`). + # unnamed ones created by `class Foo < Struct.new(:bar)`, `Data.define` or `Class.new`, and named structs like + # `Struct.new("Bar", :baz)`, which are written as `Struct`. Abstract and generic superclasses are not included + # (see `fold_into_constant?`). The methods and mixins of these superclasses are folded into the constant. # # Only the gem that first defines the constant picks its superclass, so other gems that reopen the constant get # no superclasses. Otherwise, they would repeat the superclass methods in their own RBI. @@ -259,7 +259,7 @@ def superclass_picked_by_gem?(constant) def fold_into_constant?(superclass) # Most superclasses are named and written to the RBI, so check the name first name = name_of(superclass) - return false unless name.nil? + return false unless name.nil? || named_struct?(superclass, name) # Abstract methods and type variables can't be written on the constant, so stop at these superclasses return false if abstract_type_of(superclass) @@ -268,6 +268,16 @@ def fold_into_constant?(superclass) true end + # Whether the superclass is a named struct like `Struct::Bar`, which `name_of` writes as `Struct` + #: (Class[top] superclass, String name) -> bool + def named_struct?(superclass, name) + return false unless name == "Struct" + return false if are_equal?(superclass, ::Struct) + + # Use `ancestors` instead of `<`, which classes can override + ancestors_of(superclass).any? { |ancestor| are_equal?(ancestor, ::Struct) } + end + #: (Gemfile::GemSpec gem) -> Set[String] def load_bootstrap_symbols(gem) engine_symbols = Static::SymbolLoader.engine_symbols(gem) From 4cca265f3babd22afaec0f298831917651bb9447 Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 12:09:47 -0500 Subject: [PATCH 09/10] Compare method owners by identity The prepend handling compared `UnboundMethod#owner` against modules with `==`/`!=`, which a class can override. Use `are_equal?` instead. --- lib/tapioca/gem/listeners/methods.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/tapioca/gem/listeners/methods.rb b/lib/tapioca/gem/listeners/methods.rb index 2c3f3dd12..db80f90a5 100644 --- a/lib/tapioca/gem/listeners/methods.rb +++ b/lib/tapioca/gem/listeners/methods.rb @@ -46,7 +46,7 @@ def compile_directly_owned_methods( method = mod.instance_method(name) method_visibility = visibility - if method.owner != mod + unless are_equal?(method.owner, mod) # Use the visibility of the method `mod` itself defines, ignoring any modules prepended to it. # Methods from anonymous superclasses aren't defined by `mod`, so check those superclasses next. method_visibility = visibility_defined_by_constant(name, mod) || @@ -202,7 +202,7 @@ def method_defined_by_constant(method, constant) method = method #: UnboundMethod? while method - return method if method.owner == constant + return method if are_equal?(method.owner, constant) return method if @pipeline.anonymous_superclass_owning(method, constant) method = method.super_method @@ -221,14 +221,14 @@ def method_defined_by_constant(method, constant) #: (UnboundMethod method, Module[top] constant) -> untyped def signature_defined_by_constant(method, constant) signature = signature_of!(method) - return signature if signature && signature.method.owner == method.owner + return signature if signature && are_equal?(signature.method.owner, method.owner) # Widen the type of `prepended_method` to be nilable prepended_method = constant.instance_method(method.name) #: UnboundMethod? - while prepended_method && prepended_method.owner != method.owner + while prepended_method && !are_equal?(prepended_method.owner, method.owner) signature = signature_of(prepended_method) - return signature if signature && signature.method.owner == method.owner + return signature if signature && are_equal?(signature.method.owner, method.owner) prepended_method = prepended_method.super_method end From 464edbcd8d1dbf96f8260748fe9f45ea33b90e00 Mon Sep 17 00:00:00 2001 From: Dane Wanke Date: Wed, 7 Oct 2026 18:25:04 -0500 Subject: [PATCH 10/10] Remove unnecessary tests --- spec/tapioca/gem/pipeline_spec.rb | 61 ------------------------------- 1 file changed, 61 deletions(-) diff --git a/spec/tapioca/gem/pipeline_spec.rb b/spec/tapioca/gem/pipeline_spec.rb index 540d0ffbf..65dade0e9 100644 --- a/spec/tapioca/gem/pipeline_spec.rb +++ b/spec/tapioca/gem/pipeline_spec.rb @@ -2024,43 +2024,6 @@ def new(*_arg0); end assert_equal(output, compile(include_loc: true)) end - it "compiles Struct anonymous superclass methods overridden by a prepended module" do - add_ruby_file("point.rb", <<~RUBY) - module Rounded - def x - super.round - end - end - - class Point < Struct.new(:x) - prepend Rounded - end - RUBY - - output = template(<<~RBI) - class Point < ::Struct - include ::Rounded - - def x; end - def x=(_); end - - class << self - def [](*_arg0); end - def inspect; end - def keyword_init?; end - def members; end - def new(*_arg0); end - end - end - - module Rounded - def x; end - end - RBI - - assert_equal(output, compile) - end - it "compiles Struct anonymous superclass methods behind a module prepended into the anonymous superclass" do add_ruby_file("point.rb", <<~RUBY) module Rounded @@ -2522,30 +2485,6 @@ def build; end assert_equal(output, compile) end - it "does not compile methods a DelegateClass superclass delegates" do - add_ruby_file("foo.rb", <<~RUBY) - class Bar - def bar; end - end - - class Foo < DelegateClass(Bar) - def foo; end - end - RUBY - - output = template(<<~RBI) - class Bar - def bar; end - end - - class Foo - def foo; end - end - RBI - - assert_equal(output, compile) - end - it "compiles public methods ActiveSupport defines in an anonymous superclass" do add_ruby_file("list.rb", <<~RUBY) require "active_support"