From a5d62637c9fe019a25a8af6872868fb573acc9da Mon Sep 17 00:00:00 2001 From: Stefan Magnuson Date: Mon, 8 Jun 2026 12:04:00 +0100 Subject: [PATCH] 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 ef1a2327..4e1bb6d7 100644 --- a/rbi/spoom.rbi +++ b/rbi/spoom.rbi @@ -3040,9 +3040,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)