Skip to content

Preserve comments inside Sorbet signatures - #653

Open
Morriar wants to merge 4 commits into
mainfrom
preserve-sig-parameter-comments
Open

Preserve comments inside Sorbet signatures#653
Morriar wants to merge 4 commits into
mainfrom
preserve-sig-parameter-comments

Conversation

@Morriar

@Morriar Morriar commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Preserve comments attached to parameters inside Sorbet signatures when parsing RBI files and printing RBS.

This work is based on and supersedes #612 by @KaanOzkan.

  • Move shared comment parsing into the base parser visitor so TreeBuilder and SigBuilder consume the same comment map.
  • Attach comments inside a signature to the closest representable node:
    • signature-level comments to Sig#comments
    • parameter comments to SigParam#comments
    • comments outside the signature source range to the method
  • Bound parameter comment lookup to its containing params(...) call.
  • Preserve inline and leading parameter comments without capturing comments from surrounding calls.
  • Print ordinary RBS parameter comments at the end of their parameter line.
  • Print block parameter comments at the end of the complete signature line.
  • Keep block signatures inline after overload separators.
  • Retain the legacy TreeBuilder(..., comments:) keyword used by Spoom.
  • Regenerate the exported RBI.

Example

Given:

sig do
  params(
    # User identifier
    id: Integer,
    # Callback
    block: T.proc.void
  ).void
end
def foo(id, &block); end

RBS output becomes:

def foo: (
  Integer id # User identifier
) { -> void } -> void # Callback

Overloads remain inline:

def foo: -> void
       | { -> void } -> void # Callback

Multiple comments on an ordinary parameter remain aligned:

Integer id, # User identifier
            # Generated externally

Morriar and others added 4 commits August 19, 2026 14:03
Co-authored-by: Kaan Ozkan <kaanozkan97@gmail.com>
Co-authored-by: Kaan Ozkan <kaanozkan97@gmail.com>
Co-authored-by: Kaan Ozkan <kaanozkan97@gmail.com>
Co-authored-by: Kaan Ozkan <kaanozkan97@gmail.com>
@Morriar
Morriar requested a review from a team as a code owner August 19, 2026 18:20
Comment thread lib/rbi/parser.rb
def initialize(source, file:, comments: nil, comments_by_line: {})
if comments
comments_by_line = comments.to_h { |comment| [comment.location.start_line, comment] }
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wdyt of raising if both comments and comments_by_line are supplied so we don't ignore it silently?

If we don't go that route: Is there a downside of prioritizing comments_by_line instead of calculating it based on comments? Using what's supplied feels better.

Comment thread lib/rbi/parser.rb

visit(node.receiver)
visit(node.block)
@current.comments.concat(comments_inside(node)) if node.message == "sig"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agent brought up that sig comments aren't printed so this case fails now:

def test_preserves_comments_inside_sig_in_rbs
  rbi = parse_rbi(<<~RBI)
    sig do
      # keep me
      void
    end
    def foo; end
  RBI

  assert_includes(rbi.rbs_string, "# keep me")
end

@amomchilov amomchilov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great feature!

Comment thread lib/rbi/parser.rb
# If we find the start of a RBS comment, we create a new RBSComment
# Note that we ignore RDoc directives such as `:nodoc:`
# See https://ruby.github.io/rdoc/RDoc/MarkupReference.html#class-RDoc::MarkupReference-label-Directives
if text.start_with?("#:") && !(text =~ /^#:[a-z_]+:/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esoteric syntax

Suggested change
if text.start_with?("#:") && !(text =~ /^#:[a-z_]+:/)
if text.start_with?("#:") && !text.match?(/^#:[a-z_]+:/)

Comment thread lib/rbi/parser.rb
continuation_text = rbs_comment.location.slice.sub(/^#\| ?/, "").strip
continuation_loc = Loc.from_prism(@file, rbs_comment.location)
loc = loc.join(continuation_loc)
text = "#{text}#{continuation_text}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid the reallocation here

Suggested change
text = "#{text}#{continuation_text}"
text.concat(continuation_text)

Comment thread lib/rbi/parser.rb
end

rbs_continuation.clear
comments.unshift(RBSComment.new(text, loc: loc))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we << them to the end instead, and only reverse! at the end?

Comment thread lib/rbi/parser.rb
rbs_continuation.clear
comments.unshift(RBSComment.new(text, loc: loc))
else
# If we have unused continuation comments, we should inject them back to not lose them

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be helpful to pair these comments with a visual example.

Comment thread lib/rbi/parser.rb
@comments_by_line.delete(line)
end

# If we have unused continuation comments, we should inject them back to not lose them

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this differ from the same logic on line 220?

Comment thread lib/rbi/parser.rb
def parse_comment(node)
text = node.location.slice.sub(/^# ?/, "").rstrip
loc = Loc.from_prism(@file, node.location)
Comment.new(text, loc: loc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps this should be a factory function on Comment

Suggested change
Comment.new(text, loc: loc)
Comment.from_prism_node(node)

Comment thread lib/rbi/parser.rb
super(source, file: file)
#: (String source, file: String, ?comments: Array[Prism::Comment]?,
#| ?comments_by_line: Hash[Integer, Prism::Comment]) -> void
def initialize(source, file:, comments: nil, comments_by_line: {})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parameter pack is starting to get unwieldy. I think we should extract a class like something like SourceFile, which stores all this stuff

https://refactoring.guru/introduce-parameter-object

Comment thread lib/rbi/parser.rb
Comment on lines 754 to 762
comments = [] #: Array[Comment]

sigs.each do |sig|
comments += sig.comments.dup
sig.comments.clear
inside, outside = sig.comments.partition { |comment| loc_inside?(comment.loc, sig.loc) }
comments.concat(outside)
sig.comments.replace(inside)
end

comments

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you feel about flat_map with a sideffect?

Suggested change
comments = [] #: Array[Comment]
sigs.each do |sig|
comments += sig.comments.dup
sig.comments.clear
inside, outside = sig.comments.partition { |comment| loc_inside?(comment.loc, sig.loc) }
comments.concat(outside)
sig.comments.replace(inside)
end
comments
sigs.flat_map do |sig|
inside, outside = sig.comments.partition { |comment| loc_inside?(comment.loc, sig.loc) }
sig.comments.replace(inside)
outside
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The partition+replace combo is a bit tricky.

What do you think of vendoring a copy of extract! from ActiveSupport?

https://github.com/rails/rails/blob/afa0f9244d22abc0b88b46c38c906b518b5fc3c1/activesupport/lib/active_support/core_ext/array/extract.rb#L5-L22

Then this could just be:

Suggested change
comments = [] #: Array[Comment]
sigs.each do |sig|
comments += sig.comments.dup
sig.comments.clear
inside, outside = sig.comments.partition { |comment| loc_inside?(comment.loc, sig.loc) }
comments.concat(outside)
sig.comments.replace(inside)
end
comments
sigs.flat_map do |sig|
sig.comments.extract! { |comment| !loc_inside?(comment.loc, sig.loc) }
end

Comment thread lib/rbi/parser.rb
builder.current.loc = node_loc(node)
builder.visit_call_node(node)
builder.current.comments = node_comments(node)
builder.current.comments = node_comments(node) + builder.current.comments

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One less alloc + copy

Suggested change
builder.current.comments = node_comments(node) + builder.current.comments
builder.current.comments = node_comments(node).concat(builder.current.comments)

Comment thread lib/rbi/parser.rb

#: (String content, file: String) -> void
def initialize(content, file:)
# Bounds sig param comment lookup to comments inside the current `params(...)` call.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants