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="::", where line is 1-based and col is the 1-based UTF-16 column of the <. That is exactly the coordinate space src-tauri/src/live_preview/loc_resolve.rs resolves against (+approx_byte+ counts UTF-16 units, 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

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