diff --git a/lib/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments.rb b/lib/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments.rb index 91608d88..5baa1e5b 100644 --- a/lib/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments.rb +++ b/lib/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments.rb @@ -79,6 +79,11 @@ def visit_def_node(node) last_sigs.each do |node, sig| next if sig.is_abstract && !@translate_abstract_methods + # RBS type variables shadow constants and class type members with the + # same name. Refuse colliding method type parameters so the user can + # rename them and keep names consistent. + raise_on_colliding_type_params!(sig) + preserve_multiline_signatures = !!(@preserve_multiline_signatures && sig.loc&.multiline?) out = rbs_print( @@ -388,6 +393,33 @@ def delete_extend_t_generics @extend_t_generics.clear end + #: (RBI::Sig) -> void + def raise_on_colliding_type_params!(sig) + return unless sig.type_params? + + occupied = simple_type_names(sig) + class_type_member_names + + sig.type_params.each do |type_param| + next unless occupied.any? { |name| name == type_param || name.start_with?("#{type_param}::") } + + raise Error, "Type parameter `#{type_param}` collides with a constant or class type member. Rename the type parameter to avoid the collision." + end + end + + #: (RBI::Sig) -> Array[String] + def simple_type_names(sig) + collector = SimpleTypeNameCollector.new + collector.visit_sig(sig) + collector.names + end + + #: -> Array[String] + def class_type_member_names + @type_members.map do |member| + member.sub(/^(in|out)\s+/, "").split(/[\s<=]/).first #: as String + end + end + # Collects the last signatures visited and clears the current list #: -> Array[[Prism::CallNode, RBI::Sig]] def collect_last_sigs @@ -417,6 +449,77 @@ def rbs_print(indent, preserve_multiline_signatures:, &block) end end.join + "\n" end + + class SigTypeWalker + #: (RBI::Sig) -> void + def visit_sig(sig) + sig.params.each do |param| + type = coerce_type(param.type) + param.instance_variable_set(:@type, type) + visit_type(type) + end + return_type = coerce_type(sig.return_type) + sig.return_type = return_type + visit_type(return_type) + end + + #: (RBI::Type | String) -> RBI::Type + def coerce_type(type) + type.is_a?(String) ? RBI::Type.parse_string(type) : type + end + + #: (RBI::Type) -> void + def visit_type(type) + case type + when RBI::Type::Simple + visit_simple(type) + when RBI::Type::TypeParameter + visit_type_parameter(type) + when RBI::Type::Generic + type.params.each { |param| visit_type(param) } + when RBI::Type::All, RBI::Type::Any, RBI::Type::Tuple + type.types.each { |inner| visit_type(inner) } + when RBI::Type::Nilable, RBI::Type::Class, RBI::Type::Module + visit_type(type.type) + when RBI::Type::ClassOf + visit_type(type.type) + type.type_parameters.each { |param| visit_type(param) } + when RBI::Type::TypeAlias + visit_type(type.aliased_type) + when RBI::Type::Proc + type.proc_params.each_value { |param| visit_type(param) } + if (proc_returns = type.proc_returns) + visit_type(proc_returns) + end + if (proc_bind = type.proc_bind) + visit_type(proc_bind) + end + when RBI::Type::Shape + type.types.each_value { |inner| visit_type(inner) } + end + end + + #: (RBI::Type::Simple) -> void + def visit_simple(type); end + + #: (RBI::Type::TypeParameter) -> void + def visit_type_parameter(type); end + end + + class SimpleTypeNameCollector < SigTypeWalker + #: Array[String] + attr_reader :names + + #: -> void + def initialize + @names = [] #: Array[String] + end + + #: (RBI::Type::Simple) -> void + def visit_simple(type) + @names << type.name + end + end end end end diff --git a/test/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments_test.rb b/test/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments_test.rb index 4bd7a0fd..181271a5 100644 --- a/test/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments_test.rb +++ b/test/spoom/sorbet/translate/sorbet_sigs_to_rbs_comments_test.rb @@ -621,6 +621,82 @@ def baz(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, RBS end + def test_translate_type_parameter_that_collides_with_a_constant + contents = <<~RB + class Store + module Credential + module Multiple; end + end + + sig do + type_parameters(:Credential) + .params(type: T.all(T::Module[T.type_parameter(:Credential)], T::Module[Credential::Multiple])) + .returns(T::Array[T.type_parameter(:Credential)]) + end + def find(type) + [] + end + end + RB + + error = assert_raises(Translate::Error) do + sorbet_sigs_to_rbs_comments(contents) + end + assert_equal( + "Type parameter `Credential` collides with a constant or class type member. Rename the type parameter to avoid the collision.", + error.message, + ) + end + + def test_translate_type_parameter_that_collides_with_a_class_type_member + contents = <<~RB + class Box + extend T::Generic + + Elem = type_member + + sig do + type_parameters(:Elem) + .params(value: T.type_parameter(:Elem)) + .returns(Elem) + end + def wrap(value) + value + end + end + RB + + error = assert_raises(Translate::Error) do + sorbet_sigs_to_rbs_comments(contents) + end + assert_equal( + "Type parameter `Elem` collides with a constant or class type member. Rename the type parameter to avoid the collision.", + error.message, + ) + end + + def test_translate_type_parameter_without_name_collision + contents = <<~RB + sig do + type_parameters(:U) + .params(value: T.type_parameter(:U)) + .returns(T.type_parameter(:U)) + end + def identity(value) + value + end + RB + + assert_equal(<<~RBS, sorbet_sigs_to_rbs_comments(contents)) + #: [U] ( + #| U value + #| ) -> U + def identity(value) + value + end + RBS + end + private #: (