Skip to content
Draft
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
157 changes: 157 additions & 0 deletions lib/error_highlight/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ def self.spot(obj, **opts)
prism_find(loc)
end

# For Ractor::IsolationError, highlight the outer variable usage
# inside the block rather than the method call or its arguments.
if Ractor::IsolationError === exc && !opts[:backtrace_location]
result = spot_isolation_error(exc, node)
return result if result
end

Spotter.new(node, **opts).spot

when RubyVM::AbstractSyntaxTree::Node, Prism::Node
Expand Down Expand Up @@ -96,6 +103,156 @@ def self.prism_find(location)

private_class_method :prism_find

# For Ractor::IsolationError, highlight the outer variable usage or yield
# inside the block rather than the method call or its arguments. This
# provides a more helpful highlight that points to the actual cause.
#
# Supports both Prism AST nodes and legacy RubyVM::AbstractSyntaxTree nodes.
def self.spot_isolation_error(exc, node)
return nil unless node

outer_vars = begin; exc.outer_variables; rescue; []; end
yield_called = begin; exc.yield_called; rescue; false; end

# Nothing to highlight if there are no outer variables and yield wasn't called
return nil if outer_vars.empty? && !yield_called

block_body = find_block_body(node)
return nil unless block_body

# Search the block body for the first matching node:
# prefer outer variable reads, fall back to yield
target = nil

unless outer_vars.empty?
var_names = outer_vars.map { |v| v.respond_to?(:to_sym) ? v.to_sym : v.to_s.to_sym }
target = find_first_matching_variable(block_body, var_names)
end

if target.nil? && yield_called
target = find_first_yield(block_body)
end

return nil unless target

build_spot(target, node.script_lines)
rescue
nil
end

private_class_method :spot_isolation_error

# Navigate from a call/block node to find the block body.
# Handles both Prism and legacy AST node types.
def self.find_block_body(node)
case node.type
when :call_node then node.block&.body # Prism
when :block_node then node.body # Prism
when :ITER # Legacy AST
_fcall, scope = node.children
scope
end
end

private_class_method :find_block_body

# Recursively search an AST subtree for the first local variable read
# matching one of the given names.
#
# Prism: matches :local_variable_read_node
# Legacy AST: matches :LVAR and :DVAR nodes
def self.find_first_matching_variable(node, var_names)
case node.type
when :local_variable_read_node # Prism
return node if var_names.include?(node.name)
when :LVAR, :DVAR # Legacy AST
return node if var_names.include?(node.children.first)
end

ast_each_child(node) do |child|
result = find_first_matching_variable(child, var_names)
return result if result
end

nil
end

private_class_method :find_first_matching_variable

# Recursively search an AST subtree for the first yield node.
#
# Prism: matches :yield_node
# Legacy AST: matches :YIELD node
def self.find_first_yield(node)
case node.type
when :yield_node, :YIELD # Prism / Legacy AST
return node
end

ast_each_child(node) do |child|
result = find_first_yield(child)
return result if result
end

nil
end

private_class_method :find_first_yield

# Iterate over child nodes of either a Prism node or a legacy AST node.
def self.ast_each_child(node)
if node.is_a?(RubyVM::AbstractSyntaxTree::Node)
node.children.each do |child|
yield child if child.is_a?(RubyVM::AbstractSyntaxTree::Node)
end
else
node.child_nodes&.each do |child|
yield child if child
end
end
end

private_class_method :ast_each_child

# Build a spot hash from an AST node and script_lines.
# Handles both Prism nodes (which use .location) and legacy AST nodes
# (which have .first_lineno/.first_column/.last_lineno/.last_column directly).
def self.build_spot(node, lines)
if node.is_a?(RubyVM::AbstractSyntaxTree::Node)
first_lineno = node.first_lineno
first_column = node.first_column
last_lineno = node.last_lineno
last_column = node.last_column
else
loc = node.location
first_lineno = loc.start_line
first_column = loc.start_column
last_lineno = loc.end_line
last_column = loc.end_column
end

return nil unless first_lineno == last_lineno
return nil unless lines

snippet = lines[first_lineno - 1]
return nil unless snippet
snippet = snippet.chomp + "\n"

# Reject non-ASCII snippets (same limitation as Spotter)
return nil unless snippet.ascii_only?

{
first_lineno: first_lineno,
first_column: first_column,
last_lineno: last_lineno,
last_column: last_column,
snippet: snippet,
script_lines: lines,
}
end

private_class_method :build_spot

class Spotter
class NonAscii < Exception; end
private_constant :NonAscii
Expand Down
4 changes: 4 additions & 0 deletions lib/error_highlight/core_ext.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,9 @@ def to_s
# This is because changing ArgumentError#message is highly incompatible.
TypeError.prepend(CoreExt)
ArgumentError.prepend(CoreExt)

