From ffa0c7ea1792c8f09e51110f98414ba7251d5e53 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 26 May 2026 16:17:21 -0600 Subject: [PATCH 1/3] 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 | 13 +- .../base_translator.rb | 21 ++- test/spoom/cli/srb/sigs_test.rb | 40 +++++ .../rbs_comments_to_sorbet_sigs_test.rb | 150 +++++++++++++++++- 4 files changed, 215 insertions(+), 9 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 eb652099..ee88a3e1 100644 --- a/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb +++ b/lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs.rb @@ -24,11 +24,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) - HumanReadableTranslator.new(ruby_contents, file:, max_line_length:, overloads_strategy:).rewrite + HumanReadableTranslator.new( + ruby_contents, + file:, + max_line_length:, + overloads_strategy:, + erase_generic_types:, + ).rewrite end end end 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 2528ffe5..11d2ef27 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 @@ -11,8 +11,9 @@ class BaseTranslator < Translator ALLOWED_OVERLOAD_STRATEGIES = [:translate_all, :translate_last, :raise].freeze #: Array[Symbol] - #: (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) @@ -22,7 +23,8 @@ def initialize(ruby_contents, file:, max_line_length: nil, overloads_strategy: : @max_line_length = max_line_length @overloads_strategy = overloads_strategy - @type_translator = RBI::RBS::TypeTranslator.new #: RBI::RBS::TypeTranslator + @erase_generic_types = erase_generic_types + @type_translator = RBI::RBS::TypeTranslator.new(erase_generic_types:) #: RBI::RBS::TypeTranslator end # @override @@ -150,7 +152,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) @@ -264,6 +266,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 diff --git a/test/spoom/cli/srb/sigs_test.rb b/test/spoom/cli/srb/sigs_test.rb index 831bb886..5fd501b1 100644 --- a/test/spoom/cli/srb/sigs_test.rb +++ b/test/spoom/cli/srb/sigs_test.rb @@ -197,6 +197,46 @@ 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 + E = T.type_alias { ::T.anything } + + sig { returns(Array) } + def values + [] + end + + sig { params(value: E).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 1deb6cf7..767ca1fa 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,126 @@ class << self ) end + def test_translate_to_rbi_preserves_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_with_erase_generic_class_types + contents = <<~RB + #: [T, in A, out B, C < Numeric, D > Numeric, E = String] + class Result + #: (T, E?) -> void + def initialize(value, error) + @value = value + @error = error + end + + #: T + attr_reader :value + + #: E? + attr_writer :error + end + + #: (String) -> Result[Integer, String] + def parse_int(str) + Result.new(Integer(str), nil) #: Result[Integer, String] + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Result + T = T.type_alias { ::T.anything } + + A = T.type_alias { ::T.anything } + + B = T.type_alias { ::T.anything } + + C = T.type_alias { ::T.anything } + + D = T.type_alias { ::T.anything } + + E = T.type_alias { ::T.anything } + + sig { params(value: T, error: ::T.nilable(E)).void } + def initialize(value, error) + @value = value + @error = error + end + + sig { returns(T) } + attr_reader :value + + sig { params(error: ::T.nilable(E)).returns(::T.nilable(E)) } + attr_writer :error + end + + sig { params(str: String).returns(Result) } + def parse_int(str) + Result.new(Integer(str), nil) #: Result[Integer, String] + end + RB + end + + def test_translate_to_rbi_with_erase_generic_method_types + contents = <<~RB + class Factory + #: [T] (T?) -> T? + def self.identity(value) + value + end + + class << self + #: [U] (U) -> U + def wrap(value) + value + end + end + + #: [V] + class << self + #: -> V + def build; end + end + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Factory + sig { params(value: ::T.nilable(::T.anything)).returns(::T.nilable(::T.anything)) } + def self.identity(value) + value + end + + class << self + sig { params(value: ::T.anything).returns(::T.anything) } + def wrap(value) + value + end + end + + class << self + V = T.type_alias { ::T.anything } + + sig { returns(V) } + def build; end + end + end + RB + end + def test_translate_to_rbi_in_block assert_rewrites_rbs( from: <<~RUBY, @@ -673,6 +793,30 @@ def double_items(items) ) end + def test_translate_type_alias_with_erase_generic_types + contents = <<~RB + class Box; end + + #: type boxed_string = Box[String] + + #: () -> boxed_string + def build_box + Box.new + end + RB + + assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) + class Box; end + + BoxedString = T.type_alias { Box } + + sig { returns(BoxedString) } + def build_box + Box.new + end + RB + end + def test_translate_type_alias_with_union assert_rewrites_rbs( from: <<~RUBY, @@ -983,13 +1127,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::HumanReadableTranslator.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 da7d320df4b27fa5f570cbc6a60589df29a8a01a Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 16 Jun 2026 09:44:32 -0600 Subject: [PATCH 2/3] 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 ++++-- 2 files changed, 9 insertions(+), 2 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 From 21467221bc4a50470e4dfbd7998b61576f4bbb28 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 16 Jun 2026 14:37:45 -0600 Subject: [PATCH 3/3] Fully qualify T.type_alias to ::T.type_alias When erasing generic types we replace `type_member`s with a `T.anything` type alias of the same name. Qualifying `T.type_alias` to `::T.type_alias` ensures we avoid conflicts like `T = T.type_alias`. This matches --- .../base_translator.rb | 4 +-- test/spoom/cli/srb/sigs_test.rb | 2 +- .../rbs_comments_to_sorbet_sigs_test.rb | 36 +++++++++---------- 3 files changed, 21 insertions(+), 21 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 11d2ef27..0f7da9eb 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 @@ -270,7 +270,7 @@ def apply_class_annotations(node) type_params.each do |type_param| @rewriter << Source::Insert.new( insert_pos, - "\n#{indent}#{type_param.name} = T.type_alias { ::T.anything }\n", + "\n#{indent}#{type_param.name} = ::T.type_alias { ::T.anything }\n", ) end @@ -417,7 +417,7 @@ def apply_type_aliases(comments) ) @rewriter << Source::Delete.new(from, to) - content = "#{indent}#{alias_name} = T.type_alias { #{sorbet_type.to_rbi} }\n" + content = "#{indent}#{alias_name} = ::T.type_alias { #{sorbet_type.to_rbi} }\n" @rewriter << Source::Insert.new(insert_pos, content) rescue ::RBS::ParsingError, ::RBI::Error # Ignore type aliases with errors diff --git a/test/spoom/cli/srb/sigs_test.rb b/test/spoom/cli/srb/sigs_test.rb index 5fd501b1..ecb74e1b 100644 --- a/test/spoom/cli/srb/sigs_test.rb +++ b/test/spoom/cli/srb/sigs_test.rb @@ -223,7 +223,7 @@ def push(value) # typed: true class Box - E = T.type_alias { ::T.anything } + E = ::T.type_alias { ::T.anything } sig { returns(Array) } def values 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 767ca1fa..77efd53d 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 @@ -490,17 +490,17 @@ def parse_int(str) assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) class Result - T = T.type_alias { ::T.anything } + T = ::T.type_alias { ::T.anything } - A = T.type_alias { ::T.anything } + A = ::T.type_alias { ::T.anything } - B = T.type_alias { ::T.anything } + B = ::T.type_alias { ::T.anything } - C = T.type_alias { ::T.anything } + C = ::T.type_alias { ::T.anything } - D = T.type_alias { ::T.anything } + D = ::T.type_alias { ::T.anything } - E = T.type_alias { ::T.anything } + E = ::T.type_alias { ::T.anything } sig { params(value: T, error: ::T.nilable(E)).void } def initialize(value, error) @@ -560,7 +560,7 @@ def wrap(value) end class << self - V = T.type_alias { ::T.anything } + V = ::T.type_alias { ::T.anything } sig { returns(V) } def build; end @@ -687,8 +687,8 @@ def bar(a) to_pretty_format_for_humans: <<~RUBY, module Aliases - Foo = T.type_alias { ::T.any(Integer, String) } - MultiLine = T.type_alias { ::T.any(Integer, String) } + Foo = ::T.type_alias { ::T.any(Integer, String) } + MultiLine = ::T.type_alias { ::T.any(Integer, String) } end sig { params(a: Aliases::Foo).returns(Aliases::MultiLine) } @@ -712,8 +712,8 @@ def process_user(data) RUBY to_pretty_format_for_humans: <<~RUBY, - Foo::UserId = T.type_alias { Integer } - ::Bar::UserData = T.type_alias { { id: Foo::UserId, name: String } } + Foo::UserId = ::T.type_alias { Integer } + ::Bar::UserData = ::T.type_alias { { id: Foo::UserId, name: String } } sig { params(data: ::Bar::UserData).returns(Foo::UserId) } def process_user(data) @@ -738,7 +738,7 @@ def get_status to_pretty_format_for_humans: <<~RUBY, class Example - Status = T.type_alias { Symbol } + Status = ::T.type_alias { Symbol } sig { returns(Status) } def get_status @@ -761,7 +761,7 @@ def status; end to_pretty_format_for_humans: <<~RUBY, class Example - Status = T.type_alias { Symbol } + Status = ::T.type_alias { Symbol } sig { returns(Status) } def status; end end @@ -783,7 +783,7 @@ def double_items(items) RUBY to_pretty_format_for_humans: <<~RUBY, - List = T.type_alias { ::T::Array[Integer] } + List = ::T.type_alias { ::T::Array[Integer] } sig { params(items: List).returns(List) } def double_items(items) @@ -808,7 +808,7 @@ def build_box assert_equal(<<~RB, rbs_comments_to_sorbet_sigs(contents, erase_generic_types: true)) class Box; end - BoxedString = T.type_alias { Box } + BoxedString = ::T.type_alias { Box } sig { returns(BoxedString) } def build_box @@ -829,7 +829,7 @@ def ensure_string(text) RUBY to_pretty_format_for_humans: <<~RUBY, - NullableString = T.type_alias { ::T.nilable(String) } + NullableString = ::T.type_alias { ::T.nilable(String) } sig { params(text: NullableString).returns(String) } def ensure_string(text) @@ -871,7 +871,7 @@ def foo RUBY to_pretty_format_for_humans: <<~RUBY, - MultiLine = T.type_alias { ::T.any(String, Integer) } + MultiLine = ::T.type_alias { ::T.any(String, Integer) } # foo bar baz #| | Symbol @@ -908,7 +908,7 @@ class Range to_pretty_format_for_humans: <<~RUBY, module Foo - SerializedRange = T.type_alias { [Integer, Integer] } + SerializedRange = ::T.type_alias { [Integer, Integer] } class Range end end