Skip to content
Merged
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
114 changes: 43 additions & 71 deletions lib/error_highlight/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def self.spot(obj, **opts)
# includes "prism" when the ISEQ was compiled with the prism compiler.
# In this case, we'll try to parse again with prism instead.
raise unless error.message.include?("prism")
prism_find(loc, **opts)
prism_find(loc)
end

Spotter.new(node, **opts).spot
Expand All @@ -82,66 +82,16 @@ def self.spot(obj, **opts)
end

# Accepts a Thread::Backtrace::Location object and returns a Prism::Node
# corresponding to the location in the source code.
def self.prism_find(loc, point_type: :name, name: nil)
# corresponding to the backtrace location in the source code.
def self.prism_find(location)
require "prism"
return nil if Prism::VERSION < "0.29.0"

path = loc.absolute_path
return unless path

lineno = loc.lineno
column = RubyVM::AbstractSyntaxTree.node_id_for_backtrace_location(loc)
tunnel = Prism.parse_file(path).value.tunnel(lineno, column)

# Prism provides the Prism::Node#tunnel API to find all of the nodes that
# correspond to the given line and column in the source code, with the first
# node in the list being the top-most node and the last node in the list
# being the bottom-most node.
tunnel.each_with_index.reverse_each.find do |part, index|
case part
when Prism::CallNode, Prism::CallOperatorWriteNode, Prism::IndexOperatorWriteNode, Prism::LocalVariableOperatorWriteNode
# If we find any of these nodes, we can stop searching as these are the
# nodes that triggered the exceptions.
break part
when Prism::ConstantReadNode, Prism::ConstantPathNode
if index != 0 && tunnel[index - 1].is_a?(Prism::ConstantPathOperatorWriteNode)
# If we're inside of a constant path operator write node, then this
# constant path may be highlighting a couple of different kinds of
# parts.
if part.name == name
# Explicitly turn off Foo::Bar += 1 where Foo and Bar are on
# different lines because error highlight expects this to not work.
break nil if part.delimiter_loc.end_line != part.name_loc.start_line

# Otherwise, because we have matched the name we can return this
# part.
break part
end
absolute_path = location.absolute_path
return unless absolute_path

# If we haven't matched the name, it's the operator that we're looking
# for, and we can return the parent node here.
break tunnel[index - 1]
elsif part.name == name
# If we have matched the name of the constant, then we can return this
# inner node as the node that triggered the exception.
break part
else
# If we are at the beginning of the tunnel or we are at the beginning
# of a constant lookup chain, then we will return this node.
break part if index == 0 || !tunnel[index - 1].is_a?(Prism::ConstantPathNode)
end
when Prism::LocalVariableReadNode, Prism::ParenthesesNode
# If we find any of these nodes, we want to continue searching up the
# tree because these nodes cannot trigger the exceptions.
false
else
# If we find a different kind of node that we haven't already handled,
# we don't know how to handle it so we'll stop searching and assume this
# is not an exception we can decorate.
break nil
end
end
node_id = RubyVM::AbstractSyntaxTree.node_id_for_backtrace_location(location)
Prism.parse_file(absolute_path).value.breadth_first_search { |node| node.node_id == node_id }
end

private_class_method :prism_find
Expand Down Expand Up @@ -178,31 +128,49 @@ def initialize(node, point_type: :name, name: nil)
def spot
return nil unless @node

if OPT_GETCONSTANT_PATH && @node.type == :COLON2
if OPT_GETCONSTANT_PATH
# In Ruby 3.2 or later, a nested constant access (like `Foo::Bar::Baz`)
# is compiled to one instruction (opt_getconstant_path).
# @node points to the node of the whole `Foo::Bar::Baz` even if `Foo`
# or `Foo::Bar` causes NameError.
# So we try to spot the sub-node that causes the NameError by using
# `NameError#name`.
subnodes = []
node = @node
while node.type == :COLON2
node2, const = node.children
subnodes << node if const == @name
node = node2
end
if node.type == :CONST || node.type == :COLON3
if node.children.first == @name
case @node.type
when :COLON2
subnodes = []
node = @node
while node.type == :COLON2
node2, const = node.children
subnodes << node if const == @name
node = node2
end
if node.type == :CONST || node.type == :COLON3
if node.children.first == @name
subnodes << node
end

# If we found only one sub-node whose name is equal to @name, use it
return nil if subnodes.size != 1
@node = subnodes.first
else
# Do nothing; opt_getconstant_path is used only when the const base is
# NODE_CONST (`Foo`) or NODE_COLON3 (`::Foo`)
end
when :constant_path_node
subnodes = []
node = @node

begin
subnodes << node if node.name == @name
end while (node = node.parent).is_a?(Prism::ConstantPathNode)

if node.is_a?(Prism::ConstantReadNode) && node.name == @name
subnodes << node
end

# If we found only one sub-node whose name is equal to @name, use it
return nil if subnodes.size != 1
@node = subnodes.first
else
# Do nothing; opt_getconstant_path is used only when the const base is
# NODE_CONST (`Foo`) or NODE_COLON3 (`::Foo`)
end
end

Expand Down Expand Up @@ -847,7 +815,11 @@ def prism_spot_constant_path
# Foo::Bar += 1
# ^^^^^^^^
def prism_spot_constant_path_operator_write
prism_location(@node.binary_operator_loc.chop)
if @name == (target = @node.target).name
prism_location(target.delimiter_loc.join(target.name_loc))
else
prism_location(@node.binary_operator_loc.chop)
end
end
end

Expand Down
17 changes: 7 additions & 10 deletions test/test_error_highlight.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,6 @@
require "tempfile"

class ErrorHighlightTest < Test::Unit::TestCase
# We can't revisit instruction sequences to find node ids if the prism
# compiler was used instead of the parse.y compiler. In that case, we'll omit
# some tests.
def self.compiling_with_prism?
RubyVM::InstructionSequence.compile("").to_a[4][:parser] == :prism
end

class DummyFormatter
def self.message_for(corrections)
""
Expand Down Expand Up @@ -876,11 +869,11 @@ def test_COLON2_4
end
end

if ErrorHighlight.const_get(:Spotter).const_get(:OPT_GETCONSTANT_PATH) && !compiling_with_prism?
if ErrorHighlight.const_get(:Spotter).const_get(:OPT_GETCONSTANT_PATH)
def test_COLON2_5
# Unfortunately, we cannot identify which `NotDefined` caused the NameError
assert_error_message(NameError, <<~END) do
uninitialized constant ErrorHighlightTest::NotDefined
uninitialized constant ErrorHighlightTest::NotDefined
END

ErrorHighlightTest::NotDefined::NotDefined
Expand Down Expand Up @@ -1342,7 +1335,11 @@ def test_spot_with_backtrace_location

def test_spot_with_node
omit unless RubyVM::AbstractSyntaxTree.respond_to?(:node_id_for_backtrace_location)
omit if ErrorHighlightTest.compiling_with_prism?

# We can't revisit instruction sequences to find node ids if the prism
# compiler was used instead of the parse.y compiler. In that case, we'll
# omit some tests.
omit if RubyVM::InstructionSequence.compile("").to_a[4][:parser] == :prism

begin
raise_name_error
Expand Down