mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -07:00
Skip the expensive recursive VariableParser regex for simple lookups like 'product.title' (~90% of real-world cases). SIMPLE_LOOKUP_RE validates the input is a plain a.b.c chain (no brackets, no quotes). On match, byte-walks on dots to split segments instead of invoking the regex engine. Falls through to the original VariableParser scan for complex inputs.
154 lines
5.0 KiB
Ruby
154 lines
5.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Liquid
|
|
class VariableLookup
|
|
COMMAND_METHODS = ['size', 'first', 'last'].freeze
|
|
|
|
# Matches simple identifier chains: name(.name)* with no brackets/quotes
|
|
SIMPLE_LOOKUP_RE = /\A[\w-]+\??(?:\.[\w-]+\??)*\z/
|
|
|
|
# Returns true when markup is a simple dotted identifier chain that the
|
|
# fast path in initialize can handle. Accepts:
|
|
# - Single names: "product", "item"
|
|
# - Dotted chains: "product.title", "cart.items.first"
|
|
# - Question-mark suffixes: "product.available?"
|
|
# - Hyphens in names: "my-var.some-field"
|
|
# Rejects (falls through to VariableParser regex):
|
|
# - Bracket lookups: "product[0]", "hash['key']"
|
|
# - Quoted strings, empty input, leading/trailing dots
|
|
# Fallback: when this returns false, initialize uses the original
|
|
# markup.scan(VariableParser) path — behavior is identical to
|
|
# the pre-optimization code for any input the fast path rejects.
|
|
def self.simple_lookup?(markup)
|
|
markup.bytesize > 0 && markup.match?(SIMPLE_LOOKUP_RE)
|
|
end
|
|
|
|
attr_reader :name, :lookups
|
|
|
|
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
|
|
new(markup, string_scanner, cache)
|
|
end
|
|
|
|
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
|
|
if self.class.simple_lookup?(markup)
|
|
dot_pos = markup.index('.')
|
|
if dot_pos.nil?
|
|
@name = markup
|
|
@lookups = Const::EMPTY_ARRAY
|
|
@command_flags = 0
|
|
return
|
|
end
|
|
|
|
@name = markup.byteslice(0, dot_pos)
|
|
@lookups = []
|
|
@command_flags = 0
|
|
pos = dot_pos + 1
|
|
len = markup.bytesize
|
|
while pos < len
|
|
seg_start = pos
|
|
pos += 1 while pos < len && markup.getbyte(pos) != ByteTables::DOT
|
|
seg = markup.byteslice(seg_start, pos - seg_start)
|
|
@command_flags |= 1 << @lookups.length if COMMAND_METHODS.include?(seg)
|
|
@lookups << seg
|
|
pos += 1 # skip dot
|
|
end
|
|
return
|
|
end
|
|
|
|
lookups = markup.scan(VariableParser)
|
|
|
|
name = lookups.shift
|
|
if name&.start_with?('[') && name&.end_with?(']')
|
|
name = Expression.parse(
|
|
name[1..-2],
|
|
string_scanner,
|
|
cache,
|
|
)
|
|
end
|
|
@name = name
|
|
|
|
@lookups = lookups
|
|
@command_flags = 0
|
|
|
|
@lookups.each_index do |i|
|
|
lookup = lookups[i]
|
|
if lookup&.start_with?('[') && lookup&.end_with?(']')
|
|
lookups[i] = Expression.parse(
|
|
lookup[1..-2],
|
|
string_scanner,
|
|
cache,
|
|
)
|
|
elsif COMMAND_METHODS.include?(lookup)
|
|
@command_flags |= 1 << i
|
|
end
|
|
end
|
|
end
|
|
|
|
def lookup_command?(lookup_index)
|
|
@command_flags & (1 << lookup_index) != 0
|
|
end
|
|
|
|
def evaluate(context)
|
|
name = context.evaluate(@name)
|
|
object = context.find_variable(name)
|
|
|
|
@lookups.each_index do |i|
|
|
key = context.evaluate(@lookups[i])
|
|
|
|
# Cast "key" to its liquid value to enable it to act as a primitive value
|
|
key = Liquid::Utils.to_liquid_value(key)
|
|
|
|
# If object is a hash- or array-like object we look for the
|
|
# presence of the key and if its available we return it
|
|
if object.respond_to?(:[]) &&
|
|
((object.respond_to?(:key?) && object.key?(key)) ||
|
|
(object.respond_to?(:fetch) && key.is_a?(Integer)))
|
|
|
|
# if its a proc we will replace the entry with the proc
|
|
res = context.lookup_and_evaluate(object, key)
|
|
object = res.to_liquid
|
|
|
|
# Some special cases. If the part wasn't in square brackets and
|
|
# no key with the same name was found we interpret following calls
|
|
# as commands and call them on the current object
|
|
elsif lookup_command?(i) && object.respond_to?(key)
|
|
object = object.send(key).to_liquid
|
|
|
|
# Handle string first/last like ActiveSupport does (returns first/last character)
|
|
# ActiveSupport returns "" for empty strings, not nil
|
|
elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last")
|
|
object = key == "first" ? (object[0] || "") : (object[-1] || "")
|
|
|
|
# No key was present with the desired value and it wasn't one of the directly supported
|
|
# keywords either. The only thing we got left is to return nil or
|
|
# raise an exception if `strict_variables` option is set to true
|
|
else
|
|
return nil unless context.strict_variables
|
|
raise Liquid::UndefinedVariable, "undefined variable #{key}"
|
|
end
|
|
|
|
# If we are dealing with a drop here we have to
|
|
object.context = context if object.respond_to?(:context=)
|
|
end
|
|
|
|
object
|
|
end
|
|
|
|
def ==(other)
|
|
self.class == other.class && state == other.state
|
|
end
|
|
|
|
protected
|
|
|
|
def state
|
|
[@name, @lookups, @command_flags]
|
|
end
|
|
|
|
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
|
def children
|
|
@node.lookups
|
|
end
|
|
end
|
|
end
|
|
end
|