Skip to content

Commit a87bbdf

Browse files
authored
Merge pull request rails#53763 from martinemde/martinemde/highlight-multiline-template-errors-in-blocks
Improve error highlighting of multiline methods in ERB templates
2 parents cef9cdb + a74d263 commit a87bbdf

File tree

10 files changed

+160
-57
lines changed

10 files changed

+160
-57
lines changed

actionpack/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* In ExceptionWrapper, match backtrace lines with built templates more often,
2+
allowing improved highlighting of errors within do-end blocks in templates.
3+
Fix for Ruby 3.4 to match new method labels in backtrace.
4+
5+
*Martin Emde*
6+
17
* Allow setting content type with a symbol of the Mime type.
28

39
```ruby

actionpack/lib/action_dispatch/middleware/exception_wrapper.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,13 @@ def build_backtrace
261261
end
262262

263263
(@exception.backtrace_locations || []).map do |loc|
264-
if built_methods.key?(loc.label.to_s)
264+
if built_methods.key?(loc.base_label)
265265
thread_backtrace_location = if loc.respond_to?(:__getobj__)
266266
loc.__getobj__
267267
else
268268
loc
269269
end
270-
SourceMapLocation.new(thread_backtrace_location, built_methods[loc.label.to_s])
270+
SourceMapLocation.new(thread_backtrace_location, built_methods[loc.base_label])
271271
else
272272
loc
273273
end

actionpack/test/dispatch/exception_wrapper_test.rb

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ def backtrace
2828
end
2929
end
3030

31+
class TestTemplate
32+
attr_reader :method_name
33+
34+
def initialize(method_name)
35+
@method_name = method_name
36+
end
37+
38+
def spot(backtrace_location)
39+
{ first_lineno: 1, script_lines: ["compiled @ #{backtrace_location.base_label}:#{backtrace_location.lineno}"] }
40+
end
41+
42+
def translate_location(backtrace_location, spot)
43+
# note: extract_source_fragment_lines pulls lines from script_lines for indexes near first_lineno
44+
# since we're mocking the behavior, we need to leave the first_lineno close to 1
45+
{ first_lineno: 1, script_lines: ["translated @ #{backtrace_location.base_label}:#{backtrace_location.lineno}"] }
46+
end
47+
end
48+
3149
setup do
3250
@cleaner = ActiveSupport::BacktraceCleaner.new
3351
@cleaner.remove_filters!
@@ -70,7 +88,7 @@ def backtrace
7088
end
7189

7290
class_eval "def throw_syntax_error; eval %(
73-
'abc' + pluralize 'def'
91+
pluralize { # create a syntax error without a parser warning
7492
); end", "lib/file.rb", 42
7593

7694
test "#source_extracts works with eval syntax error" do
@@ -79,8 +97,8 @@ def backtrace
7997
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1))
8098

8199
assert_called_with(wrapper, :source_fragment, ["lib/file.rb", 42], returns: "foo") do
82-
assert_equal [ code: "foo", line_number: 42 ], wrapper.source_extracts
83-
end
100+
assert_equal [ code: "foo", line_number: 42 ], wrapper.source_extracts
101+
end
84102
end
85103

86104
test "#source_extracts works with nil backtrace_locations" do
@@ -108,6 +126,62 @@ def backtrace
108126
assert_equal({ code: code, line_number: lineno + 2 }, wrapper.source_extracts.first)
109127
end
110128

129+
class_eval "def _app_views_tests_show_html_erb;
130+
raise TestError; end", "app/views/tests/show.html.erb", 2
131+
132+
test "#source_extracts wraps template lines in a SourceMapLocation" do
133+
exception = begin _app_views_tests_show_html_erb; rescue TestError => ex; ex; end
134+
135+
template = TestTemplate.new("_app_views_tests_show_html_erb")
136+
resolver = Data.define(:built_templates).new(built_templates: [template])
137+
138+
wrapper = nil
139+
assert_called(ActionView::PathRegistry, :all_resolvers, nil, returns: [resolver]) do
140+
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 1))
141+
end
142+
143+
assert_equal [{
144+
code: { 1 => "translated @ _app_views_tests_show_html_erb:3" },
145+
line_number: 1
146+
}], wrapper.source_extracts
147+
end
148+
149+
class_eval "def _app_views_tests_nested_html_erb;
150+
[1].each do
151+
[2].each do
152+
raise TestError
153+
end
154+
end
155+
end", "app/views/tests/nested.html.erb", 2
156+
157+
test "#source_extracts works with nested template code" do
158+
exception = begin _app_views_tests_nested_html_erb; rescue TestError => ex; ex; end
159+
160+
template = TestTemplate.new("_app_views_tests_nested_html_erb")
161+
resolver = Data.define(:built_templates).new(built_templates: [template])
162+
163+
wrapper = nil
164+
assert_called(ActionView::PathRegistry, :all_resolvers, nil, returns: [resolver]) do
165+
wrapper = ExceptionWrapper.new(nil, TopErrorProxy.new(exception, 5))
166+
end
167+
168+
extracts = wrapper.source_extracts
169+
assert_equal({
170+
code: { 1 => "translated @ _app_views_tests_nested_html_erb:5" },
171+
line_number: 1
172+
}, extracts[0])
173+
# extracts[1] is Array#each (unreliable backtrace across rubies)
174+
assert_equal({
175+
code: { 1 => "translated @ _app_views_tests_nested_html_erb:4" },
176+
line_number: 1
177+
}, extracts[2])
178+
# extracts[3] is Array#each (unreliable backtrace across rubies)
179+
assert_equal({
180+
code: { 1 => "translated @ _app_views_tests_nested_html_erb:3" },
181+
line_number: 1
182+
}, extracts[4])
183+
end
184+
111185
test "#application_trace returns traces only from the application" do
112186
exception = begin index; rescue TestError => ex; ex; end
113187
wrapper = ExceptionWrapper.new(@cleaner, TopErrorProxy.new(exception, 1))

actionview/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
* Improve error highlighting of multi-line methods in ERB templates or
2+
templates where the error occurs within a do-end block.
3+
4+
*Martin Emde*
5+
16
* Fix a crash in ERB template error highlighting when the error occurs on a
27
line in the compiled template that is past the end of the source template.
38

actionview/lib/action_view/template/handlers/erb.rb

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,19 @@ def handles_encoding?
4040

4141
# Translate an error location returned by ErrorHighlight to the correct
4242
# source location inside the template.
43-
def translate_location(spot, backtrace_location, source)
44-
# Tokenize the source line
43+
def translate_location(spot, _backtrace_location, source)
44+
compiled = spot[:script_lines]
45+
highlight = compiled[spot[:first_lineno] - 1]&.byteslice((spot[:first_column] - 1)...spot[:last_column])
46+
return nil if highlight.blank?
47+
4548
source_lines = source.lines
46-
return nil if source_lines.size < backtrace_location.lineno
47-
tokens = ::ERB::Util.tokenize(source_lines[backtrace_location.lineno - 1])
48-
new_first_column = find_offset(spot[:snippet], tokens, spot[:first_column])
49-
lineno_delta = spot[:first_lineno] - backtrace_location.lineno
49+
lineno_delta = find_lineno_offset(compiled, source_lines, highlight, spot[:first_lineno])
50+
51+
tokens = ::ERB::Util.tokenize(source_lines[spot[:first_lineno] - lineno_delta - 1])
52+
column_delta = find_offset(spot[:snippet], tokens, spot[:first_column])
53+
5054
spot[:first_lineno] -= lineno_delta
5155
spot[:last_lineno] -= lineno_delta
52-
53-
column_delta = spot[:first_column] - new_first_column
5456
spot[:first_column] -= column_delta
5557
spot[:last_column] -= column_delta
5658
spot[:script_lines] = source_lines
@@ -107,6 +109,28 @@ def valid_encoding(string, encoding)
107109
raise WrongEncodingError.new(string, string.encoding)
108110
end
109111

112+
# Return the offset between the error lineno and the source lineno.
113+
# Searches in reverse from the backtrace lineno so we have a better
114+
# chance of finding the correct line
115+
#
116+
# The compiled template is likely to be longer than the source.
117+
# Use the difference between the compiled and source sizes to
118+
# determine the earliest line that could contain the highlight.
119+
def find_lineno_offset(compiled, source_lines, highlight, error_lineno)
120+
first_index = error_lineno - 1 - compiled.size + source_lines.size
121+
first_index = 0 if first_index < 0
122+
123+
last_index = error_lineno - 1
124+
last_index = source_lines.size - 1 if last_index >= source_lines.size
125+
126+
last_index.downto(first_index) do |line_index|
127+
next unless source_lines[line_index].include?(highlight)
128+
return error_lineno - 1 - line_index
129+
end
130+
131+
raise LocationParsingError, "Couldn't find code snippet"
132+
end
133+
110134
# Find which token in the source template spans the byte range that
111135
# contains the error_column, then return the offset compared to the
112136
# original source template.
@@ -137,7 +161,7 @@ def find_offset(compiled, source_tokens, error_column)
137161
matched_str = true
138162

139163
if name == :CODE && compiled.pos <= error_column && compiled.pos + str.bytesize >= error_column
140-
return error_column - compiled.pos + offset
164+
return compiled.pos - offset
141165
end
142166

143167
compiled.pos += str.bytesize
@@ -152,8 +176,9 @@ def find_offset(compiled, source_tokens, error_column)
152176

153177
def offset_source_tokens(source_tokens)
154178
source_offset = 0
155-
with_offset = source_tokens.filter_map do |(name, str)|
156-
result = [name, str, source_offset] if name == :CODE || name == :TEXT
179+
with_offset = source_tokens.filter_map do |name, str|
180+
result = [:CODE, str, source_offset] if name == :CODE || name == :PLAIN
181+
result = [:TEXT, str, source_offset] if name == :TEXT
157182
source_offset += str.bytesize
158183
result
159184
end

actionview/test/template/template_test.rb

Lines changed: 18 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ def render(implicit_locals: [], **locals)
6767

6868
def spot_highlight(compiled, highlight, first_column: nil, **options)
6969
# rindex by default since our tests usually put the highlight last
70-
first_column ||= compiled.rindex(highlight) || 999
71-
last_column = first_column + highlight.size
70+
first_column ||= compiled.byterindex(highlight) || 999
71+
last_column = first_column + highlight.bytesize
7272
spot = {
7373
first_column:, last_column:, snippet: compiled,
7474
first_lineno: 1, last_lineno: 1, script_lines: compiled.lines,
@@ -332,121 +332,99 @@ def test_template_translate_location
332332
source = "<%= nomethoderror %>"
333333
compiled = "'.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
334334

335-
backtrace_location = Data.define(:lineno).new(lineno: 1)
336335
spot = spot_highlight(compiled, highlight)
337336
expected = spot_highlight(source, highlight, snippet: compiled)
338337

339-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
338+
assert_equal expected, new_template(source).translate_location(nil, spot)
340339
end
341340

342-
# merely tests for the case where the backtrace and spot disagree about lineno
343-
def test_template_translate_location_lineno_offset
344-
highlight = "nomethoderror"
345-
source = "<%= nomethoderror %>"
346-
compiled = "'.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='"
347-
348-
backtrace_location = Data.define(:lineno).new(lineno: 1)
349-
spot = spot_highlight(compiled, highlight, first_lineno: 2, last_lineno: 2)
350-
expected = spot_highlight(source, highlight, snippet: compiled)
351-
352-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
353-
end
354-
355-
# We are testing the failure case here. `find_offset` doesn't correctly handle the case
356-
# where the line number is not the same in the backtrace and template.
357341
def test_template_translate_location_with_multiline_code_source
358342
highlight = "nomethoderror"
359343
source = "<%=\ngood(\n nomethoderror\n) %>"
360344
extracted_line = " nomethoderror\n"
361-
compiled = "ValidatedOutputBuffer.wrap(@output_buffer, ({}), ' \ngood(\n nomethoderror\n" \
362-
") '.freeze, true).safe_none_append=( \ngood(\n nomethoderror\n) );\n@output_buffer"
345+
compiled = "ValidatedOutputBuffer.wrap(@output_buffer, ({}), '\ngood(\n nomethoderror\n) '.freeze, true).safe_none_append=(\ngood(\n nomethoderror\n) );\n@output_buffer"
363346

364-
backtrace_location = Data.define(:lineno).new(lineno: 6)
365347
spot = spot_highlight(compiled, highlight, first_column: 1, first_lineno: 6, last_lineno: 6, snippet: extracted_line)
348+
expected = spot_highlight(source, highlight, first_column: 1, first_lineno: 3, last_lineno: 3, snippet: extracted_line)
366349

367-
assert_equal spot, new_template(source).translate_location(backtrace_location, spot)
350+
assert_equal expected, new_template(source).translate_location(nil, spot)
368351
end
369352

370353
def test_template_translate_location_with_multibye_string_before_highlight
371-
highlight = "nomethoderror"
372-
source = String.new("\u{a5}<%= nomethoderror %>", encoding: Encoding::UTF_8) # yen symbol
373-
compiled = String.new("\u{a5}'.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n", encoding: Encoding::UTF_8)
354+
highlight = "nope"
355+
# ensure the byte offset is enough to make us miss the highlight if wrong
356+
multibyte = String.new("\u{a5}\u{a5}\u{a5}\u{a5}\u{a5}\u{a5}\u{a5}", encoding: Encoding::UTF_8) # yen symbol
357+
source = "#{multibyte}<%= nope %>"
358+
compiled = "#{multibyte}'.freeze; @output_buffer.append= nope ; @output_buffer.safe_append='\n"
374359

375-
backtrace_location = Data.define(:lineno).new(lineno: 1)
376360
spot = spot_highlight(compiled, highlight)
377361
expected = spot_highlight(source, highlight, snippet: compiled)
378362

379-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
363+
assert_equal expected, new_template(source).translate_location(nil, spot)
380364
end
381365

382366
def test_template_translate_location_no_match_in_compiled
383367
highlight = "nomatch"
384368
source = "<%= nomatch %>"
385369
compiled = "this source does not contain the highlight, so the original spot is returned"
386370

387-
backtrace_location = Data.define(:lineno).new(lineno: 1)
388371
spot = spot_highlight(compiled, highlight, first_column: 50)
389372

390-
assert_equal spot, new_template(source).translate_location(backtrace_location, spot)
373+
assert_equal spot, new_template(source).translate_location(nil, spot)
391374
end
392375

393376
def test_template_translate_location_text_includes_highlight
394377
highlight = "nomethoderror"
395378
source = " nomethoderror <%= nomethoderror %>"
396379
compiled = " nomethoderror '.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
397380

398-
backtrace_location = Data.define(:lineno).new(lineno: 1)
399381
spot = spot_highlight(compiled, highlight)
400382
expected = spot_highlight(source, highlight, snippet: compiled)
401383

402-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
384+
assert_equal expected, new_template(source).translate_location(nil, spot)
403385
end
404386

405387
def test_template_translate_location_space_separated_erb_tags
406388
highlight = "nomethoderror"
407389
source = "<%= goodcode %> <%= nomethoderror %>"
408390
compiled = "'.freeze; @output_buffer.append= goodcode ; @output_buffer.safe_append=' '.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
409391

410-
backtrace_location = Data.define(:lineno).new(lineno: 1)
411392
spot = spot_highlight(compiled, highlight)
412393
expected = spot_highlight(source, highlight, snippet: compiled)
413394

414-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
395+
assert_equal expected, new_template(source).translate_location(nil, spot)
415396
end
416397

417398
def test_template_translate_location_consecutive_erb_tags
418399
highlight = "nomethoderror"
419400
source = "<%= goodcode %><%= nomethoderror %>"
420401
compiled = "'.freeze; @output_buffer.append= goodcode ; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
421402

422-
backtrace_location = Data.define(:lineno).new(lineno: 1)
423403
spot = spot_highlight(compiled, highlight)
424404
expected = spot_highlight(source, highlight, snippet: compiled)
425405

426-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
406+
assert_equal expected, new_template(source).translate_location(nil, spot)
427407
end
428408

429409
def test_template_translate_location_repeated_highlight_in_compiled_template
430410
highlight = "nomethoderror"
431411
source = "<%= nomethoderror %>"
432412
compiled = "ValidatedOutputBuffer.wrap(@output_buffer, ({}), ' nomethoderror '.freeze, true).safe_none_append= nomethoderror ; @output_buffer.safe_append='\n"
433413

434-
backtrace_location = Data.define(:lineno).new(lineno: 1)
435414
spot = spot_highlight(compiled, highlight)
436415
expected = spot_highlight(source, highlight, snippet: compiled)
437416

438-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
417+
assert_equal expected, new_template(source).translate_location(nil, spot)
439418
end
440419

441420
def test_template_translate_location_flaky_pathological_template
442421
highlight = "flakymethod"
443422
source = "<%= flakymethod %> flakymethod <%= flakymethod " # fails on second call, no tailing %>
444423
compiled = "ValidatedOutputBuffer.wrap(@output_buffer, ({}), ' flakymethod '.freeze, true).safe_none_append=( flakymethod );@output_buffer.safe_append=' flakymethod '.freeze;ValidatedOutputBuffer.wrap(@output_buffer, ({}), ' flakymethod '.freeze, true).safe_none_append=( flakymethod "
445424

446-
backtrace_location = Data.define(:lineno).new(lineno: 1)
447425
spot = spot_highlight(compiled, highlight)
448426
expected = spot_highlight(source, highlight, snippet: compiled)
449427

450-
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
428+
assert_equal expected, new_template(source).translate_location(nil, spot)
451429
end
452430
end

activesupport/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* Alter `ERB::Util.tokenize` to return :PLAIN token with full input string when string doesn't contain ERB tags.
2+
3+
*Martin Emde*
4+
15
* Fix a bug in `ERB::Util.tokenize` that causes incorrect tokenization when ERB tags are preceeded by multibyte characters.
26

37
*Martin Emde*

activesupport/lib/active_support/core_ext/erb/util.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def self.tokenize(source) # :nodoc:
169169
while !source.eos?
170170
pos = source.pos
171171
source.scan_until(/(?:#{start_re}|#{finish_re})/)
172-
raise NotImplementedError if source.matched.nil?
172+
return [[:PLAIN, source.string]] unless source.matched?
173173
len = source.pos - source.matched.bytesize - pos
174174

175175
case source.matched

activesupport/lib/active_support/syntax_error_proxy.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ def spot(_)
1818

1919
def label
2020
end
21+
22+
def base_label
23+
end
2124
end
2225

2326
class BacktraceLocationProxy < DelegateClass(Thread::Backtrace::Location) # :nodoc:

0 commit comments

Comments
 (0)