mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-30 07:35:12 -07:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e39ae4cc3 | ||
|
|
4e5198ba69 | ||
|
|
3bdbd4ac2f | ||
|
|
807d45a6b3 | ||
|
|
7a5e45fc47 | ||
|
|
9efca9f718 | ||
|
|
529800f46b | ||
|
|
7b368dffb8 | ||
|
|
742ac3dbf5 | ||
|
|
1954a2655c | ||
|
|
6d81b1b68c | ||
|
|
dfddd8f390 | ||
|
|
95ce7e7fa1 | ||
|
|
197d755e0c | ||
|
|
d0c5444db1 | ||
|
|
c99036046e | ||
|
|
0d5c15a03e | ||
|
|
346166b600 | ||
|
|
532b439063 |
+1
-1
@@ -1 +1 @@
|
||||
3.4.1
|
||||
4.0.2
|
||||
|
||||
@@ -32,6 +32,7 @@ group :test do
|
||||
end
|
||||
|
||||
group :spec do
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main'
|
||||
# Includes the range resource-limit specs from https://github.com/Shopify/liquid-spec/pull/165.
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '84bf25e0edbca5f7e530574788b0f875ab331f50'
|
||||
gem 'activesupport', require: false
|
||||
end
|
||||
|
||||
+16
@@ -1,5 +1,21 @@
|
||||
# Liquid Change Log
|
||||
|
||||
## 5.14.0
|
||||
|
||||
* Avoid materializing integer ranges in `for` and `tablerow` loops, and account for each visited range item [Ian Ker-Seymer]
|
||||
|
||||
## 5.13.0
|
||||
|
||||
* Add TruffleRuby in CI [Benoit Daloze]
|
||||
* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze]
|
||||
* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy]
|
||||
* Add strict2_parse to assign and capture tags by [Alok Swamy]
|
||||
* Add strict2_parse to increment and decrement tags by [Alok Swamy]
|
||||
* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer]
|
||||
* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro]
|
||||
* Fix `SelfDrop` equality [Guilherme Carreiro]
|
||||
* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer]
|
||||
|
||||
## 5.11.0
|
||||
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
|
||||
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
|
||||
|
||||
@@ -149,6 +149,13 @@ template.render!({ 'x' => 1}, { strict_variables: true })
|
||||
#=> Liquid::UndefinedVariable: Liquid error: undefined variable y
|
||||
```
|
||||
|
||||
### Resource limits
|
||||
|
||||
`render_score_limit` and `cumulative_render_score_limit` account for each item visited by
|
||||
integer-range `for` and `tablerow` loops, including loops with empty bodies. This bounds range
|
||||
iteration work when a score limit is configured; `render_length_limit` only bounds generated
|
||||
output and does not by itself limit CPU work for output-free loops.
|
||||
|
||||
### Usage tracking
|
||||
|
||||
To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you.
|
||||
|
||||
@@ -151,6 +151,8 @@ end
|
||||
|
||||
desc('run liquid-spec suite across all adapters')
|
||||
task :spec do
|
||||
adapters = Dir['./spec/*.rb'].join(',')
|
||||
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
|
||||
Dir['./spec/*.rb'].sort.each do |adapter|
|
||||
puts "=== Running #{adapter} ==="
|
||||
sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures'
|
||||
end
|
||||
end
|
||||
|
||||
+2
-1
@@ -52,7 +52,6 @@ end
|
||||
require "liquid/version"
|
||||
require "liquid/deprecations"
|
||||
require "liquid/const"
|
||||
require "liquid/byte_tables"
|
||||
require 'liquid/standardfilters'
|
||||
require 'liquid/file_system'
|
||||
require 'liquid/parser_switching'
|
||||
@@ -66,6 +65,7 @@ require 'liquid/lexer'
|
||||
require 'liquid/parser'
|
||||
require 'liquid/i18n'
|
||||
require 'liquid/drop'
|
||||
require 'liquid/self_drop'
|
||||
require 'liquid/tablerowloop_drop'
|
||||
require 'liquid/forloop_drop'
|
||||
require 'liquid/extensions'
|
||||
@@ -83,6 +83,7 @@ require 'liquid/resource_limits'
|
||||
require 'liquid/expression'
|
||||
require 'liquid/template'
|
||||
require 'liquid/condition'
|
||||
require 'liquid/range_slice'
|
||||
require 'liquid/utils'
|
||||
require 'liquid/tokenizer'
|
||||
require 'liquid/parse_context'
|
||||
|
||||
+10
-84
@@ -99,7 +99,9 @@ module Liquid
|
||||
context.handle_error(exc, line_number)
|
||||
else
|
||||
error_message = context.handle_error(exc, line_number)
|
||||
unless blank_tag # conditional for backwards compatibility
|
||||
error_mode = context.registers.static[:template_error_mode]
|
||||
suppress_error_text = blank_tag && error_mode != :strict2 && error_mode != :rigid
|
||||
unless suppress_error_text # blank-tag suppression is kept for backwards compatibility outside strict2
|
||||
output << error_message
|
||||
end
|
||||
end
|
||||
@@ -130,21 +132,16 @@ module Liquid
|
||||
case
|
||||
when token.start_with?(TAGSTART)
|
||||
whitespace_handler(token, parse_context)
|
||||
# rubocop:disable Metrics/BlockNesting
|
||||
fast = try_parse_tag_token(token)
|
||||
if fast
|
||||
tag_name, markup, newlines = fast
|
||||
elsif token =~ FullToken
|
||||
tag_name = Regexp.last_match(2)
|
||||
markup = Regexp.last_match(4)
|
||||
newlines = parse_context.line_number ? Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n") : 0
|
||||
else
|
||||
unless token =~ FullToken
|
||||
return handle_invalid_tag_token(token, parse_context, &block)
|
||||
end
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
tag_name = Regexp.last_match(2)
|
||||
markup = Regexp.last_match(4)
|
||||
|
||||
if parse_context.line_number && newlines > 0
|
||||
parse_context.line_number += newlines
|
||||
if parse_context.line_number
|
||||
# newlines inside the tag should increase the line number,
|
||||
# particularly important for multiline {% liquid %} tags
|
||||
parse_context.line_number += Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n")
|
||||
end
|
||||
|
||||
if tag_name == 'liquid'
|
||||
@@ -265,77 +262,6 @@ module Liquid
|
||||
BlockBody.raise_missing_variable_terminator(token, parse_context)
|
||||
end
|
||||
|
||||
# Fast path for parsing "{%[-] tag_name markup [-]%}" tag tokens.
|
||||
# Returns [tag_name, markup, newline_count] or nil.
|
||||
#
|
||||
# Accepts tokens where:
|
||||
# - Tag name is '#' or starts with [a-zA-Z_] followed by \w chars
|
||||
# (matching TagName = /#|\w+/ exactly — no hyphens, no '?' suffix)
|
||||
# - Whitespace is spaces, tabs, newlines, \r, \f, \v
|
||||
# - Whitespace control dashes are at positions 2 and len-3
|
||||
# Rejects (returns nil → caller falls back to FullToken regex):
|
||||
# - Tokens shorter than "{%x%}" (4 bytes)
|
||||
# - Tag names starting with a digit (valid in FullToken but rare)
|
||||
# - Any structure the byte-walk can't confidently parse
|
||||
# Fallback: nil return triggers the original `token =~ FullToken` regex
|
||||
# match in parse_for_document, preserving identical behavior for any
|
||||
# input the fast path doesn't handle.
|
||||
def try_parse_tag_token(token)
|
||||
len = token.bytesize
|
||||
pos = 2 # skip "{%"
|
||||
return if pos >= len
|
||||
|
||||
pos += 1 if token.getbyte(pos) == ByteTables::DASH
|
||||
newline_count = 0
|
||||
|
||||
# Skip whitespace before tag name, count newlines
|
||||
while pos < len
|
||||
b = token.getbyte(pos)
|
||||
if b == ByteTables::NEWLINE
|
||||
pos += 1
|
||||
newline_count += 1
|
||||
elsif ByteTables::WHITESPACE[b]
|
||||
pos += 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
return if pos >= len
|
||||
|
||||
# Scan tag name: '#' or \w+ (matching TagName = /#|\w+/)
|
||||
name_start = pos
|
||||
b = token.getbyte(pos)
|
||||
if b == ByteTables::HASH
|
||||
pos += 1
|
||||
elsif ByteTables::IDENT_START[b]
|
||||
pos += 1
|
||||
pos += 1 while pos < len && ByteTables::WORD[token.getbyte(pos)]
|
||||
else
|
||||
return
|
||||
end
|
||||
tag_name = token.byteslice(name_start, pos - name_start)
|
||||
|
||||
# Skip whitespace after tag name, count newlines
|
||||
while pos < len
|
||||
b = token.getbyte(pos)
|
||||
if b == ByteTables::NEWLINE
|
||||
pos += 1
|
||||
newline_count += 1
|
||||
elsif ByteTables::WHITESPACE[b]
|
||||
pos += 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Markup: everything up to optional '-' before '%}'
|
||||
markup_end = len - 2 # skip '%}'
|
||||
markup_end -= 1 if markup_end > pos && token.getbyte(markup_end - 1) == ByteTables::DASH
|
||||
markup = pos >= markup_end ? "" : token.byteslice(pos, markup_end - pos)
|
||||
|
||||
[tag_name, markup, newline_count]
|
||||
end
|
||||
|
||||
# @deprecated Use {.raise_missing_tag_terminator} instead
|
||||
def raise_missing_tag_terminator(token, parse_context)
|
||||
BlockBody.raise_missing_tag_terminator(token, parse_context)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# Pre-computed 256-entry boolean lookup tables for byte classification.
|
||||
# Built once at load time; used as TABLE[byte] — a single array index
|
||||
# instead of 3-5 comparison operators per check.
|
||||
module ByteTables
|
||||
# [a-zA-Z_] — valid first byte of an identifier
|
||||
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 # _
|
||||
end.freeze
|
||||
|
||||
# [a-zA-Z0-9_-] — valid continuation byte of an identifier
|
||||
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 # -
|
||||
end.freeze
|
||||
|
||||
# [a-zA-Z0-9_] — \w equivalent (no hyphen), for tag name scanning
|
||||
WORD = 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 # _
|
||||
end.freeze
|
||||
|
||||
# [0-9] — ASCII digit
|
||||
DIGIT = Array.new(256, false).tap do |t|
|
||||
(48..57).each { |b| t[b] = true }
|
||||
end.freeze
|
||||
|
||||
# Matches bytes removed by Ruby's String#strip: \x00, \t, \n, \v, \f, \r, space
|
||||
WHITESPACE = Array.new(256, false).tap do |t|
|
||||
[0, 9, 10, 11, 12, 13, 32].each { |b| t[b] = true }
|
||||
end.freeze
|
||||
|
||||
# Byte constants for delimiters and punctuation
|
||||
NEWLINE = 10
|
||||
DASH = 45 # '-'
|
||||
DOT = 46 # '.'
|
||||
HASH = 35 # '#'
|
||||
end
|
||||
end
|
||||
+19
-1
@@ -187,6 +187,15 @@ module Liquid
|
||||
find_variable(key, raise_on_not_found: false) != nil
|
||||
end
|
||||
|
||||
# Checks whether a variable is defined in any scope, including nil-valued keys.
|
||||
# Unlike #key?, this uses Hash#key? so that variables explicitly set to nil
|
||||
# are still considered defined.
|
||||
def variable_defined?(key)
|
||||
@scopes.any? { |s| s.key?(key) } ||
|
||||
@environments.any? { |e| e.key?(key) } ||
|
||||
@static_environments.any? { |e| e.key?(key) }
|
||||
end
|
||||
|
||||
def evaluate(object)
|
||||
object.respond_to?(:evaluate) ? object.evaluate(self) : object
|
||||
end
|
||||
@@ -197,12 +206,21 @@ module Liquid
|
||||
# path and find_index() is optimized in MRI to reduce object allocation
|
||||
index = @scopes.find_index { |s| s.key?(key) }
|
||||
|
||||
fallback_to_self_drop = key == Expression::SELF && index.nil?
|
||||
|
||||
variable = if index
|
||||
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
|
||||
else
|
||||
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
|
||||
try_variable_find_in_environments(
|
||||
key,
|
||||
raise_on_not_found: raise_on_not_found && !fallback_to_self_drop,
|
||||
)
|
||||
end
|
||||
|
||||
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
|
||||
# but only after the normal environment lookup doesn't find a value.
|
||||
return @self_drop ||= SelfDrop.new(self) if fallback_to_self_drop && variable.nil?
|
||||
|
||||
# update variable's context before invoking #to_liquid
|
||||
variable.context = self if variable.respond_to?(:context=)
|
||||
|
||||
|
||||
+51
-68
@@ -2,6 +2,8 @@
|
||||
|
||||
module Liquid
|
||||
class Expression
|
||||
SELF = 'self'
|
||||
|
||||
LITERALS = {
|
||||
nil => nil,
|
||||
'nil' => nil,
|
||||
@@ -16,9 +18,16 @@ module Liquid
|
||||
'-' => VariableLookup.parse("-", nil).freeze,
|
||||
}.freeze
|
||||
|
||||
DOT = ".".ord
|
||||
ZERO = "0".ord
|
||||
NINE = "9".ord
|
||||
DASH = "-".ord
|
||||
|
||||
# Use an atomic group (?>...) to avoid pathological backtracing from
|
||||
# malicious input as described in https://github.com/Shopify/liquid/issues/1357
|
||||
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
|
||||
INTEGER_REGEX = /\A(-?\d+)\z/
|
||||
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
|
||||
|
||||
class << self
|
||||
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
|
||||
@@ -28,15 +37,7 @@ module Liquid
|
||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||
return unless markup
|
||||
|
||||
# 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
|
||||
markup = markup.strip # markup can be a frozen string
|
||||
|
||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||
(markup.start_with?("'") && markup.end_with?("'"))
|
||||
@@ -72,74 +73,56 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
# Fast path for number parsing. Accepts:
|
||||
# - Simple integers: "42", "-7"
|
||||
# - Simple floats: "3.14", "-0.5"
|
||||
# - Multi-dot floats (truncated at second dot): "1.2.3" → 1.2
|
||||
# - Trailing-dot floats: "123." → 123.0
|
||||
# Rejects (returns nil → caller treats as VariableLookup):
|
||||
# - Non-numeric input: "hello", ""
|
||||
# - 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
|
||||
|
||||
pos = 0
|
||||
first = markup.getbyte(pos)
|
||||
|
||||
if first == ByteTables::DASH
|
||||
pos += 1
|
||||
return if pos >= len
|
||||
return unless ByteTables::DIGIT[markup.getbyte(pos)]
|
||||
|
||||
pos += 1
|
||||
elsif ByteTables::DIGIT[first]
|
||||
pos += 1
|
||||
else
|
||||
return
|
||||
def parse_number(markup, ss)
|
||||
# check if the markup is simple integer or float
|
||||
case markup
|
||||
when INTEGER_REGEX
|
||||
return Integer(markup, 10)
|
||||
when FLOAT_REGEX
|
||||
return markup.to_f
|
||||
end
|
||||
|
||||
# Scan digits
|
||||
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
|
||||
ss.string = markup
|
||||
# the first byte must be a digit or a dash
|
||||
byte = ss.scan_byte
|
||||
|
||||
# Consumed everything = simple integer
|
||||
return Integer(markup, 10) if pos == len
|
||||
return false if byte != DASH && (byte < ZERO || byte > NINE)
|
||||
|
||||
# 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
|
||||
if byte == DASH
|
||||
peek_byte = ss.peek_byte
|
||||
|
||||
dot_pos = pos
|
||||
pos += 1
|
||||
digit_start = pos
|
||||
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
|
||||
# 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
|
||||
|
||||
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
|
||||
# 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
|
||||
check += 1
|
||||
end
|
||||
markup.byteslice(0, num_end || len).to_f
|
||||
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
|
||||
# Case 3: trailing dot like "123."
|
||||
markup.byteslice(0, dot_pos).to_f
|
||||
# number ends with a dot "123."
|
||||
markup.byteslice(0, first_dot_pos).to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,7 +38,7 @@ module Liquid
|
||||
|
||||
def new_parser(input)
|
||||
@string_scanner.string = input
|
||||
Parser.new(@string_scanner)
|
||||
Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid)
|
||||
end
|
||||
|
||||
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
module Liquid
|
||||
class Parser
|
||||
def initialize(input)
|
||||
def initialize(input, reject_bare_brackets: false)
|
||||
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
|
||||
@tokens = Lexer.tokenize(ss)
|
||||
@p = 0 # pointer to current location
|
||||
@reject_bare_brackets = reject_bare_brackets
|
||||
end
|
||||
|
||||
def jump(point)
|
||||
@@ -53,6 +54,9 @@ module Liquid
|
||||
str = consume
|
||||
str << variable_lookups
|
||||
when :open_square
|
||||
if @reject_bare_brackets
|
||||
raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead"
|
||||
end
|
||||
str = consume.dup
|
||||
str << expression
|
||||
str << consume(:close_square)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
class RangeSlice
|
||||
attr_reader :length
|
||||
|
||||
def initialize(range, from, to, resource_limits)
|
||||
range_length = range.end - range.begin
|
||||
range_length += 1 unless range.exclude_end?
|
||||
range_length = 0 if range_length.negative?
|
||||
|
||||
start = [from, 0].max
|
||||
finish = [to || range_length, range_length].min
|
||||
|
||||
@first = range.begin + start
|
||||
@length = [finish - start, 0].max
|
||||
@direction = 1
|
||||
@resource_limits = resource_limits
|
||||
end
|
||||
|
||||
def empty?
|
||||
@length.zero?
|
||||
end
|
||||
|
||||
def each
|
||||
return enum_for(:each) unless block_given?
|
||||
|
||||
value = @first
|
||||
@length.times do
|
||||
@resource_limits.increment_render_score(1)
|
||||
yield value
|
||||
value += @direction
|
||||
end
|
||||
end
|
||||
|
||||
def reverse!
|
||||
unless empty?
|
||||
@first += @direction * (@length - 1)
|
||||
@direction = -@direction
|
||||
end
|
||||
self
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# @liquid_public_docs
|
||||
# @liquid_type object
|
||||
# @liquid_name self
|
||||
# @liquid_summary
|
||||
# Provides access to variables through the current scope chain.
|
||||
# @liquid_description
|
||||
# The `self` object resolves variables through the normal lookup hierarchy
|
||||
# (local > file > global) without exposing filters, interrupts, errors,
|
||||
# or other context internals. It's used when bare bracket notation
|
||||
# (`['variable']`) needs to be replaced with an explicit variable lookup.
|
||||
#
|
||||
# If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`),
|
||||
# then the local value takes precedence over the `self` object.
|
||||
# @liquid_access global
|
||||
class SelfDrop < Drop
|
||||
def initialize(self_context)
|
||||
super()
|
||||
@self_context = self_context
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@self_context.find_variable(key)
|
||||
rescue UndefinedVariable
|
||||
nil
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
@self_context.variable_defined?(key)
|
||||
end
|
||||
|
||||
def to_liquid
|
||||
self
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
other.is_a?(SelfDrop) && other.self_context.equal?(@self_context)
|
||||
end
|
||||
|
||||
alias_method :eql?, :==
|
||||
|
||||
def hash
|
||||
@self_context.object_id.hash
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
attr_reader :self_context
|
||||
|
||||
undef context=
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,8 @@ module Liquid
|
||||
# @liquid_syntax_keyword variable_name The name of the variable being created.
|
||||
# @liquid_syntax_keyword value The value you want to assign to the variable.
|
||||
class Assign < Tag
|
||||
include ParserSwitching
|
||||
|
||||
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
|
||||
|
||||
# @api private
|
||||
@@ -29,6 +31,10 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, parse_context)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@to = Regexp.last_match(1)
|
||||
@from = Variable.new(Regexp.last_match(2), parse_context)
|
||||
@@ -37,6 +43,25 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
unless markup =~ Syntax
|
||||
self.class.raise_syntax_error(parse_context)
|
||||
end
|
||||
|
||||
lhs = Regexp.last_match(1).strip
|
||||
rhs = Regexp.last_match(2)
|
||||
|
||||
p = @parse_context.new_parser(lhs)
|
||||
@to = p.consume(:id)
|
||||
p.consume(:end_of_string)
|
||||
|
||||
@from = Variable.new(rhs, parse_context)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
val = @from.render(context)
|
||||
context.scopes.last[@to] = val
|
||||
|
||||
@@ -20,10 +20,18 @@ module Liquid
|
||||
# @liquid_syntax_keyword variable The name of the variable being created.
|
||||
# @liquid_syntax_keyword value The value you want to assign to the variable.
|
||||
class Capture < Block
|
||||
include ParserSwitching
|
||||
|
||||
Syntax = /(#{VariableSignature}+)/o
|
||||
|
||||
attr_reader :to
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@to = Regexp.last_match(1)
|
||||
else
|
||||
@@ -31,6 +39,16 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup.strip)
|
||||
@to = p.consume(:id)
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
context.resource_limits.with_capture do
|
||||
capture_output = render(context)
|
||||
|
||||
@@ -23,13 +23,29 @@ module Liquid
|
||||
# {% decrement variable_name %}
|
||||
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
|
||||
class Decrement < Tag
|
||||
include ParserSwitching
|
||||
|
||||
attr_reader :variable_name
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
@variable_name = markup.strip
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup.strip)
|
||||
@variable_name = p.consume(:id)
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
counter_environment = context.environments.first
|
||||
value = counter_environment[@variable_name] || 0
|
||||
|
||||
@@ -130,7 +130,6 @@ module Liquid
|
||||
end
|
||||
|
||||
collection = context.evaluate(@collection_name)
|
||||
collection = collection.to_a if collection.is_a?(Range)
|
||||
|
||||
limit_value = context.evaluate(@limit)
|
||||
to = if limit_value.nil?
|
||||
@@ -139,7 +138,9 @@ module Liquid
|
||||
Utils.to_integer(limit_value) + from
|
||||
end
|
||||
|
||||
segment = Utils.slice_collection(collection, from, to)
|
||||
segment = Utils.slice_collection_for_iteration(
|
||||
collection, from, to, context.resource_limits, use_range_to_a: true
|
||||
)
|
||||
segment.reverse! if @reversed
|
||||
|
||||
offsets[@name] = from + segment.length
|
||||
|
||||
@@ -20,7 +20,8 @@ module Liquid
|
||||
class Include < Tag
|
||||
prepend Tag::Disableable
|
||||
|
||||
SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
||||
FOR = 'for'
|
||||
SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
||||
Syntax = SYNTAX
|
||||
|
||||
attr_reader :template_name_expr, :variable_name_expr, :attributes
|
||||
@@ -84,12 +85,18 @@ module Liquid
|
||||
alias_method :parse_context, :options
|
||||
private :parse_context
|
||||
|
||||
def for_loop?
|
||||
@is_for_loop
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@template_name_expr = safe_parse_expression(p)
|
||||
@variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with")
|
||||
with_or_for = p.id?("for") || p.id?("with")
|
||||
@variable_name_expr = safe_parse_expression(p) if with_or_for
|
||||
@alias_name = p.consume(:id) if p.id?("as")
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@@ -111,11 +118,13 @@ module Liquid
|
||||
def lax_parse(markup)
|
||||
if markup =~ SYNTAX
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@alias_name = Regexp.last_match(6)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@is_for_loop = (with_or_for == FOR)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
|
||||
@@ -23,13 +23,29 @@ module Liquid
|
||||
# {% increment variable_name %}
|
||||
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
|
||||
class Increment < Tag
|
||||
include ParserSwitching
|
||||
|
||||
attr_reader :variable_name
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
@variable_name = markup.strip
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup.strip)
|
||||
@variable_name = p.consume(:id)
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
counter_environment = context.environments.first
|
||||
value = counter_environment[@variable_name] || 0
|
||||
|
||||
@@ -85,12 +85,13 @@ module Liquid
|
||||
from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0
|
||||
to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil
|
||||
|
||||
collection = Utils.slice_collection(collection, from, to)
|
||||
collection = Utils.slice_collection_for_iteration(collection, from, to, context.resource_limits, allow_endless: true)
|
||||
length = collection.length
|
||||
|
||||
cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length
|
||||
|
||||
output << "<tr class=\"row1\">\n"
|
||||
context.resource_limits.increment_write_score(output)
|
||||
context.stack do
|
||||
tablerowloop = Liquid::TablerowloopDrop.new(length, cols)
|
||||
context['tablerowloop'] = tablerowloop
|
||||
@@ -101,6 +102,7 @@ module Liquid
|
||||
output << "<td class=\"col#{tablerowloop.col}\">"
|
||||
super
|
||||
output << '</td>'
|
||||
context.resource_limits.increment_write_score(output)
|
||||
|
||||
# Handle any interrupts if they exist.
|
||||
if context.interrupt?
|
||||
@@ -110,6 +112,7 @@ module Liquid
|
||||
|
||||
if tablerowloop.col_last && !tablerowloop.last
|
||||
output << "</tr>\n<tr class=\"row#{tablerowloop.row + 1}\">"
|
||||
context.resource_limits.increment_write_score(output)
|
||||
end
|
||||
|
||||
tablerowloop.send(:increment!)
|
||||
@@ -117,6 +120,7 @@ module Liquid
|
||||
end
|
||||
|
||||
output << "</tr>\n"
|
||||
context.resource_limits.increment_write_score(output)
|
||||
output
|
||||
end
|
||||
|
||||
|
||||
+13
-2
@@ -151,8 +151,10 @@ module Liquid
|
||||
|
||||
c
|
||||
when Liquid::Drop
|
||||
drop = args.shift
|
||||
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
||||
drop = args.shift
|
||||
c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
||||
drop.context = c if drop.respond_to?(:context=)
|
||||
c
|
||||
when Hash
|
||||
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
||||
when nil
|
||||
@@ -187,12 +189,20 @@ module Liquid
|
||||
|
||||
context.template_name ||= name
|
||||
|
||||
previous_error_mode = context.registers.static[:template_error_mode]
|
||||
context.registers.static[:template_error_mode] = @error_mode
|
||||
|
||||
begin
|
||||
# render the nodelist.
|
||||
@root.render_to_output_buffer(context, output || +'')
|
||||
rescue Liquid::MemoryError => e
|
||||
context.handle_error(e)
|
||||
ensure
|
||||
if previous_error_mode
|
||||
context.registers.static[:template_error_mode] = previous_error_mode
|
||||
else
|
||||
context.registers.static.delete(:template_error_mode)
|
||||
end
|
||||
@errors = context.errors
|
||||
end
|
||||
end
|
||||
@@ -224,6 +234,7 @@ module Liquid
|
||||
end
|
||||
|
||||
@warnings = parse_context.warnings
|
||||
@error_mode = parse_context.error_mode
|
||||
parse_context
|
||||
end
|
||||
|
||||
|
||||
@@ -13,6 +13,26 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
# This is intentionally separate from slice_collection, whose Array-returning
|
||||
# behavior is used outside of the iteration tags.
|
||||
def self.slice_collection_for_iteration(
|
||||
collection, from, to, resource_limits, allow_endless: false, use_range_to_a: false
|
||||
)
|
||||
if integer_range?(collection)
|
||||
RangeSlice.new(collection, from, to, resource_limits)
|
||||
elsif collection.is_a?(Range)
|
||||
if use_range_to_a && range_method_overridden?(collection, :to_a)
|
||||
# For historically honored custom Range#to_a. Charge the resulting
|
||||
# selection before buffering it, just as for a custom #each.
|
||||
slice_collection_for_iteration_using_each(collection.to_a, from, to, resource_limits)
|
||||
else
|
||||
slice_range_using_each(collection, from, to, resource_limits, allow_endless: allow_endless)
|
||||
end
|
||||
else
|
||||
slice_collection(collection, from, to)
|
||||
end
|
||||
end
|
||||
|
||||
def self.slice_collection_using_each(collection, from, to)
|
||||
segments = []
|
||||
index = 0
|
||||
@@ -38,6 +58,55 @@ module Liquid
|
||||
segments
|
||||
end
|
||||
|
||||
# Arithmetic slicing must not bypass a Range subclass's custom #each.
|
||||
def self.integer_range?(collection)
|
||||
collection.instance_of?(Range) && collection.begin.is_a?(Integer) && collection.end.is_a?(Integer)
|
||||
end
|
||||
private_class_method :integer_range?
|
||||
|
||||
# Preserve support for Ruby-supplied string ranges and custom Range#each.
|
||||
# Their selected length cannot be inferred from integer bounds, but the tags
|
||||
# need it before rendering for loop metadata, continuation offsets, and columns.
|
||||
# Buffer the selection so we do not have to replay a potentially custom iterator.
|
||||
def self.slice_range_using_each(collection, from, to, resource_limits, allow_endless:)
|
||||
# TableRow historically accepted an endless subclass when its custom #each
|
||||
# was finite, while For historically raised through Range#to_a.
|
||||
if collection.end.nil? && !(allow_endless && (!to.nil? || range_method_overridden?(collection, :each)))
|
||||
raise RangeError, "cannot convert endless range to an array"
|
||||
end
|
||||
if collection.begin.nil? && !range_method_overridden?(collection, :each)
|
||||
raise TypeError, "can't iterate from NilClass"
|
||||
end
|
||||
|
||||
slice_collection_for_iteration_using_each(collection, from, to, resource_limits)
|
||||
end
|
||||
private_class_method :slice_range_using_each
|
||||
|
||||
# Custom Range#each can make a nominally beginless range finite; standard
|
||||
# beginless ranges were rejected before reaching this budgeted traversal.
|
||||
def self.slice_collection_for_iteration_using_each(collection, from, to, resource_limits)
|
||||
return [] if to && to <= from
|
||||
|
||||
segments = []
|
||||
index = 0
|
||||
collection.each do |item|
|
||||
break if to && to <= index
|
||||
|
||||
# Charge preparation, including skipped offsets, before buffering; checking
|
||||
# only while rendering the buffered values would leave this work unbudgeted.
|
||||
resource_limits.increment_render_score(1)
|
||||
segments << item if from <= index
|
||||
index += 1
|
||||
end
|
||||
segments
|
||||
end
|
||||
private_class_method :slice_collection_for_iteration_using_each
|
||||
|
||||
def self.range_method_overridden?(collection, method_name)
|
||||
collection.method(method_name).owner != Range.instance_method(method_name).owner
|
||||
end
|
||||
private_class_method :range_method_overridden?
|
||||
|
||||
def self.to_integer(num)
|
||||
return num if num.is_a?(Integer)
|
||||
num = num.to_s
|
||||
|
||||
@@ -37,6 +37,10 @@ module Liquid
|
||||
@markup
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
self.class == other.class && name == other.name && filters == other.filters
|
||||
end
|
||||
|
||||
def markup_context(markup)
|
||||
"in \"{{#{markup}}}\""
|
||||
end
|
||||
|
||||
@@ -4,25 +4,6 @@ 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)
|
||||
@@ -30,31 +11,6 @@ module Liquid
|
||||
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
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
VERSION = "5.12.0"
|
||||
VERSION = "5.14.0"
|
||||
end
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Quick benchmark: measures parse µs, render µs, and object allocations
|
||||
# Outputs machine-readable metrics to stdout
|
||||
|
||||
require_relative 'theme_runner'
|
||||
|
||||
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
|
||||
|
||||
runner = ThemeRunner.new
|
||||
|
||||
# Warmup — enough iterations for YJIT to fully optimize hot paths
|
||||
20.times { runner.compile }
|
||||
20.times { runner.render }
|
||||
|
||||
GC.start
|
||||
GC.compact if GC.respond_to?(:compact)
|
||||
|
||||
# Measure parse
|
||||
parse_times = []
|
||||
10.times do
|
||||
GC.disable
|
||||
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
runner.compile
|
||||
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
GC.enable
|
||||
GC.start
|
||||
parse_times << (t1 - t0) * 1_000_000 # µs
|
||||
end
|
||||
|
||||
# Measure render
|
||||
render_times = []
|
||||
10.times do
|
||||
GC.disable
|
||||
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
runner.render
|
||||
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
GC.enable
|
||||
GC.start
|
||||
render_times << (t1 - t0) * 1_000_000 # µs
|
||||
end
|
||||
|
||||
# Measure object allocations for one parse+render cycle
|
||||
require 'objspace'
|
||||
GC.start
|
||||
GC.disable
|
||||
before = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
|
||||
runner.compile
|
||||
runner.render
|
||||
after = ObjectSpace.count_objects.values_at(:TOTAL).first - ObjectSpace.count_objects.values_at(:FREE).first
|
||||
GC.enable
|
||||
allocations = after - before
|
||||
|
||||
parse_us = parse_times.min.round(0)
|
||||
render_us = render_times.min.round(0)
|
||||
combined_us = parse_us + render_us
|
||||
|
||||
puts "RESULTS"
|
||||
puts "parse_us=#{parse_us}"
|
||||
puts "render_us=#{render_us}"
|
||||
puts "combined_us=#{combined_us}"
|
||||
puts "allocations=#{allocations}"
|
||||
+16
-3
@@ -6,14 +6,24 @@
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'liquid'
|
||||
require_relative 'support/liquid_spec_adapter_helper'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
# Run core Liquid specs
|
||||
config.features = [:core]
|
||||
config.missing_features = [
|
||||
:activesupport,
|
||||
:lax_parsing,
|
||||
:shopify_filters,
|
||||
:shopify_includes,
|
||||
:shopify_blank,
|
||||
:shopify_error_handling,
|
||||
:shopify_error_format,
|
||||
:shopify_string_access,
|
||||
]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
options[:error_mode] ||= :strict
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
@@ -28,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
LiquidSpecAdapterHelper.with_frozen_time do
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
end
|
||||
|
||||
+16
-4
@@ -6,15 +6,24 @@
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'liquid'
|
||||
require_relative 'support/liquid_spec_adapter_helper'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
config.features = [:core, :lax_parsing]
|
||||
config.missing_features = [
|
||||
:activesupport,
|
||||
:shopify_filters,
|
||||
:shopify_includes,
|
||||
:shopify_blank,
|
||||
:shopify_error_handling,
|
||||
:shopify_error_format,
|
||||
:shopify_string_access,
|
||||
]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
# Force lax mode
|
||||
options = options.merge(error_mode: :lax)
|
||||
# Default to lax mode while still honoring specs that explicitly set error_mode.
|
||||
options = { error_mode: :lax }.merge(options)
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
@@ -26,9 +35,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
LiquidSpecAdapterHelper.with_frozen_time do
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,14 +7,23 @@
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'active_support/all'
|
||||
require 'liquid'
|
||||
require_relative 'support/liquid_spec_adapter_helper'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
|
||||
config.features = [:core, :activesupport]
|
||||
config.missing_features = [
|
||||
:lax_parsing,
|
||||
:shopify_filters,
|
||||
:shopify_includes,
|
||||
:shopify_blank,
|
||||
:shopify_error_handling,
|
||||
:shopify_error_format,
|
||||
:shopify_string_access,
|
||||
]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
options[:error_mode] ||= :strict
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
@@ -29,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
LiquidSpecAdapterHelper.with_frozen_time do
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,18 @@ end
|
||||
|
||||
require 'active_support/all'
|
||||
require 'liquid'
|
||||
require_relative 'support/liquid_spec_adapter_helper'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
config.features = [:core, :activesupport]
|
||||
config.missing_features = [
|
||||
:lax_parsing,
|
||||
:shopify_filters,
|
||||
:shopify_includes,
|
||||
:shopify_blank,
|
||||
:shopify_error_handling,
|
||||
:shopify_error_format,
|
||||
:shopify_string_access,
|
||||
]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
@@ -33,9 +42,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
LiquidSpecAdapterHelper.with_frozen_time do
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LiquidSpecAdapterHelper
|
||||
extend self
|
||||
|
||||
def resource_limits(render_options)
|
||||
return unless render_options[:resource_limits]
|
||||
|
||||
Liquid::ResourceLimits.new({}).tap do |limits|
|
||||
render_options[:resource_limits].each do |key, value|
|
||||
limits.public_send(:"#{key}=", value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_frozen_time(&block)
|
||||
original_tz = ENV['TZ']
|
||||
ENV['TZ'] = 'UTC'
|
||||
|
||||
Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block)
|
||||
ensure
|
||||
ENV['TZ'] = original_tz
|
||||
end
|
||||
end
|
||||
@@ -97,6 +97,46 @@ class AssignTest < Minitest::Test
|
||||
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
|
||||
end
|
||||
|
||||
def test_assign_with_valid_identifier_in_strict2
|
||||
assert_template_result("hello", "{% assign my_var = 'hello' %}{{ my_var }}", error_mode: :strict2)
|
||||
end
|
||||
|
||||
def test_assign_with_hyphen_in_strict2
|
||||
assert_template_result("hello", "{% assign my-var = 'hello' %}{{ my-var }}", error_mode: :strict2)
|
||||
end
|
||||
|
||||
def test_assign_rejects_parentheses_in_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% assign (a(b(c) = 1234 %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_assign_rejects_brackets_in_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% assign [x.y] = 'hello' %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_assign_rejects_dot_in_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% assign a.b = 'hello' %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_assign_rejects_numeric_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% assign 1abc = 'hello' %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_assign_allows_invalid_names_in_lax
|
||||
assert_template_result("1234", "{% assign (a(b(c) = 1234 %}{{ self['(a(b(c)'] }}", error_mode: :lax)
|
||||
end
|
||||
|
||||
def test_assign_with_filter_in_strict2
|
||||
assert_template_result("HELLO", "{% assign my_var = 'hello' | upcase %}{{ my_var }}", error_mode: :strict2)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
class ObjectWrapperDrop < Liquid::Drop
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class BlankBodyErrorHandlingTest < Minitest::Test
|
||||
COMPARISON_ERROR = 'Liquid error (line 1): comparison of Integer with String failed'
|
||||
INVALID_INTEGER_ERROR = 'Liquid error (line 1): invalid integer'
|
||||
|
||||
def render_inline(source, error_mode:, assigns: {})
|
||||
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render(assigns, render_errors: true)
|
||||
end
|
||||
|
||||
def assert_render_raises(source, error_mode:, assigns: {}, message: nil)
|
||||
error = assert_raises(Liquid::ArgumentError) do
|
||||
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns)
|
||||
end
|
||||
assert_includes(error.message, message) if message
|
||||
end
|
||||
|
||||
def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict
|
||||
[:lax, :strict].each do |mode|
|
||||
assert_equal('', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode))
|
||||
end
|
||||
end
|
||||
|
||||
def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict
|
||||
[:lax, :strict].each do |mode|
|
||||
assert_equal('', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode))
|
||||
end
|
||||
end
|
||||
|
||||
def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict
|
||||
[:lax, :strict].each do |mode|
|
||||
assert_equal('', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }))
|
||||
end
|
||||
end
|
||||
|
||||
def test_strict2_blank_if_body_shows_inline_error_text
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_whitespace_if_body_shows_inline_error_text
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_assign_if_body_shows_inline_error_text
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_comment_if_body_shows_inline_error_text
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_capture_if_body_shows_inline_error_text
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_blank_unless_body_shows_inline_error_text
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2))
|
||||
end
|
||||
|
||||
def test_strict2_blank_for_body_shows_inline_error_text
|
||||
assert_equal(INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }))
|
||||
end
|
||||
|
||||
def test_nonblank_bodies_show_inline_error_text_in_all_modes
|
||||
[:lax, :strict, :strict2].each do |mode|
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode))
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode))
|
||||
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode))
|
||||
end
|
||||
end
|
||||
|
||||
def test_raised_errors_are_not_swallowed_by_blank_if_body
|
||||
[:lax, :strict, :strict2].each do |mode|
|
||||
assert_render_raises('{% if 5 > "x" %}{% endif %}', error_mode: mode, message: 'comparison of Integer with String failed')
|
||||
end
|
||||
end
|
||||
|
||||
def test_raised_errors_are_not_swallowed_by_blank_for_body
|
||||
[:lax, :strict, :strict2].each do |mode|
|
||||
assert_render_raises('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }, message: 'invalid integer')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,11 @@ class CaptureTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_captures_block_content_in_variable
|
||||
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {})
|
||||
assert_template_result("test string", "{% capture var %}test string{% endcapture %}{{var}}", {})
|
||||
end
|
||||
|
||||
def test_captures_block_content_in_quoted_variable_in_lax
|
||||
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}, error_mode: :lax)
|
||||
end
|
||||
|
||||
def test_capture_with_hyphen_in_variable_name
|
||||
@@ -49,4 +53,35 @@ class CaptureTest < Minitest::Test
|
||||
t.render!
|
||||
assert_equal(9, t.resource_limits.assign_score)
|
||||
end
|
||||
|
||||
def test_capture_with_valid_identifier_in_strict2
|
||||
assert_template_result("hello", "{% capture my_var %}hello{% endcapture %}{{ my_var }}", error_mode: :strict2)
|
||||
end
|
||||
|
||||
def test_capture_with_hyphen_in_strict2
|
||||
assert_template_result("hello", "{% capture my-var %}hello{% endcapture %}{{ my-var }}", error_mode: :strict2)
|
||||
end
|
||||
|
||||
def test_capture_rejects_parentheses_in_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_capture_rejects_dot_in_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% capture a.b %}hello{% endcapture %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_capture_rejects_numeric_variable_name_in_strict2
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Template.parse("{% capture 1abc %}hello{% endcapture %}", error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_capture_allows_invalid_names_in_lax
|
||||
t = Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :lax)
|
||||
assert_equal("(x[y", t.root.nodelist.first.to)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -296,8 +296,8 @@ class ContextTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_access_variable_with_hash_notation
|
||||
assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
|
||||
assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
|
||||
assert_template_result('baz', '{{ foo }}', { "foo" => "baz" })
|
||||
assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
|
||||
end
|
||||
|
||||
def test_access_hashes_with_hash_access_variables
|
||||
|
||||
@@ -265,11 +265,13 @@ class ErrorHandlingTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_bug_compatible_silencing_of_errors_in_blank_nodes
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("Liquid error: comparison of Integer with String failed0", output)
|
||||
with_error_modes(:lax, :strict) do
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("Liquid error: comparison of Integer with String failed0", output)
|
||||
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("0", output)
|
||||
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
|
||||
assert_equal("0", output)
|
||||
end
|
||||
end
|
||||
|
||||
def test_syntax_error_is_raised_with_template_name
|
||||
|
||||
@@ -61,12 +61,15 @@ class SecurityTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_does_not_add_drop_methods_to_symbol_table
|
||||
assert_no_new_symbols do
|
||||
assigns = { 'drop' => Drop.new }
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
|
||||
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
|
||||
assigns = { 'drop' => Drop.new }
|
||||
method_names = Array.new(3) { |index| "untrusted_drop_method_#{object_id}_#{index}" }
|
||||
|
||||
method_names.each do |method_name|
|
||||
assert_equal("", Template.parse("{{ drop.#{method_name} }}").render!(assigns))
|
||||
end
|
||||
|
||||
# JITs can intern internal metadata; untrusted Drop method names must not be interned.
|
||||
assert_equal([], Symbol.all_symbols.map(&:to_s) & method_names)
|
||||
end
|
||||
|
||||
def assert_no_new_symbols
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class SelfDropContextTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_self_drop_passed_as_render_param_preserves_original_scope
|
||||
source = <<~LIQUID
|
||||
{%- assign var = 42 -%}
|
||||
{%- assign s = self -%}
|
||||
{%- render "snippet1", other_self: s -%}
|
||||
LIQUID
|
||||
|
||||
partials = {
|
||||
'snippet1' => <<~LIQUID,
|
||||
{%- assign var = 43 -%}
|
||||
{{- other_self.var }}|{{ self.var -}}
|
||||
LIQUID
|
||||
}
|
||||
|
||||
assert_template_result('42|43', source, partials: partials)
|
||||
end
|
||||
|
||||
def test_self_drop_in_render_without_passing_resolves_inner_scope
|
||||
source = <<~LIQUID
|
||||
{%- assign var = 42 -%}
|
||||
{%- render "snippet1" -%}
|
||||
LIQUID
|
||||
|
||||
partials = {
|
||||
'snippet1' => <<~LIQUID,
|
||||
{%- assign var = 99 -%}
|
||||
{{- self.var -}}
|
||||
LIQUID
|
||||
}
|
||||
|
||||
assert_template_result('99', source, partials: partials)
|
||||
end
|
||||
|
||||
def test_self_drop_passed_to_nested_renders_preserves_each_level
|
||||
source = <<~LIQUID
|
||||
{%- assign a = 1 -%}
|
||||
{%- assign s1 = self -%}
|
||||
{%- render "snippet1", outer: s1 -%}
|
||||
LIQUID
|
||||
|
||||
partials = {
|
||||
'snippet1' => <<~LIQUID,
|
||||
{%- assign a = 2 -%}
|
||||
{%- assign s2 = self -%}
|
||||
{%- render "snippet2", outer: outer, middle: s2 -%}
|
||||
LIQUID
|
||||
'snippet2' => <<~LIQUID,
|
||||
{%- assign a = 3 -%}
|
||||
{{- outer.a }}|{{ middle.a }}|{{ self.a -}}
|
||||
LIQUID
|
||||
}
|
||||
|
||||
assert_template_result('1|2|3', source, partials: partials)
|
||||
end
|
||||
|
||||
def test_self_drop_reflects_variables_assigned_after_creation
|
||||
source = <<~LIQUID
|
||||
{%- assign s = self -%}
|
||||
{%- assign x = 42 %}{{ s.x -}}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('42', source)
|
||||
end
|
||||
|
||||
def test_self_drop_context_setter_is_undefined
|
||||
context = Context.new
|
||||
drop = SelfDrop.new(context)
|
||||
refute(drop.respond_to?(:context=))
|
||||
|
||||
assert_template_result('42', '{{ self.x }}', { 'x' => 42 })
|
||||
end
|
||||
|
||||
def test_self_drop_repeated_lookups_compare_equal_for_same_context
|
||||
context = Context.new
|
||||
drop = context.find_variable("self")
|
||||
cached_drop = context.find_variable("self")
|
||||
|
||||
assert_same(drop, cached_drop)
|
||||
assert_equal(drop.object_id, cached_drop.object_id)
|
||||
assert_equal(drop, cached_drop)
|
||||
end
|
||||
|
||||
def test_assigned_self_drop_compares_equal_to_itself
|
||||
assert_template_result('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}')
|
||||
end
|
||||
|
||||
def test_distinct_self_assignments_compare_equal_for_same_context
|
||||
assert_template_result('T', '{% assign a = self %}{% assign b = self %}{% if a == b %}T{% else %}F{% endif %}')
|
||||
end
|
||||
|
||||
def test_bare_self_compares_equal_to_bare_self
|
||||
assert_template_result('T', '{% if self == self %}T{% else %}F{% endif %}')
|
||||
end
|
||||
|
||||
def test_self_drop_with_strict_variables_does_not_raise_for_defined_var
|
||||
t = Template.parse('{{ self.x }}')
|
||||
result = t.render({ 'x' => 42 }, strict_variables: true)
|
||||
assert_equal('42', result)
|
||||
end
|
||||
|
||||
def test_self_drop_with_strict_variables_returns_nil_for_undefined_var
|
||||
t = Template.parse('{{ self.x }}')
|
||||
result = t.render({}, strict_variables: true)
|
||||
assert_equal('', result)
|
||||
end
|
||||
|
||||
def test_self_drop_can_be_passed_as_bare_drop_to_render
|
||||
t = Template.parse('{{ self.x }}')
|
||||
drop = SelfDrop.new(Context.new({ 'x' => 42 }))
|
||||
result = t.render(drop)
|
||||
assert_equal('42', result)
|
||||
end
|
||||
end
|
||||
@@ -105,10 +105,8 @@ class CycleTagTest < Minitest::Test
|
||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||
|
||||
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
|
||||
|
||||
assert_match(expected_error, error1.message)
|
||||
assert_match(expected_error, error2.message)
|
||||
assert_match(/Liquid syntax error:/, error1.message)
|
||||
assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -465,4 +465,85 @@ HERE
|
||||
|
||||
assert(context.registers[:for_stack].empty?)
|
||||
end
|
||||
|
||||
def test_integer_range_is_not_materialized_and_charges_empty_iterations
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse('{% for i in numbers %}{% endfor %}')
|
||||
template.resource_limits.render_score_limit = 3
|
||||
|
||||
assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
|
||||
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_equal(4, template.resource_limits.render_score)
|
||||
end
|
||||
|
||||
def test_integer_range_uses_arithmetic_offsets_reversal_and_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% for i in numbers reversed offset:997 limit:2 %}{{ forloop.length }}:{{ i }}{% endfor %}',
|
||||
)
|
||||
template.resource_limits.render_score_limit = 10
|
||||
|
||||
assert_equal('2:9992:998', template.render!('numbers' => range))
|
||||
end
|
||||
|
||||
def test_range_scores_cannot_be_bypassed_by_repeated_renders
|
||||
template = Template.parse('{% for i in (1..2) %}{% endfor %}')
|
||||
template.resource_limits.cumulative_render_score_limit = 3
|
||||
assert_equal('', template.render!)
|
||||
assert_raises(Liquid::MemoryError) { template.render! }
|
||||
end
|
||||
|
||||
def test_range_break_charges_only_visited_items_and_preserves_full_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% for i in numbers reversed %}{{ forloop.length }}:{{ i }}{% break %}{% endfor %}',
|
||||
)
|
||||
template.resource_limits.render_score_limit = 10
|
||||
|
||||
assert_equal('1000:1000', template.render!('numbers' => range))
|
||||
assert_operator(template.resource_limits.render_score, :<=, 10)
|
||||
end
|
||||
|
||||
def test_range_subclass_uses_its_custom_each
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(nil, 3)
|
||||
|
||||
assert_template_result('1020', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
|
||||
end
|
||||
|
||||
def test_range_subclass_custom_to_a_is_honored_for_finite_and_open_bounds
|
||||
range_class = Class.new(Range) do
|
||||
def to_a
|
||||
[42]
|
||||
end
|
||||
end
|
||||
|
||||
[range_class.new(1, 3), range_class.new(nil, 3), range_class.new(1, nil)].each do |range|
|
||||
assert_template_result('42', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range })
|
||||
end
|
||||
end
|
||||
|
||||
def test_endless_range_remains_unsupported_with_a_limit
|
||||
template = Template.parse('{% for i in numbers limit:2 %}{{ i }}{% endfor %}')
|
||||
|
||||
assert_raises(RangeError) { template.render!('numbers' => (1..)) }
|
||||
end
|
||||
|
||||
def test_endless_range_subclass_with_custom_each_remains_unsupported
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(1, nil)
|
||||
|
||||
assert_raises(RangeError) do
|
||||
Template.parse('{% for i in numbers %}{{ i }}{% endfor %}').render!('numbers' => range)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -439,4 +439,49 @@ class IncludeTagTest < Minitest::Test
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_for_loop_true_with_for_keyword
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
template = Template.parse("{% include 'product' for products %}")
|
||||
include_node = template.root.nodelist.first
|
||||
|
||||
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' keyword")
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_for_loop_false_with_with_keyword
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
template = Template.parse("{% include 'product' with product %}")
|
||||
include_node = template.root.nodelist.first
|
||||
|
||||
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' keyword")
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_for_loop_false_without_keyword
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
template = Template.parse("{% include 'header' %}")
|
||||
include_node = template.root.nodelist.first
|
||||
|
||||
refute(include_node.for_loop?, "Expected for_loop? to be false when no keyword")
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_for_loop_with_alias
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
template = Template.parse("{% include 'product' for products as item %}")
|
||||
include_node = template.root.nodelist.first
|
||||
|
||||
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' with alias")
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_with_keyword_and_alias
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
template = Template.parse("{% include 'product' with products[0] as item %}")
|
||||
include_node = template.root.nodelist.first
|
||||
|
||||
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' with alias")
|
||||
end
|
||||
end
|
||||
end # IncludeTagTest
|
||||
|
||||
@@ -27,4 +27,50 @@ class IncrementTagTest < Minitest::Test
|
||||
'{%decrement starboard %}',
|
||||
)
|
||||
end
|
||||
|
||||
def test_increment_strict2_rejects_invalid_variable_name
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse('{% increment foo bar %}', error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_increment_strict2_rejects_variable_starting_with_number
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse('{% increment 11aa %}', error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_increment_strict2_accepts_valid_variable_name
|
||||
template = Template.parse('{% increment my-var %}', error_mode: :strict2)
|
||||
assert_equal('0', template.render)
|
||||
end
|
||||
|
||||
def test_decrement_strict2_rejects_invalid_variable_name
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse('{% decrement foo bar %}', error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_decrement_strict2_rejects_variable_starting_with_number
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse('{% decrement 11aa %}', error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_decrement_strict2_accepts_valid_variable_name
|
||||
template = Template.parse('{% decrement my-var %}', error_mode: :strict2)
|
||||
assert_equal('-1', template.render)
|
||||
end
|
||||
|
||||
def test_increment_strict2_rejects_empty_variable_name
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse('{% increment %}', error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
|
||||
def test_decrement_strict2_rejects_empty_variable_name
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse('{% decrement %}', error_mode: :strict2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -174,16 +174,16 @@ class RenderTagTest < Minitest::Test
|
||||
def test_increment_is_isolated_between_renders
|
||||
assert_template_result(
|
||||
'010',
|
||||
'{% increment %}{% increment %}{% render "incr" %}',
|
||||
partials: { 'incr' => '{% increment %}' },
|
||||
'{% increment port %}{% increment port %}{% render "incr" %}',
|
||||
partials: { 'incr' => '{% increment port %}' },
|
||||
)
|
||||
end
|
||||
|
||||
def test_decrement_is_isolated_between_renders
|
||||
assert_template_result(
|
||||
'-1-2-1',
|
||||
'{% decrement %}{% decrement %}{% render "decr" %}',
|
||||
partials: { 'decr' => '{% decrement %}' },
|
||||
'{% decrement port %}{% decrement port %}{% render "decr" %}',
|
||||
partials: { 'decr' => '{% decrement port %}' },
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -465,4 +465,94 @@ class TableRowTest < Minitest::Test
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_integer_range_is_not_materialized_and_charges_empty_iterations
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse('{% tablerow i in numbers %}{% endtablerow %}')
|
||||
template.resource_limits.render_score_limit = 3
|
||||
|
||||
assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) }
|
||||
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_equal(4, template.resource_limits.render_score)
|
||||
end
|
||||
|
||||
def test_integer_range_uses_arithmetic_offsets_limit_and_full_metadata
|
||||
range = bounded_integer_range_with_tripwires
|
||||
template = Template.parse(
|
||||
'{% tablerow i in numbers offset:997 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}',
|
||||
)
|
||||
|
||||
assert_equal(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">2:1:998</td><td class=\"col2\">2:2:999</td></tr>\n",
|
||||
template.render!('numbers' => range),
|
||||
)
|
||||
end
|
||||
|
||||
def test_tablerow_checks_generated_output_during_empty_body_iteration
|
||||
template = Template.parse('{% tablerow i in (1..100) %}{% endtablerow %}')
|
||||
template.resource_limits.render_length_limit = 40
|
||||
checked_lengths = []
|
||||
limits = template.resource_limits
|
||||
original_increment_write_score = limits.method(:increment_write_score)
|
||||
limits.define_singleton_method(:increment_write_score) do |output|
|
||||
checked_lengths << output.bytesize
|
||||
original_increment_write_score.call(output)
|
||||
end
|
||||
|
||||
assert_equal('Liquid error: Memory limits exceeded', template.render)
|
||||
assert(template.resource_limits.reached?)
|
||||
assert_operator(checked_lengths.last, :<, 1000)
|
||||
end
|
||||
|
||||
def test_tablerow_range_scores_persist_across_renders
|
||||
template = Template.parse('{% tablerow i in (1..2) %}{% endtablerow %}')
|
||||
template.resource_limits.cumulative_render_score_limit = 3
|
||||
template.render!
|
||||
assert_raises(Liquid::MemoryError) { template.render! }
|
||||
end
|
||||
|
||||
def test_range_subclass_uses_its_custom_each_with_beginless_bounds
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(nil, 3)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">10</td><td class=\"col2\">20</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
|
||||
def test_endless_range_subclass_uses_its_custom_each_without_a_limit
|
||||
range = Class.new(Range) do
|
||||
def each
|
||||
yield 10
|
||||
yield 20
|
||||
end
|
||||
end.new(1, nil)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">10</td><td class=\"col2\">20</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
|
||||
def test_range_subclass_custom_to_a_is_not_used
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
[42]
|
||||
end
|
||||
end.new(1, 3)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td><td class=\"col3\">3</td></tr>\n",
|
||||
'{% tablerow i in numbers %}{{ i }}{% endtablerow %}',
|
||||
{ 'numbers' => range },
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -132,7 +132,7 @@ class TemplateTest < Minitest::Test
|
||||
assert_equal("Liquid error: Memory limits exceeded", t.render)
|
||||
assert(t.resource_limits.reached?)
|
||||
|
||||
t.resource_limits.render_score_limit = 200
|
||||
t.resource_limits.render_score_limit = 201
|
||||
assert_equal(" foo " * 100, t.render!)
|
||||
refute_nil(t.resource_limits.render_score)
|
||||
end
|
||||
|
||||
@@ -55,7 +55,7 @@ class VariableTest < Minitest::Test
|
||||
|
||||
def test_expression_with_whitespace_in_square_brackets
|
||||
assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } })
|
||||
assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
|
||||
assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
|
||||
end
|
||||
|
||||
def test_ignore_unknown
|
||||
@@ -135,17 +135,17 @@ class VariableTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_dynamic_find_var
|
||||
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
|
||||
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
|
||||
end
|
||||
|
||||
def test_raw_value_variable
|
||||
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
|
||||
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
|
||||
end
|
||||
|
||||
def test_dynamic_find_var_with_drop
|
||||
assert_template_result(
|
||||
'bar',
|
||||
'{{ [list[settings.zero]] }}',
|
||||
'{{ self[list[settings.zero]] }}',
|
||||
{
|
||||
'list' => ['foo'],
|
||||
'settings' => SettingsDrop.new("zero" => 0),
|
||||
@@ -155,7 +155,7 @@ class VariableTest < Minitest::Test
|
||||
|
||||
assert_template_result(
|
||||
'foo',
|
||||
'{{ [list[settings.zero]["foo"]] }}',
|
||||
'{{ self[list[settings.zero]["foo"]] }}',
|
||||
{
|
||||
'list' => [{ 'foo' => 'bar' }],
|
||||
'settings' => SettingsDrop.new("zero" => 0),
|
||||
|
||||
@@ -32,6 +32,15 @@ module Minitest
|
||||
module Assertions
|
||||
include Liquid
|
||||
|
||||
# Exact Range fixture for fast-path tests; singleton tripwires must remain
|
||||
# untouched because the arithmetic path does not materialize or traverse it.
|
||||
def bounded_integer_range_with_tripwires
|
||||
range = (1..1000).dup
|
||||
range.define_singleton_method(:to_a) { raise 'range was materialized' }
|
||||
range.define_singleton_method(:each) { raise 'range was traversed' }
|
||||
range
|
||||
end
|
||||
|
||||
def assert_template_result(
|
||||
expected, template, assigns = {},
|
||||
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
# Tests for the byte-walking fast paths introduced in the ByteTables optimization.
|
||||
# Each fast path is tested for equivalence with the original regex-based code path
|
||||
# it replaces, covering accepted inputs, rejected inputs, and boundary cases.
|
||||
class FastPathUnitTest < Minitest::Test
|
||||
# ── Expression.parse_number ────────────────────────────────────────
|
||||
# Fast path: byte-walk with ByteTables::DIGIT
|
||||
# Replaces: INTEGER_REGEX, FLOAT_REGEX, StringScanner loop
|
||||
|
||||
def test_parse_number_simple_integers
|
||||
assert_equal(42, Liquid::Expression.parse_number("42"))
|
||||
assert_equal(0, Liquid::Expression.parse_number("0"))
|
||||
assert_equal(999, Liquid::Expression.parse_number("999"))
|
||||
end
|
||||
|
||||
def test_parse_number_negative_integers
|
||||
assert_equal(-7, Liquid::Expression.parse_number("-7"))
|
||||
assert_equal(-0, Liquid::Expression.parse_number("-0"))
|
||||
assert_equal(-123, Liquid::Expression.parse_number("-123"))
|
||||
end
|
||||
|
||||
def test_parse_number_simple_floats
|
||||
assert_equal(3.14, Liquid::Expression.parse_number("3.14"))
|
||||
assert_equal(0.5, Liquid::Expression.parse_number("0.5"))
|
||||
assert_equal(-0.5, Liquid::Expression.parse_number("-0.5"))
|
||||
assert_equal(100.0, Liquid::Expression.parse_number("100.0"))
|
||||
end
|
||||
|
||||
def test_parse_number_trailing_dot
|
||||
# "123." → 123.0 (truncate before dot)
|
||||
assert_equal(123.0, Liquid::Expression.parse_number("123."))
|
||||
assert_equal(0.0, Liquid::Expression.parse_number("0."))
|
||||
end
|
||||
|
||||
def test_parse_number_multi_dot_floats
|
||||
# "1.2.3" → 1.2 (truncate at second dot)
|
||||
assert_equal(1.2, Liquid::Expression.parse_number("1.2.3"))
|
||||
assert_equal(1.2, Liquid::Expression.parse_number("1.2.3.4"))
|
||||
assert_equal(0.0, Liquid::Expression.parse_number("0.0.0"))
|
||||
end
|
||||
|
||||
def test_parse_number_rejects_non_numeric
|
||||
assert_nil(Liquid::Expression.parse_number("hello"))
|
||||
assert_nil(Liquid::Expression.parse_number(""))
|
||||
assert_nil(Liquid::Expression.parse_number("abc123"))
|
||||
assert_nil(Liquid::Expression.parse_number(".5"))
|
||||
assert_nil(Liquid::Expression.parse_number("-.5"))
|
||||
end
|
||||
|
||||
def test_parse_number_rejects_trailing_alpha_after_multi_dot
|
||||
# "1.2.3a" must be nil, not 1.2 — these are not valid numbers
|
||||
assert_nil(Liquid::Expression.parse_number("1.2.3a"))
|
||||
assert_nil(Liquid::Expression.parse_number("1.2.3.4a"))
|
||||
assert_nil(Liquid::Expression.parse_number("1.2.34a"))
|
||||
assert_nil(Liquid::Expression.parse_number("-1.2.3a"))
|
||||
end
|
||||
|
||||
def test_parse_number_rejects_bare_dash
|
||||
assert_nil(Liquid::Expression.parse_number("-"))
|
||||
assert_nil(Liquid::Expression.parse_number("-a"))
|
||||
end
|
||||
|
||||
# ── Expression.parse strip guard ───────────────────────────────────
|
||||
# Fast path: skip String#strip when no leading/trailing whitespace
|
||||
# Must produce identical results to unconditional .strip
|
||||
|
||||
def test_parse_strips_leading_whitespace
|
||||
assert_equal(42, Liquid::Expression.parse(" 42"))
|
||||
assert_equal(42, Liquid::Expression.parse("\t42"))
|
||||
assert_equal(42, Liquid::Expression.parse("\n42"))
|
||||
end
|
||||
|
||||
def test_parse_strips_trailing_whitespace
|
||||
assert_equal(42, Liquid::Expression.parse("42 "))
|
||||
assert_equal(42, Liquid::Expression.parse("42\t"))
|
||||
assert_equal(42, Liquid::Expression.parse("42\n"))
|
||||
end
|
||||
|
||||
def test_parse_strips_both_sides
|
||||
assert_equal(42, Liquid::Expression.parse(" 42 "))
|
||||
assert_equal("hello", Liquid::Expression.parse(" 'hello' "))
|
||||
end
|
||||
|
||||
def test_parse_no_strip_needed
|
||||
assert_equal(42, Liquid::Expression.parse("42"))
|
||||
assert_equal("hello", Liquid::Expression.parse("'hello'"))
|
||||
assert_equal(true, Liquid::Expression.parse("true"))
|
||||
end
|
||||
|
||||
def test_parse_strips_null_bytes
|
||||
# String#strip removes \x00 — the WHITESPACE table must match
|
||||
assert_equal(true, Liquid::Expression.parse("\x00true"))
|
||||
assert_equal(true, Liquid::Expression.parse("true\x00"))
|
||||
end
|
||||
|
||||
# ── VariableLookup.simple_lookup? ──────────────────────────────────
|
||||
# Fast path: regex gate for simple a.b.c chains
|
||||
# Must accept only inputs the byte-walk can handle correctly
|
||||
|
||||
def test_simple_lookup_accepts_single_names
|
||||
assert(Liquid::VariableLookup.simple_lookup?("product"))
|
||||
assert(Liquid::VariableLookup.simple_lookup?("x"))
|
||||
assert(Liquid::VariableLookup.simple_lookup?("_private"))
|
||||
end
|
||||
|
||||
def test_simple_lookup_accepts_dotted_chains
|
||||
assert(Liquid::VariableLookup.simple_lookup?("product.title"))
|
||||
assert(Liquid::VariableLookup.simple_lookup?("a.b.c.d"))
|
||||
end
|
||||
|
||||
def test_simple_lookup_accepts_question_marks
|
||||
assert(Liquid::VariableLookup.simple_lookup?("product.available?"))
|
||||
assert(Liquid::VariableLookup.simple_lookup?("empty?"))
|
||||
end
|
||||
|
||||
def test_simple_lookup_accepts_hyphens
|
||||
assert(Liquid::VariableLookup.simple_lookup?("my-var"))
|
||||
assert(Liquid::VariableLookup.simple_lookup?("my-var.some-field"))
|
||||
end
|
||||
|
||||
def test_simple_lookup_rejects_brackets
|
||||
refute(Liquid::VariableLookup.simple_lookup?("product[0]"))
|
||||
refute(Liquid::VariableLookup.simple_lookup?("hash['key']"))
|
||||
refute(Liquid::VariableLookup.simple_lookup?("[0]"))
|
||||
end
|
||||
|
||||
def test_simple_lookup_rejects_empty_and_malformed
|
||||
refute(Liquid::VariableLookup.simple_lookup?(""))
|
||||
refute(Liquid::VariableLookup.simple_lookup?(".leading"))
|
||||
refute(Liquid::VariableLookup.simple_lookup?("trailing."))
|
||||
refute(Liquid::VariableLookup.simple_lookup?("a..b"))
|
||||
end
|
||||
|
||||
# ── VariableLookup fast path equivalence ───────────────────────────
|
||||
# The fast path must produce identical name, lookups, and command_flags
|
||||
# to the original VariableParser regex path
|
||||
|
||||
def test_fast_path_simple_name
|
||||
vl = Liquid::VariableLookup.new("product")
|
||||
assert_equal("product", vl.name)
|
||||
assert_equal([], vl.lookups)
|
||||
end
|
||||
|
||||
def test_fast_path_dotted_chain
|
||||
vl = Liquid::VariableLookup.new("product.title")
|
||||
assert_equal("product", vl.name)
|
||||
assert_equal(["title"], vl.lookups)
|
||||
end
|
||||
|
||||
def test_fast_path_deep_chain
|
||||
vl = Liquid::VariableLookup.new("a.b.c.d")
|
||||
assert_equal("a", vl.name)
|
||||
assert_equal(["b", "c", "d"], vl.lookups)
|
||||
end
|
||||
|
||||
def test_fast_path_command_methods
|
||||
vl = Liquid::VariableLookup.new("items.size")
|
||||
assert_equal("items", vl.name)
|
||||
assert_equal(["size"], vl.lookups)
|
||||
assert(vl.lookup_command?(0))
|
||||
|
||||
vl2 = Liquid::VariableLookup.new("items.first")
|
||||
assert(vl2.lookup_command?(0))
|
||||
|
||||
vl3 = Liquid::VariableLookup.new("items.last")
|
||||
assert(vl3.lookup_command?(0))
|
||||
end
|
||||
|
||||
def test_fast_path_non_command_lookups
|
||||
vl = Liquid::VariableLookup.new("product.title")
|
||||
refute(vl.lookup_command?(0))
|
||||
end
|
||||
|
||||
def test_fast_path_question_mark
|
||||
vl = Liquid::VariableLookup.new("product.available?")
|
||||
assert_equal("product", vl.name)
|
||||
assert_equal(["available?"], vl.lookups)
|
||||
end
|
||||
|
||||
def test_bracket_lookup_falls_to_regex_path
|
||||
vl = Liquid::VariableLookup.new("product[0]")
|
||||
assert_equal("product", vl.name)
|
||||
assert_equal([0], vl.lookups)
|
||||
end
|
||||
|
||||
# ── BlockBody.try_parse_tag_token ──────────────────────────────────
|
||||
# Fast path: byte-walk tag tokens instead of FullToken regex
|
||||
# Must produce identical [tag_name, markup, newline_count] or nil
|
||||
|
||||
def test_tag_token_simple
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% if x %}")
|
||||
assert_equal(["if", "x ", 0], result)
|
||||
end
|
||||
|
||||
def test_tag_token_whitespace_control_leading
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{%- if x %}")
|
||||
assert_equal(["if", "x ", 0], result)
|
||||
end
|
||||
|
||||
def test_tag_token_whitespace_control_trailing
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% if x -%}")
|
||||
assert_equal(["if", "x ", 0], result)
|
||||
end
|
||||
|
||||
def test_tag_token_whitespace_control_both
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{%- if x -%}")
|
||||
assert_equal(["if", "x ", 0], result)
|
||||
end
|
||||
|
||||
def test_tag_token_no_markup
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% endif %}")
|
||||
assert_equal(["endif", "", 0], result)
|
||||
end
|
||||
|
||||
def test_tag_token_hash_comment
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% # this is a comment %}")
|
||||
assert_equal(["#", "this is a comment ", 0], result)
|
||||
end
|
||||
|
||||
def test_tag_token_with_newlines
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% \n if \n x %}")
|
||||
assert_equal(["if", "x ", 2], result)
|
||||
end
|
||||
|
||||
def test_tag_token_hyphenated_name_stops_at_hyphen
|
||||
# TagName = /\w+/ does not include hyphens
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% my-tag markup %}")
|
||||
assert_equal("my", result[0])
|
||||
end
|
||||
|
||||
def test_tag_token_complex_markup
|
||||
body = Liquid::BlockBody.new
|
||||
result = body.send(:try_parse_tag_token, "{% for item in collection reversed %}")
|
||||
assert_equal("for", result[0])
|
||||
assert_equal("item in collection reversed ", result[1])
|
||||
end
|
||||
|
||||
def test_tag_token_malformed_returns_nil
|
||||
body = Liquid::BlockBody.new
|
||||
# Token too short
|
||||
assert_nil(body.send(:try_parse_tag_token, "{%"))
|
||||
# No valid tag name start (digit)
|
||||
assert_nil(body.send(:try_parse_tag_token, "{% 123 %}"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class RangeSliceUnitTest < Minitest::Test
|
||||
def test_selects_and_reverses_an_integer_range_without_enumerating_it
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 997, 999, limits)
|
||||
|
||||
assert_equal(2, slice.length)
|
||||
refute(slice.empty?)
|
||||
slice.reverse!
|
||||
assert_equal([999, 998], slice.each.to_a)
|
||||
assert_equal(2, limits.render_score)
|
||||
end
|
||||
|
||||
def test_charges_only_values_yielded_before_a_break
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 0, nil, limits)
|
||||
|
||||
slice.each { break }
|
||||
|
||||
assert_equal(1, limits.render_score)
|
||||
end
|
||||
|
||||
def test_non_integer_ranges_are_sliced_without_to_a_and_charge_visited_values
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
raise 'range was materialized'
|
||||
end
|
||||
end.new('a', 'c')
|
||||
|
||||
assert_equal(['b', 'c'], Liquid::Utils.slice_collection_for_iteration(range, 1, nil, limits))
|
||||
assert_equal(3, limits.render_score)
|
||||
|
||||
limited = Liquid::ResourceLimits.new(render_score_limit: 2)
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
Liquid::Utils.slice_collection_for_iteration('a'..'z', 10, 11, limited)
|
||||
end
|
||||
end
|
||||
|
||||
def test_non_integer_range_empty_windows_do_not_visit_a_sentinel_value
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
|
||||
assert_equal([], Liquid::Utils.slice_collection_for_iteration('a'..'z', 2, 2, limits))
|
||||
assert_equal(0, limits.render_score)
|
||||
assert_equal(['a', 'b'], Liquid::Utils.slice_collection_for_iteration('a'..'z', 0, 2, limits))
|
||||
assert_equal(2, limits.render_score)
|
||||
end
|
||||
|
||||
def test_standard_beginless_range_raises_for_empty_windows
|
||||
[[0, 0], [1, 0]].each do |from, to|
|
||||
assert_raises(TypeError) do
|
||||
Liquid::Utils.slice_collection_for_iteration(Range.new(nil, 3), from, to, Liquid::ResourceLimits.new({}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_custom_range_to_a_is_sliced_with_a_budget
|
||||
range = Class.new(Range) do
|
||||
def to_a
|
||||
[1, 2, 3]
|
||||
end
|
||||
end.new(nil, 3)
|
||||
limits = Liquid::ResourceLimits.new(render_score_limit: 2)
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
Liquid::Utils.slice_collection_for_iteration(range, 1, 3, limits, use_range_to_a: true)
|
||||
end
|
||||
assert_equal(3, limits.render_score)
|
||||
end
|
||||
|
||||
def test_preserves_slice_bounds_for_negative_offsets_and_limits
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
|
||||
assert_equal([1, 2], Liquid::RangeSlice.new(1..5, -2, 2, limits).each.to_a)
|
||||
empty = Liquid::RangeSlice.new(1..5, 2, 1, limits)
|
||||
assert(empty.empty?)
|
||||
assert_equal([], empty.each.to_a)
|
||||
assert_equal(2, limits.render_score) # the empty window performs no work
|
||||
assert(Liquid::RangeSlice.new(5..1, 0, nil, limits).empty?)
|
||||
assert_equal([1, 2, 3, 4], Liquid::RangeSlice.new(1...5, 0, nil, limits).each.to_a)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user