module ErrorHighlight

Constants

VERSION

Public Class Methods

formatter() click to toggle source
# File lib/error_highlight/formatter.rb, line 16
def self.formatter
  Ractor.current[:__error_highlight_formatter__] || DefaultFormatter
end
formatter=(formatter) click to toggle source
# File lib/error_highlight/formatter.rb, line 20
def self.formatter=(formatter)
  Ractor.current[:__error_highlight_formatter__] = formatter
end
spot(obj, **opts) click to toggle source

Identify the code fragment at that a given exception occurred.

Options:

point_type: :name | :args

:name (default) points the method/variable name that the exception occurred.
:args points the arguments of the method call that 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 +ary+), the method will return nil. This restriction may be removed in the future.

# File lib/error_highlight/base.rb, line 33
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, **opts)
      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

Private Class Methods

prism_find(loc, point_type: :name, name: nil) click to toggle source

Accepts a Thread::Backtrace::Location object and returns a Prism::Node corresponding to the location in the source code.

# File lib/error_highlight/base.rb, line 86
def self.prism_find(loc, point_type: :name, name: nil)
  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

        # 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
end