Files
liquid/lib/liquid/variable.rb
T

506 lines
17 KiB
Ruby

# frozen_string_literal: true
module Liquid
# Holds variables. Variables are only loaded "just in time"
# and are not evaluated as part of the render stage
#
# {{ monkey }}
# {{ user.name }}
#
# Variables can be combined with filters:
#
# {{ user | link }}
#
class Variable
# Checks if markup is a simple "name.lookup.chain" with no filters/brackets/quotes.
# Returns the trimmed markup string, or nil if not simple.
# Avoids regex MatchData allocation.
def self.simple_variable_markup(markup)
len = markup.bytesize
return if len == 0
# Skip leading whitespace
pos = 0
while pos < len
b = markup.getbyte(pos)
break unless b == 32 || b == 9 || b == 10 || b == 13
pos += 1
end
return if pos >= len
start = pos
# First char must be [a-zA-Z_]
b = markup.getbyte(pos)
return unless (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95
pos += 1
# Scan segments: [\w-]* (. [\w-]*)*
while pos < len
b = markup.getbyte(pos)
if (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || (b >= 48 && b <= 57) || b == 95 || b == 45
pos += 1
elsif b == 46 # '.'
pos += 1
# After dot, must have [a-zA-Z_]
return if pos >= len
b = markup.getbyte(pos)
return unless (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95
pos += 1
else
break
end
end
content_end = pos
# Skip trailing whitespace
while pos < len
b = markup.getbyte(pos)
return unless b == 32 || b == 9 || b == 10 || b == 13
pos += 1
end
# Must have consumed everything
return unless pos == len
if start == 0 && content_end == len
markup
else
markup.byteslice(start, content_end - start)
end
end
FilterMarkupRegex = /#{FilterSeparator}\s*(.*)/om
FilterParser = /(?:\s+|#{QuotedFragment}|#{ArgumentSeparator})+/o
FilterArgsRegex = /(?:#{FilterArgumentSeparator}|#{ArgumentSeparator})\s*((?:\w+\s*\:\s*)?#{QuotedFragment})/o
JustTagAttributes = /\A#{TagAttributes}\z/o
MarkupWithQuotedFragment = /(#{QuotedFragment})(.*)/om
attr_accessor :filters, :name, :line_number
attr_reader :parse_context
alias_method :options, :parse_context
include ParserSwitching
def initialize(markup, parse_context)
@markup = markup
@name = nil
@parse_context = parse_context
@line_number = parse_context.line_number
# Fast path: try to parse without going through Lexer → Parser
# Skip for strict2/rigid modes which require different parsing
# Fast path only for lax/warn modes — strict modes need full error checking
error_mode = parse_context.error_mode
if error_mode == :strict2 || error_mode == :rigid || error_mode == :strict || !try_fast_parse(markup, parse_context)
strict_parse_with_error_mode_fallback(markup)
end
end
private def try_fast_parse(markup, parse_context)
len = markup.bytesize
return false if len == 0
# Skip leading whitespace
pos = 0
while pos < len
b = markup.getbyte(pos)
break unless b == 32 || b == 9 || b == 10 || b == 13
pos += 1
end
return false if pos >= len
b = markup.getbyte(pos)
if b == 39 || b == 34 # single or double quote
# Quoted string literal: scan to matching close quote
quote = b
name_start = pos
pos += 1
pos += 1 while pos < len && markup.getbyte(pos) != quote
pos += 1 if pos < len # skip closing quote
name_end = pos
elsif (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95
# Identifier: scan [\w-]*(\.[\w-]*)*
name_start = pos
pos += 1
while pos < len
b = markup.getbyte(pos)
if (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || (b >= 48 && b <= 57) || b == 95 || b == 45
pos += 1
elsif b == 46 # '.'
pos += 1
return false if pos >= len
b = markup.getbyte(pos)
return false unless (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95
pos += 1
else
break
end
end
name_end = pos
else
return false
end
# Skip whitespace after name
while pos < len
b = markup.getbyte(pos)
break unless b == 32 || b == 9 || b == 10 || b == 13
pos += 1
end
# Resolve the name expression — avoid byteslice when markup is already the name
expr_markup = if name_start == 0 && name_end == len
markup # no whitespace, no filters — reuse the string
else
markup.byteslice(name_start, name_end - name_start)
end
cache = parse_context.expression_cache
ss = parse_context.string_scanner
first_byte = expr_markup.getbyte(0)
@name = if first_byte == 39 || first_byte == 34 # quoted string
# Strip quotes for string literal
expr_markup.byteslice(1, expr_markup.bytesize - 2)
elsif Expression::LITERALS.key?(expr_markup)
Expression::LITERALS[expr_markup]
elsif cache
cache[expr_markup] || (cache[expr_markup] = VariableLookup.parse_simple(expr_markup, ss, cache).freeze)
else
VariableLookup.parse_simple(expr_markup, ss || StringScanner.new(""), nil).freeze
end
# End of markup? No filters.
if pos >= len
@filters = Const::EMPTY_ARRAY
return true
end
# Must be a pipe for filters
return false unless markup.getbyte(pos) == 124 # '|'
# Try fast filter scanning first — handles no-arg and simple-arg filters
# Falls through to Lexer-based parsing for complex cases
@filters = []
filter_pos = pos
while filter_pos < len && markup.getbyte(filter_pos) == 124 # '|'
filter_pos += 1
# Skip whitespace
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == 32
# Scan filter name
fname_start = filter_pos
b = filter_pos < len ? markup.getbyte(filter_pos) : nil
break unless b && ((b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95)
filter_pos += 1
while filter_pos < len
b = markup.getbyte(filter_pos)
break unless (b >= 97 && b <= 122) || (b >= 65 && b <= 90) || (b >= 48 && b <= 57) || b == 95 || b == 45
filter_pos += 1
end
filtername = markup.byteslice(fname_start, filter_pos - fname_start)
# Skip whitespace
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == 32
# Has arguments — try fast scanning for simple single-arg filters first
if filter_pos < len && markup.getbyte(filter_pos) == 58 # ':'
filter_pos += 1 # skip ':'
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == 32
# Try to parse simple args: one positional arg (quoted string, number, or identifier)
arg_expr = nil
has_kwargs = false
arg_start = filter_pos
b = filter_pos < len ? markup.getbyte(filter_pos) : nil
if b == 39 || b == 34 # quoted string
quote = b
filter_pos += 1
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) != quote
filter_pos += 1 if filter_pos < len # skip closing quote
arg_expr = markup.byteslice(arg_start + 1, filter_pos - arg_start - 2)
elsif b && ((b >= 48 && b <= 57) || (b == 45 && filter_pos + 1 < len && markup.getbyte(filter_pos + 1) >= 48 && markup.getbyte(filter_pos + 1) <= 57))
# Number
filter_pos += 1 if b == 45 # skip -
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) >= 48 && markup.getbyte(filter_pos) <= 57
if filter_pos < len && markup.getbyte(filter_pos) == 46 # float
filter_pos += 1
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) >= 48 && markup.getbyte(filter_pos) <= 57
end
num_str = markup.byteslice(arg_start, filter_pos - arg_start)
arg_expr = num_str.include?('.') ? num_str.to_f : num_str.to_i
elsif b && ((b >= 97 && b <= 122) || (b >= 65 && b <= 90) || b == 95)
# Identifier — could be keyword arg "key: val" or positional
id_start = filter_pos
filter_pos += 1
while filter_pos < len
b2 = markup.getbyte(filter_pos)
break unless (b2 >= 97 && b2 <= 122) || (b2 >= 65 && b2 <= 90) || (b2 >= 48 && b2 <= 57) || b2 == 95 || b2 == 45 || b2 == 46
filter_pos += 1
end
# Check for '?' suffix
filter_pos += 1 if filter_pos < len && markup.getbyte(filter_pos) == 63
# Check if this is a keyword arg (id followed by ':')
kw_check = filter_pos
kw_check += 1 while kw_check < len && markup.getbyte(kw_check) == 32
if kw_check < len && markup.getbyte(kw_check) == 58
has_kwargs = true # fall through to Lexer
else
id_markup = markup.byteslice(id_start, filter_pos - id_start)
cache = parse_context.expression_cache
arg_expr = Expression.parse(id_markup, parse_context.string_scanner, cache)
end
end
if arg_expr != nil && !has_kwargs
# Skip trailing whitespace after arg
filter_pos += 1 while filter_pos < len && markup.getbyte(filter_pos) == 32
# Check if there's a comma (multiple args) — fall through to Lexer
if filter_pos < len && markup.getbyte(filter_pos) == 44 # ','
has_kwargs = true
end
end
unless arg_expr != nil && !has_kwargs
# Complex filter — fall to Lexer for this and remaining filters
rest_start = fname_start
rest_start -= 1 while rest_start > pos && markup.getbyte(rest_start) != 124
rest_markup = markup.byteslice(rest_start, len - rest_start)
p = parse_context.new_parser(rest_markup)
while p.consume?(:pipe)
fn = p.consume(:id)
fa = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << lax_parse_filter_expressions(fn, fa)
end
p.consume(:end_of_string)
@filters = Const::EMPTY_ARRAY if @filters.empty?
return true
end
@filters << [filtername, [arg_expr]]
else
# No args — add as simple filter
@filters << [filtername, Const::EMPTY_ARRAY]
end
# Skip whitespace between filters
filter_pos += 1 while filter_pos < len && (markup.getbyte(filter_pos) == 32 || markup.getbyte(filter_pos) == 9 || markup.getbyte(filter_pos) == 10 || markup.getbyte(filter_pos) == 13)
end
# Must have consumed everything
return false if filter_pos < len
@filters = Const::EMPTY_ARRAY if @filters.empty?
true
rescue SyntaxError
# If fast parse fails, fall back to full parse
@name = nil
@filters = nil
false
end
def raw
@markup
end
def markup_context(markup)
"in \"{{#{markup}}}\""
end
def lax_parse(markup)
@filters = Const::EMPTY_ARRAY
return unless markup =~ MarkupWithQuotedFragment
name_markup = Regexp.last_match(1)
filter_markup = Regexp.last_match(2)
@name = parse_context.parse_expression(name_markup)
if filter_markup =~ FilterMarkupRegex
filters = Regexp.last_match(1).scan(FilterParser)
filters.each do |f|
next unless f =~ /\w+/
filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten
@filters = [] if @filters.frozen?
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
end
end
def strict_parse(markup)
@filters = Const::EMPTY_ARRAY
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
while p.consume?(:pipe)
@filters = [] if @filters.frozen?
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
p.consume(:end_of_string)
end
def strict2_parse(markup)
@filters = Const::EMPTY_ARRAY
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
while p.consume?(:pipe)
@filters = [] if @filters.frozen?
@filters << strict2_parse_filter_expressions(p)
end
p.consume(:end_of_string)
end
def parse_filterargs(p)
# first argument
filterargs = [p.argument]
# followed by comma separated others
filterargs << p.argument while p.consume?(:comma)
filterargs
end
def render(context)
obj = context.evaluate(@name)
@filters.each do |filter_name, filter_args, filter_kwargs|
if filter_args.empty? && !filter_kwargs
obj = context.invoke_single(filter_name, obj)
elsif !filter_kwargs && filter_args.length == 1
# Single positional arg — most common after no-arg
obj = context.invoke_two(filter_name, obj, context.evaluate(filter_args[0]))
else
filter_args = evaluate_filter_expressions(context, filter_args, filter_kwargs)
obj = context.invoke(filter_name, obj, *filter_args)
end
end
context.apply_global_filter(obj)
end
def render_to_output_buffer(context, output)
# Fast path: no filters and no global filter
obj = if @filters.empty? && context.global_filter.nil?
context.evaluate(@name)
else
render(context)
end
render_obj_to_output(obj, output)
output
end
def render_obj_to_output(obj, output)
if obj.instance_of?(String)
output << obj
elsif obj.nil?
# Do nothing
elsif obj.instance_of?(Array)
obj.each do |o|
render_obj_to_output(o, output)
end
else
output << Liquid::Utils.to_s(obj)
end
end
def disabled?(_context)
false
end
def disabled_tags
Const::EMPTY_ARRAY
end
private
def lax_parse_filter_expressions(filter_name, unparsed_args)
filter_args = []
keyword_args = nil
unparsed_args.each do |a|
# Fast check: keyword args must contain ':'
if a.include?(':') && (matches = a.match(JustTagAttributes))
keyword_args ||= {}
keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
else
filter_args << parse_context.parse_expression(a)
end
end
result = [filter_name, filter_args]
result << keyword_args if keyword_args
result
end
# Surprisingly, positional and keyword arguments can be mixed.
#
# filter = filtername [":" filterargs?]
# filterargs = argument ("," argument)*
# argument = (positional_argument | keyword_argument)
# positional_argument = expression
# keyword_argument = id ":" expression
def strict2_parse_filter_expressions(p)
filtername = p.consume(:id)
filter_args = []
keyword_args = {}
if p.consume?(:colon)
# Parse first argument (no leading comma)
argument(p, filter_args, keyword_args) unless end_of_arguments?(p)
# Parse remaining arguments (with leading commas) and optional trailing comma
argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p)
end
result = [filtername, filter_args]
result << keyword_args unless keyword_args.empty?
result
end
def argument(p, positional_arguments, keyword_arguments)
if p.look(:id) && p.look(:colon, 1)
key = p.consume(:id)
p.consume(:colon)
value = parse_context.safe_parse_expression(p)
keyword_arguments[key] = value
else
positional_arguments << parse_context.safe_parse_expression(p)
end
end
def end_of_arguments?(p)
p.look(:pipe) || p.look(:end_of_string)
end
def evaluate_filter_expressions(context, filter_args, filter_kwargs)
if filter_kwargs
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
parsed_kwargs = {}
filter_kwargs.each do |key, expr|
parsed_kwargs[key] = context.evaluate(expr)
end
parsed_args << parsed_kwargs
parsed_args
elsif filter_args.empty?
Const::EMPTY_ARRAY
else
filter_args.map { |expr| context.evaluate(expr) }
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.name] + @node.filters.flatten
end
end
end
end