mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-20 11:20:41 -07:00
Expression: byte-walk parse_number and guard strip allocation
parse_number: replace INTEGER_REGEX/FLOAT_REGEX matching and StringScanner loop with a single byte-walking pass using ByteTables::DIGIT. Avoids MatchData allocation and StringScanner reset on every call. Expression.parse: only call String#strip when leading/trailing whitespace is actually present (checked via ByteTables::WHITESPACE). Avoids allocating a new String on ~4,464 calls per compile.
This commit is contained in:
+73
-54
@@ -16,16 +16,9 @@ module Liquid
|
|||||||
'-' => VariableLookup.parse("-", nil).freeze,
|
'-' => VariableLookup.parse("-", nil).freeze,
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
DOT = ".".ord
|
|
||||||
ZERO = "0".ord
|
|
||||||
NINE = "9".ord
|
|
||||||
DASH = "-".ord
|
|
||||||
|
|
||||||
# Use an atomic group (?>...) to avoid pathological backtracing from
|
# Use an atomic group (?>...) to avoid pathological backtracing from
|
||||||
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
|
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
|
||||||
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
|
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
|
||||||
INTEGER_REGEX = /\A(-?\d+)\z/
|
|
||||||
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
|
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
|
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
|
||||||
@@ -35,7 +28,15 @@ module Liquid
|
|||||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||||
return unless markup
|
return unless markup
|
||||||
|
|
||||||
markup = markup.strip # markup can be a frozen string
|
# Guard: only call .strip when the first or last byte is whitespace.
|
||||||
|
# String#strip always allocates a new String, even when there's nothing
|
||||||
|
# to strip. ByteTables::WHITESPACE matches the same bytes that strip
|
||||||
|
# removes (space, \t, \n, \v, \f, \r, \x00). When neither end has
|
||||||
|
# whitespace, we skip the call and avoid ~4,464 allocations per compile.
|
||||||
|
first = markup.getbyte(0)
|
||||||
|
if first && (ByteTables::WHITESPACE[first] || ByteTables::WHITESPACE[markup.getbyte(markup.bytesize - 1)])
|
||||||
|
markup = markup.strip
|
||||||
|
end
|
||||||
|
|
||||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||||
(markup.start_with?("'") && markup.end_with?("'"))
|
(markup.start_with?("'") && markup.end_with?("'"))
|
||||||
@@ -71,56 +72,74 @@ module Liquid
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_number(markup, ss)
|
# Fast path for number parsing. Accepts:
|
||||||
# check if the markup is simple integer or float
|
# - Simple integers: "42", "-7"
|
||||||
case markup
|
# - Simple floats: "3.14", "-0.5"
|
||||||
when INTEGER_REGEX
|
# - Multi-dot floats (truncated at second dot): "1.2.3" → 1.2
|
||||||
return Integer(markup, 10)
|
# - Trailing-dot floats: "123." → 123.0
|
||||||
when FLOAT_REGEX
|
# Rejects (returns nil → caller treats as VariableLookup):
|
||||||
return markup.to_f
|
# - Non-numeric input: "hello", ""
|
||||||
end
|
# - Inputs with non-digit/non-dot bytes after the number: "1.2.3a"
|
||||||
|
# Fallback: nil return causes caller to fall through to VariableLookup.parse,
|
||||||
|
# which is the same path the old regex-based code took on non-match.
|
||||||
|
def parse_number(markup, _ss = nil)
|
||||||
|
len = markup.bytesize
|
||||||
|
return if len == 0
|
||||||
|
|
||||||
ss.string = markup
|
pos = 0
|
||||||
# the first byte must be a digit or a dash
|
first = markup.getbyte(pos)
|
||||||
byte = ss.scan_byte
|
|
||||||
|
|
||||||
return false if byte != DASH && (byte < ZERO || byte > NINE)
|
if first == ByteTables::DASH
|
||||||
|
pos += 1
|
||||||
|
return if pos >= len
|
||||||
|
return unless ByteTables::DIGIT[markup.getbyte(pos)]
|
||||||
|
|
||||||
if byte == DASH
|
pos += 1
|
||||||
peek_byte = ss.peek_byte
|
elsif ByteTables::DIGIT[first]
|
||||||
|
pos += 1
|
||||||
# if it starts with a dash, the next byte must be a digit
|
|
||||||
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
|
|
||||||
end
|
|
||||||
|
|
||||||
# The markup could be a float with multiple dots
|
|
||||||
first_dot_pos = nil
|
|
||||||
num_end_pos = nil
|
|
||||||
|
|
||||||
while (byte = ss.scan_byte)
|
|
||||||
return false if byte != DOT && (byte < ZERO || byte > NINE)
|
|
||||||
|
|
||||||
# we found our number and now we are just scanning the rest of the string
|
|
||||||
next if num_end_pos
|
|
||||||
|
|
||||||
if byte == DOT
|
|
||||||
if first_dot_pos.nil?
|
|
||||||
first_dot_pos = ss.pos
|
|
||||||
else
|
|
||||||
# we found another dot, so we know that the number ends here
|
|
||||||
num_end_pos = ss.pos - 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
num_end_pos = markup.length if ss.eos?
|
|
||||||
|
|
||||||
if num_end_pos
|
|
||||||
# number ends with a number "123.123"
|
|
||||||
markup.byteslice(0, num_end_pos).to_f
|
|
||||||
else
|
else
|
||||||
# number ends with a dot "123."
|
return
|
||||||
markup.byteslice(0, first_dot_pos).to_f
|
end
|
||||||
|
|
||||||
|
# Scan digits
|
||||||
|
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
|
||||||
|
|
||||||
|
# Consumed everything = simple integer
|
||||||
|
return Integer(markup, 10) if pos == len
|
||||||
|
|
||||||
|
# Check for dot — three float cases:
|
||||||
|
# 1. Simple float: "123.456" → markup.to_f
|
||||||
|
# 2. Multi-dot: "1.2.3.4" → truncate at second dot → 1.2
|
||||||
|
# 3. Trailing dot: "123." → truncate before dot → 123.0
|
||||||
|
return unless markup.getbyte(pos) == ByteTables::DOT
|
||||||
|
|
||||||
|
dot_pos = pos
|
||||||
|
pos += 1
|
||||||
|
digit_start = pos
|
||||||
|
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
|
||||||
|
|
||||||
|
if pos > digit_start && pos == len
|
||||||
|
# Case 1: simple float like "123.456"
|
||||||
|
markup.to_f
|
||||||
|
elsif pos > digit_start
|
||||||
|
# Case 2: multi-dot like "1.2.3.4" — find where the numeric
|
||||||
|
# portion ends. Reject if any non-digit, non-dot byte is found
|
||||||
|
# (e.g. "1.2.3a" → nil, matching the old regex-based behavior).
|
||||||
|
num_end = nil
|
||||||
|
check = pos
|
||||||
|
while check < len
|
||||||
|
b = markup.getbyte(check)
|
||||||
|
if b == ByteTables::DOT
|
||||||
|
num_end ||= check
|
||||||
|
elsif !ByteTables::DIGIT[b]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
check += 1
|
||||||
|
end
|
||||||
|
markup.byteslice(0, num_end || len).to_f
|
||||||
|
else
|
||||||
|
# Case 3: trailing dot like "123."
|
||||||
|
markup.byteslice(0, dot_pos).to_f
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user