Use error_highlight gem to locate the columns where an error was raised

This change incorporates to Rails a feature called error_highlight that
has been available since Ruby 3.1. This allow Rails' error report screen
to display the fine-grained location where an error occurred (not only a
line number but also beginning and end column numbers of the code
fragment).

For ErrorHighlight, see https://bugs.ruby-lang.org/issues/17930 in
detail.

To use error_highlight, ExceptionWrapper now prefers
`Exception#backtrace_locations` (since Ruby 2.1), which returns an array
of `Thread::Backtrace::Location`s, instead of `Exception#backtrace`.
This is because error_highlight requires `Thread::Backtrace::Location`
to locate the column where an error was raised.

Co-Authored-By: John Hawthorn <john@hawthorn.email>
Co-Authored-By: Jean Boussier <jean.boussier@gmail.com>
This commit is contained in:
Yusuke Endoh 2022-08-10 18:16:25 +09:00
parent 342bd8545a
commit 3beb2aff3b
13 changed files with 120 additions and 19 deletions

View File

@ -176,3 +176,10 @@ end
gem "tzinfo-data", platforms: [:mingw, :mswin, :x64_mingw, :jruby]
gem "wdm", ">= 0.1.0", platforms: [:mingw, :mswin, :x64_mingw, :mswin64]
# The error_highlight gem only works on CRuby 3.1 or later.
# Also, Rails depends on a new API available since error_highlight 0.4.0.
# (Note that Ruby 3.1 bundles error_highlight 0.3.0.)
if RUBY_VERSION >= "3.1"
gem "error_highlight", ">= 0.4.0", platforms: [:ruby]
end

View File

@ -205,6 +205,7 @@ GEM
http_parser.rb (>= 0.6.0)
em-socksify (0.3.2)
eventmachine (>= 1.0.0.beta.4)
error_highlight (0.4.0)
erubi (1.11.0)
et-orbi (1.2.6)
tzinfo
@ -572,6 +573,7 @@ DEPENDENCIES
debug (>= 1.1.0)
delayed_job
delayed_job_active_record
error_highlight (>= 0.4.0)
google-cloud-storage (~> 1.11)
image_processing (~> 1.2)
importmap-rails

View File

@ -121,6 +121,7 @@ module ActionDispatch
trace_to_show: wrapper.trace_to_show,
routes_inspector: routes_inspector(wrapper.exception),
source_extracts: wrapper.source_extracts,
error_highlight_available: wrapper.error_highlight_available?,
line_number: wrapper.line_number,
file: wrapper.file
)

View File

@ -124,15 +124,18 @@ module ActionDispatch
def source_extracts
backtrace.map do |trace|
file, line_number = extract_file_and_line_number(trace)
{
code: source_fragment(file, line_number),
line_number: line_number
}
extract_source(trace)
end
end
def error_highlight_available?
# ErrorHighlight.spot with backtrace_location keyword is available since error_highlight 0.4.0
unless defined?(@@error_highlight_available)
@@error_highlight_available = defined?(ErrorHighlight) && Gem::Version.new(ErrorHighlight::VERSION) >= Gem::Version.new("0.4.0")
end
@@error_highlight_available
end
def trace_to_show
if traces["Application Trace"].empty? && rescue_template != "routing_error"
"Full Trace"
@ -147,7 +150,17 @@ module ActionDispatch
private
def backtrace
Array(@exception.backtrace)
backtrace_locations = @exception.backtrace_locations
backtrace = @exception.backtrace
if backtrace_locations && backtrace_locations.size == backtrace.size
# Prefer #backtrace_locations as it looks consistent with #backtrace
Array(backtrace_locations)
else
# Conservatively fallback to #backtrace as they are inconsistent;
# probably #set_backtrace is used somewhere?
Array(backtrace)
end
end
def causes_for(exception)
@ -168,19 +181,55 @@ module ActionDispatch
end
end
def extract_source(trace)
if error_highlight_available?
spot = ErrorHighlight.spot(@exception, backtrace_location: trace)
if spot
line = spot[:first_lineno]
code = extract_source_fragment_lines(spot[:script_lines], line)
if line == spot[:last_lineno]
code[line] = [
code[line][0, spot[:first_column]],
code[line][spot[:first_column]...spot[:last_column]],
code[line][spot[:last_column]..-1],
]
end
return {
code: code,
line_number: line
}
end
end
file, line_number = extract_file_and_line_number(trace)
{
code: source_fragment(file, line_number),
line_number: line_number
}
end
def extract_source_fragment_lines(source_lines, line)
start = [line - 3, 0].max
lines = source_lines.drop(start).take(6)
Hash[*(start + 1..(lines.count + start)).zip(lines).flatten]
end
def source_fragment(path, line)
return unless Rails.respond_to?(:root) && Rails.root
full_path = Rails.root.join(path)
if File.exist?(full_path)
File.open(full_path, "r") do |file|
start = [line - 3, 0].max
lines = file.each_line.drop(start).take(6)
Hash[*(start + 1..(lines.count + start)).zip(lines).flatten]
extract_source_fragment_lines(file.each_line, line)
end
end
end
def extract_file_and_line_number(trace)
return [trace.path, trace.lineno] if Thread::Backtrace::Location === trace
# Split by the first colon followed by some digits, which works for both
# Windows and Unix path styles.
file, line = trace.match(/^(.+?):(\d+).*$/, &:captures) || trace

