Skip to content
Open
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
252 changes: 170 additions & 82 deletions lib/rbi/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ def parse(source, file:)
raise ParseError.new(message, location)
end

visitor = TreeBuilder.new(source, comments: result.comments, file: file)
comments_by_line = result.comments.to_h { |c| [c.location.start_line, c] }
visitor = TreeBuilder.new(source, comments_by_line: comments_by_line, file: file)
visitor.visit(result.value)
visitor.tree
rescue ParseError => e
Expand All @@ -112,12 +113,13 @@ def parse(source, file:)
end

class Visitor < Prism::Visitor
#: (String source, file: String) -> void
def initialize(source, file:)
#: (String source, file: String, ?comments_by_line: Hash[Integer, Prism::Comment]) -> void
def initialize(source, file:, comments_by_line: {})
super()

@source = source
@file = file
@comments_by_line = comments_by_line
end

private
Expand Down Expand Up @@ -174,6 +176,146 @@ def self?(node)
def t_sig_without_runtime?(node)
!!(node.is_a?(Prism::ConstantPathNode) && node_string(node) =~ /(::)?T::Sig::WithoutRuntime/)
end

#: (Prism::Node node, ?min_line: Integer?) -> Array[Comment]
def node_comments(node, min_line: nil)
comments = []

latest_line, earliest_line = comment_lookup_range(node, min_line: min_line)

rbs_continuation = [] #: Array[Prism::Comment]

latest_line.downto(earliest_line) do |line|
comment = @comments_by_line[line]
break unless comment

text = comment.location.slice

# If we find a RBS comment continuation `#|`, we store it until we find the start with `#:`
if text.start_with?("#|")
rbs_continuation << comment
@comments_by_line.delete(line)
next
end

loc = Loc.from_prism(@file, comment.location)

# 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_]+:/)

text = text.sub(/^#: ?/, "").rstrip

# If we found continuation comments, we merge them in reverse order (since we go from bottom to top)
rbs_continuation.reverse_each do |rbs_comment|
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)

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?

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.

rbs_continuation.each do |rbs_comment|
comments.unshift(parse_comment(rbs_comment))
end

rbs_continuation.clear
comments.unshift(parse_comment(comment))
end

@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?

rbs_continuation.each do |rbs_comment|
comments.unshift(parse_comment(rbs_comment))
end
rbs_continuation.clear

comments
end

#: (Prism::Node node, ?min_line: Integer?) -> [Integer, Integer]
def comment_lookup_range(node, min_line: nil)
node_start_line = node.location.start_line
latest_line = node_start_line
comment = @comments_by_line[latest_line]
# Start comment lookup from line above the node if there are no trailing 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.

Example?

latest_line -= 1 unless comment && inline_comment_for_node?(node, comment)

earliest_line = min_line || 1
# End comment lookup at the line after `min_line` if `min_line` solely
# belongs to the surrounding node.
# The parent comment should not attach to the `foo` node.
# Example:
# `parent( # parent comment`
# ` foo: Integer # foo comment`
earliest_line += 1 if min_line && min_line < node_start_line

[latest_line, earliest_line]
end

#: (Prism::Node node, Prism::Comment comment) -> bool
def inline_comment_for_node?(node, comment)
return false unless node.location.start_line == comment.location.start_line

between = @source[node.location.end_offset...comment.location.start_offset] || ""
between.each_char.all? do |char|
char == "," || char.match?(/\s/)
end
Comment on lines +265 to +268

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.

Is the char at comment.location.start_offset the #?

Suggested change
between = @source[node.location.end_offset...comment.location.start_offset] || ""
between.each_char.all? do |char|
char == "," || char.match?(/\s/)
end
between = @source[node.location.end_offset..comment.location.start_offset] || ""
between.match?("^,?\s*#$")

end

#: (Prism::Node node) -> Array[Comment]
def comments_inside(node)
Comment on lines +271 to +272

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.

Doc comment with example please

comments = [] #: Array[Comment]

node.location.start_line.upto(node.location.end_line) do |line|
comment = @comments_by_line[line]
next unless comment

location = comment.location
next if location.start_offset < node.location.start_offset
next if location.end_offset > node.location.end_offset
Comment on lines +280 to +281

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.

What are these checking?


comments << parse_comment(comment)
@comments_by_line.delete(line)
end

comments
end

#: (Loc? inner, Loc? outer) -> bool
def loc_inside?(inner, outer)
return false unless inner && outer

