mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -07:00
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 [email protected]?(/\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.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
|
||||
module Liquid
|
||||
class BlockBody
|
||||
LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o
|
||||
@@ -122,8 +121,6 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
def self.blank_string?(str)
|
||||
str.match?(WhitespaceOrNothing)
|
||||
end
|
||||
@@ -257,10 +254,10 @@ module Liquid
|
||||
resource_limits = context.resource_limits
|
||||
resource_limits.increment_render_score(@nodelist.length)
|
||||
|
||||
# Check if we need per-node write score tracking
|
||||
check_write = resource_limits.render_length_limit || resource_limits.last_capture_length
|
||||
|
||||
# Hot render loop — split on check_write so the common case (no resource
|
||||
# limits) pays zero branch cost per node.
|
||||
idx = 0
|
||||
if resource_limits.render_length_limit || resource_limits.last_capture_length
|
||||
while (node = @nodelist[idx])
|
||||
if node.instance_of?(String)
|
||||
output << node
|
||||
@@ -269,8 +266,18 @@ module Liquid
|
||||
break if context.interrupt?
|
||||
end
|
||||
idx += 1
|
||||
|
||||
resource_limits.increment_write_score(output) if check_write
|
||||
resource_limits.increment_write_score(output)
|
||||
end
|
||||
else
|
||||
while (node = @nodelist[idx])
|
||||
if node.instance_of?(String)
|
||||
output << node
|
||||
else
|
||||
render_node(context, output, node)
|
||||
break if context.interrupt?
|
||||
end
|
||||
idx += 1
|
||||
end
|
||||
end
|
||||
|
||||
output
|
||||
@@ -283,7 +290,6 @@ module Liquid
|
||||
BlockBody.render_node(context, output, node)
|
||||
end
|
||||
|
||||
|
||||
def create_variable(token, parse_context)
|
||||
len = token.bytesize
|
||||
if len >= 4 && token.getbyte(len - 1) == Cursor::RCURLY && token.getbyte(len - 2) == Cursor::RCURLY
|
||||
|
||||
+10
-10
@@ -12,29 +12,29 @@ module Liquid
|
||||
# ByteTables::IDENT_START[b]
|
||||
module ByteTables
|
||||
# [a-zA-Z_] — valid first byte of an identifier
|
||||
IDENT_START = Array.new(256, false).tap { |t|
|
||||
IDENT_START = Array.new(256, false).tap do |t|
|
||||
(97..122).each { |b| t[b] = true } # a-z
|
||||
(65..90).each { |b| t[b] = true } # A-Z
|
||||
t[95] = true # _
|
||||
}.freeze
|
||||
end.freeze
|
||||
|
||||
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
|
||||
IDENT_CONT = Array.new(256, false).tap { |t|
|
||||
IDENT_CONT = Array.new(256, false).tap do |t|
|
||||
(97..122).each { |b| t[b] = true } # a-z
|
||||
(65..90).each { |b| t[b] = true } # A-Z
|
||||
(48..57).each { |b| t[b] = true } # 0-9
|
||||
t[95] = true # _
|
||||
t[45] = true # -
|
||||
}.freeze
|
||||
end.freeze
|
||||
|
||||
# [0-9] — ASCII digit
|
||||
DIGIT = Array.new(256, false).tap { |t|
|
||||
DIGIT = Array.new(256, false).tap do |t|
|
||||
(48..57).each { |b| t[b] = true }
|
||||
}.freeze
|
||||
end.freeze
|
||||
|
||||
# [ \t\n\r\f] — ASCII whitespace
|
||||
WHITESPACE = Array.new(256, false).tap { |t|
|
||||
[32, 9, 10, 13, 12].each { |b| t[b] = true } # space, tab, \n, \r, \f
|
||||
}.freeze
|
||||
# [ \t\n\v\f\r] — ASCII whitespace (mirrors Ruby's \s)
|
||||
WHITESPACE = Array.new(256, false).tap do |t|
|
||||
[32, 9, 10, 11, 12, 13].each { |b| t[b] = true } # space, tab, \n, \v, \f, \r
|
||||
end.freeze
|
||||
end
|
||||
end
|
||||
|
||||
+10
-15
@@ -63,27 +63,20 @@ module Liquid
|
||||
end
|
||||
|
||||
# ── Whitespace ──────────────────────────────────────────────────
|
||||
# Skip spaces/tabs/newlines/cr, return count of newlines skipped
|
||||
# Skip spaces/tabs/newlines/cr
|
||||
def skip_ws
|
||||
nl = 0
|
||||
while (b = @ss.peek_byte)
|
||||
case b
|
||||
when SPACE, TAB, CR, FF then @ss.scan_byte
|
||||
when NL then @ss.scan_byte
|
||||
nl += 1
|
||||
when SPACE, TAB, CR, FF, NL then @ss.scan_byte
|
||||
else break
|
||||
end
|
||||
end
|
||||
nl
|
||||
end
|
||||
|
||||
# Check if remaining bytes are all whitespace
|
||||
# Check if remaining bytes are all whitespace (or EOS).
|
||||
# exist?(/\S/) returns nil when no non-whitespace remains, without advancing position.
|
||||
def rest_blank?
|
||||
saved = @ss.pos
|
||||
@ss.skip(/\s*/)
|
||||
result = @ss.eos?
|
||||
@ss.pos = saved
|
||||
result
|
||||
!@ss.exist?(/\S/)
|
||||
end
|
||||
|
||||
# Regex for identifier: [a-zA-Z_][\w-]*\??
|
||||
@@ -232,7 +225,8 @@ module Liquid
|
||||
b = token.getbyte(pos)
|
||||
case b
|
||||
when SPACE, TAB, CR, FF then pos += 1
|
||||
when NL then pos += 1; nl += 1
|
||||
when NL then pos += 1
|
||||
nl += 1
|
||||
else break
|
||||
end
|
||||
end
|
||||
@@ -247,6 +241,7 @@ module Liquid
|
||||
while pos < len
|
||||
b = token.getbyte(pos)
|
||||
break unless ByteTables::IDENT_CONT[b]
|
||||
|
||||
pos += 1
|
||||
end
|
||||
pos += 1 if pos < len && token.getbyte(pos) == QMARK
|
||||
@@ -260,7 +255,8 @@ module Liquid
|
||||
b = token.getbyte(pos)
|
||||
case b
|
||||
when SPACE, TAB, CR, FF then pos += 1
|
||||
when NL then pos += 1; nl += 1
|
||||
when NL then pos += 1
|
||||
nl += 1
|
||||
else break
|
||||
end
|
||||
end
|
||||
@@ -317,6 +313,5 @@ module Liquid
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -277,16 +277,18 @@ module Liquid
|
||||
|
||||
return input if words + 1 > MAX_I32
|
||||
|
||||
# Build result incrementally — avoids split() array + string allocations
|
||||
# Scan words tracking byte positions; build the normalized (single-space)
|
||||
# result string only when truncation is actually needed.
|
||||
len = input.bytesize
|
||||
pos = 0
|
||||
word_count = 0
|
||||
result = nil
|
||||
# Flat array of [start, end, start, end, ...] for up to `words` words.
|
||||
# Avoids allocating a result string in the common no-truncation case.
|
||||
positions = []
|
||||
|
||||
# Skip leading whitespace
|
||||
while pos < len
|
||||
b = input.getbyte(pos)
|
||||
break unless ByteTables::WHITESPACE[b]
|
||||
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
|
||||
pos += 1
|
||||
end
|
||||
|
||||
@@ -294,34 +296,49 @@ module Liquid
|
||||
word_start = pos
|
||||
word_count += 1
|
||||
|
||||
# Skip non-whitespace chars (word body)
|
||||
# Scan to end of word
|
||||
while pos < len
|
||||
b = input.getbyte(pos)
|
||||
break if ByteTables::WHITESPACE[b]
|
||||
break if ByteTables::WHITESPACE[input.getbyte(pos)]
|
||||
pos += 1
|
||||
end
|
||||
|
||||
if word_count > words
|
||||
# Truncate — result already has the first N words
|
||||
truncate_string = Utils.to_s(truncate_string)
|
||||
return result.concat(truncate_string)
|
||||
end
|
||||
|
||||
# Append word to result (only allocate result when we know truncation is possible)
|
||||
if result
|
||||
result << " " << input.byteslice(word_start, pos - word_start)
|
||||
if word_count <= words
|
||||
positions.push(word_start, pos) # [start, end, start, end, ...]
|
||||
else
|
||||
result = +input.byteslice(word_start, pos - word_start)
|
||||
# Truncation confirmed — build normalized result from stored positions
|
||||
result = +input.byteslice(positions[0], positions[1] - positions[0])
|
||||
i = 2
|
||||
while i < positions.length
|
||||
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
|
||||
i += 2
|
||||
end
|
||||
return result << Utils.to_s(truncate_string)
|
||||
end
|
||||
|
||||
# Skip whitespace between words
|
||||
while pos < len
|
||||
b = input.getbyte(pos)
|
||||
break unless ByteTables::WHITESPACE[b]
|
||||
break unless ByteTables::WHITESPACE[input.getbyte(pos)]
|
||||
pos += 1
|
||||
end
|
||||
end
|
||||
|
||||
# Fewer words than requested — no truncation needed, return original unchanged.
|
||||
return input if word_count < words
|
||||
|
||||
# Exactly `words` words. Ruby's split(" ", words+1) would produce a words+1-th
|
||||
# empty element when input has trailing whitespace, triggering the truncation path.
|
||||
# Match that behaviour: if the input ends with whitespace, normalize and append
|
||||
# truncate_string even though no word was cut.
|
||||
if len > 0 && ByteTables::WHITESPACE[input.getbyte(len - 1)]
|
||||
result = +input.byteslice(positions[0], positions[1] - positions[0])
|
||||
i = 2
|
||||
while i < positions.length
|
||||
result << " " << input.byteslice(positions[i], positions[i + 1] - positions[i])
|
||||
i += 2
|
||||
end
|
||||
return result << Utils.to_s(truncate_string)
|
||||
end
|
||||
|
||||
input
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user