mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-15 00:40:40 -07:00
Compare commits
141
Commits
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Liquid
|
||||
on: [push, pull_request]
|
||||
on: [push]
|
||||
|
||||
env:
|
||||
BUNDLE_JOBS: 4
|
||||
@@ -9,30 +9,34 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
entry:
|
||||
- { ruby: 3.0, allowed-failure: false } # minimum supported
|
||||
- { ruby: 3.2, allowed-failure: false }
|
||||
- { ruby: 3.3, allowed-failure: false }
|
||||
- { ruby: 3.3, allowed-failure: false }
|
||||
- { ruby: 3.4, allowed-failure: false } # latest
|
||||
- {
|
||||
ruby: 3.4,
|
||||
allowed-failure: false,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: 3.3, allowed-failure: false } # minimum supported
|
||||
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
|
||||
- { ruby: ruby-head, allowed-failure: false }
|
||||
- { ruby: 4.0, allowed-failure: false } # latest stable
|
||||
- {
|
||||
ruby: ruby-head,
|
||||
ruby: 4.0,
|
||||
allowed-failure: false,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
|
||||
name: Test Ruby ${{ matrix.entry.ruby }}
|
||||
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
|
||||
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
|
||||
- { ruby: truffleruby, allowed-failure: false }
|
||||
|
||||
# Head can have failures due to being in development
|
||||
- { ruby: head, allowed-failure: true }
|
||||
- {
|
||||
ruby: head,
|
||||
allowed-failure: true,
|
||||
rubyopt: "--enable-frozen-string-literal",
|
||||
}
|
||||
- { ruby: head, allowed-failure: true, rubyopt: "--yjit" }
|
||||
- { ruby: head, allowed-failure: true, rubyopt: "--zjit" }
|
||||
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
ruby-version: ${{ matrix.entry.ruby }}
|
||||
bundler-cache: true
|
||||
@@ -42,11 +46,28 @@ jobs:
|
||||
env:
|
||||
RUBYOPT: ${{ matrix.entry.rubyopt }}
|
||||
|
||||
spec:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BUNDLE_WITH: spec
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
bundler: latest
|
||||
- name: Run liquid-spec for all adapters
|
||||
run: |
|
||||
for adapter in spec/*.rb; do
|
||||
echo "=== Running $adapter ==="
|
||||
bundle exec liquid-spec run "$adapter" --no-max-failures
|
||||
done
|
||||
|
||||
memory_profile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ruby/setup-ruby@v1
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
||||
with:
|
||||
bundler-cache: true
|
||||
- run: bundle exec rake memory_profile:run
|
||||
|
||||
+10
-1
@@ -174,7 +174,16 @@ Style/WordArray:
|
||||
|
||||
# Offense count: 117
|
||||
# This cop supports safe auto-correction (--auto-correct).
|
||||
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns.
|
||||
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns.
|
||||
# URISchemes: http, https
|
||||
Layout/LineLength:
|
||||
Max: 260
|
||||
|
||||
Naming/PredicatePrefix:
|
||||
Enabled: false
|
||||
|
||||
# Offense count: 1
|
||||
# This is intentional - early return from begin/rescue in assignment context
|
||||
Lint/NoReturnInBeginEndBlocks:
|
||||
Exclude:
|
||||
- 'lib/liquid/standardfilters.rb'
|
||||
|
||||
@@ -25,7 +25,13 @@ group :development do
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem 'rubocop', '~> 1.61.0'
|
||||
gem 'rubocop-shopify', '~> 2.12.0', require: false
|
||||
gem 'benchmark'
|
||||
gem 'rubocop', '~> 1.82.0'
|
||||
gem 'rubocop-shopify', '~> 2.18.0', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
end
|
||||
|
||||
group :spec do
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main'
|
||||
gem 'activesupport', require: false
|
||||
end
|
||||
|
||||
+12
-1
@@ -1,6 +1,17 @@
|
||||
# Liquid Change Log
|
||||
|
||||
## 5.8.1 (unreleased)
|
||||
## 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]
|
||||
|
||||
## 5.10.0
|
||||
* Introduce support for Inline Snippets [Julia Boutin]
|
||||
|
||||
## 5.9.0
|
||||
* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro]
|
||||
|
||||
## 5.8.7
|
||||
* Expose body content in the `Doc` tag [James Meng]
|
||||
|
||||
## 5.8.1
|
||||
|
||||
|
||||
@@ -99,14 +99,14 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem
|
||||
Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make
|
||||
it very hard to debug and can lead to unexpected behaviour.
|
||||
|
||||
Liquid also comes with a stricter parser that can be used when editing templates to give better error messages
|
||||
Liquid also comes with different parsers that can be used when editing templates to give better error messages
|
||||
when templates are invalid. You can enable this new parser like this:
|
||||
|
||||
```ruby
|
||||
Liquid::Environment.default.error_mode = :strict
|
||||
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used
|
||||
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
|
||||
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
|
||||
Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags
|
||||
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
|
||||
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
|
||||
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
|
||||
```
|
||||
|
||||
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
|
||||
|
||||
@@ -33,7 +33,7 @@ task :rubocop do
|
||||
end
|
||||
end
|
||||
|
||||
desc('runs test suite with both strict and lax parsers')
|
||||
desc('runs test suite with lax, strict, and strict2 parsers')
|
||||
task :test do
|
||||
ENV['LIQUID_PARSER_MODE'] = 'lax'
|
||||
Rake::Task['base_test'].invoke
|
||||
@@ -42,6 +42,10 @@ task :test do
|
||||
Rake::Task['base_test'].reenable
|
||||
Rake::Task['base_test'].invoke
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict2'
|
||||
Rake::Task['base_test'].reenable
|
||||
Rake::Task['base_test'].invoke
|
||||
|
||||
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
|
||||
ENV['LIQUID_PARSER_MODE'] = 'lax'
|
||||
Rake::Task['integration_test'].reenable
|
||||
@@ -50,6 +54,10 @@ task :test do
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
|
||||
ENV['LIQUID_PARSER_MODE'] = 'strict2'
|
||||
Rake::Task['integration_test'].reenable
|
||||
Rake::Task['integration_test'].invoke
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,8 +88,13 @@ namespace :benchmark do
|
||||
ruby "./performance/benchmark.rb strict"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with both lax and strict parsing"
|
||||
task run: [:lax, :strict]
|
||||
desc "Run the liquid benchmark with strict2 parsing"
|
||||
task :strict2 do
|
||||
ruby "./performance/benchmark.rb strict2"
|
||||
end
|
||||
|
||||
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
|
||||
task run: [:lax, :strict, :strict2]
|
||||
|
||||
desc "Run unit benchmarks"
|
||||
namespace :unit do
|
||||
@@ -135,3 +148,9 @@ end
|
||||
task :console do
|
||||
exec 'irb -I lib -r liquid'
|
||||
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"
|
||||
end
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'bundler/setup'
|
||||
require 'liquid'
|
||||
|
||||
class VirtualFileSystem
|
||||
def initialize
|
||||
snippet_1 = <<~LIQUID
|
||||
<h1>
|
||||
{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!
|
||||
</h1>
|
||||
LIQUID
|
||||
snippet_2 = <<~LIQUID
|
||||
{%- for i in (1..5) -%}
|
||||
> {{ i }}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
@templates = {
|
||||
'snippet-1' => snippet_1,
|
||||
'snippet-2' => snippet_2,
|
||||
}
|
||||
end
|
||||
|
||||
def read_template_file(key)
|
||||
@templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'")
|
||||
end
|
||||
end
|
||||
|
||||
def source
|
||||
File.read(ARGV[0])
|
||||
rescue StandardError
|
||||
'Usage: bin/render example/server/templates/index.liquid'
|
||||
end
|
||||
|
||||
def assigns
|
||||
{
|
||||
'date' => Time.now,
|
||||
}
|
||||
end
|
||||
|
||||
puts Liquid::Template
|
||||
.parse(source, error_mode: :strict2)
|
||||
.tap { |t| t.registers[:file_system] = VirtualFileSystem.new }
|
||||
.render(assigns)
|
||||
@@ -52,6 +52,7 @@ 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'
|
||||
|
||||
@@ -130,16 +130,21 @@ module Liquid
|
||||
case
|
||||
when token.start_with?(TAGSTART)
|
||||
whitespace_handler(token, parse_context)
|
||||
unless token =~ FullToken
|
||||
# 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
|
||||
return handle_invalid_tag_token(token, parse_context, &block)
|
||||
end
|
||||
tag_name = Regexp.last_match(2)
|
||||
markup = Regexp.last_match(4)
|
||||
# rubocop:enable Metrics/BlockNesting
|
||||
|
||||
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")
|
||||
if parse_context.line_number && newlines > 0
|
||||
parse_context.line_number += newlines
|
||||
end
|
||||
|
||||
if tag_name == 'liquid'
|
||||
@@ -260,6 +265,77 @@ 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)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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
|
||||
+57
-14
@@ -48,8 +48,8 @@ module Liquid
|
||||
@@operators
|
||||
end
|
||||
|
||||
def self.parse_expression(parse_context, markup)
|
||||
@@method_literals[markup] || parse_context.parse_expression(markup)
|
||||
def self.parse_expression(parse_context, markup, safe: false)
|
||||
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
|
||||
end
|
||||
|
||||
attr_reader :attachment, :child_condition
|
||||
@@ -113,24 +113,67 @@ module Liquid
|
||||
|
||||
def equal_variables(left, right)
|
||||
if left.is_a?(MethodLiteral)
|
||||
if right.respond_to?(left.method_name)
|
||||
return right.send(left.method_name)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
return call_method_literal(left, right)
|
||||
end
|
||||
|
||||
if right.is_a?(MethodLiteral)
|
||||
if left.respond_to?(right.method_name)
|
||||
return left.send(right.method_name)
|
||||
else
|
||||
return nil
|
||||
end
|
||||
return call_method_literal(right, left)
|
||||
end
|
||||
|
||||
left == right
|
||||
end
|
||||
|
||||
def call_method_literal(literal, value)
|
||||
method_name = literal.method_name
|
||||
|
||||
# If the object responds to the method (e.g., ActiveSupport is loaded), use it
|
||||
if value.respond_to?(method_name)
|
||||
value.send(method_name)
|
||||
else
|
||||
# Emulate ActiveSupport's blank?/empty? to make Liquid invariant
|
||||
# to whether ActiveSupport is loaded or not
|
||||
case method_name
|
||||
when :blank?
|
||||
liquid_blank?(value)
|
||||
when :empty?
|
||||
liquid_empty?(value)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Implement blank? semantics matching ActiveSupport
|
||||
# blank? returns true for nil, false, empty strings, whitespace-only strings,
|
||||
# empty arrays, and empty hashes
|
||||
def liquid_blank?(value)
|
||||
case value
|
||||
when NilClass, FalseClass
|
||||
true
|
||||
when TrueClass, Numeric
|
||||
false
|
||||
when String
|
||||
# Blank if empty or whitespace only (matches ActiveSupport)
|
||||
value.empty? || value.match?(/\A\s*\z/)
|
||||
when Array, Hash
|
||||
value.empty?
|
||||
else
|
||||
# Fall back to empty? if available, otherwise false
|
||||
value.respond_to?(:empty?) ? value.empty? : false
|
||||
end
|
||||
end
|
||||
|
||||
# Implement empty? semantics
|
||||
# Note: nil is NOT empty. empty? checks if a collection has zero elements.
|
||||
def liquid_empty?(value)
|
||||
case value
|
||||
when String, Array, Hash
|
||||
value.empty?
|
||||
else
|
||||
value.respond_to?(:empty?) ? value.empty? : false
|
||||
end
|
||||
end
|
||||
|
||||
def interpret_condition(left, right, op, context)
|
||||
# If the operator is empty this means that the decision statement is just
|
||||
# a single variable. We can just poll this variable from the context and
|
||||
@@ -154,8 +197,8 @@ module Liquid
|
||||
end
|
||||
|
||||
def deprecated_default_context
|
||||
warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
|
||||
" and will be removed from Liquid 6.0.0.")
|
||||
warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \
|
||||
"and will be removed from Liquid 6.0.0.")
|
||||
Context.new
|
||||
end
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
self[key] != nil
|
||||
find_variable(key, raise_on_not_found: false) != nil
|
||||
end
|
||||
|
||||
def evaluate(object)
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ module Liquid
|
||||
|
||||
# Catch all for the method
|
||||
def liquid_method_missing(method)
|
||||
return nil unless @context&.strict_variables
|
||||
return unless @context&.strict_variables
|
||||
raise Liquid::UndefinedDropMethod, "undefined method #{method}"
|
||||
end
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ module Liquid
|
||||
# @param file_system The default file system that is used
|
||||
# to load templates from.
|
||||
# @param error_mode [Symbol] The default error mode for all templates
|
||||
# (either :strict, :warn, or :lax).
|
||||
# (either :strict2, :strict, :warn, or :lax).
|
||||
# @param exception_renderer [Proc] The exception renderer that is used to
|
||||
# render exceptions.
|
||||
# @yieldparam environment [Environment] The environment instance that is being built.
|
||||
|
||||
+78
-55
@@ -16,22 +16,27 @@ 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)
|
||||
parse(parser.expression, ss, cache)
|
||||
end
|
||||
|
||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||
return unless markup
|
||||
|
||||
markup = markup.strip # markup can be a frozen string
|
||||
# Guard: only call .strip when the first or last byte is whitespace.
|
||||
# String#strip always allocates a new String, even when there's nothing
|
||||
# to strip. ByteTables::WHITESPACE matches the same bytes that strip
|
||||
# removes (space, \t, \n, \v, \f, \r, \x00). When neither end has
|
||||
# whitespace, we skip the call and avoid ~4,464 allocations per compile.
|
||||
first = markup.getbyte(0)
|
||||
if first && (ByteTables::WHITESPACE[first] || ByteTables::WHITESPACE[markup.getbyte(markup.bytesize - 1)])
|
||||
markup = markup.strip
|
||||
end
|
||||
|
||||
if (markup.start_with?('"') && markup.end_with?('"')) ||
|
||||
(markup.start_with?("'") && markup.end_with?("'"))
|
||||
@@ -51,7 +56,7 @@ module Liquid
|
||||
end
|
||||
|
||||
def inner_parse(markup, ss, cache)
|
||||
if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
|
||||
if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX
|
||||
return RangeLookup.parse(
|
||||
Regexp.last_match(1),
|
||||
Regexp.last_match(2),
|
||||
@@ -67,56 +72,74 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
ss.string = markup
|
||||
# the first byte must be a digit or a dash
|
||||
byte = ss.scan_byte
|
||||
pos = 0
|
||||
first = markup.getbyte(pos)
|
||||
|
||||
return false if byte != DASH && (byte < ZERO || byte > NINE)
|
||||
if first == ByteTables::DASH
|
||||
pos += 1
|
||||
return if pos >= len
|
||||
return unless ByteTables::DIGIT[markup.getbyte(pos)]
|
||||
|
||||
if byte == DASH
|
||||
peek_byte = ss.peek_byte
|
||||
|
||||
# if it starts with a dash, the next byte must be a digit
|
||||
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
|
||||
end
|
||||
|
||||
# The markup could be a float with multiple dots
|
||||
first_dot_pos = nil
|
||||
num_end_pos = nil
|
||||
|
||||
while (byte = ss.scan_byte)
|
||||
return false if byte != DOT && (byte < ZERO || byte > NINE)
|
||||
|
||||
# we found our number and now we are just scanning the rest of the string
|
||||
next if num_end_pos
|
||||
|
||||
if byte == DOT
|
||||
if first_dot_pos.nil?
|
||||
first_dot_pos = ss.pos
|
||||
else
|
||||
# we found another dot, so we know that the number ends here
|
||||
num_end_pos = ss.pos - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
num_end_pos = markup.length if ss.eos?
|
||||
|
||||
if num_end_pos
|
||||
# number ends with a number "123.123"
|
||||
markup.byteslice(0, num_end_pos).to_f
|
||||
pos += 1
|
||||
elsif ByteTables::DIGIT[first]
|
||||
pos += 1
|
||||
else
|
||||
# number ends with a dot "123."
|
||||
markup.byteslice(0, first_dot_pos).to_f
|
||||
return
|
||||
end
|
||||
|
||||
# Scan digits
|
||||
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
|
||||
|
||||
# Consumed everything = simple integer
|
||||
return Integer(markup, 10) if pos == len
|
||||
|
||||
# Check for dot — three float cases:
|
||||
# 1. Simple float: "123.456" → markup.to_f
|
||||
# 2. Multi-dot: "1.2.3.4" → truncate at second dot → 1.2
|
||||
# 3. Trailing dot: "123." → truncate before dot → 123.0
|
||||
return unless markup.getbyte(pos) == ByteTables::DOT
|
||||
|
||||
dot_pos = pos
|
||||
pos += 1
|
||||
digit_start = pos
|
||||
pos += 1 while pos < len && ByteTables::DIGIT[markup.getbyte(pos)]
|
||||
|
||||
if pos > digit_start && pos == len
|
||||
# Case 1: simple float like "123.456"
|
||||
markup.to_f
|
||||
elsif pos > digit_start
|
||||
# Case 2: multi-dot like "1.2.3.4" — find where the numeric
|
||||
# portion ends. Reject if any non-digit, non-dot byte is found
|
||||
# (e.g. "1.2.3a" → nil, matching the old regex-based behavior).
|
||||
num_end = nil
|
||||
check = pos
|
||||
while check < len
|
||||
b = markup.getbyte(check)
|
||||
if b == ByteTables::DOT
|
||||
num_end ||= check
|
||||
elsif !ByteTables::DIGIT[b]
|
||||
return
|
||||
end
|
||||
check += 1
|
||||
end
|
||||
markup.byteslice(0, num_end || len).to_f
|
||||
else
|
||||
# Case 3: trailing dot like "123."
|
||||
markup.byteslice(0, dot_pos).to_f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ module Liquid
|
||||
def interpolate(name, vars)
|
||||
name.gsub(/%\{(\w+)\}/) do
|
||||
# raise TranslationError, "Undefined key #{$1} for interpolation in translation #{name}" unless vars[$1.to_sym]
|
||||
(vars[Regexp.last_match(1).to_sym]).to_s
|
||||
vars[Regexp.last_match(1).to_sym].to_s
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
invalid_template_encoding: "Invalid template encoding"
|
||||
render: "Syntax error in tag 'render' - Template name must be a quoted string"
|
||||
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
|
||||
table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range"
|
||||
tag_never_closed: "'%{block_name}' tag was never closed"
|
||||
tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}"
|
||||
unexpected_else: "%{block_name} tag does not expect 'else' tag"
|
||||
|
||||
@@ -50,7 +50,22 @@ module Liquid
|
||||
)
|
||||
end
|
||||
|
||||
def parse_expression(markup)
|
||||
def safe_parse_expression(parser)
|
||||
Expression.safe_parse(parser, @string_scanner, @expression_cache)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
if !safe && @error_mode == :strict2
|
||||
# parse_expression is a widely used API. To maintain backward
|
||||
# compatibility while raising awareness about strict2 parser standards,
|
||||
# the safe flag supports API users make a deliberate decision.
|
||||
#
|
||||
# In strict2 mode, markup MUST come from a string returned by the parser
|
||||
# (e.g., parser.expression). We're not calling the parser here to
|
||||
# prevent redundant parser overhead.
|
||||
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
|
||||
end
|
||||
|
||||
Expression.parse(markup, @string_scanner, @expression_cache)
|
||||
end
|
||||
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
|
||||
module Liquid
|
||||
module ParserSwitching
|
||||
# Do not use this.
|
||||
#
|
||||
# It's basically doing the same thing the {#parse_with_selected_parser},
|
||||
# except this will try the strict parser regardless of the error mode,
|
||||
# and fall back to the lax parser if the error mode is lax or warn,
|
||||
# except when in strict2 mode where it uses the strict2 parser.
|
||||
#
|
||||
# @deprecated Use {#parse_with_selected_parser} instead.
|
||||
def strict_parse_with_error_mode_fallback(markup)
|
||||
return strict2_parse_with_error_context(markup) if strict2_mode?
|
||||
|
||||
strict_parse_with_error_context(markup)
|
||||
rescue SyntaxError => e
|
||||
case parse_context.error_mode
|
||||
when :rigid
|
||||
rigid_warn
|
||||
raise
|
||||
when :strict2
|
||||
raise
|
||||
when :strict
|
||||
raise
|
||||
when :warn
|
||||
@@ -16,11 +31,13 @@ module Liquid
|
||||
|
||||
def parse_with_selected_parser(markup)
|
||||
case parse_context.error_mode
|
||||
when :strict then strict_parse_with_error_context(markup)
|
||||
when :lax then lax_parse(markup)
|
||||
when :rigid then rigid_warn && strict2_parse_with_error_context(markup)
|
||||
when :strict2 then strict2_parse_with_error_context(markup)
|
||||
when :strict then strict_parse_with_error_context(markup)
|
||||
when :lax then lax_parse(markup)
|
||||
when :warn
|
||||
begin
|
||||
strict_parse_with_error_context(markup)
|
||||
strict2_parse_with_error_context(markup)
|
||||
rescue SyntaxError => e
|
||||
parse_context.warnings << e
|
||||
lax_parse(markup)
|
||||
@@ -28,8 +45,24 @@ module Liquid
|
||||
end
|
||||
end
|
||||
|
||||
def strict2_mode?
|
||||
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rigid_warn
|
||||
Deprecations.warn(':rigid', ':strict2')
|
||||
end
|
||||
|
||||
def strict2_parse_with_error_context(markup)
|
||||
strict2_parse(markup)
|
||||
rescue SyntaxError => e
|
||||
e.line_number = line_number
|
||||
e.markup_context = markup_context(markup)
|
||||
raise e
|
||||
end
|
||||
|
||||
def strict_parse_with_error_context(markup)
|
||||
strict_parse(markup)
|
||||
rescue SyntaxError => e
|
||||
|
||||
@@ -2,24 +2,39 @@
|
||||
|
||||
module Liquid
|
||||
class ResourceLimits
|
||||
attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
|
||||
attr_reader :render_score, :assign_score
|
||||
attr_accessor :render_length_limit,
|
||||
:render_score_limit,
|
||||
:assign_score_limit,
|
||||
:cumulative_render_score_limit,
|
||||
:cumulative_assign_score_limit
|
||||
attr_reader :render_score,
|
||||
:assign_score,
|
||||
:cumulative_render_score,
|
||||
:cumulative_assign_score
|
||||
|
||||
def initialize(limits)
|
||||
@render_length_limit = limits[:render_length_limit]
|
||||
@render_score_limit = limits[:render_score_limit]
|
||||
@assign_score_limit = limits[:assign_score_limit]
|
||||
@render_length_limit = limits[:render_length_limit]
|
||||
@render_score_limit = limits[:render_score_limit]
|
||||
@assign_score_limit = limits[:assign_score_limit]
|
||||
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
|
||||
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
|
||||
@cumulative_render_score = 0
|
||||
@cumulative_assign_score = 0
|
||||
reset
|
||||
end
|
||||
|
||||
def increment_render_score(amount)
|
||||
@render_score += amount
|
||||
@cumulative_render_score += amount
|
||||
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
|
||||
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
|
||||
end
|
||||
|
||||
def increment_assign_score(amount)
|
||||
@assign_score += amount
|
||||
@cumulative_assign_score += amount
|
||||
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
|
||||
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
|
||||
end
|
||||
|
||||
# update either render_length or assign_score based on whether or not the writes are captured
|
||||
@@ -47,6 +62,8 @@ module Liquid
|
||||
@reached_limit = false
|
||||
@last_capture_length = nil
|
||||
@render_score = @assign_score = 0
|
||||
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
|
||||
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
|
||||
end
|
||||
|
||||
def with_capture
|
||||
|
||||
@@ -8,10 +8,19 @@ module Liquid
|
||||
MAX_I32 = (1 << 31) - 1
|
||||
private_constant :MAX_I32
|
||||
|
||||
MIN_I64 = -(1 << 63)
|
||||
MAX_I64 = (1 << 63) - 1
|
||||
I64_RANGE = MIN_I64..MAX_I64
|
||||
private_constant :MIN_I64, :MAX_I64, :I64_RANGE
|
||||
supports_64bit_indices = begin
|
||||
[][1 << 33, 1 << 33]
|
||||
true
|
||||
rescue RangeError
|
||||
false
|
||||
end
|
||||
|
||||
INDEX_RANGE = if supports_64bit_indices
|
||||
(-(1 << 63))..((1 << 63) - 1)
|
||||
else
|
||||
(-(1 << 31))..((1 << 31) - 1)
|
||||
end
|
||||
private_constant :INDEX_RANGE
|
||||
|
||||
HTML_ESCAPE = {
|
||||
'&' => '&',
|
||||
@@ -214,11 +223,11 @@ module Liquid
|
||||
Utils.to_s(input).slice(offset, length) || ''
|
||||
end
|
||||
rescue RangeError
|
||||
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
|
||||
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
|
||||
raise # unexpected error
|
||||
end
|
||||
offset = offset.clamp(I64_RANGE)
|
||||
length = length.clamp(I64_RANGE)
|
||||
offset = offset.clamp(INDEX_RANGE)
|
||||
length = length.clamp(INDEX_RANGE)
|
||||
retry
|
||||
end
|
||||
end
|
||||
@@ -293,6 +302,19 @@ module Liquid
|
||||
input.split(pattern)
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category string
|
||||
# @liquid_summary
|
||||
# Removes leading and trailing whitespace and collapses consecutive whitespace to a single space.
|
||||
# @liquid_syntax string | squish
|
||||
# @liquid_return [string]
|
||||
def squish(input)
|
||||
return if input.nil?
|
||||
|
||||
Utils.to_s(input).strip.gsub(/\s+/, ' ')
|
||||
end
|
||||
|
||||
# @liquid_public_docs
|
||||
# @liquid_type filter
|
||||
# @liquid_category string
|
||||
@@ -768,6 +790,8 @@ module Liquid
|
||||
# @liquid_syntax array | first
|
||||
# @liquid_return [untyped]
|
||||
def first(array)
|
||||
# ActiveSupport returns "" for empty strings, not nil
|
||||
return array[0] || "" if array.is_a?(String)
|
||||
array.first if array.respond_to?(:first)
|
||||
end
|
||||
|
||||
@@ -779,6 +803,8 @@ module Liquid
|
||||
# @liquid_syntax array | last
|
||||
# @liquid_return [untyped]
|
||||
def last(array)
|
||||
# ActiveSupport returns "" for empty strings, not nil
|
||||
return array[-1] || "" if array.is_a?(String)
|
||||
array.last if array.respond_to?(:last)
|
||||
end
|
||||
|
||||
|
||||
+6
-2
@@ -68,8 +68,12 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def parse_expression(markup)
|
||||
parse_context.parse_expression(markup)
|
||||
def safe_parse_expression(parser)
|
||||
parse_context.safe_parse_expression(parser)
|
||||
end
|
||||
|
||||
def parse_expression(markup, safe: false)
|
||||
parse_context.parse_expression(markup, safe: safe)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,10 @@ module Liquid
|
||||
# Creates a new variable.
|
||||
# @liquid_description
|
||||
# You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_syntax
|
||||
# {% assign variable_name = value %}
|
||||
# @liquid_syntax_keyword variable_name The name of the variable being created.
|
||||
|
||||
@@ -9,6 +9,10 @@ module Liquid
|
||||
# Creates a new variable with a string value.
|
||||
# @liquid_description
|
||||
# You can create complex strings with Liquid logic and variables.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_syntax
|
||||
# {% capture variable %}
|
||||
# value
|
||||
|
||||
+42
-6
@@ -31,12 +31,7 @@ module Liquid
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
@blocks = []
|
||||
|
||||
if markup =~ Syntax
|
||||
@left = parse_expression(Regexp.last_match(1))
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case")
|
||||
end
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
@@ -91,9 +86,50 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def strict2_parse(markup)
|
||||
parser = @parse_context.new_parser(markup)
|
||||
@left = safe_parse_expression(parser)
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@left = parse_expression(Regexp.last_match(1))
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case")
|
||||
end
|
||||
end
|
||||
|
||||
def record_when_condition(markup)
|
||||
body = new_body
|
||||
|
||||
if strict2_mode?
|
||||
parse_strict2_when(markup, body)
|
||||
else
|
||||
parse_lax_when(markup, body)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_strict2_when(markup, body)
|
||||
parser = @parse_context.new_parser(markup)
|
||||
|
||||
loop do
|
||||
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
|
||||
block = Condition.new(@left, '==', expr)
|
||||
block.attach(body)
|
||||
@blocks << block
|
||||
|
||||
break unless parser.id?('or') || parser.consume?(:comma)
|
||||
end
|
||||
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def parse_lax_when(markup, body)
|
||||
while markup
|
||||
unless markup =~ WhenSyntax
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
|
||||
|
||||
+69
-16
@@ -17,23 +17,13 @@ module Liquid
|
||||
class Cycle < Tag
|
||||
SimpleSyntax = /\A#{QuotedFragment}+/o
|
||||
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
|
||||
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
|
||||
|
||||
attr_reader :variables
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
case markup
|
||||
when NamedSyntax
|
||||
@variables = variables_from_string(Regexp.last_match(2))
|
||||
@name = parse_expression(Regexp.last_match(1))
|
||||
@is_named = true
|
||||
when SimpleSyntax
|
||||
@variables = variables_from_string(markup)
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(/\w+:0x\h{8}/)
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
|
||||
end
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def named?
|
||||
@@ -65,19 +55,82 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
# cycle [name:] expression(, expression)*
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@variables = []
|
||||
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
|
||||
|
||||
first_expression = safe_parse_expression(p)
|
||||
if p.look(:colon)
|
||||
# cycle name: expr1, expr2, ...
|
||||
@name = first_expression
|
||||
@is_named = true
|
||||
p.consume(:colon)
|
||||
# After the colon, parse the first variable (required for named cycles)
|
||||
@variables << maybe_dup_lookup(safe_parse_expression(p))
|
||||
else
|
||||
# cycle expr1, expr2, ...
|
||||
@variables << maybe_dup_lookup(first_expression)
|
||||
end
|
||||
|
||||
# Parse remaining comma-separated expressions
|
||||
while p.consume?(:comma)
|
||||
break if p.look(:end_of_string)
|
||||
|
||||
@variables << maybe_dup_lookup(safe_parse_expression(p))
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
|
||||
unless @is_named
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
|
||||
end
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
case markup
|
||||
when NamedSyntax
|
||||
@variables = variables_from_string(Regexp.last_match(2))
|
||||
@name = parse_expression(Regexp.last_match(1))
|
||||
@is_named = true
|
||||
when SimpleSyntax
|
||||
@variables = variables_from_string(markup)
|
||||
@name = @variables.to_s
|
||||
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
|
||||
end
|
||||
end
|
||||
|
||||
def variables_from_string(markup)
|
||||
markup.split(',').collect do |var|
|
||||
var =~ /\s*(#{QuotedFragment})\s*/o
|
||||
next unless Regexp.last_match(1)
|
||||
|
||||
# Expression Parser returns cached objects, and we need to dup them to
|
||||
# start the cycle over for each new cycle call.
|
||||
# Liquid-C does not have a cache, so we don't need to dup the object.
|
||||
var = parse_expression(Regexp.last_match(1))
|
||||
var.is_a?(VariableLookup) ? var.dup : var
|
||||
maybe_dup_lookup(var)
|
||||
end.compact
|
||||
end
|
||||
|
||||
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
|
||||
# we make it so that the @variables.to_s produces different strings for cycles
|
||||
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
|
||||
# This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value.
|
||||
# This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter.
|
||||
# I was really scratching my head about this one, but migrating away from this would be more headache
|
||||
# than it's worth. So we're keeping this quirk for now.
|
||||
def maybe_dup_lookup(var)
|
||||
var.is_a?(VariableLookup) ? var.dup : var
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
Array(@node.variables)
|
||||
|
||||
@@ -7,6 +7,10 @@ module Liquid
|
||||
# @liquid_name decrement
|
||||
# @liquid_summary
|
||||
# Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_description
|
||||
# Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
|
||||
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
|
||||
|
||||
@@ -36,6 +36,8 @@ module Liquid
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
@body = +""
|
||||
|
||||
while (token = tokens.shift)
|
||||
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
|
||||
|
||||
@@ -43,8 +45,10 @@ module Liquid
|
||||
|
||||
if tag_name == block_delimiter
|
||||
parse_context.trim_whitespace = (token[-3] == WhitespaceControl)
|
||||
@body << Regexp.last_match(1) if Regexp.last_match(1) != ""
|
||||
return
|
||||
end
|
||||
@body << token unless token.empty?
|
||||
end
|
||||
|
||||
raise_tag_never_closed(block_name)
|
||||
@@ -55,11 +59,11 @@ module Liquid
|
||||
end
|
||||
|
||||
def blank?
|
||||
true
|
||||
@body.empty?
|
||||
end
|
||||
|
||||
def nodelist
|
||||
[]
|
||||
[@body]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
+11
-7
@@ -20,8 +20,8 @@ module Liquid
|
||||
# @liquid_syntax_keyword variable The current item in the array.
|
||||
# @liquid_syntax_keyword array The array to iterate over.
|
||||
# @liquid_syntax_keyword expression The expression to render for each iteration.
|
||||
# @liquid_optional_param limit [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param limit: [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
|
||||
# @liquid_optional_param reversed [untyped] Iterate in reverse order.
|
||||
class For < Block
|
||||
@@ -93,7 +93,7 @@ module Liquid
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
|
||||
|
||||
collection_name = p.expression
|
||||
@collection_name = parse_expression(collection_name)
|
||||
@collection_name = parse_expression(collection_name, safe: true)
|
||||
|
||||
@name = "#{@variable_name}-#{collection_name}"
|
||||
@reversed = p.id?('reversed')
|
||||
@@ -104,13 +104,17 @@ module Liquid
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
|
||||
end
|
||||
p.consume(:colon)
|
||||
set_attribute(attribute, p.expression)
|
||||
set_attribute(attribute, p.expression, safe: true)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strict2_parse(markup)
|
||||
strict_parse(markup)
|
||||
end
|
||||
|
||||
def collection_segment(context)
|
||||
offsets = context.registers[:for] ||= {}
|
||||
|
||||
@@ -174,16 +178,16 @@ module Liquid
|
||||
output
|
||||
end
|
||||
|
||||
def set_attribute(key, expr)
|
||||
def set_attribute(key, expr, safe: false)
|
||||
case key
|
||||
when 'offset'
|
||||
@from = if expr == 'continue'
|
||||
:continue
|
||||
else
|
||||
parse_expression(expr)
|
||||
parse_expression(expr, safe: safe)
|
||||
end
|
||||
when 'limit'
|
||||
@limit = parse_expression(expr)
|
||||
@limit = parse_expression(expr, safe: safe)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def strict2_parse(markup)
|
||||
strict_parse(markup)
|
||||
end
|
||||
|
||||
def push_block(tag, markup)
|
||||
block = if tag == 'else'
|
||||
ElseCondition.new
|
||||
@@ -77,8 +81,8 @@ module Liquid
|
||||
block.attach(new_body)
|
||||
end
|
||||
|
||||
def parse_expression(markup)
|
||||
Condition.parse_expression(parse_context, markup)
|
||||
def parse_expression(markup, safe: false)
|
||||
Condition.parse_expression(parse_context, markup, safe: safe)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
@@ -120,9 +124,9 @@ module Liquid
|
||||
end
|
||||
|
||||
def parse_comparison(p)
|
||||
a = parse_expression(p.expression)
|
||||
a = parse_expression(p.expression, safe: true)
|
||||
if (op = p.consume?(:comparison))
|
||||
b = parse_expression(p.expression)
|
||||
b = parse_expression(p.expression, safe: true)
|
||||
Condition.new(a, op, b)
|
||||
else
|
||||
Condition.new(a)
|
||||
|
||||
+44
-18
@@ -27,24 +27,7 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
|
||||
if markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.include")
|
||||
end
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def parse(_tokens)
|
||||
@@ -101,6 +84,49 @@ module Liquid
|
||||
alias_method :parse_context, :options
|
||||
private :parse_context
|
||||
|
||||
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")
|
||||
@alias_name = p.consume(:id) if p.id?("as")
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ SYNTAX
|
||||
template_name = Regexp.last_match(1)
|
||||
variable_name = Regexp.last_match(3)
|
||||
|
||||
@alias_name = Regexp.last_match(5)
|
||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||
@template_name_expr = parse_expression(template_name)
|
||||
@attributes = {}
|
||||
|
||||
markup.scan(TagAttributes) do |key, value|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
|
||||
else
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.include")
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[
|
||||
|
||||
@@ -7,6 +7,10 @@ module Liquid
|
||||
# @liquid_name increment
|
||||
# @liquid_summary
|
||||
# Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call.
|
||||
#
|
||||
# > Caution:
|
||||
# > Predefined Liquid objects can be overridden by variables with the same name.
|
||||
# > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name.
|
||||
# @liquid_description
|
||||
# Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates),
|
||||
# or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across
|
||||
|
||||
+50
-16
@@ -35,22 +35,7 @@ module Liquid
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@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|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def for_loop?
|
||||
@@ -99,6 +84,55 @@ module Liquid
|
||||
output
|
||||
end
|
||||
|
||||
# render (string) (with|for expression)? (as id)? (key: value)*
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@template_name_expr = parse_expression(strict2_template_name(p), safe: true)
|
||||
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)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict2_template_name(p)
|
||||
p.consume(:string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
|
||||
|
||||
template_name = Regexp.last_match(1)
|
||||
with_or_for = Regexp.last_match(3)
|
||||
variable_name = Regexp.last_match(4)
|
||||
|
||||
@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|
|
||||
@attributes[key] = parse_expression(value)
|
||||
end
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[
|
||||
|
||||
@@ -19,17 +19,54 @@ module Liquid
|
||||
# @liquid_syntax_keyword variable The current item in the array.
|
||||
# @liquid_syntax_keyword array The array to iterate over.
|
||||
# @liquid_syntax_keyword expression The expression to render.
|
||||
# @liquid_optional_param cols [number] The number of columns that the table should have.
|
||||
# @liquid_optional_param limit [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param cols: [number] The number of columns that the table should have.
|
||||
# @liquid_optional_param limit: [number] The number of iterations to perform.
|
||||
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
|
||||
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
|
||||
class TableRow < Block
|
||||
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
|
||||
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
|
||||
|
||||
attr_reader :variable_name, :collection_name, :attributes
|
||||
|
||||
def initialize(tag_name, markup, options)
|
||||
super
|
||||
parse_with_selected_parser(markup)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
@variable_name = p.consume(:id)
|
||||
|
||||
unless p.id?("in")
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in")
|
||||
end
|
||||
|
||||
@collection_name = safe_parse_expression(p)
|
||||
|
||||
p.consume?(:comma)
|
||||
|
||||
@attributes = {}
|
||||
while p.look(:id)
|
||||
key = p.consume
|
||||
unless ALLOWED_ATTRIBUTES.include?(key)
|
||||
raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key)
|
||||
end
|
||||
|
||||
p.consume(:colon)
|
||||
@attributes[key] = safe_parse_expression(p)
|
||||
p.consume?(:comma)
|
||||
end
|
||||
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict_parse(markup)
|
||||
lax_parse(markup)
|
||||
end
|
||||
|
||||
def lax_parse(markup)
|
||||
if markup =~ Syntax
|
||||
@variable_name = Regexp.last_match(1)
|
||||
@collection_name = parse_expression(Regexp.last_match(2))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
module Liquid
|
||||
# Templates are central to liquid.
|
||||
# Interpretating templates is a two step process. First you compile the
|
||||
# Interpreting templates is a two step process. First you compile the
|
||||
# source code you got. During compile time some extensive error checking is performed.
|
||||
# your code should expect to get some SyntaxErrors.
|
||||
#
|
||||
@@ -24,7 +24,8 @@ module Liquid
|
||||
# Sets how strict the parser should be.
|
||||
# :lax acts like liquid 2.5 and silently ignores malformed tags in most cases.
|
||||
# :warn is the default and will give deprecation warnings when invalid syntax is used.
|
||||
# :strict will enforce correct syntax.
|
||||
# :strict enforces correct syntax for most tags
|
||||
# :strict2 enforces correct syntax for all tags
|
||||
def error_mode=(mode)
|
||||
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
|
||||
Environment.default.error_mode = mode
|
||||
|
||||
@@ -117,7 +117,7 @@ module Liquid
|
||||
byte_a = byte_b = @ss.scan_byte
|
||||
|
||||
while byte_b
|
||||
byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY)
|
||||
byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY
|
||||
|
||||
break unless byte_a
|
||||
|
||||
|
||||
+8
-3
@@ -2,6 +2,9 @@
|
||||
|
||||
module Liquid
|
||||
module Utils
|
||||
DECIMAL_REGEX = /\A-?\d+\.\d+\z/
|
||||
UNIX_TIMESTAMP_REGEX = /\A\d+\z/
|
||||
|
||||
def self.slice_collection(collection, from, to)
|
||||
if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice)
|
||||
collection.load_slice(from, to)
|
||||
@@ -52,7 +55,7 @@ module Liquid
|
||||
when Numeric
|
||||
obj
|
||||
when String
|
||||
/\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
||||
DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
||||
else
|
||||
if obj.respond_to?(:to_number)
|
||||
obj.to_number
|
||||
@@ -66,14 +69,14 @@ module Liquid
|
||||
return obj if obj.respond_to?(:strftime)
|
||||
|
||||
if obj.is_a?(String)
|
||||
return nil if obj.empty?
|
||||
return if obj.empty?
|
||||
obj = obj.downcase
|
||||
end
|
||||
|
||||
case obj
|
||||
when 'now', 'today'
|
||||
Time.now
|
||||
when /\A\d+\z/, Integer
|
||||
when UNIX_TIMESTAMP_REGEX, Integer
|
||||
Time.at(obj.to_i)
|
||||
when String
|
||||
Time.parse(obj)
|
||||
@@ -92,6 +95,8 @@ module Liquid
|
||||
|
||||
def self.to_s(obj, seen = {})
|
||||
case obj
|
||||
when BigDecimal
|
||||
obj.to_s("F")
|
||||
when Hash
|
||||
# If the custom hash implementation overrides `#to_s`, use their
|
||||
# custom implementation. Otherwise we use Liquid's default
|
||||
|
||||
+55
-4
@@ -54,7 +54,7 @@ module Liquid
|
||||
next unless f =~ /\w+/
|
||||
filtername = Regexp.last_match(0)
|
||||
filterargs = f.scan(FilterArgsRegex).flatten
|
||||
@filters << parse_filter_expressions(filtername, filterargs)
|
||||
@filters << lax_parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -65,15 +65,26 @@ module Liquid
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@name = parse_context.parse_expression(p.expression)
|
||||
@name = parse_context.safe_parse_expression(p)
|
||||
while p.consume?(:pipe)
|
||||
filtername = p.consume(:id)
|
||||
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
|
||||
@filters << parse_filter_expressions(filtername, filterargs)
|
||||
@filters << lax_parse_filter_expressions(filtername, filterargs)
|
||||
end
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def strict2_parse(markup)
|
||||
@filters = []
|
||||
p = @parse_context.new_parser(markup)
|
||||
|
||||
return if p.look(:end_of_string)
|
||||
|
||||
@name = parse_context.safe_parse_expression(p)
|
||||
@filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe)
|
||||
p.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def parse_filterargs(p)
|
||||
# first argument
|
||||
filterargs = [p.argument]
|
||||
@@ -122,7 +133,7 @@ module Liquid
|
||||
|
||||
private
|
||||
|
||||
def parse_filter_expressions(filter_name, unparsed_args)
|
||||
def lax_parse_filter_expressions(filter_name, unparsed_args)
|
||||
filter_args = []
|
||||
keyword_args = nil
|
||||
unparsed_args.each do |a|
|
||||
@@ -138,6 +149,46 @@ module Liquid
|
||||
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)
|
||||
parsed_args = filter_args.map { |expr| context.evaluate(expr) }
|
||||
if filter_kwargs
|
||||
|
||||
@@ -4,6 +4,25 @@ 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)
|
||||
@@ -11,6 +30,31 @@ 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
|
||||
@@ -70,6 +114,11 @@ module Liquid
|
||||
elsif lookup_command?(i) && object.respond_to?(key)
|
||||
object = object.send(key).to_liquid
|
||||
|
||||
# Handle string first/last like ActiveSupport does (returns first/last character)
|
||||
# ActiveSupport returns "" for empty strings, not nil
|
||||
elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last")
|
||||
object = key == "first" ? (object[0] || "") : (object[-1] || "")
|
||||
|
||||
# No key was present with the desired value and it wasn't one of the directly supported
|
||||
# keywords either. The only thing we got left is to return nil or
|
||||
# raise an exception if `strict_variables` option is set to true
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
VERSION = "5.8.6"
|
||||
VERSION = "5.12.0"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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}"
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation)
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
# Run core Liquid specs
|
||||
config.features = [:core]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
# @param ctx [Hash] adapter context containing :template
|
||||
# @param assigns [Hash] environment variables
|
||||
# @param options [Hash] :registers, :strict_errors, :exception_renderer
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid with lax parsing mode
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
config.features = [:core, :lax_parsing]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
# Force lax mode
|
||||
options = options.merge(error_mode: :lax)
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
require 'active_support/all'
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
|
||||
config.features = [:core, :activesupport]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
# @param ctx [Hash] adapter context containing :template
|
||||
# @param assigns [Hash] environment variables
|
||||
# @param options [Hash] :registers, :strict_errors, :exception_renderer
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport
|
||||
#
|
||||
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||
|
||||
# Enable YJIT if available
|
||||
if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
|
||||
RubyVM::YJIT.enable
|
||||
end
|
||||
|
||||
require 'active_support/all'
|
||||
require 'liquid'
|
||||
|
||||
LiquidSpec.configure do |config|
|
||||
config.features = [:core, :activesupport]
|
||||
end
|
||||
|
||||
# Compile a template string into a Liquid::Template
|
||||
LiquidSpec.compile do |ctx, source, options|
|
||||
# Force strict mode
|
||||
options = { error_mode: :strict }.merge(options)
|
||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||
end
|
||||
|
||||
# Render a compiled template with the given context
|
||||
LiquidSpec.render do |ctx, assigns, options|
|
||||
registers = Liquid::Registers.new(options[:registers] || {})
|
||||
|
||||
context = Liquid::Context.build(
|
||||
static_environments: assigns,
|
||||
registers: registers,
|
||||
rethrow_errors: options[:strict_errors],
|
||||
)
|
||||
|
||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
||||
|
||||
ctx[:template].render(context)
|
||||
end
|
||||
@@ -632,13 +632,28 @@ class ContextTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_has_key_will_not_add_an_error_for_missing_keys
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
context = Context.new
|
||||
context.key?('unknown')
|
||||
assert_empty(context.errors)
|
||||
end
|
||||
end
|
||||
|
||||
def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled
|
||||
context = Context.new
|
||||
context.strict_variables = true
|
||||
assert_raises(Liquid::UndefinedVariable) do
|
||||
context['unknown']
|
||||
end
|
||||
end
|
||||
|
||||
def test_has_key_will_not_raise_for_missing_keys_when_strict_variables_is_enabled
|
||||
context = Context.new
|
||||
context.strict_variables = true
|
||||
refute(context.key?('unknown'))
|
||||
assert_empty(context.errors)
|
||||
end
|
||||
|
||||
def test_context_always_uses_static_registers
|
||||
registers = {
|
||||
my_register: :my_value,
|
||||
|
||||
@@ -67,7 +67,7 @@ class ErrorHandlingTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unrecognized_operator
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
|
||||
end
|
||||
|
||||
@@ -26,8 +26,12 @@ class ExpressionTest < Minitest::Test
|
||||
def test_float
|
||||
assert_template_result("-17.42", "{{ -17.42 }}")
|
||||
assert_template_result("2.5", "{{ 2.5 }}")
|
||||
assert_expression_result(0.0, "0.....5")
|
||||
assert_expression_result(0.0, "-0..1")
|
||||
|
||||
with_error_modes(:lax) do
|
||||
assert_expression_result(0.0, "0.....5")
|
||||
assert_expression_result(0.0, "-0..1")
|
||||
end
|
||||
|
||||
assert_expression_result(1.5, "1.5")
|
||||
|
||||
# this is a unfortunate quirky behavior of Liquid
|
||||
@@ -61,6 +65,7 @@ class ExpressionTest < Minitest::Test
|
||||
assert_template_result(
|
||||
"",
|
||||
"{{ - 'theme.css' - }}",
|
||||
error_mode: :lax,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -147,6 +152,35 @@ class ExpressionTest < Minitest::Test
|
||||
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
|
||||
end
|
||||
|
||||
def test_safe_parse_with_variable_lookup
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('product.title')
|
||||
result = Liquid::Expression.safe_parse(parser)
|
||||
|
||||
assert_instance_of(Liquid::VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
def test_safe_parse_with_number
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('42')
|
||||
result = Liquid::Expression.safe_parse(parser)
|
||||
|
||||
assert_equal(42, result)
|
||||
end
|
||||
|
||||
def test_safe_parse_raises_syntax_error_for_invalid_expression
|
||||
parse_context = Liquid::ParseContext.new
|
||||
parser = parse_context.new_parser('')
|
||||
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
Liquid::Expression.safe_parse(parser)
|
||||
end
|
||||
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_expression_result(expect, markup, **assigns)
|
||||
|
||||
@@ -88,17 +88,11 @@ class HashRenderingTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
|
||||
my_hash = Class.new(Hash) do
|
||||
def to_s
|
||||
"kewl"
|
||||
end
|
||||
end.new
|
||||
|
||||
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash })
|
||||
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
|
||||
end
|
||||
|
||||
def test_rendering_hash_without_custom_to_s_uses_default_inspect
|
||||
my_hash = Class.new(Hash).new
|
||||
my_hash = HashWithoutCustomToS.new
|
||||
my_hash[:foo] = :bar
|
||||
|
||||
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
|
||||
|
||||
@@ -31,18 +31,18 @@ class ParsingQuirksTest < Minitest::Test
|
||||
def test_error_on_empty_filter
|
||||
assert(Template.parse("{{test}}"))
|
||||
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert(Template.parse("{{|test}}"))
|
||||
end
|
||||
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
|
||||
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
|
||||
end
|
||||
end
|
||||
|
||||
def test_meaningless_parens_error
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
|
||||
Template.parse("{% if #{markup} %} YES {% endif %}")
|
||||
@@ -51,7 +51,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unexpected_characters_syntax_error
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
markup = "true && false"
|
||||
Template.parse("{% if #{markup} %} YES {% endif %}")
|
||||
@@ -70,7 +70,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_meaningless_parens_lax
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assigns = { 'b' => 'bar', 'c' => 'baz' }
|
||||
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
|
||||
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
|
||||
@@ -78,7 +78,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unexpected_characters_silently_eat_logic_lax
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
markup = "true && false"
|
||||
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
|
||||
markup = "false || true"
|
||||
@@ -93,7 +93,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_unanchored_filter_arguments
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
|
||||
|
||||
assert_template_result('x', "{{ 'X' | downcase) }}")
|
||||
@@ -106,14 +106,14 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_invalid_variables_work
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
|
||||
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
|
||||
end
|
||||
end
|
||||
|
||||
def test_extra_dots_in_ranges
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
|
||||
end
|
||||
end
|
||||
@@ -133,7 +133,7 @@ class ParsingQuirksTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_incomplete_expression
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert_template_result("false", "{{ false - }}")
|
||||
assert_template_result("false", "{{ false > }}")
|
||||
assert_template_result("false", "{{ false < }}")
|
||||
|
||||
@@ -44,33 +44,40 @@ class SecurityTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_does_not_permanently_add_filters_to_symbol_table
|
||||
current_symbols = Symbol.all_symbols
|
||||
assert_no_new_symbols do
|
||||
# MRI imprecisely marks objects found on the C stack, which can result
|
||||
# in uninitialized memory being marked. This can even result in the test failing
|
||||
# deterministically for a given compilation of ruby. Using a separate thread will
|
||||
# keep these writes of the symbol pointer on a separate stack that will be garbage
|
||||
# collected after Thread#join.
|
||||
Thread.new do
|
||||
test = %( {{ "some_string" | a_bad_filter }} )
|
||||
Template.parse(test).render!
|
||||
nil
|
||||
end.join
|
||||
|
||||
# MRI imprecisely marks objects found on the C stack, which can result
|
||||
# in uninitialized memory being marked. This can even result in the test failing
|
||||
# deterministically for a given compilation of ruby. Using a separate thread will
|
||||
# keep these writes of the symbol pointer on a separate stack that will be garbage
|
||||
# collected after Thread#join.
|
||||
Thread.new do
|
||||
test = %( {{ "some_string" | a_bad_filter }} )
|
||||
Template.parse(test).render!
|
||||
nil
|
||||
end.join
|
||||
|
||||
GC.start
|
||||
|
||||
assert_equal([], (Symbol.all_symbols - current_symbols))
|
||||
GC.start
|
||||
end
|
||||
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!)
|
||||
end
|
||||
end
|
||||
|
||||
def assert_no_new_symbols
|
||||
# Run once to trigger any first-time initialization which might create some symbols,
|
||||
# for example autoload or lazy method parsing might create symbols on first execution.
|
||||
yield
|
||||
|
||||
# Ensure no new symbols for further runs, i.e. the code does not leak symbols
|
||||
current_symbols = Symbol.all_symbols
|
||||
|
||||
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!)
|
||||
|
||||
assert_equal([], (Symbol.all_symbols - current_symbols))
|
||||
yield
|
||||
assert_equal([], Symbol.all_symbols - current_symbols)
|
||||
end
|
||||
|
||||
def test_max_depth_nested_blocks_does_not_raise_exception
|
||||
|
||||
@@ -116,7 +116,7 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_slice_on_arrays
|
||||
input = 'foobar'.split(//)
|
||||
input = 'foobar'.split('')
|
||||
assert_equal(%w(o o b), @filters.slice(input, 1, 3))
|
||||
assert_equal(%w(o o b a r), @filters.slice(input, 1, 1000))
|
||||
assert_equal(%w(), @filters.slice(input, 1, 0))
|
||||
@@ -164,6 +164,13 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal(['A', 'Z'], @filters.split('A1Z', 1))
|
||||
end
|
||||
|
||||
def test_squish_filter
|
||||
assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar
|
||||
\t boo " | squish }})).render)
|
||||
assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render)
|
||||
assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render)
|
||||
end
|
||||
|
||||
def test_escape
|
||||
assert_equal('<strong>', @filters.escape('<strong>'))
|
||||
assert_equal('1', @filters.escape(1))
|
||||
@@ -294,13 +301,7 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_join_calls_to_liquid_on_each_element
|
||||
drop = Class.new(Liquid::Drop) do
|
||||
def to_liquid
|
||||
'i did it'
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", "))
|
||||
assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
|
||||
end
|
||||
|
||||
def test_sort
|
||||
@@ -633,6 +634,40 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_nil(@filters.last([]))
|
||||
end
|
||||
|
||||
def test_first_last_on_strings
|
||||
# Ruby's String class does not have first/last methods by default.
|
||||
# ActiveSupport adds String#first and String#last to return the first/last character.
|
||||
# Liquid must work without ActiveSupport, so the first/last filters handle strings specially.
|
||||
#
|
||||
# This enables template patterns like:
|
||||
# {{ product.title | first }} => "S" (for "Snowboard")
|
||||
# {{ customer.name | last }} => "h" (for "Smith")
|
||||
#
|
||||
# Note: ActiveSupport returns "" for empty strings, not nil.
|
||||
assert_equal('f', @filters.first('foo'))
|
||||
assert_equal('o', @filters.last('foo'))
|
||||
assert_equal('', @filters.first(''))
|
||||
assert_equal('', @filters.last(''))
|
||||
end
|
||||
|
||||
def test_first_last_on_unicode_strings
|
||||
# Unicode strings should return the first/last grapheme cluster (character),
|
||||
# not the first/last byte. Ruby's String#[] handles this correctly with index 0/-1.
|
||||
# This ensures international text works properly:
|
||||
# {{ korean_name | first }} => "고" (not a partial byte sequence)
|
||||
assert_equal('고', @filters.first('고스트빈'))
|
||||
assert_equal('빈', @filters.last('고스트빈'))
|
||||
end
|
||||
|
||||
def test_first_last_on_strings_via_template
|
||||
# Integration test to verify the filter works end-to-end in templates.
|
||||
# Empty strings return empty output (nil renders as empty string).
|
||||
assert_template_result('f', '{{ name | first }}', { 'name' => 'foo' })
|
||||
assert_template_result('o', '{{ name | last }}', { 'name' => 'foo' })
|
||||
assert_template_result('', '{{ name | first }}', { 'name' => '' })
|
||||
assert_template_result('', '{{ name | last }}', { 'name' => '' })
|
||||
end
|
||||
|
||||
def test_replace
|
||||
assert_equal('b b b b', @filters.replace('a a a a', 'a', 'b'))
|
||||
assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2))
|
||||
@@ -1146,6 +1181,8 @@ class StandardFiltersTest < Minitest::Test
|
||||
end
|
||||
|
||||
def test_all_filters_never_raise_non_liquid_exception
|
||||
skip("too slow on non-CRuby due to many exceptions") unless RUBY_ENGINE == 'ruby'
|
||||
|
||||
test_drop = TestDrop.new(value: "test")
|
||||
test_drop.context = Context.new
|
||||
test_enum = TestEnumerable.new
|
||||
@@ -1302,7 +1339,7 @@ class StandardFiltersTest < Minitest::Test
|
||||
assert_equal(1, @filters.sum(input, true))
|
||||
assert_equal(0.2, @filters.sum(input, 1.0))
|
||||
assert_equal(-0.3, @filters.sum(input, 1))
|
||||
assert_equal(0.4, @filters.sum(input, (1..5)))
|
||||
assert_equal(0.4, @filters.sum(input, 1..5))
|
||||
assert_equal(0, @filters.sum(input, nil))
|
||||
assert_equal(0, @filters.sum(input, ""))
|
||||
end
|
||||
|
||||
@@ -3,20 +3,10 @@
|
||||
require 'test_helper'
|
||||
|
||||
class CycleTagTest < Minitest::Test
|
||||
def test_simple_cycle
|
||||
template = <<~LIQUID
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_simple_cycle_inside_for_loop
|
||||
template = <<~LIQUID
|
||||
{%- for i in (1..3) -%}
|
||||
{% cycle '1', '2', '3' %}
|
||||
{%- cycle '1', '2', '3' -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
@@ -36,13 +26,157 @@ class CycleTagTest < Minitest::Test
|
||||
assert_template_result("123", template)
|
||||
end
|
||||
|
||||
def test_cycle_tag_always_resets_cycle
|
||||
def test_cycle_named_groups_string
|
||||
template = <<~LIQUID
|
||||
{%- assign a = "1" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle 'placeholder1': 1, 2, 3 -%}
|
||||
{%- cycle 'placeholder2': 1, 2, 3 -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("11", template)
|
||||
assert_template_result("112233", template)
|
||||
end
|
||||
|
||||
def test_cycle_named_groups_vlookup
|
||||
template = <<~LIQUID
|
||||
{%- assign placeholder1 = 'placeholder1' -%}
|
||||
{%- assign placeholder2 = 'placeholder2' -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle placeholder1: 1, 2, 3 -%}
|
||||
{%- cycle placeholder2: 1, 2, 3 -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112233", template)
|
||||
end
|
||||
|
||||
def test_unnamed_cycle_have_independent_counters_when_used_with_lookups
|
||||
template = <<~LIQUID
|
||||
{%- assign a = "1" -%}
|
||||
{%- for i in (1..3) -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- cycle a, "2" -%}
|
||||
{%- endfor -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("112211", template)
|
||||
end
|
||||
|
||||
def test_unnamed_cycle_dependent_counter_when_used_with_literal_values
|
||||
template = <<~LIQUID
|
||||
{%- cycle "1", "2" -%}
|
||||
{%- cycle "1", "2" -%}
|
||||
{%- cycle "1", "2" -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("121", template)
|
||||
end
|
||||
|
||||
def test_optional_trailing_comma
|
||||
template = <<~LIQUID
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", "2", -%}
|
||||
{%- cycle "1", -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result("1211", template)
|
||||
end
|
||||
|
||||
def test_cycle_tag_without_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% cycle %}")
|
||||
end
|
||||
|
||||
assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
|
||||
end
|
||||
|
||||
def test_cycle_tag_with_error_mode
|
||||
# QuotedFragment is more permissive than what Parser#expression allows.
|
||||
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
|
||||
template2 = "{% cycle .5: 'a', 'b' %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("b", template1)
|
||||
assert_template_result("a", template2)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_with_trailing_elements
|
||||
assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
|
||||
|
||||
template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
|
||||
template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}"
|
||||
template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}"
|
||||
template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}"
|
||||
template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("a", template1)
|
||||
assert_template_result("a", template2)
|
||||
assert_template_result("a", template3)
|
||||
assert_template_result("N", template4)
|
||||
assert_template_result("N", template5)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
|
||||
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
|
||||
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
|
||||
|
||||
expected_error = /Expected end_of_string but found/
|
||||
|
||||
assert_match(expected_error, error1.message)
|
||||
assert_match(expected_error, error2.message)
|
||||
assert_match(expected_error, error3.message)
|
||||
assert_match(expected_error, error4.message)
|
||||
assert_match(expected_error, error5.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_name_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{% for i in (1..3) %}
|
||||
{% cycle foo=>bar: "a", "b" %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_cycle_variable_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{% for i in (1..3) %}
|
||||
{% cycle foo=>bar, "a", "b" %}
|
||||
{% endfor %}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -204,6 +204,32 @@ class IncludeTagTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_strict2_parsing_errors
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(
|
||||
'hello value1 value2',
|
||||
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
|
||||
)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_syntax_error(
|
||||
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
)
|
||||
assert_syntax_error(
|
||||
'{% include "snippet" | filter %}',
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_optional_commas
|
||||
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
|
||||
assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
|
||||
end
|
||||
|
||||
def test_include_tag_caches_second_read_of_same_partial
|
||||
file_system = CountingFileSystem.new
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
@@ -277,13 +303,13 @@ class IncludeTagTest < Minitest::Test
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
|
||||
end
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
|
||||
end
|
||||
assert_raises(Liquid::SyntaxError) do
|
||||
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
|
||||
end
|
||||
with_error_mode(:lax) do
|
||||
with_error_modes(:lax) do
|
||||
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
|
||||
end
|
||||
end
|
||||
@@ -374,4 +400,43 @@ class IncludeTagTest < Minitest::Test
|
||||
render_errors: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_include_template_with_invalid_expression
|
||||
template = "{% include foo=>bar %}"
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_with_invalid_expression
|
||||
template = '{% include "snippet" with foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_include_attribute_with_invalid_expression
|
||||
template = '{% include "snippet", key: foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end # IncludeTagTest
|
||||
|
||||
@@ -105,7 +105,33 @@ class RenderTagTest < Minitest::Test
|
||||
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
|
||||
end
|
||||
|
||||
def test_include_tag_caches_second_read_of_same_partial
|
||||
def test_strict2_parsing_errors
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(
|
||||
'hello value1 value2',
|
||||
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
|
||||
)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_syntax_error(
|
||||
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
|
||||
)
|
||||
assert_syntax_error(
|
||||
'{% render "snippet" | filter %}',
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_optional_commas
|
||||
partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }
|
||||
assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials)
|
||||
assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials)
|
||||
end
|
||||
|
||||
def test_render_tag_caches_second_read_of_same_partial
|
||||
file_system = StubFileSystem.new('snippet' => 'echo')
|
||||
assert_equal(
|
||||
'echoecho',
|
||||
@@ -288,4 +314,30 @@ class RenderTagTest < Minitest::Test
|
||||
render_errors: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_with_invalid_expression
|
||||
template = '{% render "snippet" with foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_render_attribute_with_invalid_expression
|
||||
template = '{% render "snippet", key: foo=>bar %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
refute_nil(Template.parse(template))
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,7 +117,7 @@ class StandardTagTest < Minitest::Test
|
||||
assigns = { 'condition' => "bad string here" }
|
||||
assert_template_result(
|
||||
'',
|
||||
'{% case condition %}{% when "string here" %} hit {% endcase %}',\
|
||||
'{% case condition %}{% when "string here" %} hit {% endcase %}',
|
||||
assigns,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -138,7 +138,7 @@ class TableRowTest < Minitest::Test
|
||||
|
||||
def test_tablerow_loop_drop_attributes
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1...2) %}
|
||||
{% tablerow i in (1..2) %}
|
||||
col: {{ tablerowloop.col }}
|
||||
col0: {{ tablerowloop.col0 }}
|
||||
col_first: {{ tablerowloop.col_first }}
|
||||
@@ -192,12 +192,14 @@ class TableRowTest < Minitest::Test
|
||||
assert_template_result(
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
|
||||
error_mode: :warn,
|
||||
render_errors: true,
|
||||
)
|
||||
|
||||
assert_template_result(
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
|
||||
error_mode: :warn,
|
||||
render_errors: true,
|
||||
)
|
||||
|
||||
@@ -205,18 +207,19 @@ class TableRowTest < Minitest::Test
|
||||
"Liquid error (line 1): invalid integer",
|
||||
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
|
||||
render_errors: true,
|
||||
error_mode: :warn,
|
||||
)
|
||||
end
|
||||
|
||||
def test_table_row_handles_interrupts
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
|
||||
'{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
|
||||
'{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}',
|
||||
)
|
||||
|
||||
assert_template_result(
|
||||
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td><td class=\"col2\"> 2 </td></tr>\n<tr class=\"row2\"><td class=\"col1\"> 3 </td></tr>\n",
|
||||
'{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
|
||||
'{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}',
|
||||
)
|
||||
end
|
||||
|
||||
@@ -255,4 +258,211 @@ class TableRowTest < Minitest::Test
|
||||
template,
|
||||
)
|
||||
end
|
||||
|
||||
def test_tablerow_with_cols_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
<tr class="row2"><td class="col1">4</td><td class="col2">5</td><td class="col3">6</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_limit_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_offset_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_range_attribute_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_multiple_attributes_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">2</td><td class="col2">3</td></tr>
|
||||
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_variable_collection_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td></tr>
|
||||
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_dotted_access_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td></tr>
|
||||
<tr class="row2"><td class="col1">3</td><td class="col2">4</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_bracketed_access_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">10</td><td class="col2">20</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_without_attributes_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_without_in_keyword_in_strict2_mode
|
||||
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode
|
||||
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_empty_collection_in_strict2_mode
|
||||
template = <<~LIQUID.chomp
|
||||
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
|
||||
LIQUID
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
</tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result(expected, template, { 'empty_array' => [] })
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_invalid_attribute_strict_vs_strict2
|
||||
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
|
||||
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td><td class="col4">4</td><td class="col5">5</td></tr>
|
||||
OUTPUT
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_tablerow_with_invalid_expression_strict_vs_strict2
|
||||
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
expected = <<~OUTPUT
|
||||
<tr class="row1">
|
||||
</tr>
|
||||
OUTPUT
|
||||
assert_template_result(expected, template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(SyntaxError) { Template.parse(template) }
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,7 +133,7 @@ class TemplateTest < Minitest::Test
|
||||
assert(t.resource_limits.reached?)
|
||||
|
||||
t.resource_limits.render_score_limit = 200
|
||||
assert_equal((" foo " * 100), t.render!)
|
||||
assert_equal(" foo " * 100, t.render!)
|
||||
refute_nil(t.resource_limits.render_score)
|
||||
end
|
||||
|
||||
@@ -179,6 +179,86 @@ class TemplateTest < Minitest::Test
|
||||
assert_equal("すごい", t.render)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_limit_across_render_tags
|
||||
file_system = StubFileSystem.new(
|
||||
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
|
||||
# Without cumulative limit, all 5 partials render successfully
|
||||
t = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
unlimited_output = t.render!
|
||||
total_cumulative = t.resource_limits.cumulative_render_score
|
||||
|
||||
# With cumulative limit set below the total, rendering stops early
|
||||
t2 = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
|
||||
limited_output = t2.render
|
||||
assert(t2.resource_limits.reached?)
|
||||
assert_operator(limited_output.length, :<, unlimited_output.length)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_limit_raises_on_render_bang
|
||||
file_system = StubFileSystem.new(
|
||||
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
t = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t.resource_limits.cumulative_render_score_limit = 20
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
t.render!
|
||||
end
|
||||
end
|
||||
|
||||
def test_cumulative_assign_score_limit_across_include_tags
|
||||
file_system = StubFileSystem.new(
|
||||
'assign_partial' => '{% assign x = "a long string value here" %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
|
||||
# Without cumulative limit, all 5 partials render
|
||||
t = Template.parse(
|
||||
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t.render!
|
||||
total_cumulative = t.resource_limits.cumulative_assign_score
|
||||
|
||||
# With cumulative limit set below the total, rendering stops early
|
||||
t2 = Template.parse(
|
||||
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
|
||||
t2.render
|
||||
assert(t2.resource_limits.reached?)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_tracks_across_partials_without_limit
|
||||
file_system = StubFileSystem.new(
|
||||
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
|
||||
)
|
||||
environment = Liquid::Environment.build(file_system: file_system)
|
||||
t = Template.parse(
|
||||
'{% render "loop" %}{% render "loop" %}{% render "loop" %}',
|
||||
environment: environment,
|
||||
)
|
||||
t.render!
|
||||
assert(
|
||||
t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
|
||||
"cumulative should exceed per-template score after multiple partials",
|
||||
)
|
||||
end
|
||||
|
||||
def test_default_resource_limits_unaffected_by_render_with_context
|
||||
context = Context.new
|
||||
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
|
||||
|
||||
@@ -209,4 +209,69 @@ class VariableTest < Minitest::Test
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_with_single_trailing_comma
|
||||
template = '{{ "hello" | append: "world", }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('helloworld', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_multiple_filters_with_trailing_commas
|
||||
template = '{{ "hello" | append: "1", | append: "2", }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('hello12', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_with_colon_but_no_arguments
|
||||
template = '{{ "test" | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('TEST', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_filter_chain_with_colon_no_args
|
||||
template = '{{ "test" | append: "x" | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('TESTX', template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_combining_trailing_comma_and_empty_args
|
||||
template = '{{ "test" | append: "x", | upcase: }}'
|
||||
|
||||
with_error_modes(:strict) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
assert_match(/is not a valid expression/, error.message)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
assert_template_result('TESTX', template)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+26
-4
@@ -34,7 +34,7 @@ module Minitest
|
||||
|
||||
def assert_template_result(
|
||||
expected, template, assigns = {},
|
||||
message: nil, partials: nil, error_mode: nil, render_errors: false,
|
||||
message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false,
|
||||
template_factory: nil
|
||||
)
|
||||
file_system = StubFileSystem.new(partials || {})
|
||||
@@ -82,10 +82,12 @@ module Minitest
|
||||
Environment.dangerously_override(environment, &blk)
|
||||
end
|
||||
|
||||
def with_error_mode(mode)
|
||||
def with_error_modes(*modes)
|
||||
old_mode = Liquid::Environment.default.error_mode
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
yield
|
||||
modes.each do |mode|
|
||||
Liquid::Environment.default.error_mode = mode
|
||||
yield
|
||||
end
|
||||
ensure
|
||||
Liquid::Environment.default.error_mode = old_mode
|
||||
end
|
||||
@@ -197,6 +199,26 @@ class ErrorDrop < Liquid::Drop
|
||||
end
|
||||
end
|
||||
|
||||
class CustomToLiquidDrop < Liquid::Drop
|
||||
def initialize(value)
|
||||
@value = value
|
||||
super()
|
||||
end
|
||||
|
||||
def to_liquid
|
||||
@value
|
||||
end
|
||||
end
|
||||
|
||||
class HashWithCustomToS < Hash
|
||||
def to_s
|
||||
"kewl"
|
||||
end
|
||||
end
|
||||
|
||||
class HashWithoutCustomToS < Hash
|
||||
end
|
||||
|
||||
class StubFileSystem
|
||||
attr_reader :file_read_count
|
||||
|
||||
|
||||
@@ -161,11 +161,208 @@ class ConditionUnitTest < Minitest::Test
|
||||
assert_equal(true, Condition.new(1, '==', 1).evaluate)
|
||||
end
|
||||
|
||||
expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \
|
||||
" and will be removed from Liquid 6.0.0."
|
||||
expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \
|
||||
"and will be removed from Liquid 6.0.0."
|
||||
assert_includes(err.lines.map(&:strip), expected)
|
||||
end
|
||||
|
||||
def test_parse_expression_in_strict_mode
|
||||
environment = Environment.build(error_mode: :strict)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
result = Condition.parse_expression(parse_context, 'product.title')
|
||||
|
||||
assert_instance_of(VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
def test_parse_expression_in_strict2_mode_raises_internal_error
|
||||
environment = Environment.build(error_mode: :strict2)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
Condition.parse_expression(parse_context, 'product.title')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_safe_true_in_strict2_mode
|
||||
environment = Environment.build(error_mode: :strict2)
|
||||
parse_context = ParseContext.new(environment: environment)
|
||||
result = Condition.parse_expression(parse_context, 'product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result)
|
||||
assert_equal('product', result.name)
|
||||
assert_equal(['title'], result.lookups)
|
||||
end
|
||||
|
||||
# Tests for blank? comparison without ActiveSupport
|
||||
#
|
||||
# Ruby's standard library does not include blank? on String, Array, Hash, etc.
|
||||
# ActiveSupport adds blank? but Liquid must work without it. These tests verify
|
||||
# that Liquid implements blank? semantics internally for use in templates like:
|
||||
# {% if x == blank %}...{% endif %}
|
||||
#
|
||||
# The blank? semantics match ActiveSupport's behavior:
|
||||
# - nil and false are blank
|
||||
# - Strings are blank if empty or contain only whitespace
|
||||
# - Arrays and Hashes are blank if empty
|
||||
# - true and numbers are never blank
|
||||
|
||||
def test_blank_with_whitespace_string
|
||||
# Template authors expect " " to be blank since it has no visible content.
|
||||
# This matches ActiveSupport's String#blank? which returns true for whitespace-only strings.
|
||||
@context['whitespace'] = ' '
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('whitespace'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_empty_string
|
||||
# An empty string has no content, so it should be considered blank.
|
||||
# This is the most basic case of a blank string.
|
||||
@context['empty_string'] = ''
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_string'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_empty_array
|
||||
# Empty arrays have no elements, so they are blank.
|
||||
# Useful for checking if a collection has items: {% if products == blank %}
|
||||
@context['empty_array'] = []
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_array'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_empty_hash
|
||||
# Empty hashes have no key-value pairs, so they are blank.
|
||||
# Useful for checking if settings/options exist: {% if settings == blank %}
|
||||
@context['empty_hash'] = {}
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_nil
|
||||
# nil represents "nothing" and is the canonical blank value.
|
||||
# Unassigned variables resolve to nil, so this enables: {% if missing_var == blank %}
|
||||
@context['nil_value'] = nil
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('nil_value'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_blank_with_false
|
||||
# false is considered blank to match ActiveSupport semantics.
|
||||
# This allows {% if some_flag == blank %} to work when flag is false.
|
||||
@context['false_value'] = false
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('false_value'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_true
|
||||
# true is a definite value, not blank.
|
||||
# Ensures {% if flag == blank %} works correctly for boolean flags.
|
||||
@context['true_value'] = true
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('true_value'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_number
|
||||
# Numbers (including zero) are never blank - they represent actual values.
|
||||
# 0 is a valid quantity, not the absence of a value.
|
||||
@context['number'] = 42
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('number'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_string_content
|
||||
# A string with actual content is not blank.
|
||||
# This is the expected behavior for most template string comparisons.
|
||||
@context['string'] = 'hello'
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('string'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_non_empty_array
|
||||
# An array with elements has content, so it's not blank.
|
||||
# Enables patterns like {% unless products == blank %}Show products{% endunless %}
|
||||
@context['array'] = [1, 2, 3]
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('array'), '==', blank_literal)
|
||||
end
|
||||
|
||||
def test_not_blank_with_non_empty_hash
|
||||
# A hash with key-value pairs has content, so it's not blank.
|
||||
# Useful for checking if configuration exists: {% if config != blank %}
|
||||
@context['hash'] = { 'a' => 1 }
|
||||
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal)
|
||||
end
|
||||
|
||||
# Tests for empty? comparison without ActiveSupport
|
||||
#
|
||||
# empty? is distinct from blank? - it only checks if a collection has zero elements.
|
||||
# For strings, empty? checks length == 0, NOT whitespace content.
|
||||
# Ruby's standard library has empty? on String, Array, and Hash, but Liquid
|
||||
# provides a fallback implementation for consistency.
|
||||
|
||||
def test_empty_with_empty_string
|
||||
# An empty string ("") has length 0, so it's empty.
|
||||
# Different from blank - empty is a stricter check.
|
||||
@context['empty_string'] = ''
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_empty_with_whitespace_string_not_empty
|
||||
# Whitespace strings have length > 0, so they are NOT empty.
|
||||
# This is the key difference between empty and blank:
|
||||
# " ".empty? => false, but " ".blank? => true
|
||||
@context['whitespace'] = ' '
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('whitespace'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_empty_with_empty_array
|
||||
# An array with no elements is empty.
|
||||
# [].empty? => true
|
||||
@context['empty_array'] = []
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_array'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_empty_with_empty_hash
|
||||
# A hash with no key-value pairs is empty.
|
||||
# {}.empty? => true
|
||||
@context['empty_hash'] = {}
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal)
|
||||
end
|
||||
|
||||
def test_nil_is_not_empty
|
||||
# nil is NOT empty - empty? checks if a collection has zero elements.
|
||||
# nil is not a collection, so it cannot be empty.
|
||||
# This differs from blank: nil IS blank, but nil is NOT empty.
|
||||
@context['nil_value'] = nil
|
||||
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
|
||||
|
||||
assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_evaluates_true(left, op, right)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class EnvironmentTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
class UnsubscribeFooter < Liquid::Tag
|
||||
def render(_context)
|
||||
'Unsubscribe Footer'
|
||||
end
|
||||
end
|
||||
|
||||
def test_custom_tag
|
||||
email_environment = Liquid::Environment.build do |environment|
|
||||
environment.register_tag("unsubscribe_footer", UnsubscribeFooter)
|
||||
end
|
||||
|
||||
assert(email_environment.tags["unsubscribe_footer"])
|
||||
assert(email_environment.tag_for_name("unsubscribe_footer"))
|
||||
template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment)
|
||||
|
||||
assert_equal('Unsubscribe Footer', template.render)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,256 @@
|
||||
# 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,123 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class ParseContextUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_safe_parse_expression_with_variable_lookup
|
||||
parser_strict = strict_parse_context.new_parser('product.title')
|
||||
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
|
||||
|
||||
parser_strict2 = strict2_parse_context.new_parser('product.title')
|
||||
result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict2)
|
||||
assert_equal('product', result_strict2.name)
|
||||
assert_equal(['title'], result_strict2.lookups)
|
||||
end
|
||||
|
||||
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
|
||||
parser_strict = strict_parse_context.new_parser('')
|
||||
parser_strict2 = strict2_parse_context.new_parser('')
|
||||
|
||||
error_strict = assert_raises(Liquid::SyntaxError) do
|
||||
strict_parse_context.safe_parse_expression(parser_strict)
|
||||
end
|
||||
assert_match(/is not a valid expression/, error_strict.message)
|
||||
|
||||
error_strict2 = assert_raises(Liquid::SyntaxError) do
|
||||
strict2_parse_context.safe_parse_expression(parser_strict2)
|
||||
end
|
||||
|
||||
assert_match(/is not a valid expression/, error_strict2.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_variable_lookup
|
||||
result_strict = strict_parse_context.parse_expression('product.title')
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
strict2_parse_context.parse_expression('product.title')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_safe_true
|
||||
result_strict = strict_parse_context.parse_expression('product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict)
|
||||
assert_equal('product', result_strict.name)
|
||||
assert_equal(['title'], result_strict.lookups)
|
||||
|
||||
result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
|
||||
|
||||
assert_instance_of(VariableLookup, result_strict2)
|
||||
assert_equal('product', result_strict2.name)
|
||||
assert_equal(['title'], result_strict2.lookups)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_empty_string
|
||||
result_strict = strict_parse_context.parse_expression('')
|
||||
assert_nil(result_strict)
|
||||
|
||||
error = assert_raises(Liquid::InternalError) do
|
||||
strict2_parse_context.parse_expression('')
|
||||
end
|
||||
|
||||
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_empty_string_and_safe_true
|
||||
result_strict = strict_parse_context.parse_expression('', safe: true)
|
||||
assert_nil(result_strict)
|
||||
|
||||
result_strict2 = strict2_parse_context.parse_expression('', safe: true)
|
||||
assert_nil(result_strict2)
|
||||
end
|
||||
|
||||
def test_safe_parse_expression_advances_parser_pointer
|
||||
parser = strict2_parse_context.new_parser('foo, bar')
|
||||
|
||||
# safe_parse_expression consumes "foo"
|
||||
first_result = strict2_parse_context.safe_parse_expression(parser)
|
||||
assert_instance_of(VariableLookup, first_result)
|
||||
assert_equal('foo', first_result.name)
|
||||
|
||||
parser.consume(:comma)
|
||||
|
||||
# safe_parse_expression consumes "bar"
|
||||
second_result = strict2_parse_context.safe_parse_expression(parser)
|
||||
assert_instance_of(VariableLookup, second_result)
|
||||
assert_equal('bar', second_result.name)
|
||||
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
|
||||
def test_parse_expression_with_whitespace_in_strict2_mode
|
||||
result = strict2_parse_context.parse_expression(' ', safe: true)
|
||||
assert_nil(result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strict_parse_context
|
||||
@strict_parse_context ||= ParseContext.new(
|
||||
environment: Environment.build(error_mode: :strict),
|
||||
)
|
||||
end
|
||||
|
||||
def strict2_parse_context
|
||||
@strict2_parse_context ||= ParseContext.new(
|
||||
environment: Environment.build(error_mode: :strict2),
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -184,7 +184,7 @@ class PartialCacheUnitTest < Minitest::Test
|
||||
},
|
||||
)
|
||||
|
||||
[:lax, :warn, :strict].each do |error_mode|
|
||||
[:lax, :warn, :strict, :strict2].each do |error_mode|
|
||||
Liquid::PartialCache.load(
|
||||
'my_partial',
|
||||
context: context,
|
||||
@@ -193,7 +193,7 @@ class PartialCacheUnitTest < Minitest::Test
|
||||
end
|
||||
|
||||
assert_equal(
|
||||
["my_partial:lax", "my_partial:warn", "my_partial:strict"],
|
||||
["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"],
|
||||
context.registers[:cached_partials].keys,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class ResourceLimitsUnitTest < Minitest::Test
|
||||
def test_cumulative_scores_initialize_to_zero
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
assert_equal(0, limits.cumulative_render_score)
|
||||
assert_equal(0, limits.cumulative_assign_score)
|
||||
end
|
||||
|
||||
def test_cumulative_limits_default_to_nil
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
assert_nil(limits.cumulative_render_score_limit)
|
||||
assert_nil(limits.cumulative_assign_score_limit)
|
||||
end
|
||||
|
||||
def test_cumulative_limits_configurable_via_hash
|
||||
limits = Liquid::ResourceLimits.new(
|
||||
cumulative_render_score_limit: 500,
|
||||
cumulative_assign_score_limit: 300,
|
||||
)
|
||||
assert_equal(500, limits.cumulative_render_score_limit)
|
||||
assert_equal(300, limits.cumulative_assign_score_limit)
|
||||
end
|
||||
|
||||
def test_cumulative_limits_configurable_via_accessor
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
limits.cumulative_render_score_limit = 500
|
||||
assert_equal(500, limits.cumulative_render_score_limit)
|
||||
end
|
||||
|
||||
def test_cumulative_scores_survive_reset
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
limits.increment_render_score(10)
|
||||
limits.increment_assign_score(5)
|
||||
|
||||
limits.reset
|
||||
|
||||
assert_equal(0, limits.render_score)
|
||||
assert_equal(0, limits.assign_score)
|
||||
assert_equal(10, limits.cumulative_render_score)
|
||||
assert_equal(5, limits.cumulative_assign_score)
|
||||
end
|
||||
|
||||
def test_cumulative_scores_accumulate_across_resets
|
||||
limits = Liquid::ResourceLimits.new({})
|
||||
limits.increment_render_score(10)
|
||||
limits.reset
|
||||
limits.increment_render_score(20)
|
||||
limits.reset
|
||||
limits.increment_render_score(30)
|
||||
|
||||
assert_equal(30, limits.render_score)
|
||||
assert_equal(60, limits.cumulative_render_score)
|
||||
end
|
||||
|
||||
def test_cumulative_render_score_limit_raises
|
||||
limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
|
||||
limits.increment_render_score(10)
|
||||
limits.reset
|
||||
limits.increment_render_score(10)
|
||||
limits.reset
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
limits.increment_render_score(10)
|
||||
end
|
||||
assert(limits.reached?)
|
||||
end
|
||||
|
||||
def test_cumulative_assign_score_limit_raises
|
||||
limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
|
||||
limits.increment_assign_score(8)
|
||||
limits.reset
|
||||
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
limits.increment_assign_score(8)
|
||||
end
|
||||
assert(limits.reached?)
|
||||
end
|
||||
|
||||
def test_per_template_limits_still_work_with_cumulative
|
||||
limits = Liquid::ResourceLimits.new(
|
||||
render_score_limit: 50,
|
||||
cumulative_render_score_limit: 1000,
|
||||
)
|
||||
assert_raises(Liquid::MemoryError) do
|
||||
limits.increment_render_score(51)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ class StrainerTemplateUnitTest < Minitest::Test
|
||||
def test_add_filter_when_wrong_filter_class
|
||||
c = Context.new
|
||||
s = c.strainer
|
||||
wrong_filter = ->(v) { v.reverse }
|
||||
wrong_filter = lambda(&:reverse)
|
||||
|
||||
exception = assert_raises(TypeError) do
|
||||
s.class.add_filter(wrong_filter)
|
||||
|
||||
@@ -9,4 +9,140 @@ class CaseTagUnitTest < Minitest::Test
|
||||
template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}')
|
||||
assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten)
|
||||
end
|
||||
|
||||
def test_case_with_trailing_element
|
||||
template = <<~LIQUID
|
||||
{%- case 1 bar -%}
|
||||
{%- when 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Expected end_of_string but found/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_trailing_element
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 1 bar -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Expected end_of_string but found/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_comma
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 2, 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_or
|
||||
template = <<~LIQUID
|
||||
{%- case 1 -%}
|
||||
{%- when 2 or 1 -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
assert_template_result("one", template)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_empty
|
||||
template = <<~LIQUID
|
||||
{%- case x -%}
|
||||
{%- when 2 or empty -%}
|
||||
2 or empty
|
||||
{%- else -%}
|
||||
not 2 or empty
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
|
||||
with_error_modes(:lax, :strict, :strict2) do
|
||||
assert_template_result("2 or empty", template, { 'x' => 2 })
|
||||
assert_template_result("2 or empty", template, { 'x' => {} })
|
||||
assert_template_result("2 or empty", template, { 'x' => [] })
|
||||
assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
|
||||
assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
|
||||
assert_template_result("not 2 or empty", template, { 'x' => 4 })
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{%- case foo=>bar -%}
|
||||
{%- when 'baz' -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
assigns = { 'foo' => { 'bar' => 'baz' } }
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template, assigns)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_case_when_with_invalid_expression
|
||||
template = <<~LIQUID
|
||||
{%- case 'baz' -%}
|
||||
{%- when foo=>bar -%}
|
||||
one
|
||||
{%- else -%}
|
||||
two
|
||||
{%- endcase -%}
|
||||
LIQUID
|
||||
assigns = { 'foo' => { 'bar' => 'baz' } }
|
||||
|
||||
with_error_modes(:lax, :strict) do
|
||||
assert_template_result("one", template, assigns)
|
||||
end
|
||||
|
||||
with_error_modes(:strict2) do
|
||||
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
|
||||
|
||||
assert_match(/Unexpected character =/, error.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,6 +20,21 @@ class DocTagUnitTest < Minitest::Test
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_body_content
|
||||
doc_content = " Documentation content\n @param {string} foo - test\n"
|
||||
template_source = "{% doc %}#{doc_content}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(doc_content, doc_tag.nodelist.first.to_s)
|
||||
end
|
||||
|
||||
def test_doc_tag_does_not_support_extra_arguments
|
||||
error = assert_raises(Liquid::SyntaxError) do
|
||||
template = <<~LIQUID.chomp
|
||||
@@ -116,6 +131,20 @@ class DocTagUnitTest < Minitest::Test
|
||||
assert_template_result('', template)
|
||||
end
|
||||
|
||||
def test_doc_tag_captures_token_before_enddoc
|
||||
template_source = "{% doc %}{{ incomplete{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s)
|
||||
end
|
||||
|
||||
def test_doc_tag_preserves_error_line_numbers
|
||||
template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true)
|
||||
{% doc %}
|
||||
@@ -145,11 +174,11 @@ class DocTagUnitTest < Minitest::Test
|
||||
|
||||
def test_doc_tag_delimiter_handling
|
||||
assert_template_result('', <<~LIQUID.chomp)
|
||||
{% if true %}
|
||||
{% doc %}
|
||||
{% docEXTRA %}wut{% enddocEXTRA %}xyz
|
||||
{% enddoc %}
|
||||
{% endif %}
|
||||
{%- if true -%}
|
||||
{%- doc -%}
|
||||
{%- docEXTRA -%}wut{% enddocEXTRA -%}xyz
|
||||
{%- enddoc -%}
|
||||
{%- endif -%}
|
||||
LIQUID
|
||||
|
||||
assert_template_result('', "{% doc %}123{% enddoc xyz %}")
|
||||
@@ -167,6 +196,80 @@ class DocTagUnitTest < Minitest::Test
|
||||
)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_empty_content
|
||||
template_source = "{% doc %}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(true, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_content
|
||||
template_source = "{% doc %}Some documentation{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(false, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_blank_with_whitespace_only
|
||||
template_source = "{% doc %} {% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal(false, doc_tag.blank?)
|
||||
end
|
||||
|
||||
def test_doc_tag_nodelist_returns_array_with_body
|
||||
doc_content = "Documentation content\n@param {string} foo"
|
||||
template_source = "{% doc %}#{doc_content}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal([doc_content], doc_tag.nodelist)
|
||||
assert_equal(1, doc_tag.nodelist.length)
|
||||
assert_equal(doc_content, doc_tag.nodelist.first)
|
||||
end
|
||||
|
||||
def test_doc_tag_nodelist_with_empty_content
|
||||
template_source = "{% doc %}{% enddoc %}"
|
||||
|
||||
doc_tag = nil
|
||||
ParseTreeVisitor
|
||||
.for(Template.parse(template_source).root)
|
||||
.add_callback_for(Liquid::Doc) do |tag|
|
||||
doc_tag = tag
|
||||
end
|
||||
.visit
|
||||
|
||||
assert_equal([""], doc_tag.nodelist)
|
||||
assert_equal(1, doc_tag.nodelist.length)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def traversal(template)
|
||||
|
||||
@@ -108,7 +108,7 @@ class VariableUnitTest < Minitest::Test
|
||||
assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name)
|
||||
assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name)
|
||||
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
|
||||
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
|
||||
@@ -135,16 +135,68 @@ class VariableUnitTest < Minitest::Test
|
||||
var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax)
|
||||
assert_equal(VariableLookup.new('number_of_comments'), var.name)
|
||||
assert_equal([['pluralize', ['comment', 'comments']]], var.filters)
|
||||
|
||||
# missing does not throws error
|
||||
create_variable(%(n | f1: ,), error_mode: :lax)
|
||||
create_variable(%(n | f1: ,| f2), error_mode: :lax)
|
||||
|
||||
# arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':'
|
||||
var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax)
|
||||
assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters)
|
||||
|
||||
# positional and kwargs parsing
|
||||
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax)
|
||||
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs intermixed (pos1, key1: val1, pos2)
|
||||
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax)
|
||||
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
|
||||
end
|
||||
|
||||
def test_strict_filter_argument_parsing
|
||||
with_error_mode(:strict) do
|
||||
with_error_modes(:strict) do
|
||||
assert_raises(SyntaxError) do
|
||||
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_strict2_filter_argument_parsing
|
||||
with_error_modes(:strict2) do
|
||||
# optional colon
|
||||
var = create_variable(%(n | f1 | f2:))
|
||||
assert_equal([['f1', []], ['f2', []]], var.filters)
|
||||
|
||||
# missing argument throws error
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
|
||||
|
||||
# arg requires colon
|
||||
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
|
||||
|
||||
# trailing comma doesn't throw
|
||||
create_variable(%(n | f1: 1, 2, 3, | f2:))
|
||||
|
||||
# missing comma throws error
|
||||
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
|
||||
|
||||
# positional and kwargs parsing
|
||||
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
|
||||
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs mixed
|
||||
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
|
||||
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
|
||||
|
||||
# positional and kwargs intermixed (pos1, key1: val1, pos2)
|
||||
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
|
||||
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
|
||||
|
||||
# string key throws
|
||||
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
|
||||
end
|
||||
end
|
||||
|
||||
def test_output_raw_source_of_variable
|
||||
var = create_variable(%( name_of_variable | upcase ))
|
||||
assert_equal(" name_of_variable | upcase ", var.raw)
|
||||
|
||||
Reference in New Issue
Block a user