summaryrefslogtreecommitdiff
path: root/lib/error_highlight
diff options
context:
space:
mode:
Diffstat (limited to 'lib/error_highlight')
-rw-r--r--lib/error_highlight/base.rb938
-rw-r--r--lib/error_highlight/core_ext.rb76
-rw-r--r--lib/error_highlight/error_highlight.gemspec27
-rw-r--r--lib/error_highlight/formatter.rb74
-rw-r--r--lib/error_highlight/version.rb3
5 files changed, 1118 insertions, 0 deletions
diff --git a/lib/error_highlight/base.rb b/lib/error_highlight/base.rb
new file mode 100644
index 0000000000..5fffe5ec34
--- /dev/null
+++ b/lib/error_highlight/base.rb
@@ -0,0 +1,938 @@
+require_relative "version"
+
+module ErrorHighlight
+ # Identify the code fragment where a given exception occurred.
+ #
+ # Options:
+ #
+ # point_type: :name | :args
+ # :name (default) points to the method/variable name where the exception occurred.
+ # :args points to the arguments of the method call where the exception occurred.
+ #
+ # backtrace_location: Thread::Backtrace::Location
+ # It locates the code fragment of the given backtrace_location.
+ # By default, it uses the first frame of backtrace_locations of the given exception.
+ #
+ # Returns:
+ # {
+ # first_lineno: Integer,
+ # first_column: Integer,
+ # last_lineno: Integer,
+ # last_column: Integer,
+ # snippet: String,
+ # script_lines: [String],
+ # } | nil
+ #
+ # Limitations:
+ #
+ # Currently, ErrorHighlight.spot only supports a single-line code fragment.
+ # Therefore, if the return value is not nil, first_lineno and last_lineno will have
+ # the same value. If the relevant code fragment spans multiple lines
+ # (e.g., Array#[] of <tt>ary[(newline)expr(newline)]</tt>), the method will return nil.
+ # This restriction may be removed in the future.
+ def self.spot(obj, **opts)
+ case obj
+ when Exception
+ exc = obj
+ loc = opts[:backtrace_location]
+ opts = { point_type: opts.fetch(:point_type, :name) }
+
+ unless loc
+ case exc
+ when TypeError, ArgumentError
+ opts[:point_type] = :args
+ end
+
+ locs = exc.backtrace_locations
+ return nil unless locs
+
+ loc = locs.first
+ return nil unless loc
+
+ opts[:name] = exc.name if NameError === obj
+ end
+
+ return nil unless Thread::Backtrace::Location === loc
+
+ node =
+ begin
+ RubyVM::AbstractSyntaxTree.of(loc, keep_script_lines: true)
+ rescue RuntimeError => error
+ # RubyVM::AbstractSyntaxTree.of raises an error with a message that
+ # 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)
+ end
+
+ Spotter.new(node, **opts).spot
+
+ when RubyVM::AbstractSyntaxTree::Node, Prism::Node
+ Spotter.new(obj, **opts).spot
+
+ else
+ raise TypeError, "Exception is expected"
+ end
+
+ rescue SyntaxError,
+ SystemCallError, # file not found or something
+ ArgumentError # eval'ed code
+
+ return nil
+ end
+
+ # Accepts a Thread::Backtrace::Location object and returns a Prism::Node
+ # corresponding to the backtrace location in the source code.
+ def self.prism_find(location)
+ require "prism"
+ return nil if Prism::VERSION < "1.0.0"
+
+ absolute_path = location.absolute_path
+ return unless absolute_path
+
+ 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
+
+ class Spotter
+ class NonAscii < Exception; end
+ private_constant :NonAscii
+
+ def initialize(node, point_type: :name, name: nil)
+ @node = node
+ @point_type = point_type
+ @name = name
+
+ # Not-implemented-yet options
+ @arg = nil # Specify the index or keyword at which argument caused the TypeError/ArgumentError
+ @multiline = false # Allow multiline spot
+
+ @fetch = -> (lineno, last_lineno = lineno) do
+ snippet = @node.script_lines[lineno - 1 .. last_lineno - 1].join("")
+ snippet += "\n" unless snippet.end_with?("\n")
+
+ # It requires some work to support Unicode (or multibyte) characters.
+ # Tentatively, we stop highlighting if the code snippet has non-ascii characters.
+ # See https://github.com/ruby/error_highlight/issues/4
+ raise NonAscii unless snippet.ascii_only?
+
+ snippet
+ end
+ end
+
+ def spot
+ return nil unless @node
+
+ # 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`.
+ 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
+ end
+
+ case @node.type
+
+ when :CALL, :QCALL
+ case @point_type
+ when :name
+ spot_call_for_name
+ when :args
+ spot_call_for_args
+ end
+
+ when :ATTRASGN
+ case @point_type
+ when :name
+ spot_attrasgn_for_name
+ when :args
+ spot_attrasgn_for_args
+ end
+
+ when :OPCALL
+ case @point_type
+ when :name
+ spot_opcall_for_name
+ when :args
+ spot_opcall_for_args
+ end
+
+ when :FCALL
+ case @point_type
+ when :name
+ spot_fcall_for_name
+ when :args
+ spot_fcall_for_args
+ end
+
+ when :VCALL
+ spot_vcall
+
+ when :OP_ASGN1
+ case @point_type
+ when :name
+ spot_op_asgn1_for_name
+ when :args
+ spot_op_asgn1_for_args
+ end
+
+ when :OP_ASGN2
+ case @point_type
+ when :name
+ spot_op_asgn2_for_name
+ when :args
+ spot_op_asgn2_for_args
+ end
+
+ when :CONST
+ spot_vcall
+
+ when :COLON2
+ spot_colon2
+
+ when :COLON3
+ spot_vcall
+
+ when :OP_CDECL
+ spot_op_cdecl
+
+ when :DEFN
+ raise NotImplementedError if @point_type != :name
+ spot_defn
+
+ when :DEFS
+ raise NotImplementedError if @point_type != :name
+ spot_defs
+
+ when :LAMBDA
+ spot_lambda
+
+ when :ITER
+ spot_iter
+
+ when :call_node
+ case @point_type
+ when :name
+ prism_spot_call_for_name
+ when :args
+ prism_spot_call_for_args
+ end
+
+ when :local_variable_operator_write_node
+ case @point_type
+ when :name
+ prism_spot_local_variable_operator_write_for_name
+ when :args
+ prism_spot_local_variable_operator_write_for_args
+ end
+
+ when :call_operator_write_node
+ case @point_type
+ when :name
+ prism_spot_call_operator_write_for_name
+ when :args
+ prism_spot_call_operator_write_for_args
+ end
+
+ when :index_operator_write_node
+ case @point_type
+ when :name
+ prism_spot_index_operator_write_for_name
+ when :args
+ prism_spot_index_operator_write_for_args
+ end
+
+ when :constant_read_node
+ prism_spot_constant_read
+
+ when :constant_path_node
+ prism_spot_constant_path
+
+ when :constant_path_operator_write_node
+ prism_spot_constant_path_operator_write
+
+ when :def_node
+ case @point_type
+ when :name
+ prism_spot_def_for_name
+ when :args
+ raise NotImplementedError
+ end
+
+ when :lambda_node
+ case @point_type
+ when :name
+ prism_spot_lambda_for_name
+ when :args
+ raise NotImplementedError
+ end
+
+ when :block_node
+ case @point_type
+ when :name
+ prism_spot_block_for_name
+ when :args
+ raise NotImplementedError
+ end
+
+ end
+
+ if @snippet && @beg_column && @end_column && @beg_column < @end_column
+ return {
+ first_lineno: @beg_lineno,
+ first_column: @beg_column,
+ last_lineno: @end_lineno,
+ last_column: @end_column,
+ snippet: @snippet,
+ script_lines: @node.script_lines,
+ }
+ else
+ return nil
+ end
+
+ rescue NonAscii
+ nil
+ end
+
+ private
+
+ # Example:
+ # x.foo
+ # ^^^^
+ # x.foo(42)
+ # ^^^^
+ # x&.foo
+ # ^^^^^
+ # x[42]
+ # ^^^^
+ # x += 1
+ # ^
+ def spot_call_for_name
+ nd_recv, mid, nd_args = @node.children
+ lineno = nd_recv.last_lineno
+ lines = @fetch[lineno, @node.last_lineno]
+ if mid == :[] && lines.match(/\G[\s)]*(\[(?:\s*\])?)/, nd_recv.last_column)
+ @beg_column = $~.begin(1)
+ @snippet = lines[/.*\n/]
+ @beg_lineno = @end_lineno = lineno
+ if nd_args
+ if nd_recv.last_lineno == nd_args.last_lineno && @snippet.match(/\s*\]/, nd_args.last_column)
+ @end_column = $~.end(0)
+ end
+ else
+ if lines.match(/\G[\s)]*?\[\s*\]/, nd_recv.last_column)
+ @end_column = $~.end(0)
+ end
+ end
+ elsif lines.match(/\G[\s)]*?(\&?\.)(\s*?)(#{ Regexp.quote(mid) }).*\n/, nd_recv.last_column)
+ lines = $` + $&
+ @beg_column = $~.begin($2.include?("\n") ? 3 : 1)
+ @end_column = $~.end(3)
+ if i = lines[..@beg_column].rindex("\n")
+ @beg_lineno = @end_lineno = lineno + lines[..@beg_column].count("\n")
+ @snippet = lines[i + 1..]
+ @beg_column -= i + 1
+ @end_column -= i + 1
+ else
+ @snippet = lines
+ @beg_lineno = @end_lineno = lineno
+ end
+ elsif mid.to_s =~ /\A\W+\z/ && lines.match(/\G\s*(#{ Regexp.quote(mid) })=.*\n/, nd_recv.last_column)
+ @snippet = $` + $&
+ @beg_lineno = @end_lineno = lineno
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ end
+
+ # Example:
+ # x.foo(42)
+ # ^^
+ # x[42]
+ # ^^
+ # x += 1
+ # ^
+ def spot_call_for_args
+ _nd_recv, _mid, nd_args = @node.children
+ if nd_args && nd_args.first_lineno == nd_args.last_lineno
+ fetch_line(nd_args.first_lineno)
+ @beg_column = nd_args.first_column
+ @end_column = nd_args.last_column
+ end
+ # TODO: support @arg
+ end
+
+ # Example:
+ # x.foo = 1
+ # ^^^^^^
+ # x[42] = 1
+ # ^^^^^^
+ def spot_attrasgn_for_name
+ nd_recv, mid, nd_args = @node.children
+ *nd_args, _nd_last_arg, _nil = nd_args.children
+ fetch_line(nd_recv.last_lineno)
+ if mid == :[]= && @snippet.match(/\G[\s)]*(\[)/, nd_recv.last_column)
+ @beg_column = $~.begin(1)
+ args_last_column = $~.end(0)
+ if nd_args.last && nd_recv.last_lineno == nd_args.last.last_lineno
+ args_last_column = nd_args.last.last_column
+ end
+ if @snippet.match(/[\s)]*\]\s*=/, args_last_column)
+ @end_column = $~.end(0)
+ end
+ elsif @snippet.match(/\G[\s)]*(\.\s*#{ Regexp.quote(mid.to_s.sub(/=\z/, "")) }\s*=)/, nd_recv.last_column)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ end
+
+ # Example:
+ # x.foo = 1
+ # ^
+ # x[42] = 1
+ # ^^^^^^^
+ # x[] = 1
+ # ^^^^^
+ def spot_attrasgn_for_args
+ nd_recv, mid, nd_args = @node.children
+ fetch_line(nd_recv.last_lineno)
+ if mid == :[]= && @snippet.match(/\G[\s)]*\[/, nd_recv.last_column)
+ @beg_column = $~.end(0)
+ if nd_recv.last_lineno == nd_args.last_lineno
+ @end_column = nd_args.last_column
+ end
+ elsif nd_args && nd_args.first_lineno == nd_args.last_lineno
+ @beg_column = nd_args.first_column
+ @end_column = nd_args.last_column
+ end
+ # TODO: support @arg
+ end
+
+ # Example:
+ # x + 1
+ # ^
+ # +x
+ # ^
+ def spot_opcall_for_name
+ nd_recv, op, nd_arg = @node.children
+ fetch_line(nd_recv.last_lineno)
+ if nd_arg
+ # binary operator
+ if @snippet.match(/\G[\s)]*(#{ Regexp.quote(op) })/, nd_recv.last_column)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ else
+ # unary operator
+ if @snippet[...nd_recv.first_column].match(/(#{ Regexp.quote(op.to_s.sub(/@\z/, "")) })\s*\(?\s*\z/)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ end
+ end
+
+ # Example:
+ # x + 1
+ # ^
+ def spot_opcall_for_args
+ _nd_recv, _op, nd_arg = @node.children
+ if nd_arg && nd_arg.first_lineno == nd_arg.last_lineno
+ # binary operator
+ fetch_line(nd_arg.first_lineno)
+ @beg_column = nd_arg.first_column
+ @end_column = nd_arg.last_column
+ end
+ end
+
+ # Example:
+ # foo(42)
+ # ^^^
+ # foo 42
+ # ^^^
+ def spot_fcall_for_name
+ mid, _nd_args = @node.children
+ fetch_line(@node.first_lineno)
+ if @snippet.match(/(#{ Regexp.quote(mid) })/, @node.first_column)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ end
+
+ # Example:
+ # foo(42)
+ # ^^
+ # foo 42
+ # ^^
+ def spot_fcall_for_args
+ _mid, nd_args = @node.children
+ if nd_args && nd_args.first_lineno == nd_args.last_lineno
+ fetch_line(nd_args.first_lineno)
+ @beg_column = nd_args.first_column
+ @end_column = nd_args.last_column
+ end
+ end
+
+ # Example:
+ # foo
+ # ^^^
+ def spot_vcall
+ if @node.first_lineno == @node.last_lineno
+ fetch_line(@node.last_lineno)
+ @beg_column = @node.first_column
+ @end_column = @node.last_column
+ end
+ end
+
+ # Example:
+ # x[1] += 42
+ # ^^^ (for [])
+ # x[1] += 42
+ # ^ (for +)
+ # x[1] += 42
+ # ^^^^^^ (for []=)
+ def spot_op_asgn1_for_name
+ nd_recv, op, nd_args, _nd_rhs = @node.children
+ fetch_line(nd_recv.last_lineno)
+ if @snippet.match(/\G[\s)]*(\[)/, nd_recv.last_column)
+ bracket_beg_column = $~.begin(1)
+ args_last_column = $~.end(0)
+ if nd_args && nd_recv.last_lineno == nd_args.last_lineno
+ args_last_column = nd_args.last_column
+ end
+ if @snippet.match(/\s*\](\s*)(#{ Regexp.quote(op) })=()/, args_last_column)
+ case @name
+ when :[], :[]=
+ @beg_column = bracket_beg_column
+ @end_column = $~.begin(@name == :[] ? 1 : 3)
+ when op
+ @beg_column = $~.begin(2)
+ @end_column = $~.end(2)
+ end
+ end
+ end
+ end
+
+ # Example:
+ # x[1] += 42
+ # ^^^^^^^^
+ def spot_op_asgn1_for_args
+ nd_recv, mid, nd_args, nd_rhs = @node.children
+ fetch_line(nd_recv.last_lineno)
+ if mid == :[]= && @snippet.match(/\G\s*\[/, nd_recv.last_column)
+ @beg_column = $~.end(0)
+ if nd_recv.last_lineno == nd_rhs.last_lineno
+ @end_column = nd_rhs.last_column
+ end
+ elsif nd_args && nd_args.first_lineno == nd_rhs.last_lineno
+ @beg_column = nd_args.first_column
+ @end_column = nd_rhs.last_column
+ end
+ # TODO: support @arg
+ end
+
+ # Example:
+ # x.foo += 42
+ # ^^^ (for foo)
+ # x.foo += 42
+ # ^ (for +)
+ # x.foo += 42
+ # ^^^^^^^ (for foo=)
+ def spot_op_asgn2_for_name
+ nd_recv, _qcall, attr, op, _nd_rhs = @node.children
+ fetch_line(nd_recv.last_lineno)
+ if @snippet.match(/\G[\s)]*(\.)\s*#{ Regexp.quote(attr) }()\s*(#{ Regexp.quote(op) })(=)/, nd_recv.last_column)
+ case @name
+ when attr
+ @beg_column = $~.begin(1)
+ @end_column = $~.begin(2)
+ when op
+ @beg_column = $~.begin(3)
+ @end_column = $~.end(3)
+ when :"#{ attr }="
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(4)
+ end
+ end
+ end
+
+ # Example:
+ # x.foo += 42
+ # ^^
+ def spot_op_asgn2_for_args
+ _nd_recv, _qcall, _attr, _op, nd_rhs = @node.children
+ if nd_rhs.first_lineno == nd_rhs.last_lineno
+ fetch_line(nd_rhs.first_lineno)
+ @beg_column = nd_rhs.first_column
+ @end_column = nd_rhs.last_column
+ end
+ end
+
+ # Example:
+ # Foo::Bar
+ # ^^^^^
+ def spot_colon2
+ nd_parent, const = @node.children
+ if nd_parent.last_lineno == @node.last_lineno
+ fetch_line(nd_parent.last_lineno)
+ @beg_column = nd_parent.last_column
+ @end_column = @node.last_column
+ else
+ fetch_line(@node.last_lineno)
+ if @snippet[...@node.last_column].match(/#{ Regexp.quote(const) }\z/)
+ @beg_lineno = @end_lineno = @node.last_lineno
+ @beg_column = $~.begin(0)
+ @end_column = $~.end(0)
+ end
+ end
+ end
+
+ # Example:
+ # Foo::Bar += 1
+ # ^^^^^^^^
+ def spot_op_cdecl
+ nd_lhs, op, _nd_rhs = @node.children
+ *nd_parent_lhs, _const = nd_lhs.children
+ if @name == op
+ fetch_line(nd_lhs.last_lineno)
+ if @snippet.match(/\G\s*(#{ Regexp.quote(op) })=/, nd_lhs.last_column)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ else
+ # constant access error
+ @end_column = nd_lhs.last_column
+ if nd_parent_lhs.empty? # example: ::C += 1
+ if nd_lhs.first_lineno == nd_lhs.last_lineno
+ fetch_line(nd_lhs.last_lineno)
+ @beg_column = nd_lhs.first_column
+ end
+ else # example: Foo::Bar::C += 1
+ if nd_parent_lhs.last.last_lineno == nd_lhs.last_lineno
+ fetch_line(nd_lhs.last_lineno)
+ @beg_column = nd_parent_lhs.last.last_column
+ end
+ end
+ end
+ end
+
+ # Example:
+ # def bar; end
+ # ^^^
+ def spot_defn
+ mid, = @node.children
+ fetch_line(@node.first_lineno)
+ if @snippet.match(/\Gdef\s+(#{ Regexp.quote(mid) }\b)/, @node.first_column)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ end
+
+ # Example:
+ # def Foo.bar; end
+ # ^^^^
+ def spot_defs
+ nd_recv, mid, = @node.children
+ fetch_line(nd_recv.last_lineno)
+ if @snippet.match(/\G\s*(\.\s*#{ Regexp.quote(mid) }\b)/, nd_recv.last_column)
+ @beg_column = $~.begin(1)
+ @end_column = $~.end(1)
+ end
+ end
+
+ # Example:
+ # -> { ... }
+ # ^^
+ def spot_lambda
+ fetch_line(@node.first_lineno)
+ if @snippet.match(/\G->/, @node.first_column)
+ @beg_column = $~.begin(0)
+ @end_column = $~.end(0)
+ end
+ end
+
+ # Example:
+ # lambda { ... }
+ # ^
+ # define_method :foo do
+ # ^^
+ def spot_iter
+ _nd_fcall, nd_scope = @node.children
+ fetch_line(nd_scope.first_lineno)
+ if @snippet.match(/\G(?:do\b|\{)/, nd_scope.first_column)
+ @beg_column = $~.begin(0)
+ @end_column = $~.end(0)
+ end
+ end
+
+ def fetch_line(lineno)
+ @beg_lineno = @end_lineno = lineno
+ @snippet = @fetch[lineno]
+ end
+
+ # Take a location from the prism parser and set the necessary instance
+ # variables.
+ def prism_location(location)
+ @beg_lineno = location.start_line
+ @beg_column = location.start_column
+ @end_lineno = location.end_line
+ @end_column = location.end_column
+ @snippet = @fetch[@beg_lineno, @end_lineno]
+ end
+
+ # Example:
+ # x.foo
+ # ^^^^
+ # x.foo(42)
+ # ^^^^
+ # x&.foo
+ # ^^^^^
+ # x[42]
+ # ^^^^
+ # x.foo = 1
+ # ^^^^^^
+ # x[42] = 1
+ # ^^^^^^
+ # x + 1
+ # ^
+ # +x
+ # ^
+ # foo(42)
+ # ^^^
+ # foo 42
+ # ^^^
+ # foo
+ # ^^^
+ def prism_spot_call_for_name
+ # Explicitly turn off foo.() syntax because error_highlight expects this
+ # to not work.
+ return nil if @node.name == :call && @node.message_loc.nil?
+
+ location = @node.message_loc || @node.call_operator_loc || @node.location
+ location = @node.call_operator_loc.join(location) if @node.call_operator_loc&.start_line == location.start_line
+
+ # If the method name ends with "=" but the message does not, then this is
+ # a method call using the "attribute assignment" syntax
+ # (e.g., foo.bar = 1). In this case we need to go retrieve the = sign and
+ # add it to the location.
+ if (name = @node.name).end_with?("=") && !@node.message.end_with?("=")
+ location = location.adjoin("=")
+ end
+
+ prism_location(location)
+
+ if !name.end_with?("=") && !name.match?(/[[:alpha:]_\[]/)
+ # If the method name is an operator, then error_highlight only
+ # highlights the first line.
+ fetch_line(location.start_line)
+ end
+ end
+
+ # Example:
+ # x.foo(42)
+ # ^^
+ # x[42]
+ # ^^
+ # x.foo = 1
+ # ^
+ # x[42] = 1
+ # ^^^^^^^
+ # x[] = 1
+ # ^^^^^
+ # x + 1
+ # ^
+ # foo(42)
+ # ^^
+ # foo 42
+ # ^^
+ def prism_spot_call_for_args
+ # Disallow highlighting arguments if there are no arguments.
+ return if @node.arguments.nil?
+
+ # Explicitly turn off foo.() syntax because error_highlight expects this
+ # to not work.
+ return nil if @node.name == :call && @node.message_loc.nil?
+
+ if @node.name == :[]= && @node.opening == "[" && (@node.arguments&.arguments || []).length == 1
+ prism_location(@node.opening_loc.copy(start_offset: @node.opening_loc.start_offset + 1).join(@node.arguments.location))
+ else
+ prism_location(@node.arguments.location)
+ end
+ end
+
+ # Example:
+ # x += 1
+ # ^
+ def prism_spot_local_variable_operator_write_for_name
+ prism_location(@node.binary_operator_loc.chop)
+ end
+
+ # Example:
+ # x += 1
+ # ^
+ def prism_spot_local_variable_operator_write_for_args
+ prism_location(@node.value.location)
+ end
+
+ # Example:
+ # x.foo += 42
+ # ^^^ (for foo)
+ # x.foo += 42
+ # ^ (for +)
+ # x.foo += 42
+ # ^^^^^^^ (for foo=)
+ def prism_spot_call_operator_write_for_name
+ if !@name.start_with?(/[[:alpha:]_]/)
+ prism_location(@node.binary_operator_loc.chop)
+ else
+ location = @node.message_loc
+ if @node.call_operator_loc.start_line == location.start_line
+ location = @node.call_operator_loc.join(location)
+ end
+
+ location = location.adjoin("=") if @name.end_with?("=")
+ prism_location(location)
+ end
+ end
+
+ # Example:
+ # x.foo += 42
+ # ^^
+ def prism_spot_call_operator_write_for_args
+ prism_location(@node.value.location)
+ end
+
+ # Example:
+ # x[1] += 42
+ # ^^^ (for [])
+ # x[1] += 42
+ # ^ (for +)
+ # x[1] += 42
+ # ^^^^^^ (for []=)
+ def prism_spot_index_operator_write_for_name
+ case @name
+ when :[]
+ prism_location(@node.opening_loc.join(@node.closing_loc))
+ when :[]=
+ prism_location(@node.opening_loc.join(@node.closing_loc).adjoin("="))
+ else
+ # Explicitly turn off foo[] += 1 syntax when the operator is not on
+ # the same line because error_highlight expects this to not work.
+ return nil if @node.binary_operator_loc.start_line != @node.opening_loc.start_line
+
+ prism_location(@node.binary_operator_loc.chop)
+ end
+ end
+
+ # Example:
+ # x[1] += 42
+ # ^^^^^^^^
+ def prism_spot_index_operator_write_for_args
+ opening_loc =
+ if @node.arguments.nil?
+ @node.opening_loc.copy(start_offset: @node.opening_loc.start_offset + 1)
+ else
+ @node.arguments.location
+ end
+
+ prism_location(opening_loc.join(@node.value.location))
+ end
+
+ # Example:
+ # Foo
+ # ^^^
+ def prism_spot_constant_read
+ prism_location(@node.location)
+ end
+
+ # Example:
+ # Foo::Bar
+ # ^^^^^
+ def prism_spot_constant_path
+ if @node.parent && @node.parent.location.end_line == @node.location.end_line
+ fetch_line(@node.parent.location.end_line)
+ prism_location(@node.delimiter_loc.join(@node.name_loc))
+ else
+ fetch_line(@node.location.end_line)
+ location = @node.name_loc
+ location = @node.delimiter_loc.join(location) if @node.delimiter_loc.end_line == location.start_line
+ prism_location(location)
+ end
+ end
+
+ # Example:
+ # Foo::Bar += 1
+ # ^^^^^^^^
+ def prism_spot_constant_path_operator_write
+ 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
+
+ # Example:
+ # def foo()
+ # ^^^
+ def prism_spot_def_for_name
+ location = @node.name_loc
+ location = @node.operator_loc.join(location) if @node.operator_loc
+ prism_location(location)
+ end
+
+ # Example:
+ # -> x, y { }
+ # ^^
+ def prism_spot_lambda_for_name
+ prism_location(@node.operator_loc)
+ end
+
+ # Example:
+ # lambda { }
+ # ^
+ # define_method :foo do |x, y|
+ # ^
+ def prism_spot_block_for_name
+ prism_location(@node.opening_loc)
+ end
+ end
+
+ private_constant :Spotter
+end
diff --git a/lib/error_highlight/core_ext.rb b/lib/error_highlight/core_ext.rb
new file mode 100644
index 0000000000..c3354f46cd
--- /dev/null
+++ b/lib/error_highlight/core_ext.rb
@@ -0,0 +1,76 @@
+require_relative "formatter"
+
+module ErrorHighlight
+ module CoreExt
+ private def generate_snippet
+ if ArgumentError === self && message =~ /\A(?:wrong number of arguments|missing keyword[s]?|unknown keyword[s]?|no keywords accepted)\b/
+ locs = self.backtrace_locations
+ return "" if locs.size < 2
+ callee_loc, caller_loc = locs
+ callee_spot = ErrorHighlight.spot(self, backtrace_location: callee_loc, point_type: :name)
+ caller_spot = ErrorHighlight.spot(self, backtrace_location: caller_loc, point_type: :name)
+ if caller_spot && callee_spot &&
+ caller_loc.path == callee_loc.path &&
+ caller_loc.lineno == callee_loc.lineno &&
+ caller_spot == callee_spot
+ callee_loc = callee_spot = nil
+ end
+ ret = +"\n"
+ [["caller", caller_loc, caller_spot], ["callee", callee_loc, callee_spot]].each do |header, loc, spot|
+ out = nil
+ if loc
+ out = " #{ header }: #{ loc.path }:#{ loc.lineno }"
+ if spot
+ _, _, snippet, highlight = ErrorHighlight.formatter.message_for(spot).lines
+ out += "\n | #{ snippet } #{ highlight }"
+ else
+ # do nothing
+ end
+ end
+ ret << "\n" + out if out
+ end
+ ret
+ else
+ spot = ErrorHighlight.spot(self)
+ return "" unless spot
+ return ErrorHighlight.formatter.message_for(spot)
+ end
+ end
+
+ if Exception.method_defined?(:detailed_message)
+ def detailed_message(highlight: false, error_highlight: true, **)
+ return super unless error_highlight
+ snippet = generate_snippet
+ if highlight
+ snippet = snippet.gsub(/.+/) { "\e[1m" + $& + "\e[m" }
+ end
+ super + snippet
+ end
+ else
+ # This is a marker to let `DidYouMean::Correctable#original_message` skip
+ # the following method definition of `to_s`.
+ # See https://github.com/ruby/did_you_mean/pull/152
+ SKIP_TO_S_FOR_SUPER_LOOKUP = true
+ private_constant :SKIP_TO_S_FOR_SUPER_LOOKUP
+
+ def to_s
+ msg = super
+ snippet = generate_snippet
+ if snippet != "" && !msg.include?(snippet)
+ msg + snippet
+ else
+ msg
+ end
+ end
+ end
+ end
+
+ NameError.prepend(CoreExt)
+
+ if Exception.method_defined?(:detailed_message)
+ # ErrorHighlight is enabled for TypeError and ArgumentError only when Exception#detailed_message is available.
+ # This is because changing ArgumentError#message is highly incompatible.
+ TypeError.prepend(CoreExt)
+ ArgumentError.prepend(CoreExt)
+ end
+end
diff --git a/lib/error_highlight/error_highlight.gemspec b/lib/error_highlight/error_highlight.gemspec
new file mode 100644
index 0000000000..edfc4b776f
--- /dev/null
+++ b/lib/error_highlight/error_highlight.gemspec
@@ -0,0 +1,27 @@
+# coding: utf-8
+lib = File.expand_path('../lib', __FILE__)
+$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
+begin
+ require_relative "lib/error_highlight/version"
+rescue LoadError # Fallback to load version file in ruby core repository
+ require_relative "version"
+end
+
+Gem::Specification.new do |spec|
+ spec.name = "error_highlight"
+ spec.version = ErrorHighlight::VERSION
+ spec.authors = ["Yusuke Endoh"]
+ spec.email = ["mame@ruby-lang.org"]
+
+ spec.summary = 'Shows a one-line code snippet with an underline in the error backtrace'
+ spec.description = 'The gem enhances Exception#message by adding a short explanation where the exception is raised'
+ spec.homepage = "https://github.com/ruby/error_highlight"
+
+ spec.license = "MIT"
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
+
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
+ end
+ spec.require_paths = ["lib"]
+end
diff --git a/lib/error_highlight/formatter.rb b/lib/error_highlight/formatter.rb
new file mode 100644
index 0000000000..d2fad9e75c
--- /dev/null
+++ b/lib/error_highlight/formatter.rb
@@ -0,0 +1,74 @@
+module ErrorHighlight
+ class DefaultFormatter
+ MIN_SNIPPET_WIDTH = 20
+
+ def self.message_for(spot)
+ # currently only a one-line code snippet is supported
+ return "" unless spot[:first_lineno] == spot[:last_lineno]
+
+ snippet = spot[:snippet]
+ first_column = spot[:first_column]
+ last_column = spot[:last_column]
+ ellipsis = "..."
+
+ # truncate snippet to fit in the viewport
+ if max_snippet_width && snippet.size > max_snippet_width
+ available_width = max_snippet_width - ellipsis.size
+ center = first_column - max_snippet_width / 2
+
+ visible_start = last_column < available_width ? 0 : [center, 0].max
+ visible_end = visible_start + max_snippet_width
+ visible_start = snippet.size - max_snippet_width if visible_end > snippet.size
+
+ prefix = visible_start.positive? ? ellipsis : ""
+ suffix = visible_end < snippet.size ? ellipsis : ""
+
+ snippet = prefix + snippet[(visible_start + prefix.size)...(visible_end - suffix.size)] + suffix
+ snippet << "\n" unless snippet.end_with?("\n")
+
+ first_column -= visible_start
+ last_column = [last_column - visible_start, snippet.size - 1].min
+ end
+
+ indent = snippet[0...first_column].gsub(/[^\t]/, " ")
+ marker = indent + "^" * (last_column - first_column)
+
+ "\n\n#{ snippet }#{ marker }"
+ end
+
+ def self.max_snippet_width
+ return if Ractor.current[:__error_highlight_max_snippet_width__] == :disabled
+
+ Ractor.current[:__error_highlight_max_snippet_width__] ||= terminal_width
+ end
+
+ def self.max_snippet_width=(width)
+ return Ractor.current[:__error_highlight_max_snippet_width__] = :disabled if width.nil?
+
+ width = width.to_i
+
+ if width < MIN_SNIPPET_WIDTH
+ warn "'max_snippet_width' adjusted to minimum value of #{MIN_SNIPPET_WIDTH}."
+ width = MIN_SNIPPET_WIDTH
+ end
+
+ Ractor.current[:__error_highlight_max_snippet_width__] = width
+ end
+
+ def self.terminal_width
+ # lazy load io/console to avoid loading it when 'max_snippet_width' is manually set
+ require "io/console"
+ $stderr.winsize[1] if $stderr.tty?
+ rescue LoadError, NoMethodError, SystemCallError
+ # skip truncation when terminal window size is unavailable
+ end
+ end
+
+ def self.formatter
+ Ractor.current[:__error_highlight_formatter__] || DefaultFormatter
+ end
+
+ def self.formatter=(formatter)
+ Ractor.current[:__error_highlight_formatter__] = formatter
+ end
+end
diff --git a/lib/error_highlight/version.rb b/lib/error_highlight/version.rb
new file mode 100644
index 0000000000..f0a5376b14
--- /dev/null
+++ b/lib/error_highlight/version.rb
@@ -0,0 +1,3 @@
+module ErrorHighlight
+ VERSION = "0.7.1"
+end