Skip to content

Commit aaebc86

Browse files
committed
Handle edge cases in ActionView::Template::Handlers::ERB.find_offset
The code for finding offsets in the ERB templates outright fails in some cases, causing 500s in the error template. In other cases it will bail out early when it's possible to find and highlight correctly. This PR fixes many of the issues that regularly occur and correctly highlights more often. Also adds test coverage of the translate_location method on ActionView::Template, many of which failed before this fix.
1 parent 5f14ba8 commit aaebc86

File tree

3 files changed

+166
-35
lines changed

3 files changed

+166
-35
lines changed

actionview/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* Improve reliability of ERB template error highlighting.
2+
Fix infinite loops and crashes in highlighting and
3+
improve tolerance for alternate ERB handlers.
4+
5+
*Martin Emde*
6+
17
* Allow `hidden_field` and `hidden_field_tag` to accept a custom autocomplete value.
28

39
*brendon*

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

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -105,51 +105,57 @@ def valid_encoding(string, encoding)
105105
raise WrongEncodingError.new(string, string.encoding)
106106
end
107107

108+
# Find which token in the source template spans the byte range that
109+
# contains the error_column, then return the offset compared to the
110+
# original source template.
111+
#
112+
# Iterate consecutive pairs of CODE or TEXT tokens, requiring
113+
# a match of the first token before matching either token.
114+
#
115+
# For example, if we want to find tokens A, B, C, we do the following:
116+
# 1. Find a match for A: test error_column or advance scanner.
117+
# 2. Find a match for B or A:
118+
# a. If B: start over with next token set (B, C).
119+
# b. If A: test error_column or advance scanner.
120+
# c. Otherwise: Advance 1 byte
121+
#
122+
# Prioritize matching the next token over the current token once
123+
# a match for the current token has been found. This is to prevent
124+
# the current token from looping past the next token if they both
125+
# match (i.e. if the current token is a single space character).
108126
def find_offset(compiled, source_tokens, error_column)
109127
compiled = StringScanner.new(compiled)
128+
offset_source_tokens(source_tokens).each_cons(2) do |(name, str, offset), (_, next_str, _)|
129+
matched_str = false
110130

111-
passed_tokens = []
131+
until compiled.eos?
132+
if matched_str && next_str && compiled.match?(next_str)
133+
break
134+
elsif compiled.match?(str)
135+
matched_str = true
112136

113-
while tok = source_tokens.shift
114-
tok_name, str = *tok
115-
case tok_name
116-
when :TEXT
117-
loop do
118-
break if compiled.match?(str)
119-
compiled.getch
120-
end
121-
raise LocationParsingError unless compiled.scan(str)
122-
when :CODE
123-
if compiled.pos > error_column
124-
raise LocationParsingError, "We went too far"
125-
end
137+
if name == :CODE && compiled.pos <= error_column && compiled.pos + str.bytesize >= error_column
138+
return error_column - compiled.pos + offset
139+
end
126140

127-
if compiled.pos + str.bytesize >= error_column
128-
offset = error_column - compiled.pos
129-
return passed_tokens.map(&:last).join.bytesize + offset
141+
compiled.pos += str.bytesize
130142
else
131-
unless compiled.scan(str)
132-
raise LocationParsingError, "Couldn't find code snippet"
133-
end
134-
end
135-
when :OPEN
136-
next_tok = source_tokens.first.last
137-
loop do
138-
break if compiled.match?(next_tok)
139-
compiled.getch
143+
compiled.pos += 1
140144
end
141-
when :CLOSE
142-
next_tok = source_tokens.first.last
143-
loop do
144-
break if compiled.match?(next_tok)
145-
compiled.getch
146-
end
147-
else
148-
raise LocationParsingError, "Not implemented: #{tok.first}"
149145
end
146+
end
147+
148+
raise LocationParsingError, "Couldn't find code snippet"
149+
end
150150

151-
passed_tokens << tok
151+
def offset_source_tokens(source_tokens)
152+
source_offset = 0
153+
with_offset = source_tokens.filter_map do |(name, str)|
154+
result = [name, str, source_offset] if name == :CODE || name == :TEXT
155+
source_offset += str.bytesize
156+
result
152157
end
158+
with_offset << [:EOS, nil, source_offset]
153159
end
154160
end
155161
end

actionview/test/template/template_test.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ def render(implicit_locals: [], **locals)
6565
@template.render(@context, locals, implicit_locals: implicit_locals)
6666
end
6767

