Skip to content

Commit 9add4d9

Browse files
authored
Merge pull request rails#53657 from martinemde/martinemde/find_offset-test-and-fix
Redesign ActionView::Template::Handlers::ERB.find_offset to handle edge cases
2 parents 5f14ba8 + aaebc86 commit 9add4d9

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)