Skip to content

Commit a8ba498

Browse files
committed
Improves performance on hot parse and render paths
Splits the render loop in block_body.rb on the loop-invariant check_write condition. The common case (no resource limits) now pays zero branch cost per node. Rewrites truncatewords in standardfilters.rb to scan word positions into a flat int array and builds the result string only when truncation is confirmed. No string allocation in the common no-truncation case beyond the array itself. Simplifies rest_blank? in cursor.rb: replaces manual save/skip/restore of StringScanner position with !@ss.exist?(/\S/). exist? does not advance position; returns nil when no non-whitespace remains; handles EOS correctly. Removes the nl newline counter from skip_ws in cursor.rb -- all callers discarded the return value. NL now handled in the same when-branch as the other whitespace bytes.
1 parent ffa8394 commit a8ba498

3 files changed

Lines changed: 49 additions & 45 deletions

File tree

lib/liquid/block_body.rb

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,20 +235,31 @@ def render_to_output_buffer(context, output)
235235
resource_limits = context.resource_limits
236236
resource_limits.increment_render_score(@nodelist.length)
237237

238-
# Check if we need per-node write score tracking
239-
check_write = resource_limits.render_length_limit || resource_limits.last_capture_length
240-
241-
idx = 0
242-
while (node = @nodelist[idx])
243-
if node.instance_of?(String)
244-
output << node
245-
else
246-
render_node(context, output, node)
247-
break if context.interrupt?
238+
# Hot render loop — split on check_write so the common case (no resource
239+
# limits) pays zero branch cost per node.
240+
if resource_limits.render_length_limit || resource_limits.last_capture_length
241+
idx = 0
242+
while (node = @nodelist[idx])
243+
if node.instance_of?(String)
244+
output << node
245+
else
246+
render_node(context, output, node)
247+
break if context.interrupt?
248+
end
249+
idx += 1
250+
resource_limits.increment_write_score(output)
251+
end
252+
else
253+
idx = 0
254+
while (node = @nodelist[idx])
255+
if node.instance_of?(String)
256+
output << node
257+
else
258+
render_node(context, output, node)
259+
break if context.interrupt?
260+
end
261+
idx += 1
248262
end
249-
idx += 1
250-
251-
resource_limits.increment_write_score(output) if check_write
252263
end
253264

254265
output

lib/liquid/cursor.rb

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,27 +63,20 @@ def slice(start, len)
6363
end
6464

6565
# ── Whitespace ──────────────────────────────────────────────────
66-
# Skip spaces/tabs/newlines/cr, return count of newlines skipped
66+
# Skip spaces/tabs/newlines/cr
6767
def skip_ws
68-
nl = 0
6968
while (b = @ss.peek_byte)
7069
case b
71-
when SPACE, TAB, CR, FF then @ss.scan_byte
72-
when NL then @ss.scan_byte
73-
nl += 1
70+
when SPACE, TAB, CR, FF, NL then @ss.scan_byte
7471
else break
7572
end
7673
end
77-
nl
7874
end
7975

80-
# Check if remaining bytes are all whitespace
76+
# Check if remaining bytes are all whitespace (or EOS).
77+
# exist?(/\S/) returns nil when no non-whitespace remains, without advancing position.
8178
def rest_blank?
82-
saved = @ss.pos
83-
@ss.skip(/\s*/)
84-
result = @ss.eos?
85-
@ss.pos = saved
86-
result
79+
!@ss.exist?(/\S/)
8780
end
8881

8982
# Regex for identifier: [a-zA-Z_][\w-]*\??

lib/liquid/standardfilters.rb

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -277,52 +277,52 @@ def truncatewords(input, words = 15, truncate_string = "...")
277277

278278
return input if words + 1 > MAX_I32
279279

280-
# Build result incrementally — avoids split() array + string allocations
280+
# Scan words tracking byte positions; build the normalized (single-space)
281+
# result string only when truncation is actually needed.
281282
len = input.bytesize
282283
pos = 0
283284
word_count = 0
284-
result = nil
285+
# Flat array of [start, end, start, end, ...] for up to `words` words.
286+
# Avoids allocating a result string in the common no-truncation case.
287+
positions = []
285288

286289
# Skip leading whitespace
287290
while pos < len
288-
b = input.getbyte(pos)
289-
break unless ByteTables::WHITESPACE[b]
291+
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
290292
pos += 1
291293
end
292294

293295
while pos < len
294296
word_start = pos
295297
word_count += 1
296298

297-
# Skip non-whitespace chars (word body)
299+
# Scan to end of word
298300
while pos < len
299-
b = input.getbyte(pos)
300-
break if ByteTables::WHITESPACE[b]
301+
break if ByteTables::WHITESPACE[input.getbyte(pos)]
301302
pos += 1
302303
end
303304

304-
if word_count > words
305-
# Truncate — result already has the first N words
306-
truncate_string = Utils.to_s(truncate_string)
307-
return result.concat(truncate_string)
308-
end
309-
310-
# Append word to result (only allocate result when we know truncation is possible)
311-
if result
312-
result << " " << input.byteslice(word_start, pos - word_start)
305+
if word_count <= words
306+
positions.push(word_start, pos) # [start, end, start, end, ...]
313307
else
314-
result = +input.byteslice(word_start, pos - word_start)
308+
# Truncation confirmed — build normalized result from stored positions
309+
result = +input.byteslice(positions[0], positions[1] - positions[0])
310+
i = 2
311+
while i < positions.length
312+
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
313+
i += 2
314+
end
315+
return result << Utils.to_s(truncate_string)
315316
end
316317

317318
# Skip whitespace between words
318319
while pos < len
319-
b = input.getbyte(pos)
320-
break unless ByteTables::WHITESPACE[b]
320+
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
321321
pos += 1
322322
end
323323
end
324324

325-
input
325+
input # word_count <= words — no truncation needed, return input unchanged
326326
end
327327

328328
# @liquid_public_docs

0 commit comments

Comments
 (0)