mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-12 23:40:45 -07:00
226 lines
6.8 KiB
Ruby
226 lines
6.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Liquid
|
|
class VariableLookup
|
|
COMMAND_METHODS = ['size', 'first', 'last'].freeze
|
|
|
|
attr_reader :name, :lookups
|
|
|
|
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
|
|
new(markup, string_scanner, cache)
|
|
end
|
|
|
|
# Fast manual scanner replacing markup.scan(VariableParser)
|
|
# VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|[\w-]+\??/
|
|
# Splits "product.variants[0].title" into ["product", "variants", "[0]", "title"]
|
|
def self.scan_variable(markup)
|
|
result = []
|
|
pos = 0
|
|
len = markup.bytesize
|
|
|
|
while pos < len
|
|
byte = markup.getbyte(pos)
|
|
|
|
if byte == 91 # '['
|
|
# Scan balanced brackets
|
|
depth = 1
|
|
start = pos
|
|
pos += 1
|
|
while pos < len && depth > 0
|
|
b = markup.getbyte(pos)
|
|
if b == 91
|
|
depth += 1
|
|
elsif b == 93
|
|
depth -= 1
|
|
end
|
|
pos += 1
|
|
end
|
|
if depth == 0
|
|
result << markup.byteslice(start, pos - start)
|
|
else
|
|
# Unbalanced bracket - skip '[' and continue
|
|
pos = start + 1
|
|
end
|
|
elsif byte == 46 # '.'
|
|
pos += 1
|
|
elsif (byte >= 97 && byte <= 122) || (byte >= 65 && byte <= 90) || (byte >= 48 && byte <= 57) || byte == 95 || byte == 45 # \w or -
|
|
start = pos
|
|
pos += 1
|
|
while pos < len
|
|
b = markup.getbyte(pos)
|
|
break unless (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || (b >= 48 && b <= 57) || b == 95 || b == 45
|
|
pos += 1
|
|
end
|
|
# Check trailing '?'
|
|
if pos < len && markup.getbyte(pos) == 63
|
|
pos += 1
|
|
end
|
|
result << markup.byteslice(start, pos - start)
|
|
else
|
|
pos += 1
|
|
end
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
# Check if markup is a simple identifier chain: [\w-]+\??(.[\w-]+\??)*
|
|
# Returns true if it only contains word chars, hyphens, dots, and optional trailing ?
|
|
def self.simple_lookup?(markup)
|
|
pos = 0
|
|
len = markup.bytesize
|
|
return false if len == 0
|
|
while pos < len
|
|
b = markup.getbyte(pos)
|
|
if (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || (b >= 48 && b <= 57) || b == 95 || b == 45 # \w or -
|
|
pos += 1
|
|
elsif b == 63 # '?'
|
|
pos += 1
|
|
# '?' must be followed by '.' or end
|
|
return true if pos >= len
|
|
return false unless markup.getbyte(pos) == 46
|
|
elsif b == 46 # '.'
|
|
pos += 1
|
|
# Must have at least one word char after dot
|
|
return false if pos >= len
|
|
b2 = markup.getbyte(pos)
|
|
return false unless (b2 >= 97 && b2 <= 122) || (b2 >= 65 && b2 <= 90) || b2 == 95
|
|
pos += 1
|
|
else
|
|
return false
|
|
end
|
|
end
|
|
true
|
|
end
|
|
|
|
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
|
|
# Fast path: simple identifier chain without brackets
|
|
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)
|
|
# Build lookups array from remaining dot-separated segments
|
|
lookups = []
|
|
@command_flags = 0
|
|
pos = dot_pos + 1
|
|
len = markup.bytesize
|
|
while pos < len
|
|
seg_start = pos
|
|
while pos < len
|
|
b = markup.getbyte(pos)
|
|
break if b == 46 # '.'
|
|
pos += 1
|
|
end
|
|
seg = markup.byteslice(seg_start, pos - seg_start)
|
|
if COMMAND_METHODS.include?(seg)
|
|
@command_flags |= 1 << lookups.length
|
|
end
|
|
lookups << seg
|
|
pos += 1 # skip dot
|
|
end
|
|
@lookups = lookups
|
|
return
|
|
end
|
|
|
|
lookups = self.class.scan_variable(markup)
|
|
|
|
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
|