From 23e2fd4893ae65389ec636cc1add684ec84bd7a8 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 26 May 2026 16:17:21 -0600 Subject: [PATCH 1/2] Add erase_generic_types option to RBSCommentsToSorbetSigs Sorbet erases generic types at runtime as they cannot be enforced. This means in runtime contexts we can drop generic types entirely, simplifying the rbs to sorbet signature translation This commit adds an `erase_generic_types` option to the `RBSCommentsToSorbetSigs` rewriter. When set, generic args are dropped from translated sigs (`Box[Integer]` to `Box`) and `extend T::Generic` is not added --- .../translate/rbs_comments_to_sorbet_sigs.rb | 39 ++- test/spoom/cli/srb/sigs_test.rb | 38 +++ .../rbs_comments_to_sorbet_sigs_test.rb | 262 +++++++++++++++++- 3 files changed, 325 insertions(+), 14 deletions(-) diff --git a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb index 989a9061..9ffc0143 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb @@ -28,16 +28,18 @@ def contains_rbs_syntax?(source) Sigils.contains_valid_sigil?(source) && source.match?(RBS_REWRITE_PATTERN) end - #: (String ruby_contents, file: String, ?max_line_length: Integer?, ?overloads_strategy: Symbol) -> String - def rewrite_if_needed(ruby_contents, file:, max_line_length: nil, overloads_strategy: :translate_all) + #: (String ruby_contents, file: String, ?max_line_length: Integer?, ?overloads_strategy: Symbol, ?erase_generic_types: bool) -> String + def rewrite_if_needed(ruby_contents, file:, max_line_length: nil, overloads_strategy: :translate_all, + erase_generic_types: false) return ruby_contents unless contains_rbs_syntax?(ruby_contents) - new(ruby_contents, file:, max_line_length:, overloads_strategy:).rewrite + new(ruby_contents, file:, max_line_length:, overloads_strategy:, erase_generic_types:).rewrite end end - #: (String, file: String, ?max_line_length: Integer?, ?overloads_strategy: Symbol) -> void - def initialize(ruby_contents, file:, max_line_length: nil, overloads_strategy: :translate_all) + #: (String, file: String, ?max_line_length: Integer?, ?overloads_strategy: Symbol, ?erase_generic_types: bool) -> void + def initialize(ruby_contents, file:, max_line_length: nil, overloads_strategy: :translate_all, + erase_generic_types: false) super(ruby_contents, file: file) unless ALLOWED_OVERLOAD_STRATEGIES.include?(overloads_strategy) @@ -47,6 +49,8 @@ def initialize(ruby_contents, file:, max_line_length: nil, overloads_strategy: : @max_line_length = max_line_length @overloads_strategy = overloads_strategy + @erase_generic_types = erase_generic_types + @type_translator = RBI::RBS::TypeTranslator.new(erase_generic_types:) #: RBI::RBS::TypeTranslator end # @override @@ -133,11 +137,11 @@ def visit_attr(node) name = node.arguments&.arguments&.first #: as Prism::SymbolNode sig.params << RBI::SigParam.new( name.slice[1..-1], #: as String - RBI::RBS::TypeTranslator.translate(attr_type), + @type_translator.translate(attr_type), ) end - sig.return_type = RBI::RBS::TypeTranslator.translate(attr_type) + sig.return_type = @type_translator.translate(attr_type) apply_member_annotations(comments.method_annotations, sig) @@ -174,7 +178,7 @@ def rewrite_def(def_node, comments) next end - translator = RBI::RBS::MethodTypeTranslator.new(rbi_node) + translator = RBI::RBS::MethodTypeTranslator.new(rbi_node, erase_generic_types: @erase_generic_types) begin translator.visit(method_type) @@ -259,7 +263,7 @@ def apply_class_annotations(node) "final!" when /^@requires_ancestor: / srb_type = ::RBS::Parser.parse_type(annotation.string.delete_prefix("@requires_ancestor: ")) - rbs_type = RBI::RBS::TypeTranslator.translate(srb_type) + rbs_type = @type_translator.translate(srb_type) "requires_ancestor { #{rbs_type} }" else next @@ -288,6 +292,17 @@ def apply_class_annotations(node) to = adjust_to_line_end(signature.location.end_offset) @rewriter << Source::Delete.new(from, to) + if @erase_generic_types + type_params.each do |type_param| + @rewriter << Source::Insert.new( + insert_pos, + "\n#{indent}#{type_param.name} = T.type_alias { T.anything }\n", + ) + end + + next + end + unless already_extends?(node, /^(::)?T::Generic$/) @rewriter << Source::Insert.new(insert_pos, "\n#{indent}extend T::Generic\n") end @@ -304,12 +319,12 @@ def apply_class_annotations(node) if type_param.upper_bound || type_param.default_type if type_param.upper_bound - rbs_type = RBI::RBS::TypeTranslator.translate(type_param.upper_bound) + rbs_type = @type_translator.translate(type_param.upper_bound) type_member = "#{type_member} {{ upper: #{rbs_type} }}" end if type_param.default_type - rbs_type = RBI::RBS::TypeTranslator.translate(type_param.default_type) + rbs_type = @type_translator.translate(type_param.default_type) type_member = "#{type_member} {{ fixed: #{rbs_type} }}" end end @@ -417,7 +432,7 @@ def apply_type_aliases(comments) next unless decls.size == 1 && decls.first.is_a?(::RBS::AST::Declarations::TypeAlias) rbs_type = decls.first - sorbet_type = RBI::RBS::TypeTranslator.translate(rbs_type.type) + sorbet_type = @type_translator.translate(rbs_type.type) alias_name = ::RBS::TypeName.new( namespace: rbs_type.name.namespace, diff --git a/test/spoom/cli/srb/sigs_test.rb b/test/spoom/cli/srb/sigs_test.rb index 831bb886..6fdb5f8c 100644 --- a/test/spoom/cli/srb/sigs_test.rb +++ b/test/spoom/cli/srb/sigs_test.rb @@ -197,6 +197,44 @@ def foo(a, b = 42, *c, d:, e: 42, **f); end RB end + def test_translate_rbs_to_rbi_with_erase_generic_types + @project.write!("file.rb", <<~RB) + # typed: true + + #: [E] + class Box + #: -> Array[E] + def values + [] + end + + #: (E) -> void + def push(value) + end + end + RB + + result = @project.spoom("srb sigs translate --from rbs --to rbi --no-color --erase-generic-types") + + assert_empty(result.err) + assert(result.status) + + assert_equal(<<~RB, @project.read("file.rb")) + # typed: true + + class Box + sig { returns(Array) } + def values + [] + end + + sig { params(value: ::T.untyped).void } + def push(value) + end + end + RB + end + def test_translate_includes_rbi_files @project.write!("file.rb", <<~RB) sig { void } 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 02e04064..b248793d 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 @@ -449,6 +449,262 @@ class << self ) end + def test_translate_to_rbi_generic_types + contents = <<~RB + #: -> Array[Integer] + def foo + [] + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents)) + sig { returns(::T::Array[Integer]) } + def foo + [] + end + RB + end + + def test_translate_to_rbi_erase_generic_types + contents = <<~RB + #: [E] + class Box + #: -> void + def initialize + @elems = [] #: Array[E] + end + end + + #: -> Box[Integer] + def box_of_int + Box.new + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Box + E = T.type_alias { T.anything } + + sig { void } + def initialize + @elems = [] #: Array[E] + end + end + + sig { returns(Box) } + def box_of_int + Box.new + end + RB + end + + def test_translate_to_rbi_erase_generic_types_in_class_member_sigs + contents = <<~RB + #: [E] + class Stack + #: -> void + def initialize + @items = [] #: Array[E] + end + + #: (E) -> void + def push(item) + @items << item + end + + #: -> E? + def pop + @items.pop + end + + #: E + attr_reader :last_pushed + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Stack + E = T.type_alias { T.anything } + + sig { void } + def initialize + @items = [] #: Array[E] + end + + sig { params(item: E).void } + def push(item) + @items << item + end + + sig { returns(::T.nilable(E)) } + def pop + @items.pop + end + + sig { returns(E) } + attr_reader :last_pushed + end + RB + end + + def test_translate_to_rbi_erase_generic_types_in_generic_method_sigs + contents = <<~RB + class Utils + #: [T] (Array[T]) -> T? + def self.first_or_nil(arr) + arr.first + end + + #: [T] (Array[T], T) -> Array[T] + def self.append(arr, item) + arr + [item] + end + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Utils + sig { params(arr: Array).returns(::T.nilable(::T.anything)) } + def self.first_or_nil(arr) + arr.first + end + + sig { params(arr: Array, item: ::T.anything).returns(Array) } + def self.append(arr, item) + arr + [item] + end + end + RB + end + + def test_translate_to_rbi_erase_generic_types_with_class_and_method_type_params + contents = <<~RB + #: [E] + class Container + #: [T] (E, T) -> void + def store(element, extra); end + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Container + E = T.type_alias { T.anything } + + sig { params(element: E, extra: ::T.anything).void } + def store(element, extra); end + end + RB + end + + def test_translate_to_rbi_erase_generic_types_in_attr_writer_and_accessor + contents = <<~RB + #: [E] + class Box + #: E + attr_writer :value + + #: E + attr_accessor :other + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Box + E = T.type_alias { T.anything } + + sig { params(value: E).returns(E) } + attr_writer :value + + sig { returns(E) } + attr_accessor :other + end + RB + end + + def test_translate_to_rbi_erase_generic_types_with_multiple_class_type_params + contents = <<~RB + #: [K, V] + class Map + #: (K) -> V? + def get(key); end + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Map + K = T.type_alias { T.anything } + + V = T.type_alias { T.anything } + + sig { params(key: K).returns(::T.nilable(V)) } + def get(key); end + end + RB + end + + def test_translate_to_rbi_erase_generic_types_in_nested_generic_classes + contents = <<~RB + #: [E] + class Outer + #: (E) -> void + def outer_m(x); end + + #: [F] + class Inner + #: (F) -> void + def inner_m(y); end + end + + #: (E) -> void + def after_inner(z); end + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Outer + E = T.type_alias { T.anything } + + sig { params(x: E).void } + def outer_m(x); end + + class Inner + F = T.type_alias { T.anything } + + sig { params(y: F).void } + def inner_m(y); end + end + + sig { params(z: E).void } + def after_inner(z); end + end + RB + end + + def test_translate_to_rbi_erase_generic_types_in_nested_generic_and_block + contents = <<~RB + #: [E] + class Holder + #: (Hash[String, E]) -> void + def consume(h); end + + #: () { (E) -> void } -> void + def each(&blk); end + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Holder + E = T.type_alias { T.anything } + + sig { params(h: Hash).void } + def consume(h); end + + sig { params(blk: ::T.proc.params(arg0: E).void).void } + def each(&blk); end + end + RB + end + def test_translate_to_rbi_in_block assert_rewrites_rbs( from: <<~RUBY, @@ -961,13 +1217,15 @@ def foo; end private - #: (String, ?max_line_length: Integer?, ?overloads_strategy: Symbol) -> String - def rbs_comments_to_sorbet_sigs(ruby_contents, max_line_length: nil, overloads_strategy: :translate_all) + #: (String, ?max_line_length: Integer?, ?overloads_strategy: Symbol, ?erase_generic_types: bool) -> String + def rbs_comments_to_sorbet_sigs(ruby_contents, max_line_length: nil, overloads_strategy: :translate_all, + erase_generic_types: false) RBSCommentsToSorbetSigs.new( ruby_contents, file: "test.rb", max_line_length: max_line_length, overloads_strategy: overloads_strategy, + erase_generic_types: erase_generic_types, ).rewrite end From 507f839f6d457a966c3502fd885b100756eb14d2 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 28 May 2026 16:28:27 -0600 Subject: [PATCH 2/2] Add --erase-generic-types flag for RBS to RBI translation Expose a flag to set the `erase_generic_types` option in `Spoom::Sorbet::Translate.rbs_comments_to_sorbet_sigs` so CLI users can drop generic types when translating from RBS to RBI --- lib/spoom/cli/srb/sigs.rb | 5 +++++ lib/spoom/sorbet/translate.rb | 6 ++++-- test/spoom/cli/srb/sigs_test.rb | 4 +++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/spoom/cli/srb/sigs.rb b/lib/spoom/cli/srb/sigs.rb index 078744bc..3c3b1270 100644 --- a/lib/spoom/cli/srb/sigs.rb +++ b/lib/spoom/cli/srb/sigs.rb @@ -22,6 +22,10 @@ class Sigs < Thor option :translate_generics, type: :boolean, desc: "Translate generics", default: false option :translate_helpers, type: :boolean, desc: "Translate helpers", default: false option :translate_abstract_methods, type: :boolean, desc: "Translate abstract methods", default: false + option :erase_generic_types, + type: :boolean, + desc: "Drop generic types when translating from RBS to RBI", + default: false def translate(*paths) from = options[:from] to = options[:to] @@ -65,6 +69,7 @@ def translate(*paths) contents, file: file, max_line_length: max_line_length, + erase_generic_types: options[:erase_generic_types], ) end end diff --git a/lib/spoom/sorbet/translate.rb b/lib/spoom/sorbet/translate.rb index bf815176..14ebf00b 100644 --- a/lib/spoom/sorbet/translate.rb +++ b/lib/spoom/sorbet/translate.rb @@ -53,13 +53,15 @@ def sorbet_sigs_to_rbs_comments( # Converts all the RBS comments in the given Ruby code to `sig` nodes. # It also handles type members and class annotations. - #: (String ruby_contents, file: String, ?max_line_length: Integer?, ?overloads_strategy: Symbol) -> String - def rbs_comments_to_sorbet_sigs(ruby_contents, file:, max_line_length: nil, overloads_strategy: :translate_all) + #: (String ruby_contents, file: String, ?max_line_length: Integer?, ?overloads_strategy: Symbol, ?erase_generic_types: bool) -> String + def rbs_comments_to_sorbet_sigs(ruby_contents, file:, max_line_length: nil, overloads_strategy: :translate_all, + erase_generic_types: false) RBSCommentsToSorbetSigs.rewrite_if_needed( ruby_contents, file: file, max_line_length: max_line_length, overloads_strategy: overloads_strategy, + erase_generic_types: erase_generic_types, ) end diff --git a/test/spoom/cli/srb/sigs_test.rb b/test/spoom/cli/srb/sigs_test.rb index 6fdb5f8c..24d47b78 100644 --- a/test/spoom/cli/srb/sigs_test.rb +++ b/test/spoom/cli/srb/sigs_test.rb @@ -223,12 +223,14 @@ def push(value) # typed: true class Box + E = T.type_alias { T.anything } + sig { returns(Array) } def values [] end - sig { params(value: ::T.untyped).void } + sig { params(value: E).void } def push(value) end end