# Ractor::IsolationError highlights the outer variable usage inside the block
# rather than the method call arguments.
Ractor::IsolationError.prepend(CoreExt)
end
end
101 changes: 98 additions & 3 deletions test/test_error_highlight.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1085,10 +1085,11 @@ def test_args_CALL_1
end
end

OF_NIL_INTO_INTEGER = RUBY_VERSION < "4.1." ? "from nil to integer" : "of nil into Integer"
def test_args_CALL_2
v = []
assert_error_message(TypeError, <<~END) do
no implicit conversion from nil to integer (TypeError)
no implicit conversion #{OF_NIL_INTO_INTEGER} (TypeError)

v[nil]
^^^
Expand All @@ -1115,7 +1116,7 @@ def test_args_ATTRASGN_1
def test_args_ATTRASGN_2
v = []
assert_error_message(TypeError, <<~END) do
no implicit conversion from nil to integer (TypeError)
no implicit conversion #{OF_NIL_INTO_INTEGER} (TypeError)

v [nil] = 1
^^^^^^^^
Expand Down Expand Up @@ -1177,7 +1178,7 @@ def test_args_OP_ASGN1_aref_1
v = []

assert_error_message(TypeError, <<~END) do
no implicit conversion from nil to integer (TypeError)
no implicit conversion #{OF_NIL_INTO_INTEGER} (TypeError)

v [nil] += 42
^^^^^^^^^^
Expand Down Expand Up @@ -1751,6 +1752,100 @@ def test_singleton_method_multiple_missing_keywords
end
end

def assert_isolation_error_message(expected_msg, &blk)
omit unless Exception.method_defined?(:detailed_message)
err = assert_raise(Ractor::IsolationError, &blk)
spot = ErrorHighlight.spot(err)
if spot
assert_kind_of(Integer, spot[:first_lineno])
assert_kind_of(Integer, spot[:first_column])
assert_kind_of(Integer, spot[:last_lineno])
assert_kind_of(Integer, spot[:last_column])
assert_kind_of(String, spot[:snippet])
assert_kind_of(Array, spot[:script_lines])
end
assert_equal(expected_msg.chomp, err.detailed_message(highlight: false))
end

def test_isolation_error_outer_variable_highlighted
err = assert_raise(Ractor::IsolationError) do
channel = 123
Ractor.new(channel) do
channel + 1
end
end
spot = ErrorHighlight.spot(err)
assert_not_nil spot
assert_equal "channel", spot[:snippet][spot[:first_column]...spot[:last_column]]
end

def test_isolation_error_multiple_outer_variables
err = assert_raise(Ractor::IsolationError) do
a = 1
b = 2
Ractor.new { a + b }
end
spot = ErrorHighlight.spot(err)
assert_not_nil spot
# Should highlight one of the outer variables (first found in the block)
highlighted = spot[:snippet][spot[:first_column]...spot[:last_column]]
assert_include ["a", "b"], highlighted
end

def test_isolation_error_yield_highlighted
err = assert_raise(Ractor::IsolationError) do
Ractor.new { yield }
end
spot = ErrorHighlight.spot(err)
assert_not_nil spot
assert_equal "yield", spot[:snippet][spot[:first_column]...spot[:last_column]]
end

def test_isolation_error_shareable_proc_unshareable
err = assert_raise(Ractor::IsolationError) do
foo = []
Ractor.shareable_proc { foo }
end
spot = ErrorHighlight.spot(err)
assert_not_nil spot
assert_equal "foo", spot[:snippet][spot[:first_column]...spot[:last_column]]
end

def test_isolation_error_shareable_proc_reassigned
err = assert_raise(Ractor::IsolationError) do
x = 123
Ractor.shareable_proc { x }
x = 456
end
spot = ErrorHighlight.spot(err)
assert_not_nil spot
assert_equal "x", spot[:snippet][spot[:first_column]...spot[:last_column]]
end

def test_isolation_error_detailed_message_includes_snippet
err = assert_raise(Ractor::IsolationError) do
channel = 123
Ractor.new(channel) do
channel + 1
end
end
msg = err.detailed_message(highlight: false)
assert_match(/channel/, msg)
assert_match(/\^{7}/, msg)
end

def test_isolation_error_without_outer_variables_falls_back
err = assert_raise(Ractor::IsolationError) do
raise Ractor::IsolationError, "some isolation error"
end
assert_equal [], err.outer_variables
assert_equal false, err.yield_called
# Should fall back to the default Spotter path (highlights "raise")
spot = ErrorHighlight.spot(err)
assert_not_nil spot
assert_equal "raise", spot[:snippet][spot[:first_column]...spot[:last_column]]
end

private

def find_node_by_id(node, node_id)
Expand Down