Module: PortBay::ErbStamper::Stamp
- Defined in:
- lib/portbay/erb_stamper/stamp.rb
Overview
Source-location stamping for Rails ERB templates.
Rewrites a template's OWN source text so every host element's opening tag
carries data-pb-loc="pick_tag_open expects the <)
and exactly the shape agent.js::locFor parses. Do not invent a
second format here; packages/vite-plugin-loc/stamp.mjs is the
reference implementation and this is its ERB port.
The scan is textual and tolerant, the same posture as loc_resolve. It
only ever INSERTS an attribute immediately after a tag name; it never
deletes or reorders a byte, so the worst failure mode is a missing stamp,
never a corrupted template. All coordinates are computed against the
ORIGINAL text and the insertions are spliced in one pass, so an earlier
insertion can never shift a later coordinate.
Why ERB is the hard half
Django's template language cannot contain arbitrary code, so skipping its constructs is three literal string searches. ERB can contain ANY Ruby, which means a naive markup scanner walks straight into text that only looks like markup:
<% if width > 100 %> # a bare > that is not a tag end
<%= link_to "a<b", url %> # a bare < that is not a tag start
<%= tag.div(class: "x") %> # markup that has no source tag at all
<%# <div>commented out</div> %> # markup that never renders
Every one of those is handled by the same rule and it is the rule that makes this tractable: an <% ... %> region is skipped WHOLE, at the top level and inside an opening tag alike, and nothing inside one is ever read as markup or stamped. What comes out is that this scanner never needs to understand Ruby — it only needs to know where Ruby starts and stops, which ERB's own delimiters state outright.
The residual cost is real and is named in the feasibility read: an
element produced BY Ruby rather than written as markup (+tag.div+,
content_tag, link_to) has no opening tag in the source to stamp, so
it gets no coordinate. That is a missing stamp, which the editor already
handles by falling back to text search — not a wrong one.
Constant Summary collapse
- LOC_ATTR =
The attribute the Rust side reads.
"data-pb-loc"- NEVER_STAMP =
Elements never stamped even though they are standard HTML: document metadata (an attribute there is inert or invalid) and raw-text containers whose bodies are skipped wholesale anyway. Kept identical to +stamp.mjs+'s NEVER_STAMP — a lane that stamps a different element set than the others is a lane whose coordinates mean something different.
%w[html head base link meta title script style slot template].freeze
- RAW_TEXT =
Elements whose content is raw text, not markup.
%w[script style].freeze
- NAME_START =
/[A-Za-z]/.freeze
- NAME_REST =
/[A-Za-z0-9\-_:.]/.freeze
Class Method Summary collapse
-
.call(source, rel_path) ⇒ Object
Returns
sourcewith every stampable opening tag carrying LOC_ATTR, ornilwhen there was nothing to stamp. -
.coord(starts, source, idx) ⇒ Object
1-based [line, UTF-16 column] of
idx. -
.find_tag_end(source, i) ⇒ Object
Index just past the
>of the opening tag whose name ended ati, ornilif the tag never closes. -
.line_starts(source) ⇒ Object
Character index of the first character of each line, for the binary search in
coord. -
.raw_text_end(source, lower_name, i) ⇒ Object
Index of the
<of the matching close tag for a raw-text element, so a<inside inline JavaScript is never parsed as markup. -
.skip_erb(source, i) ⇒ Object
Index just past the
%>of the ERB region opening ati.
Class Method Details
.call(source, rel_path) ⇒ Object
Returns source with every stampable opening tag carrying LOC_ATTR, or
nil when there was nothing to stamp.
nil rather than the unchanged string is deliberate: the caller uses it
to hand ActionView back the exact object it was given, so a template
this module has no opinion about is not even re-allocated.
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/portbay/erb_stamper/stamp.rb', line 73 def call(source, rel_path) hits = [] starts = line_starts(source) i = 0 n = source.length while i < n ch = source[i] if ch == "<" && source[i + 1] == "%" i = skip_erb(source, i) next end unless ch == "<" i += 1 next end if source[i, 4] == "<!--" close = source.index("-->", i + 4) i = close.nil? ? n : close + 3 next end nxt = source[i + 1] if nxt == "!" || nxt == "?" close = find_tag_end(source, i + 1) i = close.nil? ? n : close next end if nxt == "/" close = find_tag_end(source, i + 2) i = close.nil? ? n : close next end unless nxt && NAME_START.match?(nxt) i += 1 next end j = i + 1 j += 1 while j < n && NAME_REST.match?(source[j]) name = source[(i + 1)...j] tag_end = find_tag_end(source, j) if tag_end.nil? i = j next end lower = name.downcase unless NEVER_STAMP.include?(lower) || source[j...tag_end].include?(LOC_ATTR) line, col = coord(starts, source, i) hits << [j, %( #{LOC_ATTR}="#{rel_path}:#{line}:#{col}")] end i = if RAW_TEXT.include?(lower) && source[(tag_end - 2)...tag_end] != "/>" raw_text_end(source, lower, tag_end) else tag_end end end return nil if hits.empty? out = +"" prev = 0 hits.each do |at, text| out << source[prev...at] << text prev = at end out << source[prev..] if prev < source.length out end |
.coord(starts, source, idx) ⇒ Object
1-based [line, UTF-16 column] of idx.
Ruby strings are sequences of characters, so a character outside the BMP counts as ONE here and as TWO in the UTF-16 space the Rust resolver and the browser's DOM both use. An emoji earlier on the same line as a tag would otherwise shift every column after it by one.
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/portbay/erb_stamper/stamp.rb', line 208 def coord(starts, source, idx) lo = 0 hi = starts.length - 1 while lo < hi mid = (lo + hi + 1) / 2 if starts[mid] <= idx lo = mid else hi = mid - 1 end end col = 1 source[starts[lo]...idx].each_char do |c| col += c.ord > 0xFFFF ? 2 : 1 end [lo + 1, col] end |
.find_tag_end(source, i) ⇒ Object
Index just past the > of the opening tag whose name ended at i, or
nil if the tag never closes.
Skips quoted attribute values and ERB regions, so neither a > in
alt="a > b" nor one in <% if a > b %> ends the tag
early.
158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/portbay/erb_stamper/stamp.rb', line 158 def find_tag_end(source, i) n = source.length while i < n ch = source[i] if ch == '"' || ch == "'" close = source.index(ch, i + 1) return nil if close.nil? i = close + 1 next end if ch == "<" && source[i + 1] == "%" skipped = skip_erb(source, i) return nil if skipped >= n i = skipped next end return i + 1 if ch == ">" i += 1 end nil end |
.line_starts(source) ⇒ Object
Character index of the first character of each line, for the binary
search in coord. Computed once per template rather than per tag.
192 193 194 195 196 197 198 199 200 |
# File 'lib/portbay/erb_stamper/stamp.rb', line 192 def line_starts(source) starts = [0] idx = source.index("\n") while idx starts << idx + 1 idx = source.index("\n", idx + 1) end starts end |
.raw_text_end(source, lower_name, i) ⇒ Object
Index of the < of the matching close tag for a raw-text element, so a
< inside inline JavaScript is never parsed as markup.
185 186 187 188 |
# File 'lib/portbay/erb_stamper/stamp.rb', line 185 def raw_text_end(source, lower_name, i) close = source.downcase.index("</#{lower_name}", i) close.nil? ? source.length : close end |
.skip_erb(source, i) ⇒ Object
Index just past the %> of the ERB region opening at i.
<%% is ERB's escape for a literal <% and opens no
region, so it is stepped over rather than skipped to a %> that belongs
to something else. An unterminated region ends the scan rather than
guessing — Erubi would refuse to compile that template anyway.
145 146 147 148 149 150 |
# File 'lib/portbay/erb_stamper/stamp.rb', line 145 def skip_erb(source, i) return i + 3 if source[i, 3] == "<%%" close = source.index("%>", i + 2) close.nil? ? source.length : close + 2 end |