Skip to content

Commit 4eb03df

Browse files
committed
Fix RBS rewriter for methods with anonymous keyword/positional rest params
When a method definition uses anonymous rest params (e.g. `def resolve(**)` or `def resolve(*)`), the RBS-to-Sorbet sig translation produced invalid output: `sig { params(**kwargs: ::T.untyped)... }` -- a SyntaxError that prevented the entire file from loading. Root cause: RBI::RBS::MethodTypeTranslator falls back to the RBI parser's synthetic names (`**kwargs`, `*args`) for anonymous rest params. These splat-prefixed names are invalid in Sorbet sig params. Fix: When the method def has anonymous `**` or `*` (detected via Prism), the rewriter normalizes the corresponding translated SigParam names to quoted star names (`"**"` / `"*"`) so the RBI printer emits `params("**": ...)` / `params("*": ...)`. sorbet-runtime accepts these quoted star names as sig param names for anonymous rest params, so the sig binds correctly without rewriting the method def. This means anonymous forwarding expressions in the body (e.g. `g(**)` or `g(*)`) remain valid Ruby -- no body rewriting needed. RBS param names are non-semantic, so even when the RBS type names the param (e.g. `(**untyped kwargs)`), the sig uses the quoted star name to match the anonymous method def param. The normalization is applied per translated signature, so malformed RBS leaves the method untouched (no sig emitted, no changes).
1 parent 7a37f86 commit 4eb03df

2 files changed

Lines changed: 327 additions & 4 deletions

File tree

lib/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs/base_translator.rb

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,16 +144,23 @@ def rewrite_def(def_node, comments)
144144
return if comments.signatures.empty?
145145
return if !@translate_abstract_methods && comments.method_annotations.any?(&:abstract?)
146146

147+
builder = RBI::Parser::TreeBuilder.new(@ruby_contents, comments: [], file: @file)
148+
builder.visit(def_node)
149+
rbi_node = builder.tree.nodes.first #: as RBI::Method
150+
151+
# When the method def has anonymous rest/keyword-rest params
152+
# (e.g. `def foo(**)` or `def foo(*)`), sorbet-runtime cannot
153+
# bind a named sig param to them. Instead of rewriting the
154+
# method def, we emit the sig with quoted star names (`"*"` /
155+
# `"**"`) which sorbet-runtime accepts for anonymous rest params.
156+
anonymous_overrides = find_anonymous_rest_overrides(def_node, rbi_node)
157+
147158
signatures = apply_overloads_strategy(
148159
comments.signatures,
149160
method_name: def_node.name.to_s,
150161
location: "#{@file}:#{def_node.location.start_line}",
151162
)
152163

153-
builder = RBI::Parser::TreeBuilder.new(@ruby_contents, comments: [], file: @file)
154-
builder.visit(def_node)
155-
rbi_node = builder.tree.nodes.first #: as RBI::Method
156-
157164
known_annotations = nil #: Array[Spoom::RBS::Annotation]?
158165

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

174181
sig = translator.result
175182

183+
# Normalize SigParam names for anonymous rest params to quoted
184+
# star names so the sig binds to the anonymous method param.
185+
# RBS param names are non-semantic and may differ.
186+
anonymous_overrides.each do |index, quoted_name|
187+
old = sig.params[index]
188+
next unless old
189+
190+
sig.params[index] = RBI::SigParam.new(quoted_name, old.type)
191+
end
192+
176193
known_annotations = apply_member_annotations(comments.method_annotations, sig)
177194

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

213+
# Returns a hash mapping SigParam index → quoted star name (`'"*"'`
214+
# or `'"**"'`) for each anonymous rest/keyword-rest param in the
215+
# method def. The RBI printer emits these as `params("*": ...)`
216+
# / `params("**": ...)`, which sorbet-runtime accepts for
217+
# anonymous rest params without rewriting the method def.
218+
#: (Prism::DefNode, RBI::Method) -> Hash[Integer, String]
219+
def find_anonymous_rest_overrides(def_node, rbi_node)
220+
prism_params = def_node.parameters
221+
return {} unless prism_params
222+
223+
keyword_rest = prism_params.keyword_rest
224+
rest = prism_params.rest
225+
anonymous_keyword_rest = keyword_rest.is_a?(Prism::KeywordRestParameterNode) && keyword_rest.name.nil?
226+
anonymous_rest = rest.is_a?(Prism::RestParameterNode) && rest.name.nil?
227+
return {} unless anonymous_keyword_rest || anonymous_rest
228+
229+
overrides = {} #: Hash[Integer, String]
230+
rbi_node.params.each_with_index do |param, index|
231+
if anonymous_keyword_rest && param.is_a?(RBI::KwRestParam)
232+
overrides[index] = '"**"'
233+
elsif anonymous_rest && param.is_a?(RBI::RestParam)
234+
overrides[index] = '"*"'
235+
end
236+
end
237+
238+
overrides
239+
end
240+
196241
#: (Array[Spoom::RBS::Signature], method_name: String, location: String) -> Array[Spoom::RBS::Signature]
197242
def apply_overloads_strategy(signatures, method_name:, location:)
198243
return signatures if signatures.size <= 1

