actionview 8.0.3 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -67
  3. data/lib/action_view/base.rb +5 -2
  4. data/lib/action_view/buffers.rb +1 -1
  5. data/lib/action_view/dependency_tracker/erb_tracker.rb +1 -1
  6. data/lib/action_view/dependency_tracker.rb +6 -1
  7. data/lib/action_view/gem_version.rb +2 -2
  8. data/lib/action_view/helpers/asset_tag_helper.rb +23 -4
  9. data/lib/action_view/helpers/capture_helper.rb +2 -2
  10. data/lib/action_view/helpers/controller_helper.rb +6 -2
  11. data/lib/action_view/helpers/date_helper.rb +17 -0
  12. data/lib/action_view/helpers/form_helper.rb +2 -3
  13. data/lib/action_view/helpers/form_options_helper.rb +10 -12
  14. data/lib/action_view/helpers/form_tag_helper.rb +17 -10
  15. data/lib/action_view/helpers/javascript_helper.rb +5 -1
  16. data/lib/action_view/helpers/number_helper.rb +14 -0
  17. data/lib/action_view/helpers/tag_helper.rb +31 -34
  18. data/lib/action_view/helpers/tags/base.rb +2 -0
  19. data/lib/action_view/helpers/tags/check_box.rb +7 -1
  20. data/lib/action_view/helpers/tags/datetime_field.rb +1 -1
  21. data/lib/action_view/helpers/tags/file_field.rb +3 -1
  22. data/lib/action_view/helpers/tags/hidden_field.rb +1 -1
  23. data/lib/action_view/helpers/tags/select.rb +6 -1
  24. data/lib/action_view/helpers/tags/select_renderer.rb +5 -3
  25. data/lib/action_view/helpers/translation_helper.rb +6 -1
  26. data/lib/action_view/helpers/url_helper.rb +37 -9
  27. data/lib/action_view/locale/en.yml +3 -0
  28. data/lib/action_view/log_subscriber.rb +1 -4
  29. data/lib/action_view/railtie.rb +11 -0
  30. data/lib/action_view/record_identifier.rb +21 -0
  31. data/lib/action_view/renderer/partial_renderer.rb +16 -0
  32. data/lib/action_view/structured_event_subscriber.rb +97 -0
  33. data/lib/action_view/template/error.rb +7 -3
  34. data/lib/action_view/template/handlers/erb.rb +37 -12
  35. data/lib/action_view/test_case.rb +50 -52
  36. data/lib/action_view.rb +3 -0
  37. metadata +11 -10
@@ -44,9 +44,6 @@ module ActionView
44
44
  PRE_CONTENT_STRINGS["textarea"] = "\n"
45
45
 
46
46
  class TagBuilder # :nodoc:
47
- include CaptureHelper
48
- include OutputSafetyHelper
49
-
50
47
  def self.define_element(name, code_generator:, method_name: name)
51
48
  return if method_defined?(name)
52
49
 
@@ -226,17 +223,7 @@ module ActionView
226
223
  tag_options(attributes.to_h).to_s.strip.html_safe
227
224
  end
228
225
 
229
- def tag_string(name, content = nil, options, escape: true, &block)
230
- content = @view_context.capture(self, &block) if block
231
-
232
- content_tag_string(name, content, options, escape)
233
- end
234
-
235
- def self_closing_tag_string(name, options, escape = true, tag_suffix = " />")
236
- "<#{name}#{tag_options(options, escape)}#{tag_suffix}".html_safe
237
- end
238
-
239
- def content_tag_string(name, content, options, escape = true)
226
+ def content_tag_string(name, content, options, escape = true) # :nodoc:
240
227
  tag_options = tag_options(options, escape) if options
241
228
 
242
229
  if escape && content.present?
@@ -245,7 +232,7 @@ module ActionView
245
232
  "<#{name}#{tag_options}>#{PRE_CONTENT_STRINGS[name]}#{content}</#{name}>".html_safe
246
233
  end
247
234
 
248
- def tag_options(options, escape = true)
235
+ def tag_options(options, escape = true) # :nodoc:
249
236
  return if options.blank?
250
237
  output = +""
251
238
  sep = " "
@@ -266,7 +253,7 @@ module ActionView
266
253
  tokens = TagHelper.build_tag_values(v)
267
254
  next if tokens.none?
268
255
 
