From 22d66e64ee037bf784e6baac482afbc9d1e12fa2 Mon Sep 17 00:00:00 2001 From: River Date: Fri, 23 Oct 2026 18:45:15 +0000 Subject: [PATCH] Translate self-referential singleton [] RBS sigs to WithoutRuntime sigs When a class defines its own singleton `[]` (for example to keep `Foo[Bar]` working after dropping `sorbet-runtime`'s `T::Generic#[]`) and its RBS signature mentions a generic application of that same class, such as `#: [U] (*Class[U]) -> Class[Foo[U]]`, the translated runtime sig calls `Foo.[]` while sorbet-runtime builds it. The wrapped `Foo.[]` must build its own sig before it runs, so evaluating the sig recurses until `SystemStackError`. Tapioca's RBS rewriter translates every typed file it loads, gems included, so `tapioca dsl` in any application that runs all sig blocks fails as soon as such a gem is loaded. Emit `T::Sig::WithoutRuntime.sig` for these signatures, the same way `method_added` and `singleton_method_added` are already handled. Sorbet still checks them statically; at runtime they are never wrapped or evaluated. Co-authored-by: Ufuk Kayserilioglu --- .../base_translator.rb | 67 ++++++++++- .../rbs_comments_to_sorbet_sigs_test.rb | 104 ++++++++++++++++++ 2 files changed, 168 insertions(+), 3 deletions(-) diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb index 4e10b6df..fe04aea7 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb @@ -28,6 +28,7 @@ def initialize( @translate_abstract_methods = options.translate_abstract_methods #: bool @options = options #: Options @type_translator = RBI::RBS::TypeTranslator.new(options: options.rbi_options) #: RBI::RBS::TypeTranslator + @scopes = [] #: Array[String | Symbol] end # @override @@ -45,7 +46,7 @@ def visit_program_node(node) def visit_class_node(node) apply_class_annotations(node) - super + with_scope(node.constant_path.slice) { super } end # @override @@ -53,7 +54,7 @@ def visit_class_node(node) def visit_module_node(node) apply_class_annotations(node) - super + with_scope(node.constant_path.slice) { super } end # @override @@ -61,7 +62,7 @@ def visit_module_node(node) def visit_singleton_class_node(node) apply_class_annotations(node) - super + with_scope(node.expression.is_a?(Prism::SelfNode) ? :singleton_of_self : :singleton_of_other) { super } end # @override @@ -182,6 +183,14 @@ def rewrite_def(def_node, comments) sig.without_runtime = true end + # A sig on `C.[]` that mentions a generic application of `C` calls `C.[]` while sorbet-runtime builds it, + # and the wrapped `C.[]` builds its sig before running, so evaluating the sig recurses forever. + # This happens when a class defines its own `[]` in place of `T::Generic#[]`, typically after dropping + # `sorbet-runtime` as a dependency. + if applies_own_generic_in_singleton_bracket_sig?(def_node, sig) + sig.without_runtime = true + end + @rewriter << Source::Replace.new( signature.location.start_offset, signature.location.end_offset, @@ -194,6 +203,58 @@ def rewrite_def(def_node, comments) end end + #: (String | Symbol) { -> void } -> void + def with_scope(scope, &block) + @scopes << scope + block.call + ensure + @scopes.pop + end + + #: (Prism::DefNode, RBI::Sig) -> bool + def applies_own_generic_in_singleton_bracket_sig?(def_node, sig) + return false unless def_node.name == :[] + + receiver = def_node.receiver + singleton = if receiver + receiver.is_a?(Prism::SelfNode) + else + @scopes.last == :singleton_of_self + end + return false unless singleton + + scope_names = @scopes.grep(String) + return false if scope_names.empty? + + owner_name = scope_names.map { |name| name.delete_prefix("::") }.join("::") + + types = sig.params.map(&:type) + [sig.return_type] + types.any? { |type| type.is_a?(RBI::Type) && applies_generic?(type, owner_name) } + end + + #: (RBI::Type, String) -> bool + def applies_generic?(type, owner_name) + case type + when RBI::Type::Generic + name = type.name.delete_prefix("::") + return true if owner_name == name || owner_name.end_with?("::#{name}") + + type.params.any? { |param| applies_generic?(param, owner_name) } + when RBI::Type::Class, RBI::Type::Module, RBI::Type::Nilable + applies_generic?(type.type, owner_name) + when RBI::Type::Composite, RBI::Type::Tuple + type.types.any? { |member| applies_generic?(member, owner_name) } + when RBI::Type::Shape + type.types.values.any? { |member| applies_generic?(member, owner_name) } + when RBI::Type::Proc + [*type.proc_params.values, type.proc_returns, type.proc_bind].compact.any? do |member| + applies_generic?(member, owner_name) + end + else + false + end + end + #: (Array[Spoom::RBS::Signature], method_name: String, location: String) -> Array[Spoom::RBS::Signature] def apply_overloads_strategy(signatures, method_name:, location:) return signatures if signatures.size <= 1 diff --git a/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb b/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb index 2f2ed0f7..ad8d78ed 100644 --- a/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb +++ b/test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb @@ -283,6 +283,110 @@ def singleton_method_added(m); end ) end + def test_translate_to_rbi_singleton_bracket_applying_own_generic_is_without_runtime + assert_rewrites_rbs( + from: <<~RUBY, + module A + #: [E] + class B + class << self + #: [U] (*Class[U]) -> Class[B[U]] + def [](*types) = self + end + + #: [U] (Class[U]) -> A::B[U]? + def self.[](type) = nil + end + end + RUBY + + to_pretty_format_for_humans: <<~RUBY, + module A + class B + extend ::T::Generic + + E = type_member + + class << self + ::T::Sig::WithoutRuntime.sig { type_parameters(:U).params(types: ::T::Class[::T.type_parameter(:U)]).returns(::T::Class[B[::T.type_parameter(:U)]]) } + def [](*types) = self + end + + ::T::Sig::WithoutRuntime.sig { type_parameters(:U).params(type: ::T::Class[::T.type_parameter(:U)]).returns(::T.nilable(A::B[::T.type_parameter(:U)])) } + def self.[](type) = nil + end + end + RUBY + + to_line_matched_format_for_machines: <<~RUBY, + module A + # RBS_WRITTEN_ANNOTATION: [E] + class B; extend ::T::Generic; E = type_member + class << self + ::T::Sig::WithoutRuntime.sig { type_parameters(:U).params(types: ::T::Class[::T.type_parameter(:U)]).returns(::T::Class[B[::T.type_parameter(:U)]]) } + def [](*types) = self + end + + ::T::Sig::WithoutRuntime.sig { type_parameters(:U).params(type: ::T::Class[::T.type_parameter(:U)]).returns(::T.nilable(A::B[::T.type_parameter(:U)])) } + def self.[](type) = nil + end + end + RUBY + ) + end + + def test_translate_to_rbi_keeps_runtime_sigs_that_do_not_call_their_own_bracket_method + assert_rewrites_rbs( + from: <<~RUBY, + class A + class << self + #: (*untyped) -> self + def [](*types) = self + + #: -> A[Integer] + def build = new + end + + #: (Integer) -> A[Integer] + def [](index) = self + + #: -> Array[A] + def self.all = [] + end + + class C + #: (Class[untyped]) -> A[Integer] + def self.[](type) = A.new + end + RUBY + + to_pretty_format_for_humans: <<~RUBY, + class A + class << self + sig { params(types: ::T.untyped).returns(::T.self_type) } + def [](*types) = self + + sig { returns(A[Integer]) } + def build = new + end + + sig { params(index: Integer).returns(A[Integer]) } + def [](index) = self + + sig { returns(::T::Array[A]) } + def self.all = [] + end + + class C + sig { params(type: ::T::Class[::T.untyped]).returns(A[Integer]) } + def self.[](type) = A.new + end + RUBY + + to_line_matched_format_for_machines: :same_as_pretty_output, + ) + end + def test_translate_to_rbi_singleton_method_sigs assert_rewrites_rbs( from: <<~RUBY,