68+
def spot_highlight(compiled, highlight, first_column: nil, **options)
69+
# 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
72+
spot = {
73+
first_column:, last_column:, snippet: compiled,
74+
first_lineno: 1, last_lineno: 1, script_lines: compiled.lines,
75+
}
76+
spot.merge!(options)
77+
spot
78+
end
79+
6880
def setup
6981
@context = Context.with_empty_template_cache.empty
7082
super
@@ -314,4 +326,111 @@ def test_template_inspect
314326
@template = new_template("hello")
315327
assert_equal "#<ActionView::Template hello template locals=[]>", @template.inspect
316328
end
329+
330+
def test_template_translate_location
331+
highlight = "nomethoderror"
332+
source = "<%= nomethoderror %>"
333+
compiled = "'.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
334+
335+
backtrace_location = Data.define(:lineno).new(lineno: 1)
336+
spot = spot_highlight(compiled, highlight)
337+
expected = spot_highlight(source, highlight, snippet: compiled)
338+
339+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
340+
end
341+
342+
def test_template_translate_location_lineno_offset
343+
highlight = "nomethoderror"
344+
source = "<%= nomethoderror %>"
345+
compiled = "\n'.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
346+
347+
backtrace_location = Data.define(:lineno).new(lineno: 1)
348+
spot = spot_highlight(compiled, highlight, first_lineno: 2, last_lineno: 2)
349+
expected = spot_highlight(source, highlight, snippet: compiled)
350+
351+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
352+
end
353+
354+
def test_template_translate_location_with_multibye_string_before_highlight
355+
highlight = "nomethoderror"
356+
source = String.new("\u{a5}<%= nomethoderror %>", encoding: Encoding::UTF_8) # yen symbol
357+
compiled = String.new("\u{a5}'.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n", encoding: Encoding::UTF_8)
358+
359+
backtrace_location = Data.define(:lineno).new(lineno: 1)
360+
spot = spot_highlight(compiled, highlight)
361+
expected = spot_highlight(source, highlight, snippet: compiled)
362+
363+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
364+
end
365+
366+
def test_template_translate_location_no_match_in_compiled
367+
highlight = "nomatch"
368+
source = "<%= nomatch %>"
369+
compiled = "this source does not contain the highlight, so the original spot is returned"
370+
371+
backtrace_location = Data.define(:lineno).new(lineno: 1)
372+
spot = spot_highlight(compiled, highlight, first_column: 50)
373+
374+
assert_equal spot, new_template(source).translate_location(backtrace_location, spot)
375+
end
376+
377+
def test_template_translate_location_text_includes_highlight
378+
highlight = "nomethoderror"
379+
source = " nomethoderror <%= nomethoderror %>"
380+
compiled = " nomethoderror '.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
381+
382+
backtrace_location = Data.define(:lineno).new(lineno: 1)
383+
spot = spot_highlight(compiled, highlight)
384+
expected = spot_highlight(source, highlight, snippet: compiled)
385+
386+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
387+
end
388+
389+
def test_template_translate_location_space_separated_erb_tags
390+
highlight = "nomethoderror"
391+
source = "<%= goodcode %> <%= nomethoderror %>"
392+
compiled = "'.freeze; @output_buffer.append= goodcode ; @output_buffer.safe_append=' '.freeze; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
393+
394+
backtrace_location = Data.define(:lineno).new(lineno: 1)
395+
spot = spot_highlight(compiled, highlight)
396+
expected = spot_highlight(source, highlight, snippet: compiled)
397+
398+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
399+
end
400+
401+
def test_template_translate_location_consecutive_erb_tags
402+
highlight = "nomethoderror"
403+
source = "<%= goodcode %><%= nomethoderror %>"
404+
compiled = "'.freeze; @output_buffer.append= goodcode ; @output_buffer.append= nomethoderror ; @output_buffer.safe_append='\n"
405+
406+
backtrace_location = Data.define(:lineno).new(lineno: 1)
407+
spot = spot_highlight(compiled, highlight)
408+
expected = spot_highlight(source, highlight, snippet: compiled)
409+
410+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
411+
end
412+
413+
def test_template_translate_location_repeated_highlight_in_compiled_template
414+
highlight = "nomethoderror"
415+
source = "<%= nomethoderror %>"
416+
compiled = "ValidatedOutputBuffer.wrap(@output_buffer, ({}), ' nomethoderror '.freeze, true).safe_none_append= nomethoderror ; @output_buffer.safe_append='\n"
417+
418+
backtrace_location = Data.define(:lineno).new(lineno: 1)
419+
spot = spot_highlight(compiled, highlight)
420+
expected = spot_highlight(source, highlight, snippet: compiled)
421+
422+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
423+
end
424+
425+
def test_template_translate_location_flaky_pathological_template
426+
highlight = "flakymethod"
427+
source = "<%= flakymethod %> flakymethod <%= flakymethod " # fails on second call, no tailing %>
428+
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 "
429+
430+
backtrace_location = Data.define(:lineno).new(lineno: 1)
431+
spot = spot_highlight(compiled, highlight)
432+
expected = spot_highlight(source, highlight, snippet: compiled)
433+
434+
assert_equal expected, new_template(source).translate_location(backtrace_location, spot)
435+
end
317436
end

0 commit comments

Comments
 (0)