From d06896da9fc7f323fd4bd9d87e89ce0d2505b3a6 Mon Sep 17 00:00:00 2001 From: Stefan Magnuson Date: Mon, 8 Jun 2026 12:04:00 +0100 Subject: [PATCH 1/5] Preserve heredoc body when translating multi-line `T.let` assertions When `T.let(...)` spans multiple lines and wraps a heredoc, the replacement range covers the entire call including the heredoc body. Since the value node's source range only covers the opener line (e.g. `<<~MSG.strip`), the body and terminator were silently dropped, producing syntactically broken Ruby. --- .../sorbet_assertions_to_rbs_comments.rb | 45 ++++++++++++++++++- rbi/spoom.rbi | 6 +++ .../sorbet_assertions_to_rbs_comments_test.rb | 34 ++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb index d2ba75e4..6ad73e0f 100644 --- a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb +++ b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb @@ -103,10 +103,12 @@ def maybe_translate_assertion(node) # Otherwise, replace up to the end of the node end_offset = comment_end_offset || node.location.end_offset + heredoc_body = heredoc_body_within_range(value, end_offset) + replacement = if node.name == :bind "#{rbs_annotation}#{trailing_comment}" else - "#{dedent_value(node, value)} #{rbs_annotation}#{trailing_comment}" + "#{dedent_value(node, value)} #{rbs_annotation}#{trailing_comment}#{heredoc_body}" end @rewriter << Source::Replace.new(start_offset, end_offset - 1, replacement) @@ -212,6 +214,47 @@ def extract_trailing_comment(node) [" #{range.pack("C*")}", end_offset] end + #: (Prism::Node, Integer) -> String? + def heredoc_body_within_range(node, replace_end_offset) + heredoc_end = find_heredoc_end_offset(node) + return unless heredoc_end + return if heredoc_end > replace_end_offset + + value_end = node.location.end_offset + opener_line_end = value_end + opener_line_end += 1 while opener_line_end < @ruby_bytes.size && @ruby_bytes[opener_line_end] != LINE_BREAK + return if opener_line_end >= @ruby_bytes.size + + body_bytes = @ruby_bytes[(opener_line_end + 1)...heredoc_end] #: as !nil + body = body_bytes.pack("C*") + body.chomp! if @ruby_bytes[replace_end_offset] == LINE_BREAK + "\n#{body}" + end + + #: (Prism::Node) -> Integer? + def find_heredoc_end_offset(node) + case node + when Prism::StringNode, Prism::InterpolatedStringNode + closing = node.closing_loc + opening = node.opening_loc + if closing && opening && opening.start_line != closing.start_line + return closing.end_offset + end + when Prism::CallNode + receiver = node.receiver + if receiver + result = find_heredoc_end_offset(receiver) + return result if result + end + node.arguments&.arguments&.each do |arg| + found = find_heredoc_end_offset(arg) + return found if found + end + end + + nil + end + #: (Prism::Node, Prism::Node) -> String def dedent_value(assign, value) if value.location.start_line == assign.location.start_line diff --git a/rbi/spoom.rbi b/rbi/spoom.rbi index 78fcc670..b499ed3c 100644 --- a/rbi/spoom.rbi +++ b/rbi/spoom.rbi @@ -3259,9 +3259,15 @@ class Spoom::Sorbet::Translate::SorbetAssertionsToRBSComments < ::Spoom::Sorbet: sig { params(node: ::Prism::Node).returns([T.nilable(::String), T.nilable(::Integer)]) } def extract_trailing_comment(node); end + sig { params(node: ::Prism::Node).returns(T.nilable(::Integer)) } + def find_heredoc_end_offset(node); end + sig { params(node: ::Prism::Node).returns(T::Boolean) } def has_rbs_annotation?(node); end + sig { params(node: ::Prism::Node, replace_end_offset: ::Integer).returns(T.nilable(::String)) } + def heredoc_body_within_range(node, replace_end_offset); end + sig { params(node: ::Prism::Node).returns(T::Boolean) } def maybe_translate_assertion(node); end diff --git a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb index ed57d175..9ce82cc2 100644 --- a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb +++ b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb @@ -264,6 +264,40 @@ def test_translate_assigns_ignore_heredoc_values RB end + def test_translate_assigns_multiline_tlet_with_heredoc_values + rb = <<~RB + MSG = T.let( + <<~MSG.gsub(/[[:space:]]+/, " ").strip, + Do not use foo directly. Use bar instead. + See this guide: https://example.com/docs + MSG + String, + ) + + QUERY = T.let( + <<~SQL.squish.freeze, + SELECT id, name + FROM users + WHERE active = true + SQL + String, + ) + RB + + assert_equal(<<~RB, rbi_to_rbs(rb)) + MSG = <<~MSG.gsub(/[[:space:]]+/, " ").strip #: String + Do not use foo directly. Use bar instead. + See this guide: https://example.com/docs + MSG + + QUERY = <<~SQL.squish.freeze #: String + SELECT id, name + FROM users + WHERE active = true + SQL + RB + end + def test_translate_assigns_does_not_match_bare_strings_has_heredoc rb = <<~RB a = T.let("<<~STR", String) From b5eb672a5f484faf0e7de6534d96a644ce0ac6c0 Mon Sep 17 00:00:00 2001 From: Stefan Magnuson Date: Fri, 12 Jun 2026 17:19:55 +0100 Subject: [PATCH 2/5] Add handling of multiple heredocs Before this change we would stop at the first heredoc. --- .../sorbet_assertions_to_rbs_comments.rb | 41 ++++++++----------- rbi/spoom.rbi | 6 +-- .../sorbet_assertions_to_rbs_comments_test.rb | 21 ++++++++++ 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb index 6ad73e0f..21531c1c 100644 --- a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb +++ b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb @@ -216,43 +216,38 @@ def extract_trailing_comment(node) #: (Prism::Node, Integer) -> String? def heredoc_body_within_range(node, replace_end_offset) - heredoc_end = find_heredoc_end_offset(node) + heredoc_end = heredoc_end_offsets(node) + .select { |offset| offset <= replace_end_offset } + .max return unless heredoc_end - return if heredoc_end > replace_end_offset - - value_end = node.location.end_offset - opener_line_end = value_end - opener_line_end += 1 while opener_line_end < @ruby_bytes.size && @ruby_bytes[opener_line_end] != LINE_BREAK - return if opener_line_end >= @ruby_bytes.size + opener_line_end = adjust_to_line_end(node.location.end_offset) body_bytes = @ruby_bytes[(opener_line_end + 1)...heredoc_end] #: as !nil body = body_bytes.pack("C*") body.chomp! if @ruby_bytes[replace_end_offset] == LINE_BREAK "\n#{body}" end - #: (Prism::Node) -> Integer? - def find_heredoc_end_offset(node) + #: (Prism::Node) -> Array[Integer] + def heredoc_end_offsets(node) + offsets = [] #: Array[Integer] + case node when Prism::StringNode, Prism::InterpolatedStringNode - closing = node.closing_loc opening = node.opening_loc - if closing && opening && opening.start_line != closing.start_line - return closing.end_offset - end - when Prism::CallNode - receiver = node.receiver - if receiver - result = find_heredoc_end_offset(receiver) - return result if result - end - node.arguments&.arguments&.each do |arg| - found = find_heredoc_end_offset(arg) - return found if found + closing = node.closing_loc + if opening && closing && opening.start_line != closing.start_line + offsets << closing.end_offset end end - nil + node.child_nodes.each do |child| + next unless child + + offsets.concat(heredoc_end_offsets(child)) + end + + offsets end #: (Prism::Node, Prism::Node) -> String diff --git a/rbi/spoom.rbi b/rbi/spoom.rbi index b499ed3c..172c460d 100644 --- a/rbi/spoom.rbi +++ b/rbi/spoom.rbi @@ -3259,15 +3259,15 @@ class Spoom::Sorbet::Translate::SorbetAssertionsToRBSComments < ::Spoom::Sorbet: sig { params(node: ::Prism::Node).returns([T.nilable(::String), T.nilable(::Integer)]) } def extract_trailing_comment(node); end - sig { params(node: ::Prism::Node).returns(T.nilable(::Integer)) } - def find_heredoc_end_offset(node); end - sig { params(node: ::Prism::Node).returns(T::Boolean) } def has_rbs_annotation?(node); end sig { params(node: ::Prism::Node, replace_end_offset: ::Integer).returns(T.nilable(::String)) } def heredoc_body_within_range(node, replace_end_offset); end + sig { params(node: ::Prism::Node).returns(T::Array[::Integer]) } + def heredoc_end_offsets(node); end + sig { params(node: ::Prism::Node).returns(T::Boolean) } def maybe_translate_assertion(node); end diff --git a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb index 9ce82cc2..c6a19d65 100644 --- a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb +++ b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb @@ -298,6 +298,27 @@ def test_translate_assigns_multiline_tlet_with_heredoc_values RB end + def test_translate_assigns_multiline_tlet_with_multiple_heredocs + rb = <<~RB + both = T.let( + foo(<<~A, <<~B), + first + A + second + B + String, + ) + RB + + assert_equal(<<~RB, rbi_to_rbs(rb)) + both = foo(<<~A, <<~B) #: String + first + A + second + B + RB + end + def test_translate_assigns_does_not_match_bare_strings_has_heredoc rb = <<~RB a = T.let("<<~STR", String) From 93c37f8744c30ec96c26e4828d8e4a22726b27db Mon Sep 17 00:00:00 2001 From: Stefan Magnuson Date: Tue, 16 Jun 2026 16:57:58 +0100 Subject: [PATCH 3/5] Add support for multi-line string literals when converting to RBS --- .../sorbet_assertions_to_rbs_comments.rb | 2 +- .../sorbet_assertions_to_rbs_comments_test.rb | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb index 21531c1c..e00bd5ab 100644 --- a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb +++ b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb @@ -236,7 +236,7 @@ def heredoc_end_offsets(node) when Prism::StringNode, Prism::InterpolatedStringNode opening = node.opening_loc closing = node.closing_loc - if opening && closing && opening.start_line != closing.start_line + if opening && closing && opening.start_line != closing.start_line && opening.slice.start_with?("<<") offsets << closing.end_offset end end diff --git a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb index c6a19d65..af33694a 100644 --- a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb +++ b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb @@ -319,6 +319,21 @@ def test_translate_assigns_multiline_tlet_with_multiple_heredocs RB end + def test_translate_assigns_multiline_string_literal + rb = <<~RB + s = T.let( + "first + second", + String, + ) + RB + + assert_equal(<<~RB, rbi_to_rbs(rb)) + s = "first + second" #: String + RB + end + def test_translate_assigns_does_not_match_bare_strings_has_heredoc rb = <<~RB a = T.let("<<~STR", String) From 93a28bfb4ee40140c7e6d32f5642d2ceb7ab1dd8 Mon Sep 17 00:00:00 2001 From: Stefan Magnuson Date: Wed, 17 Jun 2026 09:58:17 +0100 Subject: [PATCH 4/5] Add RBS translation coverage for backticks in heredocs --- .../sorbet_assertions_to_rbs_comments.rb | 2 +- .../sorbet_assertions_to_rbs_comments_test.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb index e00bd5ab..9f01aa1a 100644 --- a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb +++ b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb @@ -233,7 +233,7 @@ def heredoc_end_offsets(node) offsets = [] #: Array[Integer] case node - when Prism::StringNode, Prism::InterpolatedStringNode + when Prism::StringNode, Prism::InterpolatedStringNode, Prism::XStringNode, Prism::InterpolatedXStringNode opening = node.opening_loc closing = node.closing_loc if opening && closing && opening.start_line != closing.start_line && opening.slice.start_with?("<<") diff --git a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb index af33694a..163944b2 100644 --- a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb +++ b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb @@ -334,6 +334,23 @@ def test_translate_assigns_multiline_string_literal RB end + def test_translate_assigns_multiline_tlet_with_backtick_heredoc + rb = <<~RB + x = T.let( + <<~`CMD`, + echo hello + CMD + String, + ) + RB + + assert_equal(<<~RB, rbi_to_rbs(rb)) + x = <<~`CMD` #: String + echo hello + CMD + RB + end + def test_translate_assigns_does_not_match_bare_strings_has_heredoc rb = <<~RB a = T.let("<<~STR", String) From 3c764a11f9557ea0876f21d272bdca24640aa639 Mon Sep 17 00:00:00 2001 From: Stefan Magnuson Date: Mon, 22 Jun 2026 16:50:16 +0100 Subject: [PATCH 5/5] Skip dedenting multi-line string literals in assertion translation `dedent_value` was stripping indentation from lines inside string literals, changing their content. With this change we only dedent structural indentation (arrays, hashes, etc.), not string content. --- .../translate/sorbet_assertions_to_rbs_comments.rb | 13 ++++++++++++- rbi/spoom.rbi | 3 +++ .../sorbet_assertions_to_rbs_comments_test.rb | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb index 9f01aa1a..3c1237d0 100644 --- a/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb +++ b/lib/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments.rb @@ -250,6 +250,17 @@ def heredoc_end_offsets(node) offsets end + #: (Prism::Node) -> bool + def string_literal?(node) + case node + when Prism::StringNode, Prism::InterpolatedStringNode, + Prism::XStringNode, Prism::InterpolatedXStringNode + true + else + false + end + end + #: (Prism::Node, Prism::Node) -> String def dedent_value(assign, value) if value.location.start_line == assign.location.start_line @@ -281,7 +292,7 @@ def dedent_value(assign, value) # ``` indent = value.location.start_line - assign.location.start_line lines = value.slice.lines - if lines.size > 1 + if lines.size > 1 && !string_literal?(value) lines[1..]&.each_with_index do |line, i| lines[i + 1] = line.delete_prefix(" " * indent) end diff --git a/rbi/spoom.rbi b/rbi/spoom.rbi index 172c460d..8cb45e61 100644 --- a/rbi/spoom.rbi +++ b/rbi/spoom.rbi @@ -3271,6 +3271,9 @@ class Spoom::Sorbet::Translate::SorbetAssertionsToRBSComments < ::Spoom::Sorbet: sig { params(node: ::Prism::Node).returns(T::Boolean) } def maybe_translate_assertion(node); end + sig { params(node: ::Prism::Node).returns(T::Boolean) } + def string_literal?(node); end + sig { params(node: T.nilable(::Prism::Node)).returns(T::Boolean) } def t?(node); end diff --git a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb index 163944b2..a94e3552 100644 --- a/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb +++ b/test/spoom/sorbet/translate/sorbet_assertions_to_rbs_comments_test.rb @@ -330,7 +330,7 @@ def test_translate_assigns_multiline_string_literal assert_equal(<<~RB, rbi_to_rbs(rb)) s = "first - second" #: String + second" #: String RB end