Skip to content

Commit 731f64d

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 84779f8 commit 731f64d

File tree

4 files changed

+79
-61
lines changed

4 files changed

+79
-61
lines changed

lib/liquid/block_body.rb

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# frozen_string_literal: true
22

3-
43
module Liquid
54
class BlockBody
65
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
@@ -122,8 +121,6 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag)
122121
end
123122
end
124123

125-
126-
127124
def self.blank_string?(str)
128125
str.match?(WhitespaceOrNothing)
129126
end
@@ -257,20 +254,30 @@ def render_to_output_buffer(context, output)
257254
resource_limits = context.resource_limits
258255
resource_limits.increment_render_score(@nodelist.length)
259256

260-
# Check if we need per-node write score tracking
261-
check_write = resource_limits.render_length_limit || resource_limits.last_capture_length
262-
257+
# Hot render loop — split on check_write so the common case (no resource
258+
# limits) pays zero branch cost per node.
263259
idx = 0
264-
while (node = @nodelist[idx])
265-
if node.instance_of?(String)
266-
output << node
267-
else
268-
render_node(context, output, node)
269-
break if context.interrupt?
260+
if resource_limits.render_length_limit || resource_limits.last_capture_length
261+
while (node = @nodelist[idx])
262+
if node.instance_of?(String)
263+
output << node
264+
else
265+
render_node(context, output, node)
266+
break if context.interrupt?
267+
end
268+
idx += 1
269+
resource_limits.increment_write_score(output)
270+
end
271+
else
272+
while (node = @nodelist[idx])
273+
if node.instance_of?(String)
274+
output << node
275+
else
276+
render_node(context, output, node)
277+
break if context.interrupt?
278+
end
279+
idx += 1
270280
end
271-
idx += 1
272-
273-
resource_limits.increment_write_score(output) if check_write
274281
end
275282

276283
output
@@ -283,7 +290,6 @@ def render_node(context, output, node)
283290
BlockBody.render_node(context, output, node)
284291
end
285292

286-
287293
def create_variable(token, parse_context)
288294
len = token.bytesize
289295
if len >= 4 && token.getbyte(len - 1) == Cursor::RCURLY && token.getbyte(len - 2) == Cursor::RCURLY

lib/liquid/byte_tables.rb

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,29 @@ module Liquid
1212
# ByteTables::IDENT_START[b]
1313
module ByteTables
1414
# [a-zA-Z_] — valid first byte of an identifier
15-
IDENT_START = Array.new(256, false).tap { |t|
15+
IDENT_START = Array.new(256, false).tap do |t|
1616
(97..122).each { |b| t[b] = true } # a-z
1717
(65..90).each { |b| t[b] = true } # A-Z
18-
t[95] = true # _
19-
}.freeze
18+
t[95] = true # _
19+
end.freeze
2020

2121
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
22-
IDENT_CONT = Array.new(256, false).tap { |t|
22+
IDENT_CONT = Array.new(256, false).tap do |t|
2323
(97..122).each { |b| t[b] = true } # a-z
2424
(65..90).each { |b| t[b] = true } # A-Z
2525
(48..57).each { |b| t[b] = true } # 0-9
2626
t[95] = true # _
2727
t[45] = true # -
28-
}.freeze
28+
end.freeze
2929

3030
# [0-9] — ASCII digit
31-
DIGIT = Array.new(256, false).tap { |t|
31+
DIGIT = Array.new(256, false).tap do |t|
3232
(48..57).each { |b| t[b] = true }
33-
}.freeze
33+
end.freeze
3434

35-
# [ \t\n\r\f] — ASCII whitespace
36-
WHITESPACE = Array.new(256, false).tap { |t|
37-
[32, 9, 10, 13, 12].each { |b| t[b] = true } # space, tab, \n, \r, \f
38-
}.freeze
35+
# [ \t\n\v\f\r] — ASCII whitespace (mirrors Ruby's \s)
36+
WHITESPACE = Array.new(256, false).tap do |t|
37+
[32, 9, 10, 11, 12, 13].each { |b| t[b] = true } # space, tab, \n, \v, \f, \r
38+
end.freeze
3939
end
4040
end

lib/liquid/cursor.rb

Lines changed: 10 additions & 15 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-]*\??
@@ -232,7 +225,8 @@ def parse_tag_token(token)
232225
b = token.getbyte(pos)
233226
case b
234227
when SPACE, TAB, CR, FF then pos += 1
235-
when NL then pos += 1; nl += 1
228+
when NL then pos += 1
229+
nl += 1
236230
else break
237231
end
238232
end
@@ -247,6 +241,7 @@ def parse_tag_token(token)
247241
while pos < len
248242
b = token.getbyte(pos)
249243
break unless ByteTables::IDENT_CONT[b]
244+
250245
pos += 1
251246
end
252247
pos += 1 if pos < len && token.getbyte(pos) == QMARK
@@ -260,7 +255,8 @@ def parse_tag_token(token)
260255
b = token.getbyte(pos)
261256
case b
262257
when SPACE, TAB, CR, FF then pos += 1
263-
when NL then pos += 1; nl += 1
258+
when NL then pos += 1
259+
nl += 1
264260
else break
265261
end
266262
end
@@ -317,6 +313,5 @@ def parse_simple_condition
317313

318314
true
319315
end
320-
321316
end
322317
end

lib/liquid/standardfilters.rb

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -277,51 +277,68 @@ 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+
# Fewer words than requested — no truncation needed, return original unchanged.
326+
return input if word_count < words
327+
328+
# Exactly `words` words. Ruby's split(" ", words+1) would produce a words+1-th
329+
# empty element when input has trailing whitespace, triggering the truncation path.
330+
# Match that behaviour: if the input ends with whitespace, normalize and append
331+
# truncate_string even though no word was cut.
332+
if len > 0 && ByteTables::WHITESPACE[input.getbyte(len - 1)]
333+
result = +input.byteslice(positions[0], positions[1] - positions[0])
334+
i = 2
335+
while i < positions.length
336+
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
337+
i += 2
338+
end
339+
return result << Utils.to_s(truncate_string)
340+
end
341+
325342
input
326343
end
327344

0 commit comments

Comments
 (0)