269
- v = safe_join(tokens, " ")
256
+ v = @view_context.safe_join(tokens, " ")
270
257
  else
271
258
  v = v.to_s
272
259
  end
@@ -287,28 +274,38 @@ module ActionView
287
274
  output unless output.empty?
288
275
  end
289
276
 
290
- def boolean_tag_option(key)
291
- %(#{key}="#{key}")
292
- end
277
+ private
278
+ def tag_string(name, content = nil, options, escape: true, &block)
279
+ content = @view_context.capture(self, &block) if block
293
280
 
294
- def tag_option(key, value, escape)
295
- key = ERB::Util.xml_name_escape(key) if escape
296
-
297
- case value
298
- when Array, Hash
299
- value = TagHelper.build_tag_values(value) if key.to_s == "class"
300
- value = escape ? safe_join(value, " ") : value.join(" ")
301
- when Regexp
302
- value = escape ? ERB::Util.unwrapped_html_escape(value.source) : value.source
303
- else
304
- value = escape ? ERB::Util.unwrapped_html_escape(value) : value.to_s
281
+ content_tag_string(name, content, options, escape)
305
282
  end
306
- value = value.gsub('"', "&quot;") if value.include?('"')
307
283
 
308
- %(#{key}="#{value}")
309
- end
284
+ def self_closing_tag_string(name, options, escape = true, tag_suffix = " />")
285
+ "<#{name}#{tag_options(options, escape)}#{tag_suffix}".html_safe
286
+ end
287
+
288
+ def boolean_tag_option(key)
289
+ %(#{key}="#{key}")
290
+ end
291
+
292
+ def tag_option(key, value, escape)
293
+ key = ERB::Util.xml_name_escape(key) if escape
294
+
295
+ case value
296
+ when Array, Hash
297
+ value = TagHelper.build_tag_values(value) if key.to_s == "class"
298
+ value = escape ? @view_context.safe_join(value, " ") : value.join(" ")
299
+ when Regexp
300
+ value = escape ? ERB::Util.unwrapped_html_escape(value.source) : value.source
301
+ else
302
+ value = escape ? ERB::Util.unwrapped_html_escape(value) : value.to_s
303
+ end
304
+ value = value.gsub('"', "&quot;") if value.include?('"')
305
+
306
+ %(#{key}="#{value}")
307
+ end
310
308
 
311
- private
312
309
  def prefix_tag_option(prefix, key, value, escape)
313
310
  key = "#{prefix}-#{key.to_s.dasherize}"
314
311
  unless value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(BigDecimal)
@@ -92,6 +92,7 @@ module ActionView
92
92
  end
93
93
  end
94
94
  end
95
+ alias_method :add_default_name_and_id_for_value, :add_default_name_and_field_for_value
95
96
 
96
97
  def add_default_name_and_field(options, field = "id")
97
98
  index = name_and_id_index(options)
@@ -104,6 +105,7 @@ module ActionView
104
105
  end
105
106
  end
106
107
  end
108
+ alias_method :add_default_name_and_id, :add_default_name_and_field
107
109
 
108
110
  def tag_name(multiple = false, index = nil)
109
111
  @template_object.field_name(@object_name, sanitized_method_name, multiple: multiple, index: index)
@@ -57,7 +57,13 @@ module ActionView
57
57
  end
58
58
 
59
59
  def hidden_field_for_checkbox(options)
60
- @unchecked_value ? tag("input", options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value, "autocomplete" => "off")) : "".html_safe
60
+ if @unchecked_value
61
+ tag_options = options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value)
62
+ tag_options["autocomplete"] = "off" unless ActionView::Base.remove_hidden_field_autocomplete
63
+ tag("input", tag_options)
64
+ else
65
+ "".html_safe
66
+ end
61
67
  end
62
68
  end
63
69
  end
@@ -6,7 +6,7 @@ module ActionView
6
6
  class DatetimeField < TextField # :nodoc:
7
7
  def render
8
8
  options = @options.stringify_keys
9
- options["value"] = datetime_value(options["value"] || value)
9
+ options["value"] = datetime_value(options.fetch("value", value))
10
10
  options["min"] = format_datetime(parse_datetime(options["min"]))
11
11
  options["max"] = format_datetime(parse_datetime(options["max"]))
12
12
  @options = options
@@ -18,7 +18,9 @@ module ActionView
18
18
 
19
19
  private
20
20
  def hidden_field_for_multiple_file(options)
21
- tag("input", "name" => options["name"], "type" => "hidden", "value" => "", "autocomplete" => "off")
21
+ tag_options = { "name" => options["name"], "type" => "hidden", "value" => "" }
22
+ tag_options["autocomplete"] = "off" unless ActionView::Base.remove_hidden_field_autocomplete
23
+ tag("input", tag_options)
22
24
  end
23
25
  end
24
26
  end
@@ -5,7 +5,7 @@ module ActionView
5
5
  module Tags # :nodoc:
6
6
  class HiddenField < TextField # :nodoc:
7
7
  def render
8
- @options[:autocomplete] = "off"
8
+ @options.reverse_merge!(autocomplete: "off")
9
9
  super
10
10
  end
11
11
  end
@@ -37,7 +37,12 @@ module ActionView
37
37
  # [nil, []]
38
38
  # { nil => [] }
39
39
  def grouped_choices?
40
- !@choices.blank? && @choices.first.respond_to?(:second) && Array === @choices.first.second
40
+ return false if @choices.blank?
41
+
42
+ first_choice = @choices.first
43
+ return false unless first_choice.is_a?(Enumerable)
44
+
45
+ first_choice.second.is_a?(Array)
41
46
  end
42
47
  end
43
48
  end
@@ -22,7 +22,9 @@ module ActionView
22
22
  select = content_tag("select", add_options(option_tags, options, value), html_options)
23
23
 
24
24
  if html_options["multiple"] && options.fetch(:include_hidden, true)
25
- tag("input", disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "", autocomplete: "off") + select
25
+ tag_options = { disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "" }
26
+ tag_options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete
27
+ tag("input", tag_options) + select
26
28
  else
27
29
  select
28
30
  end
@@ -37,7 +39,7 @@ module ActionView
37
39
  if options[:include_blank]
38
40
  content = (options[:include_blank] if options[:include_blank].is_a?(String))
39
41
  label = (" " unless content)
40
- option_tags = tag_builder.content_tag_string("option", content, value: "", label: label) + "\n" + option_tags
42
+ option_tags = tag_builder.option(content, value: "", label: label) + "\n" + option_tags
41
43
  end
42
44
 
43
45
  if value.blank? && options[:prompt]
@@ -45,7 +47,7 @@ module ActionView
45
47
  prompt_opts[:disabled] = true if options[:disabled] == ""
46
48
  prompt_opts[:selected] = true if options[:selected] == ""
47
49
  end
48
- option_tags = tag_builder.content_tag_string("option", prompt_text(options[:prompt]), tag_options) + "\n" + option_tags
50
+ option_tags = tag_builder.option(prompt_text(options[:prompt]), **tag_options) + "\n" + option_tags
49
51
  end
50
52
 
51
53
  option_tags
@@ -140,7 +140,12 @@ module ActionView
140
140
  end
141
141
 
142
142
  def missing_translation(key, options)
143
- keys = I18n.normalize_keys(options[:locale] || I18n.locale, key, options[:scope])
143
+ locale = options[:locale] || I18n.locale
144
+
145
+ i18n_exception = I18n::MissingTranslation.new(locale, key, options)
146
+ I18n.exception_handler.call(i18n_exception, locale, key, options)
147
+
148
+ keys = I18n.normalize_keys(locale, key, options[:scope])
144
149
 
145
150
  title = +"translation missing: #{keys.join(".")}"
146
151
 
@@ -341,8 +341,9 @@ module ActionView
341
341
  inner_tags = method_tag.safe_concat(button).safe_concat(request_token_tag)
342
342
  if params
343
343
  to_form_params(params).each do |param|
344
- inner_tags.safe_concat tag(:input, type: "hidden", name: param[:name], value: param[:value],
345
- autocomplete: "off")
344
+ options = { type: "hidden", name: param[:name], value: param[:value] }
345
+ options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete
346
+ inner_tags.safe_concat tag(:input, **options)
346
347
  end
347
348
  end
348
349
  html = content_tag("form", inner_tags, form_options)
@@ -538,24 +539,47 @@ module ActionView
538
539
  # current_page?('http://www.example.com/shop/checkout?order=desc&page=1')
539
540
  # # => true
540
541
  #
541
- # Let's say we're in the <tt>http://www.example.com/products</tt> action with method POST in case of invalid product.
542
+ # Different actions may share the same URL path but have a different HTTP method. Let's say we
543
+ # sent a POST to <tt>http://www.example.com/products</tt> and rendered a validation error.
542
544
  #
543
545
  # current_page?(controller: 'product', action: 'index')
544
546
  # # => false
545
547
  #
548
+ # current_page?(controller: 'product', action: 'create')
549
+ # # => false
550
+ #
551
+ # current_page?(controller: 'product', action: 'create', method: :post)
552
+ # # => true
553
+ #
554
+ # current_page?(controller: 'product', action: 'index', method: [:get, :post])
555
+ # # => true
556
+ #
546
557
  # We can also pass in the symbol arguments instead of strings.
547
558
  #
548
- def current_page?(options = nil, check_parameters: false, **options_as_kwargs)
559
+ def current_page?(options = nil, check_parameters: false, method: :get, **options_as_kwargs)
549
560
  unless request
550
561
  raise "You cannot use helpers that need to determine the current " \
551
562
  "page unless your view context provides a Request object " \
552
563
  "in a #request method"
553
564
  end
554
565
 
555
- return false unless request.get? || request.head?
566
+ if options.is_a?(Hash)
567
+ check_parameters = options.delete(:check_parameters) { check_parameters }
568
+ method = options.delete(:method) { method }
569
+ else
570
+ options ||= options_as_kwargs
571
+ end
572
+
573
+ method_matches = case method
574
+ when :get
575
+ request.get? || request.head?
576
+ when Array
577
+ method.include?(request.method_symbol) || (method.include?(:get) && request.head?)
578
+ else
579
+ method == request.method_symbol
580
+ end
581
+ return false unless method_matches
556
582
 
557
- options ||= options_as_kwargs
558
- check_parameters ||= options.is_a?(Hash) && options.delete(:check_parameters)
559
583
  url_string = URI::RFC2396_PARSER.unescape(url_for(options)).force_encoding(Encoding::BINARY)
560
584
 
561
585
  # We ignore any extra parameters in the request_uri if the
@@ -751,14 +775,18 @@ module ActionView
751
775
  else
752
776
  token
753
777
  end
754
- tag(:input, type: "hidden", name: request_forgery_protection_token.to_s, value: token, autocomplete: "off")
778
+ options = { type: "hidden", name: request_forgery_protection_token.to_s, value: token }
779
+ options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete
780
+ tag(:input, **options)
755
781
  else
756
782
  ""
757
783
  end
758
784
  end
759
785
 
760
786
  def method_tag(method)
761
- tag("input", type: "hidden", name: "_method", value: method.to_s, autocomplete: "off")
787
+ options = { type: "hidden", name: "_method", value: method.to_s }
788
+ options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete
789
+ tag("input", **options)
762
790
  end
763
791
 
764
792
  # Returns an array of hashes each containing :name and :value keys
@@ -43,6 +43,9 @@
43
43
  hour: "Hour"
44
44
  minute: "Minute"
45
45
  second: "Seconds"
46
+ relative:
47
+ future: "in %{time}"
48
+ past: "%{time} ago"
46
49
 
47
50
  helpers:
48
51
  select:
@@ -3,10 +3,7 @@
3
3
  require "active_support/log_subscriber"
4
4
 
5
5
  module ActionView
6
- # = Action View Log Subscriber
7
- #
8
- # Provides functionality so that \Rails can output logs from Action View.
9
- class LogSubscriber < ActiveSupport::LogSubscriber
6
+ class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc:
10
7
  VIEWS_PATTERN = /^app\/views\//
11
8
 
12
9
  def initialize
@@ -72,8 +72,19 @@ module ActionView
72
72
  end
73
73
 
74
74
  config.after_initialize do |app|
75
+ ActionView::Helpers::AssetTagHelper.auto_include_nonce_for_scripts = app.config.content_security_policy_nonce_auto && app.config.content_security_policy_nonce_directives.intersect?(["script-src", "script-src-elem", "script-src-attr"]) && app.config.content_security_policy_nonce_generator.present?
76
+ ActionView::Helpers::AssetTagHelper.auto_include_nonce_for_styles = app.config.content_security_policy_nonce_auto && app.config.content_security_policy_nonce_directives.intersect?(["style-src", "style-src-elem", "style-src-attr"]) && app.config.content_security_policy_nonce_generator.present?
77
+ ActionView::Helpers::JavaScriptHelper.auto_include_nonce = app.config.content_security_policy_nonce_auto && app.config.content_security_policy_nonce_directives.intersect?(["script-src", "script-src-elem", "script-src-attr"]) && app.config.content_security_policy_nonce_generator.present?
78
+ end
79
+
80
+ config.after_initialize do |app|
81
+ config.after_initialize do
82
+ ActionView.render_tracker = config.action_view.render_tracker
83
+ end
84
+
75
85
  ActiveSupport.on_load(:action_view) do
76
86
  app.config.action_view.each do |k, v|
87
+ next if k == :render_tracker
77
88
  send "#{k}=", v
78
89
  end
79
90
  end
@@ -101,6 +101,27 @@ module ActionView
101
101
  end
102
102
  end
103
103
 
104
+ # The DOM target convention is to concatenate any number of parameters into a string.
105
+ # Records are passed through dom_id, while string and symbols are retained.
106
+ #
107
+ # dom_target(Post.find(45)) # => "post_45"
108
+ # dom_target(Post.find(45), :edit) # => "post_45_edit"
109
+ # dom_target(Post.find(45), :edit, :special) # => "post_45_edit_special"
110
+ # dom_target(Post.find(45), Comment.find(1)) # => "post_45_comment_1"
111
+ def dom_target(*objects)
112
+ objects.map! do |object|
113
+ case object
114
+ when Symbol, String
115
+ object
116
+ when Class
117
+ dom_class(object)
118
+ else
119
+ dom_id(object)
120
+ end
121
+ end
122
+ objects.join(JOIN)
123
+ end
124
+
104
125
  private
105
126
  # Returns a string representation of the key attribute(s) that is suitable for use in an HTML DOM id.
106
127
  # This can be overwritten to customize the default generated string representation if desired.
@@ -48,6 +48,22 @@ module ActionView
48
48
  #
49
49
  # <%= render partial: "account", locals: { user: @buyer } %>
50
50
  #
51
+ # == \Rendering variants of a partial
52
+ #
53
+ # The <tt>:variants</tt> option can be used to render a different template variant of a partial. For instance:
54
+ #
55
+ # <%= render partial: "account", variants: :mobile %>
56
+ #
57
+ # This will render <tt>_account.html+mobile.erb</tt>. This option also accepts multiple variants
58
+ # like so:
59
+ #
60
+ # <%= render partial: "account", variants: [:desktop, :mobile] %>
61
+ #
62
+ # This will look for the following templates and render the first one that exists:
63
+ # * <tt>_account.html+desktop.erb</tt>
64
+ # * <tt>_account.html+mobile.erb</tt>
65
+ # * <tt>_account.html.erb</tt>
66
+ #
51
67
  # == \Rendering a collection of partials
52
68
  #
53
69
  # The example of partial use describes a familiar pattern where a template needs to iterate over an array and
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/structured_event_subscriber"
4
+
5
+ module ActionView
6
+ class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc:
7
+ VIEWS_PATTERN = /^app\/views\//
8
+
9
+ def initialize
10
+ @root = nil
11
+ super
12
+ end
13
+
14
+ def render_template(event)
15
+ emit_debug_event("action_view.render_template",
16
+ identifier: from_rails_root(event.payload[:identifier]),
17
+ layout: from_rails_root(event.payload[:layout]),
18
+ duration_ms: event.duration.round(2),
19
+ gc_ms: event.gc_time.round(2),
20
+ )
21
+ end
22
+ debug_only :render_template
23
+
24
+ def render_partial(event)
25
+ emit_debug_event("action_view.render_partial",
26
+ identifier: from_rails_root(event.payload[:identifier]),
27
+ layout: from_rails_root(event.payload[:layout]),
28
+ duration_ms: event.duration.round(2),
29
+ gc_ms: event.gc_time.round(2),
30
+ cache_hit: event.payload[:cache_hit],
31
+ )
32
+ end
33
+ debug_only :render_partial
34
+
35
+ def render_layout(event)
36
+ emit_event("action_view.render_layout",
37
+ identifier: from_rails_root(event.payload[:identifier]),
38
+ duration_ms: event.duration.round(2),
39
+ gc_ms: event.gc_time.round(2),
40
+ )
41
+ end
42
+ debug_only :render_layout
43
+
44
+ def render_collection(event)
45
+ emit_debug_event("action_view.render_collection",
46
+ identifier: from_rails_root(event.payload[:identifier] || "templates"),
47
+ layout: from_rails_root(event.payload[:layout]),
48
+ duration_ms: event.duration.round(2),
49
+ gc_ms: event.gc_time.round(2),
50
+ cache_hits: event.payload[:cache_hits],
51
+ count: event.payload[:count],
52
+ )
53
+ end
54
+ debug_only :render_collection
55
+
56
+ module Utils # :nodoc:
57
+ private
58
+ def from_rails_root(string)
59
+ return unless string
60
+
61
+ string = string.sub("#{rails_root}/", "")
62
+ string.sub!(VIEWS_PATTERN, "")
63
+ string
64
+ end
65
+
66
+ def rails_root # :doc:
67
+ @root ||= Rails.try(:root)
68
+ end
69
+ end
70
+
71
+ include Utils
72
+
73
+ class Start # :nodoc:
74
+ include Utils
75
+
76
+ def start(name, id, payload)
77
+ ActiveSupport.event_reporter.debug("action_view.render_start",
78
+ is_layout: name == "render_layout.action_view",
79
+ identifier: from_rails_root(payload[:identifier]),
80
+ layout: from_rails_root(payload[:layout]),
81
+ )
82
+ end
83
+
84
+ def finish(name, id, payload)
85
+ end
86
+ end
87
+
88
+ def self.attach_to(*)
89
+ ActiveSupport::Notifications.subscribe("render_template.action_view", Start.new)
90
+ ActiveSupport::Notifications.subscribe("render_layout.action_view", Start.new)
91
+
92
+ super
93
+ end
94
+ end
95
+ end
96
+
97
+ ActionView::StructuredEventSubscriber.attach_to :action_view
@@ -260,9 +260,13 @@ module ActionView
260
260
  end
261
261
 
262
262
  def message
263
- <<~MESSAGE
264
- Encountered a syntax error while rendering template: check #{@offending_code_string}
265
- MESSAGE
263
+ if template.is_a?(Template::Inline)
264
+ <<~MESSAGE
265
+ Encountered a syntax error while rendering template: check #{@offending_code_string}
266
+ MESSAGE
267
+ else
268
+ "Encountered a syntax error while rendering template located at: #{template.short_identifier}"
269
+ end
266
270
  end
267
271
 
268
272
  def annotated_source_code
@@ -40,17 +40,19 @@ module ActionView
40
40
 
41
41
  # Translate an error location returned by ErrorHighlight to the correct
42
42
  # 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
+
45
48
  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
+
50
54
  spot[:first_lineno] -= lineno_delta
51
55
  spot[:last_lineno] -= lineno_delta
52
-
53
- column_delta = spot[:first_column] - new_first_column
54
56
  spot[:first_column] -= column_delta
55
57
  spot[:last_column] -= column_delta
56
58
  spot[:script_lines] = source_lines
@@ -84,7 +86,7 @@ module ActionView
84
86
  }
85
87
 
86
88
  if ActionView::Base.annotate_rendered_view_with_filenames && template.format == :html
87
- options[:preamble] = "@output_buffer.safe_append='<!-- BEGIN #{template.short_identifier} -->';"
89
+ options[:preamble] = "@output_buffer.safe_append='<!-- BEGIN #{template.short_identifier}\n-->';"
88
90
  options[:postamble] = "@output_buffer.safe_append='<!-- END #{template.short_identifier} -->';@output_buffer"
89
91
  end
90
92
 
@@ -107,6 +109,28 @@ module ActionView
107
109
  raise WrongEncodingError.new(string, string.encoding)
108
110
  end
109
111
 
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
+
110
134
  # Find which token in the source template spans the byte range that
111
135
  # contains the error_column, then return the offset compared to the
112
136
  # original source template.
@@ -137,7 +161,7 @@ module ActionView
137
161
  matched_str = true
138
162
 
139
163
  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
141
165
  end
142
166
 
143
167
  compiled.pos += str.bytesize
@@ -152,8 +176,9 @@ module ActionView
152
176
 
153
177
  def offset_source_tokens(source_tokens)
154
178
  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
157
182
  source_offset += str.bytesize
158
183
  result
159
184
  end