test/spoom/sorbet/translate/rbs_comments_to_sorbet_sigs_test.rb

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,284 @@ def foo; end
13381338
end
13391339
end
13401340

1341+
def test_rewrite_named_keyword_rest_param
1342+
assert_rewrites_rbs(
1343+
from: <<~RUBY,
1344+
# typed: true
1345+
class Foo
1346+
#: (**untyped kwargs) -> untyped
1347+
def resolve(**kwargs)
1348+
end
1349+
end
1350+
RUBY
1351+
1352+
to_pretty_format_for_humans: <<~RUBY,
1353+
# typed: true
1354+
class Foo
1355+
sig { params(kwargs: ::T.untyped).returns(::T.untyped) }
1356+
def resolve(**kwargs)
1357+
end
1358+
end
1359+
RUBY
1360+
1361+
to_line_matched_format_for_machines: :same_as_pretty_output,
1362+
)
1363+
end
1364+
1365+
def test_rewrite_anonymous_keyword_rest_param
1366+
# When both the RBS type and the method definition use anonymous `**`,
1367+
# the sig uses the quoted star name `"**"` which sorbet-runtime
1368+
# accepts for anonymous keyword-rest params. The method def is
1369+
# left untouched.
1370+
assert_rewrites_rbs(
1371+
from: <<~RUBY,
1372+
# typed: true
1373+
class Foo
1374+
#: (**untyped) -> untyped
1375+
def resolve(**)
1376+
end
1377+
end
1378+
RUBY
1379+
1380+
to_pretty_format_for_humans: <<~RUBY,
1381+
# typed: true
1382+
class Foo
1383+
sig { params("**": ::T.untyped).returns(::T.untyped) }
1384+
def resolve(**)
1385+
end
1386+
end
1387+
RUBY
1388+
1389+
to_line_matched_format_for_machines: :same_as_pretty_output,
1390+
)
1391+
end
1392+
1393+
def test_rewrite_anonymous_rest_positional_param
1394+
# Same as anonymous keyword-rest: the sig uses `"*"` which
1395+
# sorbet-runtime accepts for anonymous rest params.
1396+
assert_rewrites_rbs(
1397+
from: <<~RUBY,
1398+
# typed: true
1399+
class Foo
1400+
#: (*untyped) -> untyped
1401+
def resolve(*)
1402+
end
1403+
end
1404+
RUBY
1405+
1406+
to_pretty_format_for_humans: <<~RUBY,
1407+
# typed: true
1408+
class Foo
1409+
sig { params("*": ::T.untyped).returns(::T.untyped) }
1410+
def resolve(*)
1411+
end
1412+
end
1413+
RUBY
1414+
1415+
to_line_matched_format_for_machines: :same_as_pretty_output,
1416+
)
1417+
end
1418+
1419+
def test_rewrite_named_keyword_rest_with_anonymous_def
1420+
# Even when the RBS type names the keyword-rest param, the sig uses
1421+
# the quoted star name `"**"` so it binds to the anonymous method
1422+
# param. RBS param names are non-semantic.
1423+
assert_rewrites_rbs(
1424+
from: <<~RUBY,
1425+
# typed: true
1426+
class Foo
1427+
#: (**untyped kwargs) -> untyped
1428+
def resolve(**)
1429+
end
1430+
end
1431+
RUBY
1432+
1433+
to_pretty_format_for_humans: <<~RUBY,
1434+
# typed: true
1435+
class Foo
1436+
sig { params("**": ::T.untyped).returns(::T.untyped) }
1437+
def resolve(**)
1438+
end
1439+
end
1440+
RUBY
1441+
1442+
to_line_matched_format_for_machines: :same_as_pretty_output,
1443+
)
1444+
end
1445+
1446+
def test_rewrite_anonymous_keyword_rest_with_named_def
1447+
# When the RBS type is anonymous but the method definition names the
1448+
# param, the translator picks up the name from the method and the
1449+
# sig is emitted normally.
1450+
assert_rewrites_rbs(
1451+
from: <<~RUBY,
1452+
# typed: true
1453+
class Foo
1454+
#: (**untyped) -> untyped
1455+
def resolve(**kwargs)
1456+
end
1457+
end
1458+
RUBY
1459+
1460+
to_pretty_format_for_humans: <<~RUBY,
1461+
# typed: true
1462+
class Foo
1463+
sig { params(kwargs: ::T.untyped).returns(::T.untyped) }
1464+
def resolve(**kwargs)
1465+
end
1466+
end
1467+
RUBY
1468+
1469+
to_line_matched_format_for_machines: :same_as_pretty_output,
1470+
)
1471+
end
1472+
1473+
def test_rewrite_anonymous_keyword_rest_with_overloads_translate_last
1474+
# With :translate_last, earlier overloads are discarded. The last
1475+
# overload's sig uses the quoted star name `"**"`.
1476+
assert_rewrites_rbs(
1477+
from: <<~RUBY,
1478+
# typed: true
1479+
class Foo
1480+
#: (Integer) -> String
1481+
#: (**untyped kwargs) -> untyped
1482+
def resolve(**)
1483+
end
1484+
end
1485+
RUBY
1486+
1487+
to_pretty_format_for_humans: <<~RUBY,
1488+
# typed: true
1489+
class Foo
1490+
sig { params("**": ::T.untyped).returns(::T.untyped) }
1491+
def resolve(**)
1492+
end
1493+
end
1494+
RUBY
1495+
1496+
to_line_matched_format_for_machines: <<~RUBY,
1497+
# typed: true
1498+
class Foo
1499+
# RBS_DISCARDED_OVERLOAD: (Integer) -> String
1500+
sig { params("**": ::T.untyped).returns(::T.untyped) }
1501+
def resolve(**)
1502+
end
1503+
end
1504+
RUBY
1505+
1506+
overloads_strategy: :translate_last,
1507+
)
1508+
end
1509+
1510+
def test_rewrite_anonymous_keyword_rest_with_invalid_rbs_leaves_source_unchanged
1511+
# When the RBS type is malformed, no sig can be translated, so the
1512+
# source is left completely unchanged.
1513+
assert_rewrites_rbs(
1514+
from: <<~RUBY,
1515+
# typed: true
1516+
class Foo
1517+
#: foo
1518+
def resolve(**)
1519+
end
1520+
end
1521+
RUBY
1522+
1523+
to_pretty_format_for_humans: <<~RUBY,
1524+
# typed: true
1525+
class Foo
1526+
#: foo
1527+
def resolve(**)
1528+
end
1529+
end
1530+
RUBY
1531+
1532+
to_line_matched_format_for_machines: :same_as_pretty_output,
1533+
)
1534+
end
1535+
1536+
def test_rewrite_anonymous_keyword_rest_with_forwarding
1537+
# Anonymous `**` forwarding in the body (e.g. `g(**)`) is left
1538+
# untouched — the method def is not rewritten, so forwarding
1539+
# remains valid Ruby.
1540+
assert_rewrites_rbs(
1541+
from: <<~RUBY,
1542+
# typed: true
1543+
class Foo
1544+
#: (**untyped) -> untyped
1545+
def resolve(**)
1546+
g(**)
1547+
end
1548+
end
1549+
RUBY
1550+
1551+
to_pretty_format_for_humans: <<~RUBY,
1552+
# typed: true
1553+
class Foo
1554+
sig { params("**": ::T.untyped).returns(::T.untyped) }
1555+
def resolve(**)
1556+
g(**)
1557+
end
1558+
end
1559+
RUBY
1560+
1561+
to_line_matched_format_for_machines: :same_as_pretty_output,
1562+
)
1563+
end
1564+
1565+
def test_rewrite_anonymous_rest_positional_with_forwarding
1566+
# Anonymous `*` forwarding in the body (e.g. `g(*)`) is left
1567+
# untouched — the method def is not rewritten, so forwarding
1568+
# remains valid Ruby.
1569+
assert_rewrites_rbs(
1570+
from: <<~RUBY,
1571+
# typed: true
1572+
class Foo
1573+
#: (*untyped) -> untyped
1574+
def resolve(*)
1575+
g(*)
1576+
end
1577+
end
1578+
RUBY
1579+
1580+
to_pretty_format_for_humans: <<~RUBY,
1581+
# typed: true
1582+
class Foo
1583+
sig { params("*": ::T.untyped).returns(::T.untyped) }
1584+
def resolve(*)
1585+
g(*)
1586+
end
1587+
end
1588+
RUBY
1589+
1590+
to_line_matched_format_for_machines: :same_as_pretty_output,
1591+
)
1592+
end
1593+
1594+
def test_rewrite_anonymous_rest_and_keyword_rest_combined
1595+
# Both anonymous `*` and `**` in the same method def.
1596+
assert_rewrites_rbs(
1597+
from: <<~RUBY,
1598+
# typed: true
1599+
class Foo
1600+
#: (*untyped, **untyped) -> untyped
1601+
def resolve(*, **)
1602+
end
1603+
end
1604+
RUBY
1605+
1606+
to_pretty_format_for_humans: <<~RUBY,
1607+
# typed: true
1608+
class Foo
1609+
sig { params("*": ::T.untyped, "**": ::T.untyped).returns(::T.untyped) }
1610+
def resolve(*, **)
1611+
end
1612+
end
1613+
RUBY
1614+
1615+
to_line_matched_format_for_machines: :same_as_pretty_output,
1616+
)
1617+
end
1618+
13411619
private
13421620

13431621
#: (String,

0 commit comments

Comments
 (0)