Map column information in to ERB templates

This commit maps the column information returned from ErrorHighlight in
to column information within the source ERB template.  ErrorHighlight
only understands the compiled Ruby code, so this commit adds a small
translation layer that converts the values from ErrorHighlight in to the
right values for the ERB source template
This commit is contained in:
Aaron Patterson 2022-10-01 15:37:05 -07:00
parent e85edcc45d
commit 650e99ac5b
No known key found for this signature in database
GPG Key ID: 953170BCB4FFAFC6
4 changed files with 127 additions and 1 deletions

@ -220,9 +220,28 @@ def exception_id
end
private
class SourceMapLocation < DelegateClass(Thread::Backtrace::Location)
def initialize(location, template)
super(location)
@template = template
end
def spot(exc)
location = super
if location
@template.translate_location(__getobj__, location)
end
end
end
def backtrace
@exception.backtrace_locations || []
(@exception.backtrace_locations || []).map do |loc|
if ActionView::Template::ERROR_HANDLERS.key?(loc.label)
SourceMapLocation.new(loc, ActionView::Template::ERROR_HANDLERS[loc.label])
else
loc
end
end
end
def causes_for(exception)

@ -122,6 +122,8 @@ class Template
attr_reader :identifier, :handler
attr_reader :variable, :format, :variant, :virtual_path
ERROR_HANDLERS = {}
def initialize(source, identifier, handler, locals:, format: nil, variant: nil, virtual_path: nil)
@source = source.dup
@identifier = identifier
@ -151,6 +153,16 @@ def locals
end
end
# Translate an error location returned by ErrorHighlight to the correct
# source location inside the template.
def translate_location(backtrace_location, spot)
if handler.respond_to?(:translate_location)
handler.translate_location(spot, backtrace_location, source)
else
spot
end
end
# Returns whether the underlying handler supports streaming. If so,
# a streaming buffer *may* be passed when it starts rendering.
def supports_streaming?
@ -352,6 +364,7 @@ def #{method_name}(#{method_arguments})
else
mod.module_eval(source, identifier, 0)
end
ActionView::Template::ERROR_HANDLERS[method_name] = self
rescue SyntaxError
# Account for when code in the template is not syntactically valid; e.g. if we're using
# ERB and the user writes <%= foo( %>, attempting to call a helper `foo` and interpolate

@ -1,5 +1,7 @@
# frozen_string_literal: true
require "strscan"
module ActionView
class Template
module Handlers
@ -33,6 +35,24 @@ def handles_encoding?
true
end
# Translate an error location returned by ErrorHighlight to the correct
# source location inside the template.
def translate_location(spot, backtrace_location, source)
# Tokenize the source line
tokens = tokenize(source.lines[backtrace_location.lineno - 1])
new_first_column = find_offset(spot[:snippet], tokens, spot[:first_column])
lineno_delta = spot[:first_lineno] - backtrace_location.lineno
spot[:first_lineno] -= lineno_delta
spot[:last_lineno] -= lineno_delta
column_delta = spot[:first_column] - new_first_column
spot[:first_column] -= column_delta
spot[:last_column] -= column_delta
spot[:script_lines] = source.lines
spot
end
def call(template, source)
# First, convert to BINARY, so in case the encoding is
# wrong, we can still find an encoding tag
@ -79,6 +99,77 @@ def valid_encoding(string, encoding)
# Otherwise, raise an exception
raise WrongEncodingError.new(string, string.encoding)
end
def tokenize(source)
source = StringScanner.new(source.chomp)
tokens = []
start_re = /<%(?:={1,2}|-|\#|%)?/
finish_re = /(?:[-=])?%>/
while !source.eos?
pos = source.pos
source.scan_until(/(?:#{start_re}|#{finish_re})/)
len = source.pos - source.matched.bytesize - pos
case source.matched
when start_re
tokens << [:TEXT, source.string[pos, len]] if len > 0
tokens << [:OPEN, source.matched]
if source.scan(/(.*?)(?=#{finish_re}|$)/m)
tokens << [:CODE, source.matched]
tokens << [:CLOSE, source.scan(finish_re)] unless source.eos?
else
raise NotImplemented
end
when finish_re
tokens << [:CODE, source.string[pos, len]] if len > 0
tokens << [:CLOSE, source.matched]
else
raise NotImplemented, source.matched
end
end
tokens
end
def find_offset(compiled, source_tokens, error_column)
compiled = StringScanner.new(compiled)
passed_tokens = []
while tok = source_tokens.shift
case tok
in [:TEXT, str]
raise unless compiled.scan(str)
in [:CODE, str]
raise "We went too far" if compiled.pos > error_column
if compiled.pos + str.bytesize >= error_column
offset = error_column - compiled.pos
return passed_tokens.map(&:last).join.bytesize + offset
else
raise unless compiled.scan(str)
end
in [:OPEN, str]
next_tok = source_tokens.first.last
loop do
break if compiled.match?(next_tok)
compiled.getch
end
in [:CLOSE, str]
next_tok = source_tokens.first.last
loop do
break if compiled.match?(next_tok)
compiled.getch
end
else
raise NotImplemented, tok.first
end
passed_tokens << tok
end
end
end
end
end

@ -15,6 +15,9 @@ def backtrace
class BacktraceLocation < Struct.new(:path, :lineno, :to_s)
def spot(_)
end
def label
end
end
class BacktraceLocationProxy < DelegateClass(Thread::Backtrace::Location)