Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,23 @@ def rewrite_def(def_node, comments)
return if comments.signatures.empty?
return if !@translate_abstract_methods && comments.method_annotations.any?(&:abstract?)

builder = RBI::Parser::TreeBuilder.new(@ruby_contents, comments: [], file: @file)
builder.visit(def_node)
rbi_node = builder.tree.nodes.first #: as RBI::Method

# When the method def has anonymous rest/keyword-rest params
# (e.g. `def foo(**)` or `def foo(*)`), sorbet-runtime cannot
# bind a named sig param to them. Instead of rewriting the
# method def, we emit the sig with quoted star names (`"*"` /
# `"**"`) which sorbet-runtime accepts for anonymous rest params.
anonymous_overrides = find_anonymous_rest_overrides(def_node, rbi_node)

signatures = apply_overloads_strategy(
comments.signatures,
method_name: def_node.name.to_s,
location: "#{@file}:#{def_node.location.start_line}",
)

builder = RBI::Parser::TreeBuilder.new(@ruby_contents, comments: [], file: @file)
builder.visit(def_node)
rbi_node = builder.tree.nodes.first #: as RBI::Method

known_annotations = nil #: Array[Spoom::RBS::Annotation]?

signatures.each do |signature|
Expand All @@ -173,6 +180,16 @@ def rewrite_def(def_node, comments)

sig = translator.result

# Normalize SigParam names for anonymous rest params to quoted
# star names so the sig binds to the anonymous method param.
# RBS param names are non-semantic and may differ.
anonymous_overrides.each do |index, quoted_name|
old = sig.params[index]
next unless old

sig.params[index] = RBI::SigParam.new(quoted_name, old.type)
end

known_annotations = apply_member_annotations(comments.method_annotations, sig)

# Sorbet runtime doesn't support `sig` on `method_added` or
Expand All @@ -193,6 +210,34 @@ def rewrite_def(def_node, comments)
end
end

# Returns a hash mapping SigParam index → quoted star name (`'"*"'`
# or `'"**"'`) for each anonymous rest/keyword-rest param in the
# method def. The RBI printer emits these as `params("*": ...)`
# / `params("**": ...)`, which sorbet-runtime accepts for
# anonymous rest params without rewriting the method def.
#: (Prism::DefNode, RBI::Method) -> Hash[Integer, String]
def find_anonymous_rest_overrides(def_node, rbi_node)
prism_params = def_node.parameters
return {} unless prism_params

keyword_rest = prism_params.keyword_rest
rest = prism_params.rest
anonymous_keyword_rest = keyword_rest.is_a?(Prism::KeywordRestParameterNode) && keyword_rest.name.nil?
anonymous_rest = rest.is_a?(Prism::RestParameterNode) && rest.name.nil?
return {} unless anonymous_keyword_rest || anonymous_rest

overrides = {} #: Hash[Integer, String]
rbi_node.params.each_with_index do |param, index|
if anonymous_keyword_rest && param.is_a?(RBI::KwRestParam)
overrides[index] = '"**"'
elsif anonymous_rest && param.is_a?(RBI::RestParam)
overrides[index] = '"*"'
end
end

overrides
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
Expand Down
278 changes: 278 additions & 0 deletions test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,284 @@ def foo; end
end
end

def test_rewrite_named_keyword_rest_param
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (**untyped kwargs) -> untyped
def resolve(**kwargs)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params(kwargs: ::T.untyped).returns(::T.untyped) }
def resolve(**kwargs)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_keyword_rest_param
# When both the RBS type and the method definition use anonymous `**`,
# the sig uses the quoted star name `"**"` which sorbet-runtime
# accepts for anonymous keyword-rest params. The method def is
# left untouched.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (**untyped) -> untyped
def resolve(**)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("**": ::T.untyped).returns(::T.untyped) }
def resolve(**)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_rest_positional_param
# Same as anonymous keyword-rest: the sig uses `"*"` which
# sorbet-runtime accepts for anonymous rest params.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (*untyped) -> untyped
def resolve(*)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("*": ::T.untyped).returns(::T.untyped) }
def resolve(*)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_named_keyword_rest_with_anonymous_def
# Even when the RBS type names the keyword-rest param, the sig uses
# the quoted star name `"**"` so it binds to the anonymous method
# param. RBS param names are non-semantic.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (**untyped kwargs) -> untyped
def resolve(**)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("**": ::T.untyped).returns(::T.untyped) }
def resolve(**)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_keyword_rest_with_named_def
# When the RBS type is anonymous but the method definition names the
# param, the translator picks up the name from the method and the
# sig is emitted normally.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (**untyped) -> untyped
def resolve(**kwargs)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params(kwargs: ::T.untyped).returns(::T.untyped) }
def resolve(**kwargs)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_keyword_rest_with_overloads_translate_last
# With :translate_last, earlier overloads are discarded. The last
# overload's sig uses the quoted star name `"**"`.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (Integer) -> String
#: (**untyped kwargs) -> untyped
def resolve(**)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("**": ::T.untyped).returns(::T.untyped) }
def resolve(**)
end
end
RUBY

to_line_matched_format_for_machines: <<~RUBY,
# typed: true
class Foo
# RBS_DISCARDED_OVERLOAD: (Integer) -> String
sig { params("**": ::T.untyped).returns(::T.untyped) }
def resolve(**)
end
end
RUBY

overloads_strategy: :translate_last,
)
end

def test_rewrite_anonymous_keyword_rest_with_invalid_rbs_leaves_source_unchanged
# When the RBS type is malformed, no sig can be translated, so the
# source is left completely unchanged.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: foo
def resolve(**)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
#: foo
def resolve(**)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_keyword_rest_with_forwarding
# Anonymous `**` forwarding in the body (e.g. `g(**)`) is left
# untouched — the method def is not rewritten, so forwarding
# remains valid Ruby.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (**untyped) -> untyped
def resolve(**)
g(**)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("**": ::T.untyped).returns(::T.untyped) }
def resolve(**)
g(**)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_rest_positional_with_forwarding
# Anonymous `*` forwarding in the body (e.g. `g(*)`) is left
# untouched — the method def is not rewritten, so forwarding
# remains valid Ruby.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (*untyped) -> untyped
def resolve(*)
g(*)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("*": ::T.untyped).returns(::T.untyped) }
def resolve(*)
g(*)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

def test_rewrite_anonymous_rest_and_keyword_rest_combined
# Both anonymous `*` and `**` in the same method def.
assert_rewrites_rbs(
from: <<~RUBY,
# typed: true
class Foo
#: (*untyped, **untyped) -> untyped
def resolve(*, **)
end
end
RUBY

to_pretty_format_for_humans: <<~RUBY,
# typed: true
class Foo
sig { params("*": ::T.untyped, "**": ::T.untyped).returns(::T.untyped) }
def resolve(*, **)
end
end
RUBY

to_line_matched_format_for_machines: :same_as_pretty_output,
)
end

private

#: (String,
Expand Down
Loading