Compare commits

..
Author SHA1 Message Date
Albert Chu 591d3a2c70 [WIP] Support for nested boolean expressions in parentheses
- Added unit tests for range syntax
- Added logical expression unit tests
- parser respects parentheses during expression traversal
2025-03-17 18:26:52 -06:00
Albert Chu f1b178d25e Boolean precedence unit tests 2025-03-17 14:08:44 -06:00
Albert Chu 1ae2ff103e Added more non-parity unit tests 2025-03-13 16:41:17 -06:00
Albert Chu 6148604320 Added another failing unit test for behavior in main 2025-03-13 13:26:03 -06:00
Albert Chu e6e8221c78 Fixed lax parsing test case 2025-03-13 10:53:24 -06:00
Albert Chu 484f016e1b Added failing unit test that passes in main 2025-03-12 19:23:18 -06:00
Albert Chu 4b57b2bbd9 Reintroduced broken conditional operators behaviour present in liquid main 2025-03-12 14:08:45 -06:00
Albert Chu 430794dd5a Added test for operator reading bug 2025-03-11 17:00:57 -06:00
Albert Chu 08d36b09a2 Fixed ComparisonExpression.parse with MethodLiterals 2025-03-10 21:20:44 -06:00
Albert Chu b5c3d3fe82 Introduced debugging gems 2025-03-10 21:20:44 -06:00
Guilherme CarreiroandAlbert Chu 51a05c2781 Blank parity 2025-03-10 21:20:44 -06:00
Guilherme CarreiroandAlbert Chu 263a73bd81 Remove 'assert_parity_todo!' 2025-03-10 21:20:44 -06:00
Guilherme CarreiroandAlbert Chu 9e6b628a68 * Introduce support for literal comparisons (e.g., {{ 'hello' == 'hello' }})
* Evaluate expressions as truthy/falsy to unlock scenarios, such as `<div class="{{ disabled and "modal--disabled" }}">`
* Add additional scenarios to the expression test suite
* Simplify `LogicalExpression`
2025-03-10 21:20:44 -06:00
Albert Chu b31f24bdf0 More boolean unit tests and enabled more existing parity cases 2025-03-10 21:20:44 -06:00
Albert Chu b57f4fcbcb Support usecase where a nil variable value is used in a logical expression 2025-03-10 21:20:43 -06:00
Guilherme CarreiroandAlbert Chu 26ccec12ab * Move expression handling from variable.rb to expression.rb
* Update test suite to validate parity
* Remove parentheses handling
* Split boolean into comparison and logical expressions
2025-03-10 21:20:43 -06:00
Guilherme CarreiroandAlbert Chu ba0bbe3c3f Update the parser to use the new tokens 2025-03-10 21:20:43 -06:00
Guilherme CarreiroandAlbert Chu 9b967690aa Introduce support to boolean operators in the lexer 2025-03-10 21:20:43 -06:00
Albert Chu 03feea967b Added a lot more boolean unit tests 2025-03-10 21:20:43 -06:00
Albert Chu 14b0d64b70 Rough support for parenthesis. Also better respect for and/or order precedence. 2025-03-10 21:20:43 -06:00
Albert Chu 6d4cffa000 Support for simple boolean comparisons and boolean assignments 2025-03-10 21:20:42 -06:00
Albert Chu f32c0fb4fb TDD: Improved unit tests in boolean_unit_test.rb
Added tests for existing usage cases to avoid breaking important logic when introducing changes in subsequent commits.
2025-03-10 21:20:42 -06:00
Albert Chu b036feb20a Removed infix operators from this PR 2025-03-10 21:20:42 -06:00
Albert Chu 5782a9e1d7 TDD: Unit tests for new liquid syntax 2025-03-10 21:20:42 -06:00
79 changed files with 2001 additions and 1792 deletions
-6
View File
@@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
+17 -37
View File
@@ -1,5 +1,5 @@
name: Liquid
on: [push]
on: [push, pull_request]
env:
BUNDLE_JOBS: 4
@@ -9,33 +9,30 @@ jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
entry:
- { ruby: 3.3, allowed-failure: false } # minimum supported
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false } # latest stable
- { 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: 4.0,
ruby: 3.4,
allowed-failure: false,
rubyopt: "--enable-frozen-string-literal",
}
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
# Head can have failures due to being in development
- { ruby: head, allowed-failure: true }
- { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: ruby-head, allowed-failure: false }
- {
ruby: head,
allowed-failure: true,
ruby: ruby-head,
allowed-failure: false,
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' }}
- { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" }
name: Test Ruby ${{ matrix.entry.ruby }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
@@ -45,28 +42,11 @@ 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@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.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@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec rake memory_profile:run
+1 -10
View File
@@ -174,16 +174,7 @@ Style/WordArray:
# Offense count: 117
# This cop supports safe auto-correction (--auto-correct).
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns.
# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns.
# 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'
+4 -9
View File
@@ -25,14 +25,9 @@ group :development do
end
group :test do
gem 'benchmark'
gem 'rubocop', '~> 1.82.0'
gem 'rubocop-shopify', '~> 2.18.0', require: false
gem 'ruby-lsp'
gem 'debug'
gem 'rubocop', '~> 1.61.0'
gem 'rubocop-shopify', '~> 2.12.0', require: false
gem 'rubocop-performance', require: false
end
group :spec do
# TODO: temporary - using cp-gate-lax-specs-ai branch until lax specs are properly gated
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'cp-gate-lax-specs-ai'
gem 'activesupport', require: false
end
+1 -45
View File
@@ -1,50 +1,6 @@
# Liquid Change Log
## 6.0.0
### Architectural changes
### Features
* (TODO) Add support for boolean expressions everywhere
* As variable output `{{ a or b }}`
* As filter argument `{{ collection | where: 'prop', a or b }}`
* As tag argument `{% render 'snip', enabled: a or b %}`
* As conditional tag argument `{% if cond %}` (extending previous behaviour)
* (TODO) Add support for subexpression prioritization and associativity
* In ascending order of priority:
* Logical: `and`, `or` (right to left)
* Equality: `==`, `!=`, `<>` (left to right)
* Comparison: `>`, `>=`, `<`, `<=`, `contains` (left to right)
- For example, this is now supported
* `{{ a > b == c < d or e == f }}` which is equivalent to
* `{{ ((a > b) == (c < d)) or (e == f) }}`
- (TODO) Add support for parenthesized expressions
* e.g. `(a or b) and c`
### Breaking changes
* The Environment's `error_mode` option has been removed.
* `:warn` is no longer supported
* `:lax` and `lax_parse` is no longer supported
* `:strict` and `strict_parse` is no longer supported
* `strict2_parse` is renamed to `parse_markup`
* The `warnings` system has been removed.
### Migrating from `^5.11.0`
- In custom tags that include `ParserSwitching`, rename `strict2_parse` to `parse_markup`
- Remove code depending on `:error_mode`
## 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 (unreleased)
## 5.8.1
+25
View File
@@ -93,6 +93,31 @@ LIQUID
By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`.
### Error Modes
Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted.
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
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.
```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
```ruby
Liquid::Template.parse(source, error_mode: :strict)
```
This is useful for doing things like enabling strict mode only in the theme editor.
It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created.
It is also recommended that you use it in the template editors of existing apps to give editors better error messages.
### Undefined variables and filters
By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method.
+26 -10
View File
@@ -33,12 +33,21 @@ task :rubocop do
end
end
desc('runs test suite')
desc('runs test suite with both strict and lax parsers')
task :test do
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['base_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict'
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
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
@@ -61,11 +70,19 @@ task release: :build do
end
namespace :benchmark do
desc "Run the liquid benchmark"
task :run do
ruby "./performance/benchmark.rb"
desc "Run the liquid benchmark with lax parsing"
task :lax do
ruby "./performance/benchmark.rb lax"
end
desc "Run the liquid benchmark with strict parsing"
task :strict do
ruby "./performance/benchmark.rb strict"
end
desc "Run the liquid benchmark with both lax and strict parsing"
task run: [:lax, :strict]
desc "Run unit benchmarks"
namespace :unit do
task :all do
@@ -96,6 +113,11 @@ namespace :profile do
task :run do
ruby "./performance/profile.rb"
end
desc "Run the liquid profile/performance coverage with strict parsing"
task :strict do
ruby "./performance/profile.rb strict"
end
end
namespace :memory_profile do
@@ -113,9 +135,3 @@ 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
-46
View File
@@ -1,46 +0,0 @@
#!/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)
+2
View File
@@ -80,6 +80,8 @@ require 'liquid/variable_lookup'
require 'liquid/range_lookup'
require 'liquid/resource_limits'
require 'liquid/expression'
require 'liquid/expression/comparison_expression'
require 'liquid/expression/logical_expression'
require 'liquid/template'
require 'liquid/condition'
require 'liquid/utils'
+22 -57
View File
@@ -48,8 +48,12 @@ module Liquid
@@operators
end
def self.parse_expression(parse_context, markup, safe: false)
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
def self.parse_expression(parse_context, markup)
@@method_literals[markup] || parse_context.parse_expression(markup)
end
def self.parse(markup, ss, cache)
@@method_literals[markup] || Expression.parse(markup, ss, cache)
end
attr_reader :attachment, :child_condition
@@ -112,68 +116,29 @@ module Liquid
private
def equal_variables(left, right)
if left.is_a?(MethodLiteral) && right.is_a?(MethodLiteral)
return left.to_s == right.to_s
end
if left.is_a?(MethodLiteral)
return call_method_literal(left, right)
if right.respond_to?(left.method_name)
return right.send(left.method_name)
else
return left.to_s == right
end
end
if right.is_a?(MethodLiteral)
return call_method_literal(right, left)
if left.respond_to?(right.method_name)
return left.send(right.method_name)
else
return right.to_s == left
end
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
@@ -197,8 +162,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
+7 -2
View File
@@ -60,6 +60,10 @@ module Liquid
end
# rubocop:enable Metrics/ParameterLists
def warnings
@warnings ||= []
end
def strainer
@strainer ||= @environment.create_strainer(self, @filters)
end
@@ -153,6 +157,7 @@ module Liquid
subcontext.filters = @filters
subcontext.strainer = nil
subcontext.errors = errors
subcontext.warnings = warnings
subcontext.disabled_tags = @disabled_tags
end
end
@@ -179,7 +184,7 @@ module Liquid
end
def key?(key)
find_variable(key, raise_on_not_found: false) != nil
self[key] != nil
end
def evaluate(object)
@@ -239,7 +244,7 @@ module Liquid
protected
attr_writer :base_scope_depth, :errors, :strainer, :filters, :disabled_tags
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags
private
+1 -1
View File
@@ -31,7 +31,7 @@ module Liquid
# Catch all for the method
def liquid_method_missing(method)
return unless @context&.strict_variables
return nil unless @context&.strict_variables
raise Liquid::UndefinedDropMethod, "undefined method #{method}"
end
+9 -1
View File
@@ -4,6 +4,10 @@ module Liquid
# The Environment is the container for all configuration options of Liquid, such as
# the registered tags, filters, and the default error mode.
class Environment
# The default error mode for all templates. This can be overridden on a
# per-template basis.
attr_accessor :error_mode
# The tags that are available to use in the template.
attr_accessor :tags
@@ -29,14 +33,17 @@ module Liquid
# the template.
# @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).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @return [Environment] The new environment instance.
def build(tags: nil, file_system: nil, exception_renderer: nil)
def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil)
ret = new
ret.tags = tags if tags
ret.file_system = file_system if file_system
ret.error_mode = error_mode if error_mode
ret.exception_renderer = exception_renderer if exception_renderer
yield ret if block_given?
ret.freeze
@@ -68,6 +75,7 @@ module Liquid
# @api private
def initialize
@tags = Tags::STANDARD_TAGS.dup
@error_mode = :lax
@strainer_template = Class.new(StrainerTemplate).tap do |klass|
klass.add_filter(StandardFilters)
end
+11 -11
View File
@@ -11,6 +11,9 @@ module Liquid
'false' => false,
'blank' => '',
'empty' => '',
# in lax mode, minus sign can be a VariableLookup
# For simplicity and performace, we treat it like a literal
'-' => VariableLookup.parse("-", nil).freeze,
}.freeze
DOT = ".".ord
@@ -23,23 +26,17 @@ module Liquid
RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/
INTEGER_REGEX = /\A(-?\d+)\z/
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
QUOTED_STRING = /\A#{QuotedString}\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
if (markup.start_with?('"') && markup.end_with?('"')) ||
(markup.start_with?("'") && markup.end_with?("'"))
return markup[1..-2]
elsif LITERALS.key?(markup)
return LITERALS[markup]
end
return markup[1..-2] if QUOTED_STRING.match?(markup)
return LITERALS[markup] if LITERALS.key?(markup)
# Cache only exists during parsing
if cache
@@ -52,7 +49,10 @@ module Liquid
end
def inner_parse(markup, ss, cache)
if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX
return LogicalExpression.parse(markup, ss, cache) if LogicalExpression.logical?(markup)
return ComparisonExpression.parse(markup, ss, cache) if ComparisonExpression.comparison?(markup)
if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX
return RangeLookup.parse(
Regexp.last_match(1),
Regexp.last_match(2),
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Liquid
class Expression
class ComparisonExpression
# We can improve the resiliency of lax parsing by not expecting whitespace
# surrounding the operator (ie \s+ => \s*).
# However this is not in parity with existing lax parsing behavior.
COMPARISON_REGEX = /\A\s*(.+?)\s+(==|!=|<>|<=|>=|<|>|contains)\s+(.+)\s*\z/
class << self
def comparison?(markup)
markup.match(COMPARISON_REGEX)
end
def parse(markup, ss, cache)
match = comparison?(markup)
if match
left = Condition.parse(match[1].strip, ss, cache)
operator = match[2].strip
right = Condition.parse(match[3].strip, ss, cache)
return Condition.new(left, operator, right)
end
Condition.new(parse(markup, ss, cache), nil, nil)
end
end
end
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Liquid
class Expression
class LogicalExpression
LOGICAL_REGEX = /\A\s*(.+?)\s+(and|or)\s+(.+)\s*\z/i
EXPRESSIONS_AND_OPERATORS = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o
BOOLEAN_OPERATORS = ['and', 'or'].freeze
class << self
def logical?(markup)
markup.match(LOGICAL_REGEX)
end
def boolean_operator?(markup)
BOOLEAN_OPERATORS.include?(markup)
end
def parse(markup, ss, cache)
expressions = markup.scan(EXPRESSIONS_AND_OPERATORS)
expression = expressions.pop
condition = parse_condition(expression, ss, cache)
until expressions.empty?
operator = expressions.pop.to_s.strip
next unless boolean_operator?(operator)
expression = expressions.pop.to_s.strip
new_condition = parse_condition(expression, ss, cache)
case operator
when 'and' then new_condition.and(condition)
when 'or' then new_condition.or(condition)
end
condition = new_condition
end
condition
end
private
def parse_condition(expr, ss, cache)
return ComparisonExpression.parse(expr, ss, cache) if comparison?(expr)
return LogicalExpression.parse(expr, ss, cache) if logical?(expr)
Condition.new(Expression.parse(expr, ss, cache), nil, nil)
end
def comparison?(...)
ComparisonExpression.comparison?(...)
end
end
end
end
end
+1 -1
View File
@@ -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
+6
View File
@@ -14,6 +14,8 @@ module Liquid
COMPARISON_LESS_THAN = [:comparison, "<"].freeze
COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze
COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
BOOLEAN_AND = [:boolean_operator, "and"].freeze
BOOLEAN_OR = [:boolean_operator, "or"].freeze
DASH = [:dash, "-"].freeze
DOT = [:dot, "."].freeze
DOTDOT = [:dotdot, ".."].freeze
@@ -151,6 +153,10 @@ module Liquid
# Special case for "contains"
output << if type == :id && t == "contains" && output.last&.first != :dot
COMPARISON_CONTAINS
elsif type == :id && t == "and" && output.last&.first != :dot
BOOLEAN_AND
elsif type == :id && t == "or" && output.last&.first != :dot
BOOLEAN_OR
else
[type, t]
end
-1
View File
@@ -20,7 +20,6 @@
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"
+6 -14
View File
@@ -3,13 +3,14 @@
module Liquid
class ParseContext
attr_accessor :locale, :line_number, :trim_whitespace, :depth
attr_reader :partial, :environment
attr_reader :partial, :warnings, :error_mode, :environment
def initialize(options = Const::EMPTY_HASH)
@environment = options.fetch(:environment, Environment.default)
@template_options = options ? options.dup : {}
@locale = @template_options[:locale] ||= I18n.new
@locale = @template_options[:locale] ||= I18n.new
@warnings = []
# constructing new StringScanner in Lexer, Tokenizer, etc is expensive
# This StringScanner will be shared by all of them
@@ -49,24 +50,15 @@ module Liquid
)
end
def safe_parse_expression(parser)
Expression.safe_parse(parser, @string_scanner, @expression_cache)
end
def parse_expression(markup, safe: false)
# 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. The `safe` opt-in
# exists to ensure it is not accidentally still called with
# the result of a regex.
raise Liquid::InternalError, "unsafe parse_expression cannot be used" unless safe
def parse_expression(markup)
Expression.parse(markup, @string_scanner, @expression_cache)
end
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || @environment.error_mode
end
def partial_options
+33 -4
View File
@@ -48,7 +48,7 @@ module Liquid
def expression
token = @tokens[@p]
case token[0]
expr = case token[0]
when :id
str = consume
str << variable_lookups
@@ -60,12 +60,41 @@ module Liquid
when :string, :number
consume
when :open_round
consume
first = expression
consume(:dotdot)
consume_round_parentheses(token)
else
raise SyntaxError, "#{token} is not a valid expression"
end
if look(:comparison)
operator = consume(:comparison)
left = expr
right = expression
"#{left} #{operator} #{right}"
elsif look(:boolean_operator)
operator = consume(:boolean_operator)
left = expr
right = expression
if look(:close_round)
"(#{left} #{operator} #{right})"
else
"#{left} #{operator} #{right}"
end
else
expr
end
end
def consume_round_parentheses(token)
consume
first = expression
dotdot_token = consume?(:dotdot)
if dotdot_token
last = expression
consume(:close_round)
"(#{first}..#{last})"
elsif look(:close_round)
consume(:close_round)
first
else
raise SyntaxError, "#{token} is not a valid expression"
end
+29 -3
View File
@@ -2,16 +2,42 @@
module Liquid
module ParserSwitching
def strict_parse_with_error_mode_fallback(markup)
strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
when :strict
raise
when :warn
parse_context.warnings << e
end
lax_parse(markup)
end
def parse_with_selected_parser(markup)
parse_markup(markup)
case parse_context.error_mode
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :warn
begin
strict_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
end
end
end
private
def strict_parse_with_error_context(markup)
strict_parse(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
private
def markup_context(markup)
"in \"#{markup.strip}\""
end
+1 -1
View File
@@ -4,7 +4,7 @@ module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = context.registers[:cached_partials]
cache_key = template_name.to_s
cache_key = "#{template_name}:#{parse_context.error_mode}"
cached = cached_partials[cache_key]
return cached if cached
+6 -21
View File
@@ -3,6 +3,7 @@
require 'cgi'
require 'base64'
require 'bigdecimal'
module Liquid
module StandardFilters
MAX_I32 = (1 << 31) - 1
@@ -711,16 +712,7 @@ module Liquid
input.gsub(/\r?\n/, "<br />\n")
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category date
# @liquid_summary
# Formats a date according to a specified format string.
# @liquid_description
# This filter formats a date using various format specifiers. If the format string is empty,
# the original input is returned. If the input cannot be converted to a date, the original input is returned.
#
# The following format specifiers can be used:
# Reformat a date using Ruby's core Time#strftime( string ) -> string
#
# %a - The abbreviated weekday name (``Sun'')
# %A - The full weekday name (``Sunday'')
@@ -749,8 +741,8 @@ module Liquid
# %Y - Year with century
# %Z - Time zone name
# %% - Literal ``%'' character
# @liquid_syntax date | date: string
# @liquid_return [string]
#
# See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime
def date(input, format)
str_format = Utils.to_s(format)
return input if str_format.empty?
@@ -768,8 +760,6 @@ 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
@@ -781,8 +771,6 @@ 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
@@ -1001,7 +989,7 @@ module Liquid
end
def raise_property_error(property)
raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'"
raise Liquid::ArgumentError, "cannot select the property '#{property}'"
end
def apply_operation(input, operand, operation)
@@ -1073,10 +1061,7 @@ module Liquid
end
def uniq(&block)
to_a.uniq do |item|
item = Utils.to_liquid_value(item)
block ? yield(item) : item
end
to_a.uniq(&block)
end
def compact
+2 -6
View File
@@ -68,12 +68,8 @@ module Liquid
private
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)
def parse_expression(markup)
parse_context.parse_expression(markup)
end
end
end
-4
View File
@@ -9,10 +9,6 @@ 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.
-4
View File
@@ -9,10 +9,6 @@ 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
+15 -18
View File
@@ -23,12 +23,20 @@ module Liquid
# @liquid_syntax_keyword second_expression An expression to be rendered when the variable's value matches `second_value`.
# @liquid_syntax_keyword third_expression An expression to be rendered when the variable's value has no match.
class Case < Block
Syntax = /(#{QuotedFragment})/o
WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om
attr_reader :blocks, :left
def initialize(tag_name, markup, options)
super
@blocks = []
parse_with_selected_parser(markup)
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
end
def parse(tokens)
@@ -83,31 +91,20 @@ module Liquid
private
def parse_markup(markup)
parser = @parse_context.new_parser(markup)
@left = safe_parse_expression(parser)
parser.consume(:end_of_string)
end
def record_when_condition(markup)
body = new_body
parse_when(markup, body)
end
while markup
unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
end
def parse_when(markup, body)
parser = @parse_context.new_parser(markup)
markup = Regexp.last_match(2)
loop do
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
block = Condition.new(@left, '==', expr)
block = Condition.new(@left, '==', Condition.parse_expression(parse_context, Regexp.last_match(1)))
block.attach(body)
@blocks << block
break unless parser.id?('or') || parser.consume?(:comma)
end
parser.consume(:end_of_string)
end
def record_else_condition(markup)
+24 -46
View File
@@ -15,13 +15,25 @@ module Liquid
# @liquid_syntax
# {% cycle string, string, ... %}
class Cycle < Tag
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
attr_reader :variables
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(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?(/\w+:0x\h{8}/)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end
def named?
@@ -53,51 +65,17 @@ module Liquid
private
# cycle [name:] expression(, expression)*
def parse_markup(markup)
p = @parse_context.new_parser(markup)
def variables_from_string(markup)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
next unless Regexp.last_match(1)
@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
# 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
# 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
end.compact
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
+1 -5
View File
@@ -7,14 +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
# [snippets](/themes/architecture/snippets) included in the file.
# [snippets](/themes/architecture#snippets) included in the file.
#
# Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share
+3 -10
View File
@@ -13,10 +13,6 @@ module Liquid
# Liquid code inside will be parsed but not executed. This facilitates
# tooling support for features like code completion, linting, and inline
# documentation.
#
# For detailed documentation syntax and examples, see the
# [`LiquidDoc` reference](/docs/storefronts/themes/tools/liquid-doc).
#
# @liquid_syntax
# {% doc %}
# Renders a message.
@@ -27,6 +23,7 @@ module Liquid
# @example
# {% render 'message', foo: 'Hello', bar: 'World' %}
# {% enddoc %}
# {{ foo }}, {{ bar }}!
class Doc < Block
NO_UNEXPECTED_ARGS = /\A\s*\z/
@@ -36,8 +33,6 @@ module Liquid
end
def parse(tokens)
@body = +""
while (token = tokens.shift)
tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2)
@@ -45,10 +40,8 @@ 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)
@@ -59,11 +52,11 @@ module Liquid
end
def blank?
@body.empty?
true
end
def nodelist
[@body]
[]
end
private
+25 -8
View File
@@ -20,11 +20,13 @@ 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
Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o
attr_reader :collection_name, :variable_name, :limit, :from
def initialize(tag_name, markup, options)
@@ -70,13 +72,28 @@ module Liquid
protected
def parse_markup(markup)
def lax_parse(markup)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
collection_name = Regexp.last_match(2)
@reversed = !!Regexp.last_match(3)
@name = "#{@variable_name}-#{collection_name}"
@collection_name = parse_expression(collection_name)
markup.scan(TagAttributes) do |key, value|
set_attribute(key, value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.for")
end
end
def strict_parse(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
collection_name = p.expression
@collection_name = parse_expression(collection_name, safe: true)
@collection_name = parse_expression(collection_name)
@name = "#{@variable_name}-#{collection_name}"
@reversed = p.id?('reversed')
@@ -87,7 +104,7 @@ module Liquid
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end
p.consume(:colon)
set_attribute(attribute, p.expression, safe: true)
set_attribute(attribute, p.expression)
end
p.consume(:end_of_string)
end
@@ -157,16 +174,16 @@ module Liquid
output
end
def set_attribute(key, expr, safe: false)
def set_attribute(key, expr)
case key
when 'offset'
@from = if expr == 'continue'
:continue
else
parse_expression(expr, safe: safe)
parse_expression(expr)
end
when 'limit'
@limit = parse_expression(expr, safe: safe)
@limit = parse_expression(expr)
end
end
+29 -5
View File
@@ -14,6 +14,10 @@ module Liquid
# @liquid_syntax_keyword condition The condition to evaluate.
# @liquid_syntax_keyword expression The expression to render if the condition is met.
class If < Block
Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o
ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o
BOOLEAN_OPERATORS = %w(and or).freeze
attr_reader :blocks
def initialize(tag_name, markup, options)
@@ -73,11 +77,31 @@ module Liquid
block.attach(new_body)
end
def parse_expression(markup, safe: false)
Condition.parse_expression(parse_context, markup, safe: safe)
def parse_expression(markup)
Condition.parse_expression(parse_context, markup)
end
def parse_markup(markup)
def lax_parse(markup)
expressions = markup.scan(ExpressionsAndOperators)
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop =~ Syntax
condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3)))
until expressions.empty?
operator = expressions.pop.to_s.strip
raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop.to_s =~ Syntax
new_condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3)))
raise SyntaxError, options[:locale].t("errors.syntax.if") unless BOOLEAN_OPERATORS.include?(operator)
new_condition.send(operator, condition)
condition = new_condition
end
condition
end
def strict_parse(markup)
p = @parse_context.new_parser(markup)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
@@ -96,9 +120,9 @@ module Liquid
end
def parse_comparison(p)
a = parse_expression(p.expression, safe: true)
a = parse_expression(p.expression)
if (op = p.consume?(:comparison))
b = parse_expression(p.expression, safe: true)
b = parse_expression(p.expression)
Condition.new(a, op, b)
else
Condition.new(a)
+22 -22
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category theme
# @liquid_name include
# @liquid_summary
# Renders a [snippet](/themes/architecture/snippets).
# Renders a [snippet](/themes/architecture#snippets).
# @liquid_description
# Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the
# snippet.
@@ -20,11 +20,31 @@ module Liquid
class Include < Tag
prepend Tag::Disableable
SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
Syntax = SYNTAX
attr_reader :template_name_expr, :variable_name_expr, :attributes
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(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
def parse(_tokens)
@@ -81,26 +101,6 @@ module Liquid
alias_method :parse_context, :options
private :parse_context
def parse_markup(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
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+1 -5
View File
@@ -7,14 +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
# [snippets](/themes/architecture/snippets) included in the file.
# [snippets](/themes/architecture#snippets) included in the file.
#
# Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign)
# and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share
+18 -29
View File
@@ -6,7 +6,7 @@ module Liquid
# @liquid_category theme
# @liquid_name render
# @liquid_summary
# Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks).
# @liquid_description
# Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside
# of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet)
@@ -27,6 +27,7 @@ module Liquid
# @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension.
class Render < Tag
FOR = 'for'
SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
disable_tags "include"
@@ -34,7 +35,22 @@ module Liquid
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(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
def for_loop?
@@ -83,33 +99,6 @@ module Liquid
output
end
# render (string) (with|for expression)? (as id)? (key: value)*
def parse_markup(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = parse_expression(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 template_name(p)
p.consume(:string)
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+12 -31
View File
@@ -19,46 +19,27 @@ 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
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
attr_reader :variable_name, :collection_name, :attributes
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def parse_markup(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)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2))
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
p.consume(:colon)
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
else
raise SyntaxError, options[:locale].t("errors.syntax.table_row")
end
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
+16 -2
View File
@@ -2,7 +2,7 @@
module Liquid
# Templates are central to liquid.
# Interpreting templates is a two step process. First you compile the
# Interpretating 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.
#
@@ -16,11 +16,24 @@ module Liquid
#
class Template
attr_accessor :root, :name
attr_reader :resource_limits
attr_reader :resource_limits, :warnings
attr_reader :profiler
class << self
# 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.
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
end
def error_mode
Environment.default.error_mode
end
def default_exception_renderer=(renderer)
Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=")
Environment.default.exception_renderer = renderer
@@ -209,6 +222,7 @@ module Liquid
ParseContext.new(opts)
end
@warnings = parse_context.warnings
parse_context
end
+1 -1
View File
@@ -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
+3 -8
View File
@@ -2,9 +2,6 @@
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)
@@ -55,7 +52,7 @@ module Liquid
when Numeric
obj
when String
DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
/\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
else
if obj.respond_to?(:to_number)
obj.to_number
@@ -69,14 +66,14 @@ module Liquid
return obj if obj.respond_to?(:strftime)
if obj.is_a?(String)
return if obj.empty?
return nil if obj.empty?
obj = obj.downcase
end
case obj
when 'now', 'today'
Time.now
when UNIX_TIMESTAMP_REGEX, Integer
when /\A\d+\z/, Integer
Time.at(obj.to_i)
when String
Time.parse(obj)
@@ -95,8 +92,6 @@ 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
+46 -40
View File
@@ -30,7 +30,7 @@ module Liquid
@parse_context = parse_context
@line_number = parse_context.line_number
parse_with_selected_parser(markup)
strict_parse_with_error_mode_fallback(markup)
end
def raw
@@ -41,17 +41,47 @@ module Liquid
"in \"{{#{markup}}}\""
end
def parse_markup(markup)
def lax_parse(markup)
@filters = []
return unless markup =~ MarkupWithQuotedFragment
name_markup = Regexp.last_match(1)
filter_markup = Regexp.last_match(2)
@name = parse_context.parse_expression(name_markup)
if filter_markup =~ FilterMarkupRegex
filters = Regexp.last_match(1).scan(FilterParser)
filters.each do |f|
next unless f =~ /\w+/
filtername = Regexp.last_match(0)
filterargs = f.scan(FilterArgsRegex).flatten
@filters << parse_filter_expressions(filtername, filterargs)
end
end
end
def strict_parse(markup)
@filters = []
p = @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
@filters << parse_filter_expressions(p) while p.consume?(:pipe)
@name = parse_context.parse_expression(p.expression)
while p.consume?(:pipe)
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << parse_filter_expressions(filtername, filterargs)
end
p.consume(:end_of_string)
end
def parse_filterargs(p)
# first argument
filterargs = [p.argument]
# followed by comma separated others
filterargs << p.argument while p.consume?(:comma)
filterargs
end
def render(context)
obj = context.evaluate(@name)
@@ -92,46 +122,22 @@ module Liquid
private
# 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 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)
def parse_filter_expressions(filter_name, unparsed_args)
filter_args = []
keyword_args = nil
unparsed_args.each do |a|
if (matches = a.match(JustTagAttributes))
keyword_args ||= {}
keyword_args[matches[1]] = parse_context.parse_expression(matches[2])
else
filter_args << parse_context.parse_expression(a)
end
end
result = [filtername, filter_args]
result << keyword_args unless keyword_args.empty?
result = [filter_name, filter_args]
result << keyword_args if keyword_args
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
-5
View File
@@ -70,11 +70,6 @@ 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
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.11.0"
VERSION = "5.8.1"
end
+1
View File
@@ -4,6 +4,7 @@ require 'benchmark/ips'
require_relative 'theme_runner'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
+2
View File
@@ -53,6 +53,8 @@ class Profiler
end
end
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
runner = ThemeRunner.new
Profiler.run do |x|
x.profile('parse') { runner.compile }
+1
View File
@@ -3,6 +3,7 @@
require 'stackprof'
require_relative 'theme_runner'
Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first
profiler = ThemeRunner.new
profiler.run
-36
View File
@@ -1,36 +0,0 @@
# 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
-37
View File
@@ -1,37 +0,0 @@
# 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
-39
View File
@@ -1,39 +0,0 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with YJIT + 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|
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
+4 -2
View File
@@ -39,11 +39,13 @@ class AssignTest < Minitest::Test
assert_match_syntax_error(/assign/, '{% assign foo not values %}.')
end
def test_assign_throws_on_unsupported_syntax
def test_assign_uses_error_mode
assert_match_syntax_error(
"Expected dotdot but found pipe",
"Expected dotdot but found pipe in ",
"{% assign foo = ('X' | downcase) %}",
error_mode: :strict,
)
assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax)
end
def test_expression_with_whitespace_in_square_brackets
+4 -17
View File
@@ -632,26 +632,13 @@ class ContextTest < Minitest::Test
end
def test_has_key_will_not_add_an_error_for_missing_keys
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
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']
with_error_mode(:strict) do
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
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,
+51 -4
View File
@@ -67,11 +67,20 @@ class ErrorHandlingTest < Minitest::Test
end
def test_unrecognized_operator
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
with_error_mode(:strict) do
assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
end
end
end
def test_lax_unrecognized_operator
template = Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :lax)
assert_equal(' Liquid error: Unknown operator =! ', template.render)
assert_equal(1, template.errors.size)
assert_equal(Liquid::ArgumentError, template.errors.first.class)
end
def test_with_line_numbers_adds_numbers_to_parser_errors
source = <<~LIQUID
foobar
@@ -95,6 +104,25 @@ class ErrorHandlingTest < Minitest::Test
assert_match_syntax_error(/Liquid syntax error \(line 3\)/, source)
end
def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors
template = Liquid::Template.parse(
'
foobar
{% if 1 =! 2 %}ok{% endif %}
bla
',
error_mode: :warn,
line_numbers: true,
)
assert_equal(
['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'],
template.warnings.map(&:message),
)
end
def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors
err = assert_raises(SyntaxError) do
Liquid::Template.parse(
@@ -105,6 +133,7 @@ class ErrorHandlingTest < Minitest::Test
bla
',
error_mode: :strict,
line_numbers: true,
)
end
@@ -128,16 +157,34 @@ class ErrorHandlingTest < Minitest::Test
def test_strict_error_messages
err = assert_raises(SyntaxError) do
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ')
Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :strict)
end
assert_equal('Liquid syntax error: Unexpected character = in "1 =! 2"', err.message)
err = assert_raises(SyntaxError) do
Liquid::Template.parse('{{%%%}}')
Liquid::Template.parse('{{%%%}}', error_mode: :strict)
end
assert_equal('Liquid syntax error: Unexpected character % in "{{%%%}}"', err.message)
end
def test_warnings
template = Liquid::Template.parse('{% if ~~~ %}{{%%%}}{% else %}{{ hello. }}{% endif %}', error_mode: :warn)
assert_equal(3, template.warnings.size)
assert_equal('Unexpected character ~ in "~~~"', template.warnings[0].to_s(false))
assert_equal('Unexpected character % in "{{%%%}}"', template.warnings[1].to_s(false))
assert_equal('Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].to_s(false))
assert_equal('', template.render)
end
def test_warning_line_numbers
template = Liquid::Template.parse("{% if ~~~ %}\n{{%%%}}{% else %}\n{{ hello. }}{% endif %}", error_mode: :warn, line_numbers: true)
assert_equal('Liquid syntax error (line 1): Unexpected character ~ in "~~~"', template.warnings[0].message)
assert_equal('Liquid syntax error (line 2): Unexpected character % in "{{%%%}}"', template.warnings[1].message)
assert_equal('Liquid syntax error (line 3): Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].message)
assert_equal(3, template.warnings.size)
assert_equal([1, 2, 3], template.warnings.map(&:line_number))
end
# Liquid should not catch Exceptions that are not subclasses of StandardError, like Interrupt and NoMemoryError
def test_exceptions_propagate
assert_raises(Exception) do
+14 -30
View File
@@ -26,7 +26,8 @@ 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")
assert_expression_result(1.5, "1.5")
# this is a unfortunate quirky behavior of Liquid
@@ -51,6 +52,18 @@ class ExpressionTest < Minitest::Test
)
end
def test_quirky_negative_sign_expression_markup
result = Expression.parse("-", nil)
assert(result.is_a?(VariableLookup))
assert_equal("-", result.name)
# for this template, the expression markup is "-"
assert_template_result(
"",
"{{ - 'theme.css' - }}",
)
end
def test_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
@@ -134,35 +147,6 @@ 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)
+8 -2
View File
@@ -88,11 +88,17 @@ class HashRenderingTest < Minitest::Test
end
def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new })
my_hash = Class.new(Hash) do
def to_s
"kewl"
end
end.new
assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash })
end
def test_rendering_hash_without_custom_to_s_uses_default_inspect
my_hash = HashWithoutCustomToS.new
my_hash = Class.new(Hash).new
my_hash[:foo] = :bar
assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash })
+89 -10
View File
@@ -31,25 +31,58 @@ class ParsingQuirksTest < Minitest::Test
def test_error_on_empty_filter
assert(Template.parse("{{test}}"))
assert_raises(Liquid::SyntaxError) { Template.parse("{{|test}}") }
assert_raises(Liquid::SyntaxError) { Template.parse("{{test |a|b|}}") }
with_error_mode(:lax) do
assert(Template.parse("{{|test}}"))
end
with_error_mode(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end
end
def test_meaningless_parens_error
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
with_error_mode(:strict) do
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
end
def test_unexpected_characters_syntax_error
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
with_error_mode(:strict) do
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
assert_raises(SyntaxError) do
markup = "false || true"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
assert_raises(SyntaxError) do
end
def test_no_error_on_lax_empty_filter
assert(Template.parse("{{test |a|b|}}", error_mode: :lax))
assert(Template.parse("{{test}}", error_mode: :lax))
assert(Template.parse("{{|test|}}", error_mode: :lax))
end
def test_meaningless_parens_lax
with_error_mode(: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)
end
end
def test_unexpected_characters_silently_eat_logic_lax
with_error_mode(:lax) do
markup = "true && false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}")
markup = "false || true"
Template.parse("{% if #{markup} %} YES {% endif %}")
assert_template_result('', "{% if #{markup} %} YES {% endif %}")
end
end
@@ -59,6 +92,32 @@ class ParsingQuirksTest < Minitest::Test
end
end
def test_unanchored_filter_arguments
with_error_mode(:lax) do
assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}")
assert_template_result('x', "{{ 'X' | downcase) }}")
# After the messed up quotes a filter without parameters (reverse) should work
# but one with parameters (remove) shouldn't be detected.
assert_template_result('here', "{{ 'hi there' | split:\"t\"\" | reverse | first}}")
assert_template_result('hi ', "{{ 'hi there' | split:\"t\"\" | remove:\"i\" | first}}")
end
end
def test_invalid_variables_work
with_error_mode(: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
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end
end
def test_blank_variable_markup
assert_template_result('', "{{}}")
end
@@ -72,4 +131,24 @@ class ParsingQuirksTest < Minitest::Test
def test_contains_in_id
assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true })
end
def test_incomplete_expression
with_error_mode(:lax) do
assert_template_result("false", "{{ false - }}")
assert_template_result("false", "{{ false > }}")
assert_template_result("false", "{{ false < }}")
assert_template_result("false", "{{ false = }}")
assert_template_result("false", "{{ false ! }}")
assert_template_result("false", "{{ false 1 }}")
assert_template_result("false", "{{ false a }}")
assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false =\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false !\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false 1\n%}{{ foo }}")
assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}")
end
end
end # ParsingQuirksTest
+2 -2
View File
@@ -59,7 +59,7 @@ class SecurityTest < Minitest::Test
GC.start
assert_equal([], Symbol.all_symbols - current_symbols)
assert_equal([], (Symbol.all_symbols - current_symbols))
end
def test_does_not_add_drop_methods_to_symbol_table
@@ -70,7 +70,7 @@ class SecurityTest < Minitest::Test
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)
assert_equal([], (Symbol.all_symbols - current_symbols))
end
def test_max_depth_nested_blocks_does_not_raise_exception
+9 -92
View File
@@ -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))
@@ -294,7 +294,13 @@ class StandardFiltersTest < Minitest::Test
end
def test_join_calls_to_liquid_on_each_element
assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", "))
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], ", "))
end
def test_sort
@@ -554,23 +560,12 @@ class StandardFiltersTest < Minitest::Test
end
end
def test_map_with_value_property
array = [
{ "handle" => "alpha", "value" => "A" },
{ "handle" => "beta", "value" => "B" },
{ "handle" => "gamma", "value" => "C" }
]
assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array })
end
def test_map_returns_input_with_no_property
def test_map_returns_empty_with_no_property
foo = [
[1],
[2],
[3],
]
assert_raises(Liquid::ArgumentError) do
@filters.map(foo, nil)
end
@@ -627,40 +622,6 @@ 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))
@@ -1072,23 +1033,6 @@ class StandardFiltersTest < Minitest::Test
assert_template_result(expected_output, template, { "array" => array })
end
def test_where_with_empty_string_is_a_no_op
environment = { "array" => ["alpha", "beta", "gamma"] }
expected_output = "alpha beta gamma"
template = "{{ array | where: '' | join: ' ' }}"
assert_template_result(expected_output, template, environment)
end
def test_where_with_nil_is_a_no_op
environment = { "array" => ["alpha", "beta", "gamma"] }
template = "{{ array | where: nil | join: ' ' }}"
assert_raises(Liquid::ArgumentError) do
assert_template_result("alpha beta gamma", template, environment)
end
end
def test_where_with_value
array = [
{ "handle" => "alpha", "ok" => true },
@@ -1324,33 +1268,6 @@ class StandardFiltersTest < Minitest::Test
assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input })
end
def test_sum_with_non_string_property
input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }]
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, @filters.sum(input, nil))
assert_equal(0, @filters.sum(input, ""))
end
def test_uniq_with_to_liquid_value
input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"]
expected = [StringDrop.new("foo"), StringDrop.new("bar")]
result = @filters.uniq(input)
assert_equal(expected, result)
end
def test_uniq_with_to_liquid_value_pick_correct_classes
input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")]
expected = [String, StringDrop]
result = @filters.uniq(input).map(&:class)
assert_equal(expected, result)
end
private
def with_timezone(tz)
+15 -120
View File
@@ -3,10 +3,20 @@
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
@@ -26,128 +36,13 @@ class CycleTagTest < Minitest::Test
assert_template_result("123", template)
end
def test_cycle_named_groups_string
template = <<~LIQUID
{%- 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_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
def test_cycle_tag_always_resets_cycle
template = <<~LIQUID
{%- assign a = "1" -%}
{%- for i in (1..3) -%}
{%- cycle a, "2" -%}
{%- cycle a, "2" -%}
{%- endfor -%}
{%- cycle a, "2" -%}
{%- cycle a, "2" -%}
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_unsupported_legacy_quirk
# QuotedFragment is more permissive than what Parser#expression allows.
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
template2 = "{% cycle .5: 'a', 'b' %}"
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
def test_cycle_with_trailing_elements_legacy_syntax
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' %}"
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
def test_cycle_name_with_invalid_expression
template = <<~LIQUID
{% for i in (1..3) %}
{% cycle foo=>bar: "a", "b" %}
{% endfor %}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_cycle_variable_with_invalid_expression
template = <<~LIQUID
{% for i in (1..3) %}
{% cycle foo=>bar, "a", "b" %}
{% endfor %}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
assert_template_result("11", template)
end
end
+9 -40
View File
@@ -204,22 +204,6 @@ class IncludeTagTest < Minitest::Test
)
end
def test_parsing_errors_for_legacy_quirk
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% include "snippet" | filter %}',
)
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)
@@ -291,10 +275,16 @@ class IncludeTagTest < Minitest::Test
env = Liquid::Environment.build(file_system: TestFileSystem.new)
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", environment: env).render!("template" => '{{ "X" || downcase }}')
Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(: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 %}", include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(: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
@@ -349,7 +339,7 @@ class IncludeTagTest < Minitest::Test
file_system: StubFileSystem.new('simple' => 'simple'),
)
template = Liquid::Template.parse("{% include 'simple' %}", environment: env)
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env)
template.render(nil, strict_variables: true)
assert_equal([], template.errors)
@@ -384,25 +374,4 @@ class IncludeTagTest < Minitest::Test
render_errors: true,
)
end
def test_include_template_with_invalid_expression
template = "{% include foo=>bar %}"
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_include_with_invalid_expression
template = '{% include "snippet" with foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_include_attribute_with_invalid_expression
template = '{% include "snippet", key: foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end # IncludeTagTest
+1 -29
View File
@@ -105,23 +105,7 @@ class RenderTagTest < Minitest::Test
assert_syntax_error("{% assign name = 'snippet' %}{% render name %}")
end
def test_parsing_errors_legacy_syntax
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% render "snippet" | filter %}',
)
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
def test_include_tag_caches_second_read_of_same_partial
file_system = StubFileSystem.new('snippet' => 'echo')
assert_equal(
'echoecho',
@@ -304,16 +288,4 @@ class RenderTagTest < Minitest::Test
render_errors: true,
)
end
def test_render_with_invalid_expression
template = '{% render "snippet" with foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
def test_render_attribute_with_invalid_expression
template = '{% render "snippet", key: foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
+1 -1
View File
@@ -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
+23 -163
View File
@@ -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 }}
@@ -188,15 +188,35 @@ class TableRowTest < Minitest::Test
assert_template_result(expected_output, template)
end
def test_table_row_renders_correct_error_message_for_invalid_parameters
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}',
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
render_errors: true,
)
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
@@ -235,164 +255,4 @@ class TableRowTest < Minitest::Test
template,
)
end
def test_tablerow_with_cols_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_limit_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_offset_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_range_attribute
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
assert_template_result(expected, template)
end
def test_tablerow_with_multiple_attributes
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
assert_template_result(expected, template)
end
def test_tablerow_with_variable_collection
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
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
def test_tablerow_with_dotted_access
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
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
end
def test_tablerow_with_bracketed_access
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
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
def test_tablerow_without_attributes
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
assert_template_result(expected, template)
end
def test_tablerow_without_in_keyword
template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}'
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
def test_tablerow_with_multiple_invalid_attributes_reports_first
template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}'
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
def test_tablerow_with_empty_collection
template = <<~LIQUID.chomp
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
LIQUID
expected = <<~OUTPUT
<tr class="row1">
</tr>
OUTPUT
assert_template_result(expected, template, { 'empty_array' => [] })
end
def test_tablerow_with_invalid_attribute
template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}'
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
end
def test_tablerow_with_invalid_expression
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
+12 -2
View File
@@ -44,6 +44,16 @@ class TemplateTest < Minitest::Test
assert_equal('from instance assigns', t.parse("{{ foo }}").render!)
end
def test_warnings_is_not_exponential_time
str = "false"
100.times do
str = "{% if true %}true{% else %}#{str}{% endif %}"
end
t = Template.parse(str)
assert_equal([], Timeout.timeout(1) { t.warnings })
end
def test_instance_assigns_persist_on_same_template_parsing_between_renders
t = Template.new.parse("{{ foo }}{% assign foo = 'foo' %}{{ foo }}")
assert_equal('foo', t.render!)
@@ -123,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
@@ -249,7 +259,7 @@ class TemplateTest < Minitest::Test
end
def test_nil_value_does_not_raise
t = Template.parse("some{{x}}thing")
t = Template.parse("some{{x}}thing", error_mode: :strict)
result = t.render!({ 'x' => nil }, strict_variables: true)
assert_equal(0, t.errors.count)
+29 -25
View File
@@ -176,33 +176,37 @@ class VariableTest < Minitest::Test
)
end
def test_filter_with_single_trailing_comma
template = '{{ "hello" | append: "world", }}'
def test_variable_lookup_should_not_hang_with_invalid_syntax
Timeout.timeout(1) do
assert_template_result(
'bar',
"{{['foo'}}",
{
'foo' => 'bar',
},
error_mode: :lax,
)
end
assert_template_result('helloworld', template)
end
very_long_key = "1234567890" * 100
def test_multiple_filters_with_trailing_commas
template = '{{ "hello" | append: "1", | append: "2", }}'
template_list = [
"{{['#{very_long_key}']}}", # valid
"{{['#{very_long_key}'}}", # missing closing bracket
"{{[['#{very_long_key}']}}", # extra open bracket
]
assert_template_result('hello12', template)
end
def test_filter_with_colon_but_no_arguments
template = '{{ "test" | upcase: }}'
assert_template_result('TEST', template)
end
def test_filter_chain_with_colon_no_args
template = '{{ "test" | append: "x" | upcase: }}'
assert_template_result('TESTX', template)
end
def test_combining_trailing_comma_and_empty_args
template = '{{ "test" | append: "x", | upcase: }}'
assert_template_result('TESTX', template)
template_list.each do |template|
Timeout.timeout(1) do
assert_template_result(
'bar',
template,
{
very_long_key => 'bar',
},
error_mode: :lax,
)
end
end
end
end
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
module Minitest
module Assertions
include Liquid
def assert_with_lax_parsing(template, expected_output, context = {})
prev_error_mode = Liquid::Environment.default.error_mode
Liquid::Environment.default.error_mode = :lax
begin
actual_output = Liquid::Template.parse(template).render(context)
rescue StandardError => e
actual_output = e.message
ensure
Liquid::Environment.default.error_mode = prev_error_mode
end
assert_equal(expected_output.strip, actual_output.strip)
end
def assert_parity(liquid_expression, expected_result, args = {})
assert_condition(liquid_expression, expected_result, args)
assert_expression(liquid_expression, expected_result, args)
end
def assert_expression(liquid_expression, expected_result, args = {})
assert_parity_scenario(:expression, "{{ #{liquid_expression} }}", expected_result, args)
end
def assert_condition(liquid_condition, expected_result, args = {})
assert_parity_scenario(:condition, "{% if #{liquid_condition} %}true{% else %}false{% endif %}", expected_result, args)
end
def assert_parity_scenario(kind, template, exp_output, args = {})
act_output = Liquid::Template.parse(template).render(args)
assert_equal(exp_output, act_output, <<~ERROR_MESSAGE)
#{kind.to_s.capitalize} template failure:
---
#{template}
---
args: #{args.inspect}
ERROR_MESSAGE
end
end
end
class LinkDrop < Liquid::Drop
attr_accessor :levels, :links, :title, :type, :url
def initialize(levels: nil, links: nil, title: nil, type: nil, url: nil)
super()
@levels = levels
@links = links
@title = title
@type = type
@url = url
end
end
+21 -55
View File
@@ -8,6 +8,13 @@ $LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib'))
require 'liquid.rb'
require 'liquid/profiler'
mode = :strict
if (env_mode = ENV['LIQUID_PARSER_MODE'])
puts "-- #{env_mode.upcase} ERROR MODE"
mode = env_mode.to_sym
end
Liquid::Environment.default.error_mode = mode
if Minitest.const_defined?('Test')
# We're on Minitest 5+. Nothing to do here.
else
@@ -27,27 +34,27 @@ module Minitest
def assert_template_result(
expected, template, assigns = {},
message: nil, partials: nil, render_errors: false,
message: nil, partials: nil, error_mode: nil, render_errors: false,
template_factory: nil
)
file_system = StubFileSystem.new(partials || {})
environment = Liquid::Environment.build(file_system: file_system)
template = Liquid::Template.parse(template, line_numbers: true, environment: environment)
template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment)
registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory)
context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment)
output = template.render(context)
assert_equal(expected, output, message)
end
def assert_match_syntax_error(match, template)
def assert_match_syntax_error(match, template, error_mode: nil)
exception = assert_raises(Liquid::SyntaxError) do
Template.parse(template, line_numbers: true).render
Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym).render
end
assert_match(match, exception.message)
end
def assert_syntax_error(template)
assert_match_syntax_error("", template)
def assert_syntax_error(template, error_mode: nil)
assert_match_syntax_error("", template, error_mode: error_mode)
end
def assert_usage_increment(name, times: 1)
@@ -75,6 +82,14 @@ module Minitest
Environment.dangerously_override(environment, &blk)
end
def with_error_mode(mode)
old_mode = Liquid::Environment.default.error_mode
Liquid::Environment.default.error_mode = mode
yield
ensure
Liquid::Environment.default.error_mode = old_mode
end
def with_custom_tag(tag_name, tag_class, &block)
environment = Liquid::Environment.default.dup
environment.register_tag(tag_name, tag_class)
@@ -131,35 +146,6 @@ class BooleanDrop < Liquid::Drop
end
end
class StringDrop < Liquid::Drop
include Comparable
def initialize(value)
super()
@value = value
end
def to_liquid_value
@value
end
def to_s
@value
end
def to_str
@value
end
def inspect
"#<StringDrop @value=#{@value.inspect}>"
end
def <=>(other)
to_liquid_value <=> Liquid::Utils.to_liquid_value(other)
end
end
class ErrorDrop < Liquid::Drop
def standard_error
raise Liquid::StandardError, 'standard error'
@@ -182,26 +168,6 @@ 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
+171
View File
@@ -0,0 +1,171 @@
# frozen_string_literal: true
require 'test_helper'
require 'test_boolean_helper'
class BooleanPrecedenceUnitTest < Minitest::Test
include Liquid
def test_basic_boolean_parenthesized_expressions
assert_parity("false and (false or true)", "false")
assert_parity("true and (false or true)", "true")
assert_parity("(true and false) or true", "true")
assert_parity("(false and true) or false", "false")
end
def test_nested_boolean_parentheses
assert_parity("(false and (true or false)) or true", "true")
assert_parity("true and (false or (true and true))", "true")
assert_parity("(true and (false or false)) or false", "false")
end
def test_multiple_operations_with_consistent_operators
assert_parity("(true and true) and (false or true)", "true")
assert_parity("(false or false) or (true and false)", "false")
end
def test_parentheses_changing_default_precedence
# Default precedence: (true and false) or true
assert_parity("true and false or true", "true")
# With parentheses: true and (false or true)
assert_parity("true and (false or true)", "true")
# Default precedence: false or (true and true)
assert_parity("false or true and true", "true")
# With parentheses: (false or true) and true
assert_parity("(false or true) and true", "true")
end
def test_boolean_parentheses_with_variables
assert_parity("(a or b) and c", "true", { "a" => true, "b" => false, "c" => true })
assert_parity("(a or b) and c", "false", { "a" => true, "b" => false, "c" => false })
assert_parity("a and (b or c)", "true", { "a" => true, "b" => false, "c" => true })
assert_parity("a and (b or c)", "false", { "a" => false, "b" => true, "c" => true })
end
def test_comparison_operators_inside_parentheses
assert_parity("(1 > 0) and (2 < 3)", "true")
assert_parity("(1 < 0) or (2 > 3)", "false")
assert_parity("true and (1 == 1)", "true")
assert_parity("false or (2 != 2)", "false")
end
def test_complex_nested_boolean_expressions
assert_parity("((true and false) or (false and true)) or ((false or true) and (true or false))", "true")
assert_parity("((true and true) or (false and false)) and ((true or false) and (false or true))", "true")
end
def test_not_operator_with_parentheses
# Testing how 'not' interacts with parentheses
assert_parity("not (true or false)", "false")
assert_parity("not (false and true)", "true")
assert_parity("(not false) and true", "true")
assert_parity("(not true) or false", "false")
assert_parity("not (not true)", "true")
end
def test_nil_values_with_boolean_precedence
# How nil values interact with boolean expressions and parentheses
assert_parity("nil and (true or false)", "false")
assert_parity("(nil or true) and false", "false")
assert_parity("(nil and nil) or true", "true")
assert_parity("true and (nil or false)", "false")
end
def test_mixed_primitive_types_with_parentheses
# Testing how different types interact in boolean expressions with parentheses
assert_parity("('' or 0) and true", "true")
assert_parity("(true and 'string') or false", "true")
assert_parity("(false or '') and 1", "false")
assert_parity("(nil or false) and 'text'", "false")
end
def test_triple_operator_precedence
# Testing three different operators with different parenthesizing
assert_parity("true or false and true or false", "true") # default precedence
assert_parity("true or (false and true) or false", "true")
assert_parity("(true or false) and (true or false)", "true")
assert_parity("((true or false) and true) or false", "true")
assert_parity("true or (false and (true or false))", "true")
end
def test_undefined_variables_with_parentheses
# How undefined variables behave with parentheses
assert_parity("(undefined_var or true) and false", "false")
assert_parity("true and (undefined_var or false)", "false")
assert_parity("(undefined_var and true) or true", "true")
assert_parity("false or (undefined_var and false)", "false")
end
def test_comparison_chaining_with_parentheses
# Testing how comparison chains work with parentheses
assert_parity("(1 < 2) and (2 < 3) and (3 < 4)", "true")
assert_parity("(1 < 2) and ((2 > 3) or (3 < 4))", "true")
assert_parity(
"(a > b) or ((c < d) and (e == f))",
"true",
{ "a" => 5, "b" => 3, "c" => 1, "d" => 2, "e" => 7, "f" => 7 },
)
assert_parity(
"(a > b) or ((c < d) and (e == f))",
"false",
{ "a" => 3, "b" => 5, "c" => 2, "d" => 1, "e" => 7, "f" => 8 },
)
end
def test_deeply_nested_expressions
# Testing very deep nesting to ensure parser handles it correctly
assert_parity("(((true and true) or (false and false)) and ((true or false) and (true)))", "true")
assert_parity(
"(((a or b) and c) or (d and (e or f)))",
"true",
{ "a" => false, "b" => true, "c" => true, "d" => true, "e" => true, "f" => false },
)
end
def test_malformed_parentheses
# Unbalanced parentheses - missing closing parenthesis
template = "{% if (true and false %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Unbalanced parentheses - missing opening parenthesis
template = "{% if true and false) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Empty parentheses
template = "{% if () %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Consecutive opening parentheses without operators
template = "{% if ((true) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Consecutive closing parentheses without proper opening
template = "{% if (true)) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Parentheses with missing operand
template = "{% if (and true) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Operator followed immediately by closing parenthesis
template = "{% if (true and) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Nested malformed parentheses
template = "{% if (true and (false or true) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Double parentheses with no content between them
template = "{% if true and (()) %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Misplaced parentheses around operators
template = "{% if true (and) false %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
# Parentheses at wrong position in expression
template = "{% if true) and (false %}true{% else %}false{% endif %}"
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse(template) }
end
end
+491
View File
@@ -0,0 +1,491 @@
# frozen_string_literal: true
require 'test_helper'
require 'test_boolean_helper'
class BooleanUnitTest < Minitest::Test
include Liquid
def test_simple_boolean_comparison
assert_parity("1 > 0", "true")
assert_parity("1 < 0", "false")
end
def test_boolean_and_operator
assert_parity("true and true", "true")
assert_parity("true and false", "false")
end
def test_boolean_or_operator
assert_parity("true or false", "true")
assert_parity("false or false", "false")
end
def test_operator_precedence
assert_parity("false and false or true", "false")
end
def test_complex_boolean_expressions
assert_parity("true and true and true", "true")
assert_parity("true and false and true", "false")
assert_parity("false or false or true", "true")
end
def test_boolean_with_variables
assert_parity("a and b", "true", { "a" => true, "b" => true })
assert_parity("a and b", "false", { "a" => true, "b" => false })
assert_parity("a or b", "true", { "a" => false, "b" => true })
assert_parity("a or b", "false", { "a" => false, "b" => false })
end
def test_nil_equals_nil
assert_parity("nil == nil", "true")
end
def test_nil_not_equals_nil
assert_parity("nil != nil", "false")
end
def test_nil_not_equals_empty_string
assert_parity("nil == ''", "false")
assert_parity("nil != ''", "true")
end
def test_undefined_variable_in_comparisons
assert_parity("undefined_var == nil", "true")
assert_parity("undefined_var != nil", "false")
end
def test_undefined_variable_compared_to_empty_string
assert_parity("undefined_var == ''", "false")
assert_parity("undefined_var != ''", "true")
end
def test_boolean_variable_in_comparisons
assert_parity("t == true", "true", { "t" => true })
assert_parity("f == false", "true", { "f" => false })
end
def test_boolean_variable_compared_to_nil
assert_parity("t == nil", "false", { "t" => true })
assert_parity("f == nil", "false", { "f" => false })
assert_parity("f != nil", "true", { "f" => false })
end
def test_nil_and_undefined_variables_in_boolean_expressions
assert_parity("x == undefined_var", "true", { "x" => nil })
assert_parity("x != undefined_var", "false", { "x" => nil })
end
def test_nil_literal_in_or_expression
assert_parity("nil or true", "true")
end
def test_nil_variable_in_or_expression
assert_parity("x or false", "false", { "x" => nil })
end
def test_mixed_boolean_expressions
assert_parity("a > b and c < d", "true", { "a" => 99, "b" => 0, "c" => 0, "d" => 99 })
assert_parity("a > b and c < d", "false", { "a" => 99, "b" => 0, "c" => 99, "d" => 0 })
end
def test_boolean_assignment_shorthand
template = Liquid::Template.parse("{% assign lazy_load = media_position > 1 %}{{ lazy_load }}")
assert_equal("false", template.render("media_position" => 1))
assert_equal("true", template.render("media_position" => 2))
end
def test_equality_operators_with_integer_literals
assert_expression("1", "1")
assert_expression("1 == 1", "true")
assert_expression("1 != 1", "false")
assert_expression("1 == 2", "false")
assert_expression("1 != 2", "true")
end
def test_equality_operators_with_stirng_literals
assert_expression("'hello'", "hello")
assert_expression("'hello' == 'hello'", "true")
assert_expression("'hello' != 'hello'", "false")
assert_expression("'hello' == 'world'", "false")
assert_expression("'hello' != 'world'", "true")
end
def test_equality_operators_with_float_literals
assert_expression("1.5", "1.5")
assert_expression("1.5 == 1.5", "true")
assert_expression("1.5 != 1.5", "false")
assert_expression("1.5 == 2.5", "false")
assert_expression("1.5 != 2.5", "true")
end
def test_equality_operators_with_nil_literals
assert_expression("nil", "")
assert_expression("nil == nil", "true")
assert_expression("nil != nil", "false")
assert_expression("null == nil", "true")
assert_expression("null != nil", "false")
end
def test_equality_operators_with_boolean_literals
assert_expression("true", "true")
assert_expression("false", "false")
assert_expression("true == true", "true")
assert_expression("true != true", "false")
assert_expression("false == false", "true")
assert_expression("false != false", "false")
assert_expression("true == false", "false")
assert_expression("true != false", "true")
end
def test_equality_operators_with_empty_literals
assert_expression("empty", "")
assert_expression("empty == ''", "true")
assert_expression("empty == empty", "true")
assert_expression("empty != empty", "false")
assert_expression("blank == blank", "true")
assert_expression("blank != blank", "false")
assert_expression("empty == blank", "true")
assert_expression("empty != blank", "false")
end
def test_nil_renders_as_empty_string
# No parity needed here. This is to ensure expressions rendered with {{ }}
# will still render as an empty string to preserve pre-existing behavior.
assert_expression("nil", "")
assert_expression("x", "", { "x" => nil })
assert_parity_scenario(:expression, "hello {{ x }}", "hello ", { "x" => nil })
end
def test_nil_comparison_with_blank
assert_parity("nil_value == blank", "false")
assert_parity("nil_value != blank", "true")
assert_parity("undefined != blank", "true")
assert_parity("undefined == blank", "false")
end
def test_if_with_variables
assert_parity("value", "true", { "value" => true })
assert_parity("value", "false", { "value" => false })
end
def test_nil_variable_in_and_expression
assert_condition("x and true", "false", { "x" => nil })
assert_condition("true and x", "false", { "x" => nil })
assert_expression("x and true", "", { "x" => nil })
assert_expression("true and x", "", { "x" => nil })
end
def test_boolean_variable_in_and_expression
assert_parity("true and x", "false", { "x" => false })
assert_parity("x and true", "false", { "x" => false })
assert_parity("true and x", "true", { "x" => true })
assert_parity("x and true", "true", { "x" => true })
assert_parity("true or x", "true", { "x" => false })
assert_parity("x or true", "true", { "x" => false })
assert_parity("true or x", "true", { "x" => true })
assert_parity("x or true", "true", { "x" => true })
end
def test_multi_variable_boolean_nil_and_expression
assert_condition("x and y", "false", { "x" => nil, "y" => true })
assert_condition("y and x", "false", { "x" => true, "y" => nil })
assert_expression("x and y", "", { "x" => nil, "y" => true })
assert_expression("y and x", "", { "x" => true, "y" => nil })
end
def test_multi_truthy_variables_and_expressions
assert_condition("x or y", "true", { "x" => nil, "y" => "hello" })
assert_condition("y or x", "true", { "x" => "hello", "y" => nil })
assert_expression("x or y", "hello", { "x" => nil, "y" => "hello" })
assert_expression("y or x", "hello", { "x" => "hello", "y" => nil })
end
def test_multi_variable_boolean_nil_or_expression
assert_parity("x or y", "true", { "x" => nil, "y" => true })
assert_parity("y or x", "true", { "x" => true, "y" => nil })
end
def test_links_not_blank_with_drop_returns_true_for_all_cases
link = LinkDrop.new(
levels: 0,
links: [
LinkDrop.new(levels: 1, links: [], title: "About", type: "page_link", url: "/pages/about"),
LinkDrop.new(levels: 1, links: [], title: "Contact", type: "page_link", url: "/pages/contact"),
],
title: "Main Menu",
type: "menu",
url: nil,
)
template = <<~LIQUID
{%- if link.links != blank -%}
true
{%- else -%}
false
{%- endif -%}
LIQUID
act_output = Liquid::Template.parse(template).render({ "link" => link })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => link.tap { |l| l.links = [] } })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => link.tap { |l| l.links = nil } })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => LinkDrop.new })
assert_equal("true", act_output)
end
def test_links_truthy_with_drop_returns_false_for_nil_and_empty_drop
link = LinkDrop.new(
levels: 0,
links: [
LinkDrop.new(levels: 1, links: [], title: "About", type: "page_link", url: "/pages/about"),
LinkDrop.new(levels: 1, links: [], title: "Contact", type: "page_link", url: "/pages/contact"),
],
title: "Main Menu",
type: "menu",
url: nil,
)
template = <<~LIQUID
{%- if link.links -%}
true
{%- else -%}
false
{%- endif -%}
LIQUID
act_output = Liquid::Template.parse(template).render({ "link" => link })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => link.tap { |l| l.links = [] } })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => link.tap { |l| l.links = nil } })
assert_equal("false", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => {} })
assert_equal("false", act_output)
end
def test_links_not_blank_with_hash_returns_true_for_all_cases
link = {
"levels" => 0,
"links" => [
{
"levels" => 1,
"links" => [],
"title" => { "text" => "About" },
"type" => "page_link",
"url" => "/pages/about",
},
{
"levels" => 1,
"links" => [],
"title" => { "text" => "Contact" },
"type" => "page_link",
"url" => "/pages/contact",
},
],
"title" => { "text" => "Main Menu" },
"type" => "menu",
"url" => nil,
}
template = <<~LIQUID
{%- if link.links != blank -%}
true
{%- else -%}
false
{%- endif -%}
LIQUID
act_output = Liquid::Template.parse(template).render({ "link" => link })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => { **link, "links" => [] } })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => { **link, "links" => nil } })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => {} })
assert_equal("true", act_output)
end
def test_links_truthy_with_hash_returns_false_for_nil_and_empty_hash
link = {
"levels" => 0,
"links" => [
{
"levels" => 1,
"links" => [],
"title" => { "text" => "About" },
"type" => "page_link",
"url" => "/pages/about",
},
{
"levels" => 1,
"links" => [],
"title" => { "text" => "Contact" },
"type" => "page_link",
"url" => "/pages/contact",
},
],
"title" => { "text" => "Main Menu" },
"type" => "menu",
"url" => nil,
}
template = <<~LIQUID
{%- if link.links -%}
true
{%- else -%}
false
{%- endif -%}
LIQUID
act_output = Liquid::Template.parse(template).render({ "link" => link })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => { **link, "links" => [] } })
assert_equal("true", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => { **link, "links" => nil } })
assert_equal("false", act_output)
act_output = Liquid::Template.parse(template).render({ "link" => {} })
assert_equal("false", act_output)
end
def test_conditions_with_boolean_operators_without_whitespace_around_operator
template = <<~LIQUID
<option variant_id="{{ variant.id }}" {% if current_variant.id==variant.id %}selected{%- endif -%}>{{ variant.title }}</option>
LIQUID
context = {
"variant" => {
"id" => 420,
"title" => "Default Title",
},
"current_variant" => {
"id" => 420,
},
}
# Expected output
# Note: Ideally we would like the whitespace around the boolean operator to be optional.
# So the more correct expected output would be:
#
# <option variant_id="420" selected>Default Title</option>
#
# However, the existing behaviour in liquid-ruby is that the whitespace is required around the boolean operator.
expected_lax_output = <<~HTML
<option variant_id="420" >Default Title</option>
HTML
expected_strict_output = <<~HTML
<option variant_id="420" selected>Default Title</option>
HTML
# This bugged output only happens in lax mode.
assert_with_lax_parsing(template, expected_lax_output, context)
# Default test parsing mode (strict) works as properly expected
assert_equal(expected_strict_output.delete("\n"), actual_strict_output.delete("\n"))
end
# TESTING INCORRECT BEHAVIOUR OF LIQUID-RUBY
# If liquid-vm fails this test, we should change it.
def test_boolean_conditional_with_json_filter
# Define the Liquid template to test
template = <<~LIQUID
{{ template.name == 'index' | json }}
LIQUID
# Define the context for the template where the template name is 'index'
context = {
"template" => {
"name" => "product",
},
}
# Expected output
# Note: I dont know what is the correct output here but this is the liquid-ruby 'main' output.
#
# It feels incorrect but I dont know whats better
expected_output = "product"
# Render the template with the context
actual_parsed_template = Liquid::Template.parse(template)
actual_output = actual_parsed_template.render(context)
# Assert that the actual output matches the expected output
assert_equal(expected_output, actual_output.strip)
end
# TESTING INCORRECT BEHAVIOUR OF LIQUID-RUBY
# If liquid-vm fails this test, we should change it.
def test_chained_conditional_with_object_contains
# Define the Liquid template to test
template = <<~LIQUID
{{ settings.prefilter_status and template contains 'collection' }}
LIQUID
# Test with context containing 'collection'
context_with_collection = {
"template" => {
"name" => "collection",
},
"settings" => {
"prefilter_status" => true,
},
}
# NOTE: This is a bug that liquid-ruby `main` output returns the first value.
assert_with_lax_parsing(template, "true", context_with_collection)
# Test with context not containing 'collection'
context_without_collection = {
"template" => {
"name" => "not-collection",
},
"settings" => {
"prefilter_status" => true,
},
}
# NOTE: This is a bug that liquid-ruby `main` output returns the first value.
assert_with_lax_parsing(template, "true", context_without_collection)
end
# TESTING INCORRECT BEHAVIOUR OF LIQUID-RUBY
# If liquid-vm fails this test, we should change it.
def test_assign_boolean_expression_to_variable
template = <<~LIQUID
{%- liquid
assign is_preview_mode = content_for_header contains "foo" or content_for_header contains "bar"
echo is_preview_mode
-%}
LIQUID
context = { "content_for_header" => "Some content" }
# Expected output
# This value should be "false" but it is the value of the variable from the failed expression.
assert_template_result("Some content", template, context)
# This following validation should only be supported with our changes. It is the short-hand for the above template.
# The validation for it is the expected correct output.
template = Liquid::Template.parse("{% assign is_preview_mode = content_for_header contains 'foo' or content_for_header contains 'bar' %}{{ is_preview_mode }}")
assert_equal("false", template.render(context))
end
end
+2 -189
View File
@@ -161,198 +161,11 @@ 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_with_safe_true
environment = Environment.build
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
def test_parse_expression_raises_internal_error_if_not_safe
environment = Environment.build
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/, error.message)
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)
-25
View File
@@ -1,25 +0,0 @@
# 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,219 @@
# frozen_string_literal: true
require 'test_helper'
require 'test_boolean_helper'
class LogicalExpressionTest < Minitest::Test
include Liquid
def setup
@ss = StringScanner.new("")
@cache = {}
end
def test_logical_detection
assert(Expression::LogicalExpression.logical?("foo and bar"))
assert(Expression::LogicalExpression.logical?("foo or bar"))
assert(Expression::LogicalExpression.logical?("true and false"))
assert(Expression::LogicalExpression.logical?("1 or 0"))
refute(Expression::LogicalExpression.logical?("foo"))
refute(Expression::LogicalExpression.logical?("1 == 1"))
refute(Expression::LogicalExpression.logical?("a contains b"))
refute(Expression::LogicalExpression.logical?("not foo"))
end
def test_parenthesized_logical_detection
assert(Expression::LogicalExpression.logical?("a and (b or c)"))
assert(Expression::LogicalExpression.logical?("(a or b) and c"))
end
def test_boolean_operator_detection
assert(Expression::LogicalExpression.boolean_operator?("and"))
assert(Expression::LogicalExpression.boolean_operator?("or"))
refute(Expression::LogicalExpression.boolean_operator?("not"))
refute(Expression::LogicalExpression.boolean_operator?("=="))
refute(Expression::LogicalExpression.boolean_operator?("contains"))
refute(Expression::LogicalExpression.boolean_operator?("foo"))
end
def test_basic_parsing
result = Expression::LogicalExpression.parse("true and false", @ss, @cache)
assert_instance_of(Condition, result)
result = Expression::LogicalExpression.parse("a or b", @ss, @cache)
assert_instance_of(Condition, result)
end
def test_parsing_with_different_expressions
# Test with simple variable expressions
result = Expression::LogicalExpression.parse("var1 and var2", @ss, @cache)
assert_instance_of(Condition, result)
# Test with comparison expressions
result = Expression::LogicalExpression.parse("a == 1 and b != 2", @ss, @cache)
assert_instance_of(Condition, result)
end
def test_parsing_complex_expressions
# Test with nested logical expressions
result = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
assert_instance_of(Condition, result)
result = Expression::LogicalExpression.parse("a or b and c", @ss, @cache)
assert_instance_of(Condition, result)
end
def test_parsing_parenthesized_expressions
result = Expression::LogicalExpression.parse("(a and b) or c", @ss, @cache)
assert_instance_of(Condition, result)
result = Expression::LogicalExpression.parse("a and (b or c)", @ss, @cache)
assert_instance_of(Condition, result)
# Test with complex expressions
result = Expression::LogicalExpression.parse("(a or b) and (c or d)", @ss, @cache)
assert_instance_of(Condition, result)
end
def test_evaluation_of_parsed_expressions
context = Liquid::Context.new(
"a" => true,
"b" => false,
"c" => true,
"d" => false,
)
# Test simple logical expressions
expr = Expression::LogicalExpression.parse("a and c", @ss, @cache)
assert_equal(true, expr.evaluate(context))
expr = Expression::LogicalExpression.parse("a and b", @ss, @cache)
assert_equal(false, expr.evaluate(context))
expr = Expression::LogicalExpression.parse("b or c", @ss, @cache)
assert_equal(true, expr.evaluate(context))
expr = Expression::LogicalExpression.parse("b or d", @ss, @cache)
assert_equal(false, expr.evaluate(context))
end
def test_evaluation_of_complex_expressions
context = Liquid::Context.new(
"a" => true,
"b" => false,
"c" => true,
"d" => false,
)
# Test complex logical expressions
expr = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
assert_equal(true, expr.evaluate(context))
end
def test_evaluation_of_parenthesized_expressions
context = Liquid::Context.new(
"a" => true,
"b" => false,
"c" => true,
"d" => false,
)
expr = Expression::LogicalExpression.parse("a and (b or d)", @ss, @cache)
assert_equal(false, expr.evaluate(context))
expr = Expression::LogicalExpression.parse("(a or b) and (c or d)", @ss, @cache)
assert_equal(true, expr.evaluate(context))
expr = Expression::LogicalExpression.parse("(a or b) and (b or d)", @ss, @cache)
assert_equal(false, expr.evaluate(context))
end
def test_precedence_rules
context = Liquid::Context.new(
"a" => true,
"b" => false,
"c" => true,
)
# Test precedence rules (AND has higher precedence than OR)
# This should be interpreted as: a and (b or c)
expr1 = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
assert_equal(true, expr1.evaluate(context))
# Change context to make the expressions evaluate differently
context = Liquid::Context.new(
"a" => false,
"b" => false,
"c" => true,
)
# With these values, "a and (b or c)" would be false
expr1 = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
assert_equal(false, expr1.evaluate(context))
end
def test_precedence_with_parentheses
context = Liquid::Context.new(
"a" => true,
"b" => false,
"c" => true,
)
# This should be interpreted as: (a and b) or c
expr2 = Expression::LogicalExpression.parse("(a and b) or c", @ss, @cache)
assert_equal(true, expr2.evaluate(context))
# Change context to make the expressions evaluate differently
context = Liquid::Context.new(
"a" => false,
"b" => false,
"c" => true,
)
# But "(a and b) or c" would be true
expr2 = Expression::LogicalExpression.parse("(a and b) or c", @ss, @cache)
assert_equal(true, expr2.evaluate(context))
end
def test_integration_with_if_tag
# Test that our expressions work properly in actual templates
assert_template_result("true", "{% if true and true %}true{% else %}false{% endif %}")
assert_template_result("false", "{% if true and false %}true{% else %}false{% endif %}")
assert_template_result("true", "{% if false or true %}true{% else %}false{% endif %}")
assert_template_result("false", "{% if false or false %}true{% else %}false{% endif %}")
end
def test_integration_with_parenthesized_if_tag
# Test with parenthesized expressions
assert_template_result("true", "{% if (true and false) or true %}true{% else %}false{% endif %}")
assert_template_result("false", "{% if true and (false or false) %}true{% else %}false{% endif %}")
assert_template_result("true", "{% if true and (false or true) %}true{% else %}false{% endif %}")
end
def test_integration_with_variables
# Test with variables
template = "{% if a and b %}true{% else %}false{% endif %}"
assert_template_result("true", template, { "a" => true, "b" => true })
assert_template_result("false", template, { "a" => true, "b" => false })
template = "{% if a or b %}true{% else %}false{% endif %}"
assert_template_result("true", template, { "a" => true, "b" => false })
assert_template_result("false", template, { "a" => false, "b" => false })
end
def test_integration_with_parenthesized_variables
# Test with parenthesized expressions
template = "{% if (a and b) or c %}true{% else %}false{% endif %}"
assert_template_result("true", template, { "a" => true, "b" => true, "c" => false })
assert_template_result("true", template, { "a" => false, "b" => false, "c" => true })
assert_template_result("false", template, { "a" => false, "b" => false, "c" => false })
template = "{% if a and (b or c) %}true{% else %}false{% endif %}"
assert_template_result("true", template, { "a" => true, "b" => true, "c" => false })
assert_template_result("true", template, { "a" => true, "b" => false, "c" => true })
assert_template_result("false", template, { "a" => true, "b" => false, "c" => false })
assert_template_result("false", template, { "a" => false, "b" => true, "c" => true })
end
end
+72
View File
@@ -141,6 +141,78 @@ class LexerUnitTest < Minitest::Test
)
end
def test_boolean_and_operator
exp = [
[:id, "true"],
[:boolean_operator, "and"],
[:id, "false"],
[:end_of_string],
]
act = tokenize("true and false")
assert_equal(exp, act)
end
def test_boolean_or_operator
exp = [
[:id, "false"],
[:boolean_operator, "or"],
[:id, "true"],
[:end_of_string],
]
act = tokenize("false or true")
assert_equal(exp, act)
end
def test_boolean_operators_in_complex_expressions
exp = [
[:id, "a"],
[:boolean_operator, "and"],
[:id, "b"],
[:boolean_operator, "or"],
[:id, "c"],
[:end_of_string],
]
act = tokenize("a and b or c")
assert_equal(exp, act)
end
def test_boolean_operators_with_comparisons
exp = [
[:id, "a"],
[:comparison, ">"],
[:number, "5"],
[:boolean_operator, "and"],
[:id, "b"],
[:comparison, "<"],
[:number, "10"],
[:end_of_string],
]
act = tokenize("a > 5 and b < 10")
assert_equal(exp, act)
end
def test_boolean_operators_as_property_names
exp = [
[:id, "obj"],
[:dot, "."],
[:id, "and"],
[:dot, "."],
[:id, "property"],
[:end_of_string],
]
act = tokenize("obj.and.property")
assert_equal(exp, act)
exp = [
[:id, "obj"],
[:dot, "."],
[:id, "or"],
[:end_of_string],
]
act = tokenize("obj.or")
assert_equal(exp, act)
end
private
def tokenize(input)
-86
View File
@@ -1,86 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class ParseContextUnitTest < Minitest::Test
include Liquid
def test_safe_parse_expression_with_variable_lookup
parser = parse_context.new_parser('product.title')
result = parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
parser = parse_context.new_parser('')
error = assert_raises(Liquid::SyntaxError) do
parse_context.safe_parse_expression(parser)
end
assert_match(/is not a valid expression/, error.message)
end
def test_parse_expression_with_variable_lookup
error = assert_raises(Liquid::InternalError) do
parse_context.parse_expression('product.title')
end
assert_match(/unsafe parse_expression cannot be used/, error.message)
end
def test_parse_expression_with_safe_true
result = parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parse_expression_with_empty_string
error = assert_raises(Liquid::InternalError) do
parse_context.parse_expression('')
end
assert_match(/unsafe parse_expression cannot be used/, error.message)
end
def test_parse_expression_with_empty_string_and_safe_true
result = parse_context.parse_expression('', safe: true)
assert_nil(result)
end
def test_safe_parse_expression_advances_parser_pointer
parser = parse_context.new_parser('foo, bar')
# safe_parse_expression consumes "foo"
first_result = 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 = 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
result = parse_context.parse_expression(' ', safe: true)
assert_nil(result)
end
private
def parse_context
@parse_context ||= ParseContext.new(
environment: Environment.build,
)
end
end
+9 -7
View File
@@ -175,7 +175,7 @@ class PartialCacheUnitTest < Minitest::Test
assert_equal('some/path/my_partial', partial.name)
end
def test_cache_key
def test_includes_error_mode_into_template_cache
template_factory = StubTemplateFactory.new
context = Liquid::Context.build(
registers: {
@@ -184,14 +184,16 @@ class PartialCacheUnitTest < Minitest::Test
},
)
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new,
)
[:lax, :warn, :strict].each do |error_mode|
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new(error_mode: error_mode),
)
end
assert_equal(
["my_partial"],
["my_partial:lax", "my_partial:warn", "my_partial:strict"],
context.registers[:cached_partials].keys,
)
end
+135
View File
@@ -0,0 +1,135 @@
# frozen_string_literal: true
require 'test_helper'
class RangeUnitTest < Minitest::Test
include Liquid
def test_basic_range_creation
assert_template_result("1 2 3 4 5", "{% for i in (1..5) %}{{ i }} {% endfor %}")
end
def test_range_with_variables
assert_template_result("3 4 5", "{% assign start = 3 %}{% for i in (start..5) %}{{ i }} {% endfor %}")
assert_template_result("1 2 3", "{% assign end = 3 %}{% for i in (1..end) %}{{ i }} {% endfor %}")
assert_template_result("2 3 4", "{% assign start = 2 %}{% assign end = 4 %}{% for i in (start..end) %}{{ i }} {% endfor %}")
end
def test_range_with_whitespace
assert_template_result("1 2 3", "{% for i in ( 1 .. 3 ) %}{{ i }} {% endfor %}")
assert_template_result("1 2 3", "{% for i in (1 .. 3) %}{{ i }} {% endfor %}")
end
def test_range_with_expressions
assert_template_result("3 4 5", "{% assign x = 1 %}{% assign start = x | plus: 2 %}{% for i in (start..5) %}{{ i }} {% endfor %}")
assert_template_result("1 2 3", "{% assign x = 2 %}{% assign end = x | plus: 1 %}{% for i in (1..end) %}{{ i }} {% endfor %}")
end
def test_range_with_literals_in_iteration
assert_template_result("1 2 3 4 5", "{% for i in (1..5) %}{{ i }} {% endfor %}")
end
def test_range_size_and_first_last
assert_template_result("5", "{{ (1..5) | size }}")
assert_template_result("1", "{{ (1..5) | first }}")
assert_template_result("5", "{{ (1..5) | last }}")
end
def test_empty_ranges
assert_template_result("", "{% for i in (5..1) %}{{ i }}{% endfor %}")
end
def test_ranges_in_conditionals
assert_template_result("yes", "{% if 3 >= (1..5) %}no{% else %}yes{% endif %}")
assert_template_result("yes", "{% if (1..5) contains 3 %}yes{% else %}no{% endif %}")
assert_template_result("no", "{% if (1..5) contains 6 %}yes{% else %}no{% endif %}")
end
def test_range_with_negative_numbers
assert_template_result("-3 -2 -1 0", "{% for i in (-3..0) %}{{ i }} {% endfor %}")
end
def test_range_with_floats
# Liquid doesn't support float ranges, should either error or not iterate
template = "{% for i in (1.5..3.5) %}{{ i }} {% endfor %}"
# Floats are rounded down to the nearest integer
assert_template_result("1 2 3", template)
end
# def test_ranges_with_calculated_endpoints
# assert_template_result(
# "3 4 5",
# "{% assign start = 1 %}{% assign end = 7 %}{% for i in (start | plus: 2 .. end | minus: 2) %}{{ i }} {% endfor %}",
# )
# end
def test_malformed_ranges
# Missing start value
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (..5) %}{{ i }}{% endfor %}") }
# Missing end value
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (1..) %}{{ i }}{% endfor %}") }
# Missing both values
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (..) %}{{ i }}{% endfor %}") }
# Wrong syntax (no parentheses)
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in 1..5 %}{{ i }}{% endfor %}") }
# Unbalanced parentheses
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (1..5 %}{{ i }}{% endfor %}") }
# Invalid characters in range
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (#..@) %}{{ i }}{% endfor %}") }
# Invalid range
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% assign start = 1 %}{% assign end = 7 %}{% for i in (start | plus: 2 .. end | minus: 2) %}{{ i }} {% endfor %}") }
end
def test_ranges_with_strings_and_variables
assert_template_result(
"3 4 5",
"{% assign range = (3..5) %}{% for i in range %}{{ i }} {% endfor %}",
)
assert_template_result(
"4 5 6",
"{% assign start = 4 %}{% assign range = (start..6) %}{% for i in range %}{{ i }} {% endfor %}",
)
end
def test_ranges_with_limit_and_offset
assert_template_result(
"2 3",
"{% for i in (1..5) limit:2 offset:1 %}{{ i }} {% endfor %}",
)
assert_template_result(
"3 4 5",
"{% for i in (1..5) offset:2 %}{{ i }} {% endfor %}",
)
assert_template_result(
"1 2",
"{% for i in (1..5) limit:2 %}{{ i }} {% endfor %}",
)
end
def test_reversed_ranges
assert_template_result(
"5 4 3 2 1",
"{% for i in (1..5) reversed %}{{ i }} {% endfor %}",
)
end
def test_variable_ranges_with_reversed
assert_template_result(
"4 3 2 1",
"{% assign num = 4 %}{% for i in (1..num) reversed %}{{ i }} {% endfor %}",
)
end
def test_assigned_ranges_with_reversed
assert_template_result(
"5 4 3 2 1",
"{% assign range = (1..5) %}{% for i in range reversed %}{{ i }} {% endfor %}",
)
end
private
def assert_template_result(expected, template, assigns = {})
assert_equal(expected, Liquid::Template.parse(template).render!(assigns).strip)
end
end
+1 -1
View File
@@ -8,7 +8,7 @@ class StrainerTemplateUnitTest < Minitest::Test
def test_add_filter_when_wrong_filter_class
c = Context.new
s = c.strainer
wrong_filter = lambda(&:reverse)
wrong_filter = ->(v) { v.reverse }
exception = assert_raises(TypeError) do
s.class.add_filter(wrong_filter)
-106
View File
@@ -9,110 +9,4 @@ 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
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
def test_case_when_with_trailing_element
template = <<~LIQUID
{%- case 1 -%}
{%- when 1 bar -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
def test_case_when_with_comma
template = <<~LIQUID
{%- case 1 -%}
{%- when 2, 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assert_template_result("one", template)
end
def test_case_when_with_or
template = <<~LIQUID
{%- case 1 -%}
{%- when 2 or 1 -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assert_template_result("one", template)
end
def test_case_when_empty
template = <<~LIQUID
{%- case x -%}
{%- when 2 or empty -%}
2 or empty
{%- else -%}
not 2 or empty
{%- endcase -%}
LIQUID
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
def test_case_with_invalid_expression
template = <<~LIQUID
{%- case foo=>bar -%}
{%- when 'baz' -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
assert_match(/Unexpected character =/, error.message)
end
def test_case_when_with_invalid_expression
template = <<~LIQUID
{%- case 'baz' -%}
{%- when foo=>bar -%}
one
{%- else -%}
two
{%- endcase -%}
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
assert_match(/Unexpected character =/, error.message)
end
end
+5 -108
View File
@@ -20,21 +20,6 @@ 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
@@ -131,20 +116,6 @@ 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 %}
@@ -174,11 +145,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 %}")
@@ -196,80 +167,6 @@ 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)
+22 -34
View File
@@ -72,6 +72,12 @@ class VariableUnitTest < Minitest::Test
assert_equal([['replace', ['foo', 'bar']], ['textileze', []]], var.filters)
end
def test_symbol
var = create_variable("http://disney.com/logo.gif | image: 'med' ", error_mode: :lax)
assert_equal(VariableLookup.new('http://disney.com/logo.gif'), var.name)
assert_equal([['image', ['med']]], var.filters)
end
def test_string_to_filter
var = create_variable("'http://disney.com/logo.gif' | image: 'med' ")
assert_equal('http://disney.com/logo.gif', var.name)
@@ -102,9 +108,11 @@ 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)
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
with_error_mode(:strict) do
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
end
end
def test_string_with_special_chars
@@ -123,38 +131,18 @@ class VariableUnitTest < Minitest::Test
assert_equal([['things', [], { 'greeting' => 'world', 'farewell' => 'goodbye' }]], var.filters)
end
def test_filter_argument_parsing
# optional colon
var = create_variable(%(n | f1 | f2:))
assert_equal([['f1', []], ['f2', []]], var.filters)
def test_lax_filter_argument_parsing
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)
end
# 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')) }
def test_strict_filter_argument_parsing
with_error_mode(:strict) do
assert_raises(SyntaxError) do
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
end
end
end
def test_output_raw_source_of_variable