inner_begin_line = inner.begin_line
inner_begin_column = inner.begin_column
inner_end_line = inner.end_line
inner_end_column = inner.end_column
outer_begin_line = outer.begin_line
outer_begin_column = outer.begin_column
outer_end_line = outer.end_line
outer_end_column = outer.end_column
return false unless inner_begin_line && inner_begin_column && inner_end_line && inner_end_column
return false unless outer_begin_line && outer_begin_column && outer_end_line && outer_end_column

begins_inside = inner_begin_line > outer_begin_line ||
(inner_begin_line == outer_begin_line && inner_begin_column >= outer_begin_column)
ends_inside = inner_end_line < outer_end_line ||
(inner_end_line == outer_end_line && inner_end_column <= outer_end_column)
Comment on lines +305 to +308

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 this be simpler if the calculations were done using the offsets, rather than lines/cols?

Then it just becomes a matter of checking if the inner range is a subinterval of outter range with Range#cover?


begins_inside && ends_inside
end

#: (Prism::Comment node) -> Comment
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)

end
end

class TreeBuilder < Visitor
Expand All @@ -183,11 +325,15 @@ class TreeBuilder < Visitor
#: Prism::Node?
attr_reader :last_node

#: (String source, comments: Array[Prism::Comment], file: String) -> void
def initialize(source, comments:, file:)
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

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.


super(source, file: file, comments_by_line: comments_by_line)

@comments_by_line = comments.to_h { |c| [c.location.start_line, c] } #: Hash[Integer, Prism::Comment]
@tree = Tree.new #: Tree

@scopes_stack = [@tree] #: Array[Tree]
Expand Down Expand Up @@ -608,82 +754,14 @@ def detach_comments_from_sigs(sigs)
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
Comment on lines 754 to 762

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

end

#: (Prism::Node node) -> Array[Comment]
def node_comments(node)
comments = []

start_line = node.location.start_line
start_line -= 1 unless @comments_by_line.key?(start_line)

rbs_continuation = [] #: Array[Prism::Comment]

start_line.downto(1) do |line|
comment = @comments_by_line[line]
break unless comment

text = comment.location.slice

# If we find a RBS comment continuation `#|`, we store it until we find the start with `#:`
if text.start_with?("#|")
rbs_continuation << comment
@comments_by_line.delete(line)
next
end

loc = Loc.from_prism(@file, comment.location)

# 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_]+:/)
text = text.sub(/^#: ?/, "").rstrip

# If we found continuation comments, we merge them in reverse order (since we go from bottom to top)
rbs_continuation.reverse_each do |rbs_comment|
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}"
end

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
rbs_continuation.each do |rbs_comment|
comments.unshift(parse_comment(rbs_comment))
end

rbs_continuation.clear
comments.unshift(parse_comment(comment))
end

@comments_by_line.delete(line)
end

# If we have unused continuation comments, we should inject them back to not lose them
rbs_continuation.each do |rbs_comment|
comments.unshift(parse_comment(rbs_comment))
end
rbs_continuation.clear

comments
end

#: (Prism::Comment node) -> Comment
def parse_comment(node)
text = node.location.slice.sub(/^# ?/, "").rstrip
loc = Loc.from_prism(@file, node.location)
Comment.new(text, loc: loc)
end

#: (Prism::Node? node) -> Array[Arg]
def parse_send_args(node)
args = [] #: Array[Arg]
Expand Down Expand Up @@ -783,10 +861,10 @@ def parse_params(node)

#: (Prism::CallNode node) -> Sig
def parse_sig(node)
builder = SigBuilder.new(@source, file: @file)
builder = SigBuilder.new(@source, comments_by_line: @comments_by_line, file: @file)
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)

builder.current
end

Expand Down Expand Up @@ -936,11 +1014,16 @@ class SigBuilder < Visitor
#: Sig
attr_reader :current

#: (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.

?

#: Integer?
attr_reader :params_start_line

#: (String content, comments_by_line: Hash[Integer, Prism::Comment], file: String) -> void
def initialize(content, comments_by_line:, file:)
super

@current = Sig.new #: Sig
@params_start_line = nil #: Integer?
end

# @override
Expand Down Expand Up @@ -974,7 +1057,9 @@ def visit_call_node(node)
when "overridable"
@current.is_overridable = true
when "params"
@params_start_line = node.location.start_line
visit(node.arguments)
@params_start_line = nil
when "returns"
return_type = sig_type_argument(node)
@current.return_type = return_type if return_type
Expand All @@ -991,6 +1076,7 @@ def visit_call_node(node)

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

end

# @override
Expand All @@ -999,6 +1085,8 @@ def visit_assoc_node(node)
@current.params << SigParam.new(
sig_param_name(node.key),
node_string!(node.value),
loc: node_loc(node),
comments: node_comments(node, min_line: params_start_line),
)
end

Expand Down
Loading
Loading