View File

@ -18,12 +18,19 @@
</td>
<td width="100%">
<pre>
<% source_extract[:code].each do |line, source| -%><div class="line<%= " active" if line == source_extract[:line_number] -%>"><%= source -%></div><% end -%>
<% source_extract[:code].each do |line, source| -%>
<div class="line<%= " active" if line == source_extract[:line_number] -%>"><% if source.is_a?(Array) -%><%= source[0] -%><span class="error_highlight"><%= source[1] -%></span><%= source[2] -%>
<% else -%>
<%= source -%>
<% end -%></div><% end -%>
</pre>
</td>
</tr>
</table>
</div>
<%- if defined?(ErrorHighlight) && !error_highlight_available -%>
<p class="error_highlight_tip">Tip: You may want to add <code>gem 'error_highlight', '&gt;= 0.4.0'</code> into your Gemfile, which will display the fine-grained error location.</p>
<%- end -%>
</div>
<% end %>
<% end %>

View File

@ -11,7 +11,7 @@
<%= render "rescues/message_and_suggestions", exception: @exception %>
<%= render "rescues/actions", exception: @exception, request: @request %>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx, error_index: 0 %>
<%= render "rescues/source", source_extracts: @source_extracts, error_highlight_available: @error_highlight_available, show_source_idx: @show_source_idx, error_index: 0 %>
<%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show, error_index: 0 %>
<% if @exception.cause %>
@ -26,7 +26,7 @@
</div>
<div id="<%= wrapper.exception.object_id %>" class="hidden">
<%= render "rescues/source", source_extracts: wrapper.source_extracts, show_source_idx: wrapper.source_to_show_id, error_index: index %>
<%= render "rescues/source", source_extracts: wrapper.source_extracts, error_highlight_available: wrapper.error_highlight_available?, show_source_idx: wrapper.source_to_show_id, error_index: index %>
<%= render "rescues/trace", traces: wrapper.traces, trace_to_show: wrapper.trace_to_show, error_index: index %>
</div>
<% end %>

View File

@ -18,7 +18,7 @@
<% end %>
</h2>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx %>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx, error_highlight_available: nil %>
<%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show %>
<%= render template: "rescues/_request_and_response" %>
</main>

View File

@ -148,6 +148,18 @@
background-color: #FCC;
}
.error_highlight {
display: inline-block;
background-color: #FF9;
text-decoration: #F00 wavy underline;
}
.error_highlight_tip {
color: #666;
padding: 2px 2px;
font-size: 10px;
}
.button_to {
display: inline-block;
margin-top: 0.75em;

View File

@ -5,7 +5,7 @@
<main role="main" id="container">
<h2><%= h @exception.message %></h2>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx %>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx, error_highlight_available: nil %>
<%= render "rescues/trace", traces: @traces, trace_to_show: @trace_to_show %>
<%= render template: "rescues/_request_and_response" %>
</main>

View File

@ -11,7 +11,7 @@
</p>
<pre><code><%= h @exception.message %></code></pre>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx %>
<%= render "rescues/source", source_extracts: @source_extracts, show_source_idx: @show_source_idx, error_highlight_available: nil %>
<p><%= @exception.sub_template_message %></p>

View File

@ -53,6 +53,25 @@ module ActionDispatch
end
end
if defined?(ErrorHighlight) && Gem::Version.new(ErrorHighlight::VERSION) >= Gem::Version.new("0.4.0")
test "#source_extracts works with error_highlight" do
lineno = __LINE__
begin
1.time
rescue NameError => exc
end
wrapper = ExceptionWrapper.new(nil, exc)
code = {}
File.foreach(__FILE__).to_a.drop(lineno - 1).take(6).each_with_index do |line, i|
code[lineno + i] = line
end
code[lineno + 2] = [" 1", ".time", "\n"]
assert_equal({ code: code, line_number: lineno + 2 }, wrapper.source_extracts.first)
end
end
test "#application_trace returns traces only from the application" do
exception = TestError.new(caller.prepend("lib/file.rb:42:in `index'"))
wrapper = ExceptionWrapper.new(@cleaner, exception)

View File

@ -106,7 +106,7 @@ module ActiveSupport
def filter_backtrace(backtrace)
@filters.each do |f|
backtrace = backtrace.map { |line| f.call(line) }
backtrace = backtrace.map { |line| f.call(line.to_s) }
end
backtrace
@ -114,7 +114,7 @@ module ActiveSupport
def silence(backtrace)
@silencers.each do |s|
backtrace = backtrace.reject { |line| s.call(line) }
backtrace = backtrace.reject { |line| s.call(line.to_s) }
end
backtrace
@ -123,7 +123,7 @@ module ActiveSupport
def noise(backtrace)
backtrace.select do |line|
@silencers.any? do |s|
s.call(line)
s.call(line.to_s)
end
end
end

View File

@ -56,6 +56,10 @@ group :development do
<%- end -%>
# Speed up commands on slow machines / big apps [https://github.com/rails/spring]
# gem "spring"
<%- if RUBY_VERSION >= "3.1" -%>
gem "error_highlight", ">= 0.4.0", platforms: [:ruby]
<%- end -%>
end
<%- if depends_on_system_test? -%>