Compare commits

..
Author SHA1 Message Date
Charles-P. Clermont c5bb4c8eff Add strict2 let closures for Liquid composition 2026-09-24 10:10:28 -04:00
Tobi Lütke 807d45a6b3 Surface blank-body inline errors in strict2
Keep the historical blank-body inline-error suppression for lax and strict parse modes, but stop suppressing the rendered error text when the template was parsed in strict2. Raised-error rendering continues to raise in all modes.

Store the resolved template error mode on the render context while a template renders so BlockBody can distinguish strict2 from compatibility modes when deciding whether a blank tag should hide inline error text.

Add integration coverage for lax/strict suppression, strict2 non-suppression across blank body forms, nonblank bodies, and raised-error behavior.
2026-07-05 18:56:24 +00:00
Guilherme CarreiroandGitHub 7a5e45fc47 Bump to 5.13.0 (#2103) 2026-06-29 17:41:55 +02:00
Ian Ker-SeymerandIan Ker-Seymer 9efca9f718 Use liquid-spec branch for self specs 2026-06-11 23:20:43 -04:00
Ian Ker-SeymerandIan Ker-Seymer 529800f46b Let environment self shadow SelfDrop 2026-06-11 23:20:43 -04:00
Guilherme CarreiroandGitHub 7b368dffb8 Fix SelfDrop equality (#2091) 2026-06-05 10:47:37 +02:00
Guilherme CarreiroandGitHub 742ac3dbf5 Prevent SelfDrop context mutation across render boundaries (#2082) 2026-05-20 09:34:37 +02:00
Ian Ker-SeymerandIan Ker-Seymer 1954a2655c Update liquid-spec adapters 2026-04-28 10:11:38 -04:00
Alok SwamyandGitHub 6d81b1b68c Merge pull request #2077 from Shopify/remove-strict2-from-error-message
Remove "strict2" from bare bracket error message
2026-04-24 16:21:51 -04:00
Alok SwamyandClaude Opus 4.7 dfddd8f390 Remove "strict2" from bare bracket error message
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-24 16:18:05 -04:00
Alok SwamyandGitHub 95ce7e7fa1 Merge pull request #2067 from Shopify/strict2-increment-decrement
Add strict2_parse to increment and decrement tags
2026-04-24 16:11:49 -04:00
Alok SwamyandGitHub 197d755e0c Merge pull request #2065 from Shopify/strict2-assign-capture
Add strict2_parse to assign and capture tags
2026-04-24 16:10:59 -04:00
Alok SwamyandGitHub d0c5444db1 Merge pull request #2060 from Shopify/bare-bracket-self-keyword
Reject bare-bracket syntax in strict2 and introduce `self` keyword
2026-04-24 16:06:02 -04:00
Alok SwamyandClaude Opus 4.6 c99036046e Add strict2_parse to increment and decrement tags
Both tags previously accepted any string as a variable name via
`markup.strip`. Now they use `parse_with_selected_parser` and validate
the variable name with `p.consume(:id)` in strict2 mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 15:32:53 -04:00
Kevin MenardandGitHub a9c85622dd Merge pull request #2066 from eregon/skip-slow-test
Skip slow test raising many exceptions on non-CRuby
2026-03-26 02:14:10 -04:00
Benoit Daloze 9f4d7e78b8 Skip slow test raising many exceptions on non-CRuby 2026-03-25 17:20:30 +01:00
Alok SwamyandClaude Opus 4.6 0d5c15a03e Add strict2_parse to assign and capture tags
Both tags previously used only regex (VariableSignature) to validate
variable names, which allowed invalid identifiers like (a(b(c) and
[x.y] in all parse modes.

- assign: strict2_parse uses Parser to validate the LHS as a valid
  identifier before delegating RHS to Variable
- capture: strict2_parse uses Parser to validate the variable name
  as a valid identifier
- Both tags now include ParserSwitching and dispatch through
  strict_parse_with_error_mode_fallback
- Lax mode is unchanged — invalid names are still accepted

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-25 12:07:23 -04:00
Kevin MenardandGitHub fd68d076dd Merge pull request #2038 from eregon/truffleruby-ci
Add TruffleRuby in CI
2026-03-25 11:53:01 -04:00
Benoit Daloze 96aa47d13f Add TruffleRuby in CI 2026-03-25 16:46:47 +01:00
Benoit Daloze ad70c5c459 Improve no Symbol leak tests to be more reliable 2026-03-25 16:46:47 +01:00
Benoit Daloze d824de701c Adapt slice filter to work on non-64 bit platforms 2026-03-25 16:46:47 +01:00
Alok SwamyandClaude Opus 4.6 346166b600 Add for_loop? to Include tag for AST-based keyword detection
Store @is_for_loop during parsing so consumers can determine the
with/for keyword from the AST instead of re-parsing raw markup.
Matches the existing pattern in the Render tag.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-24 14:52:55 -04:00
Alok SwamyandClaude Opus 4.6 532b439063 Reject bare-bracket syntax in strict2 and introduce self keyword
Add bare-bracket rejection to Parser#expression in strict2 mode, so that
`['var']` is disallowed and `self['var']` is the required syntax.

- Add `Expression::SELF` constant ('self')
- Add `Parser#reject_bare_brackets` option, checked in `expression`
- Add `ParseContext#reject_bare_brackets?` and `force_reject_bare_brackets`
- Add `VariableLookupDrop` for `self['var']` scope-chain lookups
- Add `Variable#==` for rewriter state comparison
- Update `Context#find_variable` to return `VariableLookupDrop` for `self`

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-23 12:13:59 -04:00
Alok SwamyandGitHub dd37353cca Merge pull request #2062 from Shopify/update-setup-ruby
Update ruby/setup-ruby to v1.295.0 for ubuntu-24.04 support
2026-03-19 15:29:47 -04:00
Alok SwamyandClaude Opus 4.6 e80f775f89 Update ruby/setup-ruby from v1.273.0 to v1.295.0
The pinned version (v1.273.0) does not have prebuilt `ruby-head` binaries
for `ubuntu-24.04`, which `ubuntu-latest` now resolves to. This causes CI
to fail with "Unavailable version head for ruby on ubuntu-24.04".

Updating to v1.295.0 picks up ubuntu-24.04 support for all Ruby versions
including head builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-19 15:22:56 -04:00
Ian Ker-SeymerandGitHub 59d8d0d22d Add cumulative resource score tracking across partial renders (#2058)
* feat: add cumulative resource score tracking across partial renders

Add cumulative_render_score and cumulative_assign_score counters to
ResourceLimits that accumulate across reset() calls, with optional
cumulative_render_score_limit and cumulative_assign_score_limit to
cap total work across all partial renders.

Also add a reached? check in BlockBody's render loop so that once a
cumulative limit triggers, the parent template stops processing
further nodes.

Bump version to 5.12.0.

* refactor: move cumulative limit enforcement into reset()

Instead of checking reached? in BlockBody's render loop, enforce
cumulative limits in reset() itself. Since reset() is called before
the begin/rescue MemoryError block in Template#render, the raise
propagates to the parent naturally — no changes to BlockBody needed.
2026-03-18 11:54:46 -04:00
Gray GilmoreandGitHub 5fa36267aa Merge pull request #2054 from Shopify/gg-fix-rubocop-offenses
Fix rubocop offenses in test file
2026-03-06 13:28:54 -08:00
Gray Gilmore a72b604680 Fix rubocop offenses in test file 2026-03-06 13:27:05 -08:00
Gray GilmoreandGitHub 3e76244cd2 Merge pull request #2050 from bakura10/squish-filter
Add squish filter
2026-03-06 13:22:18 -08:00
Michaël Gallego d589c51697 Add squish filter 2026-02-19 10:03:52 +09:00
87 changed files with 3804 additions and 1251 deletions
+9 -4
View File
@@ -22,6 +22,7 @@ jobs:
}
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
- { ruby: truffleruby, allowed-failure: false }
# Head can have failures due to being in development
- { ruby: head, allowed-failure: true }
@@ -35,7 +36,7 @@ jobs:
name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
ruby-version: ${{ matrix.entry.ruby }}
bundler-cache: true
@@ -51,18 +52,22 @@ jobs:
BUNDLE_WITH: spec
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0
- uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
bundler-cache: true
bundler: latest
- name: Run liquid-spec for all adapters
run: bin/liquid-spec-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: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
with:
bundler-cache: true
- run: bundle exec rake memory_profile:run
+1 -1
View File
@@ -1 +1 @@
3.4.1
4.0.2
+2 -2
View File
@@ -32,7 +32,7 @@ group :test do
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'
# Using feature branch until https://github.com/Shopify/liquid-spec/pull/144 is merged
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'self-drop-env-lookup-specs'
gem 'activesupport', require: false
end
+10 -48
View File
@@ -1,54 +1,16 @@
# Liquid Change Log
## 6.0.0
## 5.13.0
### Features
* 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)
* 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)
* Groupings: `( expr )`
- For example, this is now supported
* `{{ a > b == c < d or e == f }}` which is equivalent to
* `{{ ((a > b) == (c < d)) or (e == f) }}`
- Add support for parenthesized expressions
* e.g. `(a or b) == c`
### Architectural changes
* `parse_expression` and `safe_parse_expression` have been removed from `Tag` and `ParseContext`
* `Parser` methods now produce AST nodes instead of strings
* `Parser#expression` produces a value,
* `Parser#string` produces a string,
* etc.
### 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`
* Expressions
* The `warnings` system has been removed.
* `Parser#expression` is renamed to `Parser#expression_string`
* `safe_parse_expression` methods are replaced by `Parser#expression`
* `parse_expression` methods are replaced by `Parser#unsafe_parse_expression`
* `Condition`
* `new(expr)` no longer accepts an `op` or `right`. Logic moved to BinaryExpression.
* `Condition#or` and `Condition#and` were replaced by `BinaryExpression`.
* `Condition#child_relation` replaced by `BinaryExpression`.
* `Condition.operations` was removed.
* `Condtion::MethodLiteral` was moved to the `Liquid` namespace
### Migrating from `^5.11.0`
- In custom tags that include `ParserSwitching`, rename `strict2_parse` to `parse_markup`
- Remove code depending on `:error_mode`
- Replace `safe_parse_expression` calls with `Parser#expression`
* Add TruffleRuby in CI [Benoit Daloze]
* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze]
* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy]
* Add strict2_parse to assign and capture tags by [Alok Swamy]
* Add strict2_parse to increment and decrement tags by [Alok Swamy]
* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer]
* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro]
* Fix `SelfDrop` equality [Guilherme Carreiro]
* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer]
## 5.11.0
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
+69
View File
@@ -93,6 +93,75 @@ 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 different parsers that can be used when editing templates to give better error messages
when templates are invalid. You can enable this new parser like this:
```ruby
Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags
Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags
Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal
Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything.
```
If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`:
```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.
### Strict2 bindings and closures
In `error_mode: :strict2`, `let` binds a value or defines a callable Liquid body:
```liquid
{% let count = 1 %}
{% let title(step) %}
{% let count = count | plus: step %}
{{ count }}
{% endlet %}
{{ title(2) }}
```
Each call has fresh locals and writable parameters. Captures read the latest parent
bindings. `set name = expression` updates the nearest local binding, or creates a
shadow at the invocation root when the name is captured or absent. Initializers run
before installing the new binding. Loops have fresh `let` bindings each iteration
and can shadow outer names; conditional branches share their surrounding scope.
Duplicate `let` declarations in one scope
and `assign` inside closure bodies are parse errors. Calls accept positional
arguments and invoke only Liquid closures.
Custom `Liquid::Block` subclasses can use the same body rules:
```ruby
def parse(tokens)
@closure_body = parse_closure_body(tokens)
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
exports = body.invoke(context, output: output)
exports['title']&.render_to_output_buffer(context, output, ['Hello'])
output
end
```
`invoke` renders into the supplied buffer and returns direct-child body-form `let`
bindings by name. Value bindings and declarations nested in control flow are not
exported. Each invocation has fresh state; bind the body at render time rather than
caching a closure on the parsed tag. For parameterized bodies, pass
`parameters: ['name']` to `parse_closure_body`, then pass an argument array as the
second argument to `invoke`. The syntax remains visible to `ParseTreeVisitor`.
### 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.
+43 -6
View File
@@ -33,12 +33,29 @@ task :rubocop do
end
end
desc('runs test suite')
desc('runs test suite with lax, strict, and strict2 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
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['base_test'].reenable
Rake::Task['base_test'].invoke
if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby'
ENV['LIQUID_PARSER_MODE'] = 'lax'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
ENV['LIQUID_PARSER_MODE'] = 'strict2'
Rake::Task['integration_test'].reenable
Rake::Task['integration_test'].invoke
end
@@ -61,11 +78,24 @@ 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 strict2 parsing"
task :strict2 do
ruby "./performance/benchmark.rb strict2"
end
desc "Run the liquid benchmark with lax, strict, and strict2 parsing"
task run: [:lax, :strict, :strict2]
desc "Run unit benchmarks"
namespace :unit do
task :all do
@@ -96,6 +126,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
@@ -116,6 +151,8 @@ end
desc('run liquid-spec suite across all adapters')
task :spec do
adapters = Dir['./spec/*.rb'].join(',')
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
Dir['./spec/*.rb'].sort.each do |adapter|
puts "=== Running #{adapter} ==="
sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures'
end
end
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
for adapter in spec/*.rb; do
echo "=== Running $adapter ==="
bundle exec liquid-spec run "$adapter" --no-max-failures
done
+4 -2
View File
@@ -58,15 +58,17 @@ require 'liquid/parser_switching'
require 'liquid/tag'
require 'liquid/block'
require 'liquid/parse_tree_visitor'
require 'liquid/closure_body'
require 'liquid/closure'
require 'liquid/call_expression'
require 'liquid/interrupts'
require 'liquid/tags'
require "liquid/environment"
require 'liquid/lexer'
require 'liquid/method_literal'
require 'liquid/binary_expression'
require 'liquid/parser'
require 'liquid/i18n'
require 'liquid/drop'
require 'liquid/self_drop'
require 'liquid/tablerowloop_drop'
require 'liquid/forloop_drop'
require 'liquid/extensions'
-94
View File
@@ -1,94 +0,0 @@
# frozen_string_literal: true
module Liquid
class BinaryExpression
attr_reader :operator
attr_accessor :left_node, :right_node
def initialize(left, operator, right)
@left_node = left
@operator = operator
@right_node = right
end
def evaluate(context)
left = value(left_node, context)
# logical relation short circuiting
if operator == 'and'
return left && value(right_node, context)
elsif operator == 'or'
return left || value(right_node, context)
end
right = value(right_node, context)
case operator
when '>'
left > right if can_compare?(left, right)
when '>='
left >= right if can_compare?(left, right)
when '<'
left < right if can_compare?(left, right)
when '<='
left <= right if can_compare?(left, right)
when '=='
equal_variables(left, right)
when '!=', '<>'
!equal_variables(left, right)
when 'contains'
contains(left, right)
else
raise(Liquid::ArgumentError, "Unknown operator #{operator}")
end
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message
end
def to_s
"(#{left_node.inspect} #{operator} #{right_node.inspect})"
end
private
def value(expr, context)
Utils.to_liquid_value(context.evaluate(expr))
end
def can_compare?(left, right)
left.respond_to?(operator) && right.respond_to?(operator) && !left.is_a?(Hash) && !right.is_a?(Hash)
end
def contains(left, right)
if left && right && left.respond_to?(:include?)
right = right.to_s if left.is_a?(String)
left.include?(right)
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end
def apply_method_literal(node, other)
node.apply(other)
end
def equal_variables(left, right)
return apply_method_literal(left, right) if left.is_a?(MethodLiteral)
return apply_method_literal(right, left) if right.is_a?(MethodLiteral)
left == right
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
@node.left_node,
@node.right_node,
]
end
end
end
end
+14 -4
View File
@@ -10,10 +10,13 @@ module Liquid
end
def parse(tokens)
@body = new_body
while parse_body(@body, tokens)
end
@body.freeze
parse_block_body(tokens)
end
# Parse an external block body with closure validation and lexical declarations.
def parse_closure_body(tokens, parameters: Const::EMPTY_ARRAY)
parse_context.with_closure(parameters) { parse_block_body(tokens) }
ClosureBody.new(@body, parameters)
end
# For backwards compatibility
@@ -66,6 +69,13 @@ module Liquid
private
def parse_block_body(tokens)
@body = new_body
while parse_body(@body, tokens)
end
@body.freeze
end
# @api public
def new_body
parse_context.new_block_body
+3 -1
View File
@@ -99,7 +99,9 @@ module Liquid
context.handle_error(exc, line_number)
else
error_message = context.handle_error(exc, line_number)
unless blank_tag # conditional for backwards compatibility
error_mode = context.registers.static[:template_error_mode]
suppress_error_text = blank_tag && error_mode != :strict2 && error_mode != :rigid
unless suppress_error_text # blank-tag suppression is kept for backwards compatibility outside strict2
output << error_message
end
end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Liquid
class CallExpression
attr_reader :target, :arguments
def initialize(target, arguments)
@target = target.freeze
@arguments = arguments.freeze
freeze
end
def evaluate(context)
context.resource_limits.with_capture do
render_to_output_buffer(context, +'')
end
end
def render_to_output_buffer(context, output)
closure = context.evaluate(@target)
raise Liquid::ArgumentError, "Expected a Liquid closure" unless closure.is_a?(Closure)
arguments = @arguments.map { |argument| context.evaluate(argument) }
closure.render_to_output_buffer(context, output, arguments)
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.target] + @node.arguments
end
end
end
end
+96
View File
@@ -0,0 +1,96 @@
# frozen_string_literal: true
module Liquid
# A render-time value. Captures retain binding hashes, not copies of their values.
class Closure
def initialize(body, parameters, context)
@body = body
@parameters = parameters
@scopes = context.scopes.dup
@environments = context.environments.dup
@static_environments = context.static_environments
end
# Render into the supplied buffer and return direct-child body-form let bindings.
def invoke(context, arguments = Const::EMPTY_ARRAY, output: +'')
invocation = render_invocation(context, arguments, output)
@body.exports(invocation)
end
def render_to_output_buffer(context, output, arguments = Const::EMPTY_ARRAY)
render_invocation(context, arguments, output)
output
end
def to_liquid
self
end
def to_s
''
end
private
def render_invocation(context, arguments, output)
unless arguments.length == @parameters.length
raise Liquid::ArgumentError, "Expected #{@parameters.length} closure arguments, got #{arguments.length}"
end
invocation = context.new_closure_subcontext(@scopes, @environments, @static_environments)
@parameters.each_with_index { |name, index| invocation[name] = arguments[index] }
@body.render_to_output_buffer(invocation, output)
invocation
end
# @api private
class CapturedBindings
def initialize(scopes, environments, context)
@scopes = scopes
@environments = environments
@context = context
@locals = {}
end
def key?(name)
local_key?(name) || @environments.any? { |environment| environment.key?(name) }
end
def local_key?(name)
@locals.key?(name) || @scopes.any? { |scope| scope.key?(name) } ||
@environments.any? { |environment| environment.is_a?(CapturedBindings) && environment.local_key?(name) }
end
def []=(name, value)
@locals[name] = value
end
def [](name)
find_variable(name, @context, raise_on_not_found: false)
end
def find_variable(name, context, raise_on_not_found: true)
return context.lookup_and_evaluate(@locals, name, raise_on_not_found: raise_on_not_found) if @locals.key?(name)
scope = @scopes.find { |candidate| candidate.key?(name) }
return context.lookup_and_evaluate(scope, name, raise_on_not_found: raise_on_not_found) if scope
find_in_environments(name, context, raise_on_not_found: raise_on_not_found)
end
private
def find_in_environments(name, context, raise_on_not_found:)
@environments.each do |environment|
if environment.is_a?(CapturedBindings)
return environment.find_variable(name, context, raise_on_not_found: raise_on_not_found)
end
value = context.lookup_and_evaluate(environment, name, raise_on_not_found: raise_on_not_found)
return value if !value.nil? || context.strict_variables && raise_on_not_found
end
nil
end
end
end
end
+31
View File
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Liquid
# Parsed syntax shared by let and closure-aware custom blocks.
class ClosureBody
attr_reader :body
def initialize(body, parameters = Const::EMPTY_ARRAY)
@body = body
@parameters = parameters.dup.freeze
@exports = body.nodelist.select { |node| node.is_a?(Let) && node.body_form? }.map(&:to).freeze
freeze
end
def bind(context)
Closure.new(self, @parameters, context)
end
def render_to_output_buffer(context, output)
@body.render_to_output_buffer(context, output)
end
def exports(context)
context.scopes.last.slice(*@exports)
end
def nodelist
@body.nodelist
end
end
end
+166 -6
View File
@@ -5,19 +5,92 @@ module Liquid
#
# Example:
#
# c = Condition.new(expr)
# c = Condition.new(1, '==', 1)
# c.evaluate #=> true
#
class Condition # :nodoc:
attr_reader :attachment
attr_accessor :left
@@operators = {
'==' => ->(cond, left, right) { cond.send(:equal_variables, left, right) },
'!=' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) },
'<>' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) },
'<' => :<,
'>' => :>,
'>=' => :>=,
'<=' => :<=,
'contains' => lambda do |_cond, left, right|
if left && right && left.respond_to?(:include?)
right = right.to_s if left.is_a?(String)
left.include?(right)
else
false
end
rescue Encoding::CompatibilityError
# "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal
left.b.include?(right.b)
end,
}
def initialize(left = nil)
@left = left
class MethodLiteral
attr_reader :method_name, :to_s
def initialize(method_name, to_s)
@method_name = method_name
@to_s = to_s
end
end
@@method_literals = {
'blank' => MethodLiteral.new(:blank?, '').freeze,
'empty' => MethodLiteral.new(:empty?, '').freeze,
}
def self.operators
@@operators
end
def self.parse_expression(parse_context, markup, safe: false)
@@method_literals[markup] || parse_context.parse_expression(markup, safe: safe)
end
attr_reader :attachment, :child_condition
attr_accessor :left, :operator, :right
def initialize(left = nil, operator = nil, right = nil)
@left = left
@operator = operator
@right = right
@child_relation = nil
@child_condition = nil
end
def evaluate(context = deprecated_default_context)
context.evaluate(left)
condition = self
result = nil
loop do
result = interpret_condition(condition.left, condition.right, condition.operator, context)
case condition.child_relation
when :or
break if Liquid::Utils.to_liquid_value(result)
when :and
break unless Liquid::Utils.to_liquid_value(result)
else
break
end
condition = condition.child_condition
end
result
end
def or(condition)
@child_relation = :or
@child_condition = condition
end
def and(condition)
@child_relation = :and
@child_condition = condition
end
def attach(attachment)
@@ -38,6 +111,91 @@ module Liquid
private
def equal_variables(left, right)
if left.is_a?(MethodLiteral)
return call_method_literal(left, right)
end
if right.is_a?(MethodLiteral)
return call_method_literal(right, left)
end
left == right
end
def call_method_literal(literal, value)
method_name = literal.method_name
# If the object responds to the method (e.g., ActiveSupport is loaded), use it
if value.respond_to?(method_name)
value.send(method_name)
else
# Emulate ActiveSupport's blank?/empty? to make Liquid invariant
# to whether ActiveSupport is loaded or not
case method_name
when :blank?
liquid_blank?(value)
when :empty?
liquid_empty?(value)
else
false
end
end
end
# Implement blank? semantics matching ActiveSupport
# blank? returns true for nil, false, empty strings, whitespace-only strings,
# empty arrays, and empty hashes
def liquid_blank?(value)
case value
when NilClass, FalseClass
true
when TrueClass, Numeric
false
when String
# Blank if empty or whitespace only (matches ActiveSupport)
value.empty? || value.match?(/\A\s*\z/)
when Array, Hash
value.empty?
else
# Fall back to empty? if available, otherwise false
value.respond_to?(:empty?) ? value.empty? : false
end
end
# Implement empty? semantics
# Note: nil is NOT empty. empty? checks if a collection has zero elements.
def liquid_empty?(value)
case value
when String, Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : false
end
end
def interpret_condition(left, right, op, context)
# If the operator is empty this means that the decision statement is just
# a single variable. We can just poll this variable from the context and
# return this as the result.
return context.evaluate(left) if op.nil?
left = Liquid::Utils.to_liquid_value(context.evaluate(left))
right = Liquid::Utils.to_liquid_value(context.evaluate(right))
operation = self.class.operators[op] || raise(Liquid::ArgumentError, "Unknown operator #{op}")
if operation.respond_to?(:call)
operation.call(self, left, right)
elsif left.respond_to?(operation) && right.respond_to?(operation) && !left.is_a?(Hash) && !right.is_a?(Hash)
begin
left.send(operation, right)
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message
end
end
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.")
@@ -48,6 +206,8 @@ module Liquid
def children
[
@node.left,
@node.right,
@node.child_condition,
@node.attachment
].compact
end
+64 -4
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,10 +157,45 @@ module Liquid
subcontext.filters = @filters
subcontext.strainer = nil
subcontext.errors = errors
subcontext.warnings = warnings
subcontext.disabled_tags = @disabled_tags
end
end
# @api private
def new_closure_subcontext(scopes, environments, static_environments)
new_isolated_subcontext.tap do |invocation|
captures = Closure::CapturedBindings.new(scopes, environments + static_environments, invocation)
invocation.captured_bindings = captures
invocation.environments = [captures]
invocation.static_environments = static_environments
invocation.strict_variables = strict_variables
invocation.strict_filters = strict_filters
invocation.global_filter = global_filter
invocation.template_name = template_name
invocation.partial = partial
invocation.base_scope_depth = base_scope_depth + @scopes.length
end
end
# @api private
def with_binding_scope
previous_root = @binding_root
stack do
@binding_root = @scopes.first
yield
end
ensure
@binding_root = previous_root
end
# @api private
def set_local(name, value)
root = @binding_root || @scopes.last
scope = @scopes.find { |owned| owned.key?(name) || owned.equal?(root) }
scope[name] = value
end
def clear_instance_assigns
@scopes[0] = {}
end
@@ -175,14 +214,22 @@ module Liquid
# Example:
# products == empty #=> products.empty?
def [](expression)
@string_scanner.string = expression
evaluate(Parser.new(@string_scanner).expression)
evaluate(Expression.parse(expression, @string_scanner))
end
def key?(key)
find_variable(key, raise_on_not_found: false) != nil
end
# Checks whether a variable is defined in any scope, including nil-valued keys.
# Unlike #key?, this uses Hash#key? so that variables explicitly set to nil
# are still considered defined.
def variable_defined?(key)
@scopes.any? { |s| s.key?(key) } ||
@environments.any? { |e| e.key?(key) } ||
@static_environments.any? { |e| e.key?(key) }
end
def evaluate(object)
object.respond_to?(:evaluate) ? object.evaluate(self) : object
end
@@ -193,12 +240,21 @@ module Liquid
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
fallback_to_self_drop = key == Expression::SELF && index.nil? && !@captured_bindings&.local_key?(key)
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
try_variable_find_in_environments(
key,
raise_on_not_found: raise_on_not_found && !fallback_to_self_drop,
)
end
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
# but only after the normal environment lookup doesn't find a value.
return @self_drop ||= SelfDrop.new(self) if fallback_to_self_drop && variable.nil?
# update variable's context before invoking #to_liquid
variable.context = self if variable.respond_to?(:context=)
@@ -240,13 +296,17 @@ module Liquid
protected
attr_writer :base_scope_depth, :errors, :strainer, :filters, :disabled_tags
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags, :environments, :static_environments, :captured_bindings
private
attr_reader :base_scope_depth
def try_variable_find_in_environments(key, raise_on_not_found:)
if @captured_bindings
return @captured_bindings.find_variable(key, self, raise_on_not_found: raise_on_not_found)
end
@environments.each do |environment|
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
if !found_variable.nil? || @strict_variables && raise_on_not_found
+6 -2
View File
@@ -20,7 +20,9 @@ module Liquid
end
def parse(tokenizer, parse_context)
while parse_body(tokenizer)
@local_bindings = parse_context.with_document_scope do
while parse_body(tokenizer)
end
end
@body.freeze
rescue SyntaxError => e
@@ -38,7 +40,9 @@ module Liquid
end
def render_to_output_buffer(context, output)
@body.render_to_output_buffer(context, output)
return @body.render_to_output_buffer(context, output) unless @local_bindings
context.with_binding_scope { @body.render_to_output_buffer(context, output) }
end
def render(context)
+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 :strict2, :strict, :warn, or :lax).
# @param exception_renderer [Proc] The exception renderer that is used to
# render exceptions.
# @yieldparam environment [Environment] The environment instance that is being built.
# @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
+63 -16
View File
@@ -2,6 +2,8 @@
module Liquid
class Expression
SELF = 'self'
LITERALS = {
nil => nil,
'nil' => nil,
@@ -9,8 +11,11 @@ module Liquid
'' => nil,
'true' => true,
'false' => false,
'blank' => MethodLiteral::BLANK,
'empty' => MethodLiteral::EMPTY,
'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
@@ -25,8 +30,13 @@ module Liquid
FLOAT_REGEX = /\A(-?\d+)\.\d+\z/
class << self
def safe_parse(parser, ss = StringScanner.new(""), cache = nil)
parse(parser.expression, ss, cache)
end
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
return markup unless markup.is_a?(String)
markup = markup.strip # markup can be a frozen string
@@ -49,34 +59,71 @@ module Liquid
def inner_parse(markup, ss, cache)
if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX
start_markup = Regexp.last_match(1)
end_markup = Regexp.last_match(2)
start_obj = parse(start_markup, ss, cache)
end_obj = parse(end_markup, ss, cache)
return RangeLookup.create(
start_obj,
end_obj,
start_markup,
end_markup,
return RangeLookup.parse(
Regexp.last_match(1),
Regexp.last_match(2),
ss,
cache,
)
end
if (num = parse_number(markup))
if (num = parse_number(markup, ss))
num
else
VariableLookup.parse(markup, ss, cache)
end
end
def parse_number(markup)
def parse_number(markup, ss)
# check if the markup is simple integer or float
case markup
when INTEGER_REGEX
Integer(markup, 10)
return Integer(markup, 10)
when FLOAT_REGEX
markup.to_f
return markup.to_f
end
ss.string = markup
# the first byte must be a digit or a dash
byte = ss.scan_byte
return false if byte != DASH && (byte < ZERO || byte > NINE)
if byte == DASH
peek_byte = ss.peek_byte
# if it starts with a dash, the next byte must be a digit
return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE)
end
# The markup could be a float with multiple dots
first_dot_pos = nil
num_end_pos = nil
while (byte = ss.scan_byte)
return false if byte != DOT && (byte < ZERO || byte > NINE)
# we found our number and now we are just scanning the rest of the string
next if num_end_pos
if byte == DOT
if first_dot_pos.nil?
first_dot_pos = ss.pos
else
# we found another dot, so we know that the number ends here
num_end_pos = ss.pos - 1
end
end
end
num_end_pos = markup.length if ss.eos?
if num_end_pos
# number ends with a number "123.123"
markup.byteslice(0, num_end_pos).to_f
else
false
# number ends with a dot "123."
markup.byteslice(0, first_dot_pos).to_f
end
end
end
+11 -14
View File
@@ -2,28 +2,27 @@
module Liquid
class Lexer
ASSIGN = [:assign, "="].freeze
CLOSE_ROUND = [:close_round, ")"].freeze
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
COMMA = [:comma, ","].freeze
COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze
COMPARISON_CONTAINS = [:comparison, "contains"].freeze
COMPARISON_EQUAL = [:comparison, "=="].freeze
COMPARISON_GREATER_THAN = [:comparison, ">"].freeze
COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze
COMPARISON_LESS_THAN = [:comparison, "<"].freeze
COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze
EQUALITY_EQUAL_EQUAL = [:equality, "=="].freeze
EQUALITY_NOT_EQUAL = [:equality, "!="].freeze
EQUALITY_NOT_EQUAL_ALT = [:equality, "<>"].freeze
COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze
DASH = [:dash, "-"].freeze
DOT = [:dot, "."].freeze
DOTDOT = [:dotdot, ".."].freeze
DOT_ORD = ".".ord
DOUBLE_STRING_LITERAL = /"[^\"]*"/
EOS = [:end_of_string].freeze
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
LOGICAL_AND = [:logical, 'and'].freeze
LOGICAL_OR = [:logical, 'or'].freeze
NUMBER_LITERAL = /-?\d+(\.\d+)?/
IDENTIFIER = /[a-zA-Z_][\w-]*\??/
NUMBER_LITERAL = /-?\d+(\.\d+)?/
OPEN_ROUND = [:open_round, "("].freeze
OPEN_SQUARE = [:open_square, "["].freeze
PIPE = [:pipe, "|"].freeze
@@ -40,11 +39,11 @@ module Liquid
TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table|
table["=".ord] = [].tap do |sub_table|
sub_table["=".ord] = EQUALITY_EQUAL_EQUAL
sub_table["=".ord] = COMPARISON_EQUAL
sub_table.freeze
end
table["!".ord] = [].tap do |sub_table|
sub_table["=".ord] = EQUALITY_NOT_EQUAL
sub_table["=".ord] = COMPARISION_NOT_EQUAL
sub_table.freeze
end
table.freeze
@@ -53,7 +52,7 @@ module Liquid
COMPARISON_JUMP_TABLE = [].tap do |table|
table["<".ord] = [].tap do |sub_table|
sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL
sub_table[">".ord] = EQUALITY_NOT_EQUAL_ALT
sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT
sub_table.freeze
end
table[">".ord] = [].tap do |sub_table|
@@ -135,6 +134,8 @@ module Liquid
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
elsif peeked == "=".ord && ss.peek_byte != "<".ord && ss.peek_byte != ">".ord && ss.peek_byte != "!".ord
output << ASSIGN
else
raise_syntax_error(start_pos, ss)
end
@@ -153,10 +154,6 @@ 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
LOGICAL_AND
elsif type == :id && t == "or" && output.last&.first != :dot
LOGICAL_OR
else
[type, t]
end
-49
View File
@@ -1,49 +0,0 @@
# frozen_string_literal: true
module Liquid
class MethodLiteral
attr_reader :method_name, :to_s
def initialize(method_name, to_s, &evaluator)
@method_name = method_name
@to_s = to_s
@evaluator = evaluator
end
def apply(value)
if value.respond_to?(@method_name)
value.send(@method_name)
elsif @evaluator
@evaluator.call(value)
end
end
def to_liquid
to_s
end
BLANK = MethodLiteral.new(:blank?, '') do |value|
case value
when NilClass, FalseClass
true
when TrueClass, Numeric
false
when String
value.empty? || value.match?(/\A\s*\z/)
when Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : false
end
end.freeze
EMPTY = MethodLiteral.new(:empty?, '') do |value|
case value
when String, Array, Hash
value.empty?
else
value.respond_to?(:empty?) ? value.empty? : nil
end
end.freeze
end
end
+77 -3
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
@@ -37,7 +38,11 @@ module Liquid
def new_parser(input)
@string_scanner.string = input
Parser.new(@string_scanner, @expression_cache)
Parser.new(
@string_scanner,
reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid,
allow_calls: @error_mode == :strict2,
)
end
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
@@ -49,9 +54,78 @@ module Liquid
)
end
def safe_parse_expression(parser)
Expression.safe_parse(parser, @string_scanner, @expression_cache)
end
def parse_expression(markup, safe: false)
if !safe && @error_mode == :strict2
# parse_expression is a widely used API. To maintain backward
# compatibility while raising awareness about strict2 parser standards,
# the safe flag supports API users make a deliberate decision.
#
# In strict2 mode, markup MUST come from parser.expression, which returns
# markup or a parsed callable node. We're not calling the parser here to
# prevent redundant parser overhead.
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
end
Expression.parse(markup, @string_scanner, @expression_cache)
end
def with_document_scope(&block)
previous_usage = @uses_local_bindings
@uses_local_bindings = false
with_lexical_scope(&block)
@uses_local_bindings
ensure
@uses_local_bindings = previous_usage
end
def with_lexical_scope(parameters = Const::EMPTY_ARRAY)
previous_scope = @lexical_scope
@lexical_scope = parameters.to_h { |name| [name, true] }
yield
ensure
@lexical_scope = previous_scope
end
def local_declarations?
@lexical_scope && !@lexical_scope.empty?
end
def declare_local(name)
@lexical_scope ||= {}
raise SyntaxError, "Duplicate let declaration '#{name}'" if @lexical_scope.key?(name)
@lexical_scope[name] = true
end
def with_closure(parameters = Const::EMPTY_ARRAY, &block)
require_closures!
previous_closure = @in_closure
@in_closure = true
with_lexical_scope(parameters, &block)
ensure
@in_closure = previous_closure
end
def in_closure?
@in_closure
end
def require_closures!
@uses_local_bindings = true
return if @error_mode == :strict2
raise SyntaxError, "Closures require strict2 mode"
end
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
@error_mode = @options[:error_mode] || @environment.error_mode
end
def partial_options
+80 -168
View File
@@ -2,19 +2,18 @@
module Liquid
class Parser
def initialize(input, expression_cache = nil)
@ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@cache = expression_cache
@tokens = Lexer.tokenize(@ss)
def initialize(input, reject_bare_brackets: false, allow_calls: false)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location
@reject_bare_brackets = reject_bare_brackets
@allow_calls = allow_calls
end
def jump(point)
@p = point
end
# Consumes a token of specific type.
# Throws SyntaxError if token doesn't match type expectation.
def consume(type = nil)
token = @tokens[@p]
if type && token[0] != type
@@ -43,178 +42,88 @@ module Liquid
token[1]
end
# Peeks the ahead token, returning true if matching expectation
def look(type, ahead = 0)
tok = @tokens[@p + ahead]
return false unless tok
tok[0] == type
end
# expression := logical
# logical := equality (("and" | "or") equality)*
# equality := comparison (("==" | "!=" | "<>") comparison)*
# comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)*
# primary := string | number | variable_lookup | range | boolean | grouping
def expression
logical
end
# Logical relations use right-to-left associativity.
# `a and b or c` is evaluated like (a and (b or c))
# This enables short-circuit: if `a` is false, entire expression short-circuits.
# logical := equality (("and" | "or") logical)?
def logical
left = equality
if (operator = consume?(:logical))
right = logical # recursive call builds proper RTL tree
BinaryExpression.new(left, operator, right)
else
left
end
end
# equality := comparison (("==" | "!=" | "<>") comparison)*
def equality
expr = comparison
while look(:equality)
operator = consume
expr = BinaryExpression.new(expr, operator, comparison)
end
expr
end
# comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)*
def comparison
expr = primary
while look(:comparison)
operator = consume
expr = BinaryExpression.new(expr, operator, primary)
end
expr
end
# primary := string | number | variable_lookup | range | boolean | grouping
def primary
token = @tokens[@p]
case token[0]
when :id
variable_lookup_or_literal
when :open_square
unnamed_variable_lookup
when :string
string
when :number
number
when :open_round
grouping_or_range_lookup
else
raise SyntaxError, "#{token} is not a valid expression"
end
end
return callable_or_lookup if @allow_calls
def number
num = consume(:number)
Expression.parse_number(num)
end
def string
consume(:string)[1..-2]
end
# variable_lookup := id (lookup)*
# lookup := indexed_lookup | dot_lookup
# indexed_lookup := "[" expression "]"
# dot_lookup := "." id
def variable_lookup
name = consume(:id)
lookups, command_flags = variable_lookups
VariableLookup.new(name, lookups, command_flags)
end
# a variable_lookup without lookups could be a literal
def variable_lookup_or_literal
name = consume(:id)
lookups, command_flags = variable_lookups
if Expression::LITERALS.key?(name) && lookups.empty?
Expression::LITERALS[name]
else
VariableLookup.new(name, lookups, command_flags)
end
end
# unnamed_variable_lookup := indexed_lookup (lookup)*
def unnamed_variable_lookup
name = indexed_lookup
lookups, command_flags = variable_lookups
VariableLookup.new(name, lookups, command_flags)
end
# Parenthesized expressions are recursive
# grouping := "(" expression ")"
def grouping_or_range_lookup
consume(:open_round)
expr = expression
if consume?(:dotdot)
RangeLookup.create(expr, expression)
else
expr
end
ensure
consume(:close_round)
end
# range_lookup := "(" expression ".." expression ")"
def range_lookup
consume(:open_round)
first = expression
consume(:dotdot)
last = expression
consume(:close_round)
RangeLookup.create(first, last)
end
def expression_string
token = @tokens[@p]
case token[0]
when :id
str = consume
str << variable_lookups_string
str << variable_lookups
when :open_square
if @reject_bare_brackets
raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead"
end
str = consume.dup
str << expression_string
str << expression
str << consume(:close_square)
str << variable_lookups_string
str << variable_lookups
when :string, :number
consume
when :open_round
consume
first = expression_string
first = expression
consume(:dotdot)
last = expression_string
last = expression
consume(:close_round)
return RangeLookup.new(Expression.parse(first), Expression.parse(last)) unless first.is_a?(String) && last.is_a?(String)
"(#{first}..#{last})"
else
raise SyntaxError, "#{token} is not a valid expression"
end
end
def argument_string
def expression_with_markup
start = @p
# Parsing lookups appends to consumed identifier strings.
markup_tokens = @tokens[start...-1].map { |token| token[1].dup }
value = expression
markup = value.is_a?(String) ? value : markup_tokens.take(@p - start).join
[value, markup]
end
def remaining_markup
@tokens[@p...-1].map { |token| token[1] }.join(' ')
end
def parameters
parameters = []
return parameters unless consume?(:open_round)
unless look(:close_round)
parameters << consume(:id)
parameters << consume(:id) while consume?(:comma) && !look(:close_round)
end
consume(:close_round)
raise SyntaxError, "Duplicate closure parameter" unless parameters.uniq.length == parameters.length
parameters
end
def argument
str = +""
# might be a keyword argument (identifier: expression)
if look(:id) && look(:colon, 1)
str << consume << consume << ' '
end
str << expression_string
str << expression
str
end
def variable_lookups_string
def variable_lookups
str = +""
loop do
if look(:open_square)
str << consume
str << expression_string
str << expression
str << consume(:close_square)
elsif look(:dot)
str << consume
@@ -226,42 +135,45 @@ module Liquid
str
end
# Assumes safe input. For cases where you need the string.
# Don't use this unless you're sure about what you're doing.
def unsafe_parse_expression(markup)
parse_expression(markup)
end
private
def parse_expression(markup)
Expression.parse(markup, @ss, @cache)
end
def callable_or_lookup
target = callable_lookups(consume(:id))
return target unless consume?(:open_round)
def variable_lookups
lookups = []
command_flags = 0
i = -1
loop do
i += 1
if look(:open_square)
lookups << indexed_lookup
elsif consume?(:dot)
lookup = consume(:id)
lookups << lookup
command_flags |= 1 << i if VariableLookup::COMMAND_METHODS.include?(lookup)
else
break
end
arguments = []
unless look(:close_round)
arguments << Expression.parse(expression)
arguments << Expression.parse(expression) while consume?(:comma) && !look(:close_round)
end
[lookups, command_flags]
consume(:close_round)
target = VariableLookup.parse(target) if target.is_a?(String)
CallExpression.new(target, arguments)
end
def indexed_lookup
consume(:open_square)
expr = expression
consume(:close_square)
expr
def callable_lookups(target)
loop do
if consume?(:dot)
target = append_lookup(target, consume(:id), command: true)
next
end
break unless consume?(:open_square)
key = expression
consume(:close_square)
target = append_lookup(target, key)
end
target
end
def append_lookup(target, key, command: false)
if target.is_a?(String) && key.is_a?(String)
return target << (command ? ".#{key}" : "[#{key}]")
end
target = VariableLookup.parse(target) if target.is_a?(String)
target.add_lookup(command ? key : Expression.parse(key), command: command)
target
end
end
end
+61 -2
View File
@@ -2,15 +2,74 @@
module Liquid
module ParserSwitching
# Do not use this.
#
# It's basically doing the same thing the {#parse_with_selected_parser},
# except this will try the strict parser regardless of the error mode,
# and fall back to the lax parser if the error mode is lax or warn,
# except when in strict2 mode where it uses the strict2 parser.
#
# @deprecated Use {#parse_with_selected_parser} instead.
def strict_parse_with_error_mode_fallback(markup)
return strict2_parse_with_error_context(markup) if strict2_mode?
strict_parse_with_error_context(markup)
rescue SyntaxError => e
case parse_context.error_mode
when :rigid
rigid_warn
raise
when :strict2
raise
when :strict
raise
when :warn
parse_context.warnings << e
end
lax_parse(markup)
end
def parse_with_selected_parser(markup)
parse_markup(markup)
case parse_context.error_mode
when :rigid then rigid_warn && strict2_parse_with_error_context(markup)
when :strict2 then strict2_parse_with_error_context(markup)
when :strict then strict_parse_with_error_context(markup)
when :lax then lax_parse(markup)
when :warn
begin
strict2_parse_with_error_context(markup)
rescue SyntaxError => e
parse_context.warnings << e
lax_parse(markup)
end
end
end
def strict2_mode?
parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid
end
private
def rigid_warn
Deprecations.warn(':rigid', ':strict2')
end
def strict2_parse_with_error_context(markup)
strict2_parse(markup)
rescue SyntaxError => e
e.line_number = line_number
e.markup_context = markup_context(markup)
raise e
end
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
def markup_context(markup)
"in \"#{markup.strip}\""
+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
+3 -3
View File
@@ -2,15 +2,15 @@
module Liquid
class RangeLookup
def self.create(start_obj, end_obj, start_markup = nil, end_markup = nil)
def self.parse(start_markup, end_markup, string_scanner, cache = nil)
start_obj = Expression.parse(start_markup, string_scanner, cache)
end_obj = Expression.parse(end_markup, string_scanner, cache)
if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate)
new(start_obj, end_obj)
else
begin
start_obj.to_i..end_obj.to_i
rescue NoMethodError
start_markup = start_obj.to_s unless start_markup
end_markup = end_obj.to_s unless end_markup
invalid_expr = start_markup unless start_obj.respond_to?(:to_i)
invalid_expr ||= end_markup unless end_obj.respond_to?(:to_i)
if invalid_expr
+22 -5
View File
@@ -2,24 +2,39 @@
module Liquid
class ResourceLimits
attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit
attr_reader :render_score, :assign_score
attr_accessor :render_length_limit,
:render_score_limit,
:assign_score_limit,
:cumulative_render_score_limit,
:cumulative_assign_score_limit
attr_reader :render_score,
:assign_score,
:cumulative_render_score,
:cumulative_assign_score
def initialize(limits)
@render_length_limit = limits[:render_length_limit]
@render_score_limit = limits[:render_score_limit]
@assign_score_limit = limits[:assign_score_limit]
@render_length_limit = limits[:render_length_limit]
@render_score_limit = limits[:render_score_limit]
@assign_score_limit = limits[:assign_score_limit]
@cumulative_render_score_limit = limits[:cumulative_render_score_limit]
@cumulative_assign_score_limit = limits[:cumulative_assign_score_limit]
@cumulative_render_score = 0
@cumulative_assign_score = 0
reset
end
def increment_render_score(amount)
@render_score += amount
@cumulative_render_score += amount
raise_limits_reached if @render_score_limit && @render_score > @render_score_limit
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
end
def increment_assign_score(amount)
@assign_score += amount
@cumulative_assign_score += amount
raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end
# update either render_length or assign_score based on whether or not the writes are captured
@@ -47,6 +62,8 @@ module Liquid
@reached_limit = false
@last_capture_length = nil
@render_score = @assign_score = 0
raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit
raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit
end
def with_capture
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Liquid
# @liquid_public_docs
# @liquid_type object
# @liquid_name self
# @liquid_summary
# Provides access to variables through the current scope chain.
# @liquid_description
# The `self` object resolves variables through the normal lookup hierarchy
# (local > file > global) without exposing filters, interrupts, errors,
# or other context internals. It's used when bare bracket notation
# (`['variable']`) needs to be replaced with an explicit variable lookup.
#
# If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`),
# then the local value takes precedence over the `self` object.
# @liquid_access global
class SelfDrop < Drop
def initialize(self_context)
super()
@self_context = self_context
end
def [](key)
@self_context.find_variable(key)
rescue UndefinedVariable
nil
end
def key?(key)
@self_context.variable_defined?(key)
end
def to_liquid
self
end
def ==(other)
other.is_a?(SelfDrop) && other.self_context.equal?(@self_context)
end
alias_method :eql?, :==
def hash
@self_context.object_id.hash
end
protected
attr_reader :self_context
undef context=
end
end
+29 -7
View File
@@ -8,10 +8,19 @@ module Liquid
MAX_I32 = (1 << 31) - 1
private_constant :MAX_I32
MIN_I64 = -(1 << 63)
MAX_I64 = (1 << 63) - 1
I64_RANGE = MIN_I64..MAX_I64
private_constant :MIN_I64, :MAX_I64, :I64_RANGE
supports_64bit_indices = begin
[][1 << 33, 1 << 33]
true
rescue RangeError
false
end
INDEX_RANGE = if supports_64bit_indices
(-(1 << 63))..((1 << 63) - 1)
else
(-(1 << 31))..((1 << 31) - 1)
end
private_constant :INDEX_RANGE
HTML_ESCAPE = {
'&' => '&amp;',
@@ -214,11 +223,11 @@ module Liquid
Utils.to_s(input).slice(offset, length) || ''
end
rescue RangeError
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
raise # unexpected error
end
offset = offset.clamp(I64_RANGE)
length = length.clamp(I64_RANGE)
offset = offset.clamp(INDEX_RANGE)
length = length.clamp(INDEX_RANGE)
retry
end
end
@@ -293,6 +302,19 @@ module Liquid
input.split(pattern)
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
# @liquid_summary
# Removes leading and trailing whitespace and collapses consecutive whitespace to a single space.
# @liquid_syntax string | squish
# @liquid_return [string]
def squish(input)
return if input.nil?
Utils.to_s(input).strip.gsub(/\s+/, ' ')
end
# @liquid_public_docs
# @liquid_type filter
# @liquid_category string
+10
View File
@@ -65,5 +65,15 @@ module Liquid
def blank?
false
end
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)
end
end
end
+4
View File
@@ -7,6 +7,8 @@ require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
require_relative "tags/let"
require_relative "tags/set"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
@@ -37,6 +39,8 @@ module Liquid
'case' => Case,
'ifchanged' => Ifchanged,
'assign' => Assign,
'let' => Let,
'set' => SetTag,
'for' => For,
'#' => InlineComment,
'break' => Break,
+30 -4
View File
@@ -18,6 +18,8 @@ module Liquid
# @liquid_syntax_keyword variable_name The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Assign < Tag
include ParserSwitching
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
# @api private
@@ -29,6 +31,12 @@ module Liquid
def initialize(tag_name, markup, parse_context)
super
raise SyntaxError, "assign is not allowed in closure bodies" if parse_context.in_closure?
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
@from = Variable.new(Regexp.last_match(2), parse_context)
@@ -37,10 +45,29 @@ module Liquid
end
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
unless markup =~ Syntax
self.class.raise_syntax_error(parse_context)
end
lhs = Regexp.last_match(1).strip
rhs = Regexp.last_match(2)
p = @parse_context.new_parser(lhs)
@to = p.consume(:id)
p.consume(:end_of_string)
@from = Variable.new(rhs, parse_context)
end
def render_to_output_buffer(context, output)
val = @from.render(context)
context.scopes.last[@to] = val
context.resource_limits.increment_assign_score(assign_score_of(val))
context.resource_limits.increment_assign_score(self.class.assign_score_of(val))
output
end
@@ -48,9 +75,8 @@ module Liquid
true
end
private
def assign_score_of(val)
# @api private
def self.assign_score_of(val)
if val.instance_of?(String)
val.bytesize
elsif val.instance_of?(Array)
+18
View File
@@ -20,10 +20,18 @@ module Liquid
# @liquid_syntax_keyword variable The name of the variable being created.
# @liquid_syntax_keyword value The value you want to assign to the variable.
class Capture < Block
include ParserSwitching
Syntax = /(#{VariableSignature}+)/o
attr_reader :to
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@to = Regexp.last_match(1)
else
@@ -31,6 +39,16 @@ module Liquid
end
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@to = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
context.resource_limits.with_capture do
capture_output = render(context)
+40 -7
View File
@@ -23,6 +23,9 @@ 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)
@@ -83,33 +86,63 @@ module Liquid
private
def parse_markup(markup)
def strict2_parse(markup)
parser = @parse_context.new_parser(markup)
@left = parser.expression
@left = safe_parse_expression(parser)
parser.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@left = parse_expression(Regexp.last_match(1))
else
raise SyntaxError, options[:locale].t("errors.syntax.case")
end
end
def record_when_condition(markup)
body = new_body
parse_when(markup, body)
if strict2_mode?
parse_strict2_when(markup, body)
else
parse_lax_when(markup, body)
end
end
def parse_when(markup, body)
def parse_strict2_when(markup, body)
parser = @parse_context.new_parser(markup)
loop do
expr = BinaryExpression.new(@left, '==', parser.equality)
block = Condition.new(expr)
expr = Condition.parse_expression(parse_context, parser.expression, safe: true)
block = Condition.new(@left, '==', expr)
block.attach(body)
@blocks << block
break unless parser.consume?(:logical) == 'or' || parser.consume?(:comma)
break unless parser.id?('or') || parser.consume?(:comma)
end
parser.consume(:end_of_string)
end
def parse_lax_when(markup, body)
while markup
unless markup =~ WhenSyntax
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when")
end
markup = Regexp.last_match(2)
block = Condition.new(@left, '==', Condition.parse_expression(parse_context, Regexp.last_match(1)))
block.attach(body)
@blocks << block
end
end
def record_else_condition(markup)
unless markup.strip.empty?
raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_else")
+35 -4
View File
@@ -15,6 +15,8 @@ module Liquid
# @liquid_syntax
# {% cycle string, string, ... %}
class Cycle < Tag
SimpleSyntax = /\A#{QuotedFragment}+/o
NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om
UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/
attr_reader :variables
@@ -54,21 +56,21 @@ module Liquid
private
# cycle [name:] expression(, expression)*
def parse_markup(markup)
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variables = []
raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string)
first_expression = p.expression
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(p.expression)
@variables << maybe_dup_lookup(safe_parse_expression(p))
else
# cycle expr1, expr2, ...
@variables << maybe_dup_lookup(first_expression)
@@ -78,7 +80,7 @@ module Liquid
while p.consume?(:comma)
break if p.look(:end_of_string)
@variables << maybe_dup_lookup(p.expression)
@variables << maybe_dup_lookup(safe_parse_expression(p))
end
p.consume(:end_of_string)
@@ -89,6 +91,35 @@ module Liquid
end
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
case markup
when NamedSyntax
@variables = variables_from_string(Regexp.last_match(2))
@name = parse_expression(Regexp.last_match(1))
@is_named = true
when SimpleSyntax
@variables = variables_from_string(markup)
@name = @variables.to_s
@is_named = !@name.match?(UNNAMED_CYCLE_PATTERN)
else
raise SyntaxError, options[:locale].t("errors.syntax.cycle")
end
end
def variables_from_string(markup)
markup.split(',').collect do |var|
var =~ /\s*(#{QuotedFragment})\s*/o
next unless Regexp.last_match(1)
var = parse_expression(Regexp.last_match(1))
maybe_dup_lookup(var)
end.compact
end
# For backwards compatibility, whenever a lookup is used in an unnamed cycle,
# we make it so that the @variables.to_s produces different strings for cycles
# called with the same arguments (since @variables.to_s is used as the cycle counter key)
+16
View File
@@ -23,13 +23,29 @@ module Liquid
# {% decrement variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
class Decrement < Tag
include ParserSwitching
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
@variable_name = markup.strip
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@variable_name = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
+55 -23
View File
@@ -25,6 +25,8 @@ module Liquid
# @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)
@@ -36,15 +38,10 @@ module Liquid
end
def parse(tokens)
if parse_body(@for_block, tokens)
parse_body(@else_block, tokens)
parse_context.with_lexical_scope do
parse_loop(tokens)
@has_local_declarations = parse_context.local_declarations?
end
if blank?
@else_block&.remove_blank_strings
@for_block.remove_blank_strings
end
@else_block&.freeze
@for_block.freeze
end
def nodelist
@@ -70,15 +67,30 @@ 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_string
@collection_name = p.unsafe_parse_expression(collection_name)
collection_name, collection_markup = p.expression_with_markup
@collection_name = parse_expression(collection_name, safe: true)
@name = "#{@variable_name}-#{collection_name}"
@name = "#{@variable_name}-#{collection_markup}"
@reversed = p.id?('reversed')
while p.look(:comma) || p.look(:id)
@@ -87,13 +99,29 @@ module Liquid
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute")
end
p.consume(:colon)
set_attribute(attribute, p)
set_attribute(attribute, p.expression, safe: true)
end
p.consume(:end_of_string)
end
private
def parse_loop(tokens)
if parse_body(@for_block, tokens)
parse_body(@else_block, tokens)
end
if blank?
@else_block&.remove_blank_strings
@for_block.remove_blank_strings
end
@else_block&.freeze
@for_block.freeze
end
def strict2_parse(markup)
strict_parse(markup)
end
def collection_segment(context)
offsets = context.registers[:for] ||= {}
@@ -140,7 +168,7 @@ module Liquid
segment.each do |item|
context[@variable_name] = item
@for_block.render_to_output_buffer(context, output)
render_iteration(context, output)
loop_vars.send(:increment!)
# Handle any interrupts if they exist.
@@ -157,26 +185,30 @@ module Liquid
output
end
def set_attribute(key, p)
expr = p.expression_string
def render_iteration(context, output)
return @for_block.render_to_output_buffer(context, output) unless @has_local_declarations
context.stack { @for_block.render_to_output_buffer(context, output) }
end
def set_attribute(key, expr, safe: false)
case key
when 'offset'
@from = if expr == 'continue'
:continue
else
p.unsafe_parse_expression(expr)
parse_expression(expr, safe: safe)
end
when 'limit'
@limit = p.unsafe_parse_expression(expr)
@limit = parse_expression(expr, safe: safe)
end
end
def render_else(context, output)
if @else_block
@else_block.render_to_output_buffer(context, output)
else
output
end
return output unless @else_block
return @else_block.render_to_output_buffer(context, output) unless @has_local_declarations
context.stack { @else_block.render_to_output_buffer(context, output) }
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
+55 -2
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)
@@ -62,6 +66,10 @@ module Liquid
private
def strict2_parse(markup)
strict_parse(markup)
end
def push_block(tag, markup)
block = if tag == 'else'
ElseCondition.new
@@ -73,13 +81,58 @@ module Liquid
block.attach(new_body)
end
def parse_markup(markup)
def parse_expression(markup, safe: false)
Condition.parse_expression(parse_context, markup, safe: safe)
end
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 = Condition.new(p.expression)
condition = parse_binary_comparisons(p)
p.consume(:end_of_string)
condition
end
def parse_binary_comparisons(p)
condition = parse_comparison(p)
first_condition = condition
while (op = p.id?('and') || p.id?('or'))
child_condition = parse_comparison(p)
condition.send(op, child_condition)
condition = child_condition
end
first_condition
end
def parse_comparison(p)
a = parse_expression(p.expression, safe: true)
if (op = p.consume?(:comparison))
b = parse_expression(p.expression, safe: true)
Condition.new(a, op, b)
else
Condition.new(a)
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
@node.blocks
+39 -4
View File
@@ -20,6 +20,10 @@ module Liquid
class Include < Tag
prepend Tag::Disableable
FOR = 'for'
SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
Syntax = SYNTAX
attr_reader :template_name_expr, :variable_name_expr, :attributes
def initialize(tag_name, markup, options)
@@ -81,12 +85,18 @@ module Liquid
alias_method :parse_context, :options
private :parse_context
def parse_markup(markup)
def for_loop?
@is_for_loop
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = p.expression
@variable_name_expr = p.expression if p.id?("for") || p.id?("with")
@template_name_expr = safe_parse_expression(p)
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)
@@ -94,13 +104,38 @@ module Liquid
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = p.expression
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ SYNTAX
template_name = Regexp.last_match(1)
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
else
raise SyntaxError, options[:locale].t("errors.syntax.include")
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[
+16
View File
@@ -23,13 +23,29 @@ module Liquid
# {% increment variable_name %}
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
class Increment < Tag
include ParserSwitching
attr_reader :variable_name
def initialize(tag_name, markup, options)
super
parse_with_selected_parser(markup)
end
def lax_parse(markup)
@variable_name = markup.strip
end
def strict_parse(markup)
lax_parse(markup)
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup.strip)
@variable_name = p.consume(:id)
p.consume(:end_of_string)
end
def render_to_output_buffer(context, output)
counter_environment = context.environments.first
value = counter_environment[@variable_name] || 0
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Liquid
class Let < Block
attr_reader :to, :from, :parameters, :closure_body
def initialize(tag_name, markup, parse_context)
super
parse_context.require_closures!
parser = parse_context.new_parser(markup)
@to = parser.consume(:id)
if parser.consume?(:assign)
raise SyntaxError, "Expected let initializer" if parser.look(:end_of_string)
@from = Variable.new(parser, parse_context)
else
@parameters = parser.parameters.freeze
parser.consume(:end_of_string)
end
parse_context.declare_local(@to)
end
def parse(tokens)
return unless body_form?
@closure_body = parse_closure_body(tokens, parameters: @parameters)
end
def body_form?
@from.nil?
end
def nodelist
body_form? ? super : Const::EMPTY_ARRAY
end
def render_to_output_buffer(context, output)
value = body_form? ? @closure_body.bind(context) : @from.render(context)
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
context[@to] = value
output
end
def blank?
true
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
@node.body_form? ? [@node.closure_body] : [@node.from]
end
end
end
end
+29 -6
View File
@@ -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"
@@ -84,12 +85,12 @@ module Liquid
end
# render (string) (with|for expression)? (as id)? (key: value)*
def parse_markup(markup)
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@template_name_expr = template_name(p)
@template_name_expr = parse_expression(strict2_template_name(p), safe: true)
with_or_for = p.id?("for") || p.id?("with")
@variable_name_expr = p.expression if with_or_for
@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)
@@ -99,15 +100,37 @@ module Liquid
while p.look(:id)
key = p.consume
p.consume(:colon)
@attributes[key] = p.expression
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def template_name(p)
p.string
def strict2_template_name(p)
p.consume(:string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX
template_name = Regexp.last_match(1)
with_or_for = Regexp.last_match(3)
variable_name = Regexp.last_match(4)
@alias_name = Regexp.last_match(6)
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
@template_name_expr = parse_expression(template_name)
@is_for_loop = (with_or_for == FOR)
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
module Liquid
class SetTag < Tag
attr_reader :to, :from
def initialize(tag_name, markup, parse_context)
super
parse_context.require_closures!
parser = parse_context.new_parser(markup)
@to = parser.consume(:id)
parser.consume(:assign)
raise SyntaxError, "Expected set initializer" if parser.look(:end_of_string)
@from = Variable.new(parser, parse_context)
end
def render_to_output_buffer(context, output)
value = if context.variable_defined?(@to)
@from.render(context)
else
context.stack(@to => nil) { @from.render(context) }
end
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
context.set_local(@to, value)
output
end
def blank?
true
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.from]
end
end
end
end
+33 -4
View File
@@ -24,6 +24,7 @@ module Liquid
# @liquid_optional_param offset: [number] The 1-based index to start iterating at.
# @liquid_optional_param range [untyped] A custom numeric range to iterate over.
class TableRow < Block
Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o
ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze
attr_reader :variable_name, :collection_name, :attributes
@@ -33,7 +34,14 @@ module Liquid
parse_with_selected_parser(markup)
end
def parse_markup(markup)
def parse(tokens)
parse_context.with_lexical_scope do
super
@has_local_declarations = parse_context.local_declarations?
end
end
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@variable_name = p.consume(:id)
@@ -42,7 +50,7 @@ module Liquid
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in")
end
@collection_name = p.expression
@collection_name = safe_parse_expression(p)
p.consume?(:comma)
@@ -54,13 +62,30 @@ module Liquid
end
p.consume(:colon)
@attributes[key] = p.expression
@attributes[key] = safe_parse_expression(p)
p.consume?(:comma)
end
p.consume(:end_of_string)
end
def strict_parse(markup)
lax_parse(markup)
end
def lax_parse(markup)
if markup =~ Syntax
@variable_name = Regexp.last_match(1)
@collection_name = parse_expression(Regexp.last_match(2))
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = parse_expression(value)
end
else
raise SyntaxError, options[:locale].t("errors.syntax.table_row")
end
end
def render_to_output_buffer(context, output)
(collection = context.evaluate(@collection_name)) || (return '')
@@ -81,7 +106,11 @@ module Liquid
context[@variable_name] = item
output << "<td class=\"col#{tablerowloop.col}\">"
super
if @has_local_declarations
context.stack { super }
else
super
end
output << '</td>'
# Handle any interrupts if they exist.
+29 -3
View File
@@ -16,11 +16,25 @@ 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 enforces correct syntax for most tags
# :strict2 enforces correct syntax for all tags
def error_mode=(mode)
Deprecations.warn("Template.error_mode=", "Environment#error_mode=")
Environment.default.error_mode = mode
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
@@ -137,8 +151,10 @@ module Liquid
c
when Liquid::Drop
drop = args.shift
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop = args.shift
c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop.context = c if drop.respond_to?(:context=)
c
when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when nil
@@ -173,12 +189,20 @@ module Liquid
context.template_name ||= name
previous_error_mode = context.registers.static[:template_error_mode]
context.registers.static[:template_error_mode] = @error_mode
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
rescue Liquid::MemoryError => e
context.handle_error(e)
ensure
if previous_error_mode
context.registers.static[:template_error_mode] = previous_error_mode
else
context.registers.static.delete(:template_error_mode)
end
@errors = context.errors
end
end
@@ -209,6 +233,8 @@ module Liquid
ParseContext.new(opts)
end
@warnings = parse_context.warnings
@error_mode = parse_context.error_mode
parse_context
end
+75 -9
View File
@@ -25,33 +25,79 @@ module Liquid
include ParserSwitching
def initialize(markup, parse_context)
@markup = markup
@markup = markup.is_a?(Parser) ? markup.remaining_markup : markup
@name = nil
@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
@markup
end
def ==(other)
self.class == other.class && name == other.name && filters == other.filters
end
def markup_context(markup)
markup = @markup if markup.is_a?(Parser)
"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 << lax_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 = p.expression
@filters << parse_filter_expressions(p) while p.consume?(:pipe)
@name = parse_context.safe_parse_expression(p)
while p.consume?(:pipe)
filtername = p.consume(:id)
filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY
@filters << lax_parse_filter_expressions(filtername, filterargs)
end
p.consume(:end_of_string)
end
def strict2_parse(markup)
@filters = []
p = markup.is_a?(Parser) ? markup : @parse_context.new_parser(markup)
return if p.look(:end_of_string)
@name = parse_context.safe_parse_expression(p)
@filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe)
p.consume(:end_of_string)
end
def parse_filterargs(p)
# first argument
filterargs = [p.argument]
# followed by comma separated others
filterargs << p.argument while p.consume?(:comma)
filterargs
end
def render(context)
obj = context.evaluate(@name)
@@ -64,6 +110,10 @@ module Liquid
end
def render_to_output_buffer(context, output)
if @name.is_a?(CallExpression) && @filters.empty? && context.global_filter.nil?
return @name.render_to_output_buffer(context, output)
end
obj = render(context)
render_obj_to_output(obj, output)
output
@@ -92,6 +142,22 @@ module Liquid
private
def lax_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 = [filter_name, filter_args]
result << keyword_args if keyword_args
result
end
# Surprisingly, positional and keyword arguments can be mixed.
#
# filter = filtername [":" filterargs?]
@@ -99,7 +165,7 @@ module Liquid
# argument = (positional_argument | keyword_argument)
# positional_argument = expression
# keyword_argument = id ":" expression
def parse_filter_expressions(p)
def strict2_parse_filter_expressions(p)
filtername = p.consume(:id)
filter_args = []
keyword_args = {}
@@ -121,10 +187,10 @@ module Liquid
if p.look(:id) && p.look(:colon, 1)
key = p.consume(:id)
p.consume(:colon)
value = p.expression
value = parse_context.safe_parse_expression(p)
keyword_arguments[key] = value
else
positional_arguments << p.expression
positional_arguments << parse_context.safe_parse_expression(p)
end
end
@@ -146,7 +212,7 @@ module Liquid
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.name] + @node.filters.flatten
[@node.name] + @node.filters.flatten.flat_map { |value| value.is_a?(Hash) ? value.values : value }
end
end
end
+13 -9
View File
@@ -7,6 +7,10 @@ module Liquid
attr_reader :name, :lookups
def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil)
new(markup, string_scanner, cache)
end
def initialize(markup, string_scanner = StringScanner.new(""), cache = nil)
lookups = markup.scan(VariableParser)
name = lookups.shift
@@ -17,10 +21,12 @@ module Liquid
cache,
)
end
@name = name
command_flags = 0
@lookups = lookups
@command_flags = 0
lookups.each_index do |i|
@lookups.each_index do |i|
lookup = lookups[i]
if lookup&.start_with?('[') && lookup&.end_with?(']')
lookups[i] = Expression.parse(
@@ -29,17 +35,15 @@ module Liquid
cache,
)
elsif COMMAND_METHODS.include?(lookup)
command_flags |= 1 << i
@command_flags |= 1 << i
end
end
new(name, lookups, command_flags)
end
def initialize(name, lookups, command_flags)
@name = name
@lookups = lookups
@command_flags = command_flags
# @api private
def add_lookup(key, command: false)
@command_flags |= 1 << @lookups.length if command && COMMAND_METHODS.include?(key)
@lookups << key
end
def lookup_command?(lookup_index)
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.11.0"
VERSION = "5.13.0"
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
+21 -32
View File
@@ -6,7 +6,7 @@ require "benchmark/ips"
require 'liquid'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
RubyVM::YJIT.enable
STRING_MARKUPS = [
"\"foo\"",
@@ -45,14 +45,22 @@ NUMBER_MARKUPS = [
RANGE_MARKUPS = [
"(1..30)",
"(1...30)",
"(1..30..5)",
"(1.0...30.0)",
"(1.........30)",
"(1..foo)",
"(foo..30)",
"(foo..bar)",
"(foo...bar...100)",
"(foo...bar...100.0)",
]
LITERAL_MARKUPS = [
nil,
'nil',
'null',
'',
'true',
'false',
'blank',
@@ -67,39 +75,20 @@ MARKUPS = {
"range" => RANGE_MARKUPS,
}
module Liquid
Benchmark.ips do |x|
x.config(time: 5, warmup: 5)
Benchmark.ips do |x|
x.config(time: 5, warmup: 5)
ss = StringScanner.new('')
MARKUPS.each do |type, markups|
x.report("#{type} - Liquid::Expression#parse") do
markups.each do |markup|
ss.string = markup
Expression.parse(markup, ss)
end
end
x.report("#{type} - Liquid::Parser#expression") do
markups.each do |markup|
ss.string = markup
Parser.new(ss).expression
end
end
x.report("#{type} - Liquid::Expression.parse(Parser#expression_string)") do
markups.each do |markup|
ss.string = markup
Expression.parse(Parser.new(ss).expression_string, ss)
end
end
end
x.report("Liquid::Expression#parse: all") do
MARKUPS.values.flatten.each do |markup|
Expression.parse(markup)
MARKUPS.each do |type, markups|
x.report("Liquid::Expression#parse: #{type}") do
markups.each do |markup|
Liquid::Expression.parse(markup)
end
end
end
x.report("Liquid::Expression#parse: all") do
MARKUPS.values.flatten.each do |markup|
Liquid::Expression.parse(markup)
end
end
end
+3 -4
View File
@@ -6,7 +6,7 @@ require "benchmark/ips"
require 'liquid'
RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
RubyVM::YJIT.enable
EXPRESSIONS = [
"foo[1..2].baz",
@@ -31,12 +31,11 @@ EXPRESSIONS = [
Benchmark.ips do |x|
x.config(time: 10, warmup: 5)
ss = StringScanner.new('')
x.report("Liquid::Lexer#tokenize") do
EXPRESSIONS.each do |expr|
ss.string = expr
Liquid::Lexer.tokenize(ss)
l = Liquid::Lexer.new(expr)
l.tokenize
end
end
+16 -3
View File
@@ -6,14 +6,24 @@
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
# Run core Liquid specs
config.features = [:core]
config.missing_features = [
:activesupport,
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
@@ -28,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+46
View File
@@ -0,0 +1,46 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with lax parsing mode
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.missing_features = [
:activesupport,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Default to lax mode while still honoring specs that explicitly set error_mode.
options = { error_mode: :lax }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
# 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],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+15 -3
View File
@@ -7,14 +7,23 @@
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
config.features = [:core, :activesupport]
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
@@ -29,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+17 -3
View File
@@ -1,6 +1,6 @@
# frozen_string_literal: true
# Liquid Spec Adapter for Shopify/liquid with YJIT + ActiveSupport
# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport
#
# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb
@@ -13,13 +13,24 @@ end
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.features = [:core, :activesupport]
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force strict mode
options = { error_mode: :strict }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
@@ -31,9 +42,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
module LiquidSpecAdapterHelper
extend self
def resource_limits(render_options)
return unless render_options[:resource_limits]
Liquid::ResourceLimits.new({}).tap do |limits|
render_options[:resource_limits].each do |key, value|
limits.public_send(:"#{key}=", value)
end
end
end
def with_frozen_time(&block)
original_tz = ENV['TZ']
ENV['TZ'] = 'UTC'
Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block)
ensure
ENV['TZ'] = original_tz
end
end
+44 -15
View File
@@ -35,28 +35,17 @@ class AssignTest < Minitest::Test
)
end
def test_assign_boolean_expression_assignment
assert_template_result(
'it rendered',
<<~LIQUID,
{%- assign should_render = a == 0 or (b == 1 and c == 2) -%}
{%- if should_render -%}
it rendered
{%- endif -%}
LIQUID
{ 'b' => 1, 'c' => 2 },
)
end
def test_assign_syntax_error
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 close_round 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
@@ -108,6 +97,46 @@ class AssignTest < Minitest::Test
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
end
def test_assign_with_valid_identifier_in_strict2
assert_template_result("hello", "{% assign my_var = 'hello' %}{{ my_var }}", error_mode: :strict2)
end
def test_assign_with_hyphen_in_strict2
assert_template_result("hello", "{% assign my-var = 'hello' %}{{ my-var }}", error_mode: :strict2)
end
def test_assign_rejects_parentheses_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign (a(b(c) = 1234 %}", error_mode: :strict2)
end
end
def test_assign_rejects_brackets_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign [x.y] = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_rejects_dot_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign a.b = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_rejects_numeric_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% assign 1abc = 'hello' %}", error_mode: :strict2)
end
end
def test_assign_allows_invalid_names_in_lax
assert_template_result("1234", "{% assign (a(b(c) = 1234 %}{{ self['(a(b(c)'] }}", error_mode: :lax)
end
def test_assign_with_filter_in_strict2
assert_template_result("HELLO", "{% assign my_var = 'hello' | upcase %}{{ my_var }}", error_mode: :strict2)
end
private
class ObjectWrapperDrop < Liquid::Drop
@@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'test_helper'
class BlankBodyErrorHandlingTest < Minitest::Test
COMPARISON_ERROR = 'Liquid error (line 1): comparison of Integer with String failed'
INVALID_INTEGER_ERROR = 'Liquid error (line 1): invalid integer'
def render_inline(source, error_mode:, assigns: {})
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render(assigns, render_errors: true)
end
def assert_render_raises(source, error_mode:, assigns: {}, message: nil)
error = assert_raises(Liquid::ArgumentError) do
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns)
end
assert_includes(error.message, message) if message
end
def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal('', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode))
end
end
def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal('', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode))
end
end
def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict
[:lax, :strict].each do |mode|
assert_equal('', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }))
end
end
def test_strict2_blank_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2))
end
def test_strict2_whitespace_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2))
end
def test_strict2_assign_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2))
end
def test_strict2_comment_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2))
end
def test_strict2_capture_if_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2))
end
def test_strict2_blank_unless_body_shows_inline_error_text
assert_equal(COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2))
end
def test_strict2_blank_for_body_shows_inline_error_text
assert_equal(INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }))
end
def test_nonblank_bodies_show_inline_error_text_in_all_modes
[:lax, :strict, :strict2].each do |mode|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode))
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode))
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode))
end
end
def test_raised_errors_are_not_swallowed_by_blank_if_body
[:lax, :strict, :strict2].each do |mode|
assert_render_raises('{% if 5 > "x" %}{% endif %}', error_mode: mode, message: 'comparison of Integer with String failed')
end
end
def test_raised_errors_are_not_swallowed_by_blank_for_body
[:lax, :strict, :strict2].each do |mode|
assert_render_raises('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }, message: 'invalid integer')
end
end
end
+36 -1
View File
@@ -6,7 +6,11 @@ class CaptureTest < Minitest::Test
include Liquid
def test_captures_block_content_in_variable
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {})
assert_template_result("test string", "{% capture var %}test string{% endcapture %}{{var}}", {})
end
def test_captures_block_content_in_quoted_variable_in_lax
assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}, error_mode: :lax)
end
def test_capture_with_hyphen_in_variable_name
@@ -49,4 +53,35 @@ class CaptureTest < Minitest::Test
t.render!
assert_equal(9, t.resource_limits.assign_score)
end
def test_capture_with_valid_identifier_in_strict2
assert_template_result("hello", "{% capture my_var %}hello{% endcapture %}{{ my_var }}", error_mode: :strict2)
end
def test_capture_with_hyphen_in_strict2
assert_template_result("hello", "{% capture my-var %}hello{% endcapture %}{{ my-var }}", error_mode: :strict2)
end
def test_capture_rejects_parentheses_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_rejects_dot_in_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture a.b %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_rejects_numeric_variable_name_in_strict2
assert_raises(Liquid::SyntaxError) do
Liquid::Template.parse("{% capture 1abc %}hello{% endcapture %}", error_mode: :strict2)
end
end
def test_capture_allows_invalid_names_in_lax
t = Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :lax)
assert_equal("(x[y", t.root.nodelist.first.to)
end
end
+7 -5
View File
@@ -296,8 +296,8 @@ class ContextTest < Minitest::Test
end
def test_access_variable_with_hash_notation
assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
assert_template_result('baz', '{{ foo }}', { "foo" => "baz" })
assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
end
def test_access_hashes_with_hash_access_variables
@@ -632,9 +632,11 @@ 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)
with_error_modes(:strict) do
context = Context.new
context.key?('unknown')
assert_empty(context.errors)
end
end
def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled
+56 -6
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_modes(: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
@@ -221,8 +268,11 @@ class ErrorHandlingTest < Minitest::Test
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render
assert_equal("Liquid error: comparison of Integer with String failed0", output)
output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
assert_equal("0", output)
source = "{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}"
[:lax, :warn, :strict].each do |mode|
assert_equal("0", Liquid::Template.parse(source, error_mode: mode).render)
end
assert_equal("Liquid error: comparison of Integer with String failed0", Liquid::Template.parse(source, error_mode: :strict2).render)
end
def test_syntax_error_is_raised_with_template_name
+28 -10
View File
@@ -27,6 +27,11 @@ class ExpressionTest < Minitest::Test
assert_template_result("-17.42", "{{ -17.42 }}")
assert_template_result("2.5", "{{ 2.5 }}")
with_error_modes(:lax) do
assert_expression_result(0.0, "0.....5")
assert_expression_result(0.0, "-0..1")
end
assert_expression_result(1.5, "1.5")
# this is a unfortunate quirky behavior of Liquid
@@ -46,11 +51,24 @@ class ExpressionTest < Minitest::Test
"{{ (false..true) }}",
)
assert_match_syntax_error(
"Liquid syntax error (line 1): Invalid expression type '1..2' in range expression",
"Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression",
"{{ ((1..2)..3) }}",
)
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' - }}",
error_mode: :lax,
)
end
def test_expression_cache
skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled
@@ -67,7 +85,7 @@ class ExpressionTest < Minitest::Test
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
[],
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
@@ -91,7 +109,7 @@ class ExpressionTest < Minitest::Test
cache = parse_context.instance_variable_get(:@expression_cache)
assert_equal(
[],
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
@@ -112,7 +130,7 @@ class ExpressionTest < Minitest::Test
Liquid::Template.parse(template, expression_cache: cache).render
assert_equal(
[],
["1", "2", "x", "y"],
cache.to_a.map { _1[0] }.sort,
)
end
@@ -134,30 +152,30 @@ class ExpressionTest < Minitest::Test
assert(parse_context.instance_variable_get(:@expression_cache).nil?)
end
def test_parser_expression_with_variable_lookup
def test_safe_parse_with_variable_lookup
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('product.title')
result = parser.expression
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_parser_expression_with_number
def test_safe_parse_with_number
parse_context = Liquid::ParseContext.new
parser = parse_context.new_parser('42')
result = parser.expression
result = Liquid::Expression.safe_parse(parser)
assert_equal(42, result)
end
def test_parser_expression_raises_syntax_error_for_invalid_expression
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
parser.expression
Liquid::Expression.safe_parse(parser)
end
assert_match(/is not a valid expression/, error.message)
+547
View File
@@ -0,0 +1,547 @@
# frozen_string_literal: true
require 'test_helper'
class LetClosureTest < Minitest::Test
def test_value_binding_and_filtered_initializer
assert_render('HELLO:3', "{% let title = product.title | upcase %}{{ title }}:{% let n = 1 | plus: 2 %}{{ n }}", 'product' => { 'title' => 'hello' })
end
def test_initializer_reads_parent_before_installing_shadow
assert_render('231', '{% let n = 1 %}{% let title(step) %}{% let n = n | plus: step %}{{ n }}{% endlet %}{{ title(1) }}{{ title(2) }}{{ n }}')
end
def test_empty_and_parameterless_bodies
assert_render('ok', '{% let empty %}{% endlet %}{% let title() %}ok{% endlet %}{{ empty() }}{{ title() }}')
end
def test_parameters_are_writable_and_fresh
assert_render('23', '{% let title(n,) %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title(1,) }}{{ title(2) }}')
end
def test_live_captures
assert_render('12', '{% let n = 1 %}{% let title %}{{ n }}{% endlet %}{{ title() }}{% set n = 2 %}{{ title() }}')
end
def test_captured_environments_fall_through_nil_values
context = Liquid::Context.build(environments: [{ 'n' => nil }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_environments_keep_false_values
context = Liquid::Context.build(environments: [{ 'n' => false }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('false/false', parse(source).render!(context))
end
def test_captured_environments_use_hash_defaults
context = Liquid::Context.build(environments: Hash.new('fallback'))
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_environments_fall_through_drops
context = Liquid::Context.build(environments: [Liquid::Drop.new, { 'n' => 'fallback' }])
source = '{{ n }}/{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_static_environments_preserve_fallthrough_and_defaults
context = Liquid::Context.build(environments: [], static_environments: [{ 'n' => nil }, Hash.new('fallback')])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_local_nil_shadows_environment_values
context = Liquid::Context.build(environments: { 'n' => 'dynamic' }, static_environments: { 'n' => 'static' })
source = '{% let n = nil %}{{ n }}/{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('/', parse(source).render!(context))
end
def test_captured_local_self_nil_does_not_become_a_self_drop
source = '{% let self = nil %}{% let show %}{% if self == nil %}nil{% endif %}{% endlet %}{{ show() }}'
assert_render('nil', source)
end
def test_captured_environments_remain_live_and_read_only
values = { 'n' => 1 }
source = '{% let show %}{{ n }}{% set n = n | plus: 10 %}{{ n }}{% endlet %}{{ show() }}{% increment n %}{{ show() }}'
assert_render('1111212', source, values)
assert_equal(2, values['n'])
end
def test_strict_captured_environments_stop_at_nil
context = Liquid::Context.build(environments: [{ 'n' => nil }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('/', parse(source).render!(context, strict_variables: true))
end
def test_strict_captured_environments_reject_missing_keys_before_fallback
source = '{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
context = Liquid::Context.build(environments: [{}, { 'n' => 'fallback' }])
assert_raises(Liquid::UndefinedVariable) { parse(source).render!(context, strict_variables: true) }
end
def test_strict_captured_environments_reject_hash_defaults
context = Liquid::Context.build(environments: Hash.new('fallback'))
source = '{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_raises(Liquid::UndefinedVariable) { parse(source).render!(context, strict_variables: true) }
end
def test_strict_captured_self_lookup_can_fall_back_to_self_drop
source = '{% let show %}{{ self["n"] }}{% endlet %}{{ show() }}'
assert_equal('value', parse(source).render!({ 'n' => 'value' }, strict_variables: true))
end
def test_render_in_closures_inherits_only_static_globals
file_system = StubFileSystem.new('snippet' => '{{ g }}/{{ dynamic }}/{{ local }};')
environment = Liquid::Environment.build(file_system: file_system)
context = Liquid::Context.build(environment: environment, environments: { 'dynamic' => 'D' }, static_environments: { 'g' => 'G' })
source = '{% let local = "L" %}{% render "snippet" %}{% let show %}{% render "snippet" %}{% render "snippet", dynamic: dynamic, local: local %}{% endlet %}{{ show() }}'
assert_equal('G//;G//;G/D/L;', parse(source, environment: environment).render!(context))
end
def test_render_in_closures_uses_globals_despite_local_shadows
file_system = StubFileSystem.new('snippet' => '{{ g }}')
environment = Liquid::Environment.build(file_system: file_system)
context = Liquid::Context.build(environment: environment, static_environments: { 'g' => 'G' })
source = '{% render "snippet" %}/{% let g = nil %}{% let outer %}{% let inner %}{{ g }}{% render "snippet" %}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('G/G', parse(source, environment: environment).render!(context))
end
def test_capture_shadows_are_fresh_and_read_only
assert_render('221', '{% let n = 1 %}{% let title %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title() }}{{ title() }}{{ n }}')
end
def test_set_creates_missing_binding_at_invocation_root
source = '{% let title %}{% for i in (1..2) %}{% set n = n | plus: i %}{% endfor %}{{ n }}{% endlet %}{{ title() }}{{ title() }}'
assert_equal('33', parse(source).render!({}, strict_variables: true))
end
def test_set_searches_owned_control_flow_scopes
source = '{% let title(n) %}{% for i in (1..2) %}{% set n = n | plus: i %}{% set i = i | plus: 10 %}{{ i }},{% endfor %}{{ n }}{% endlet %}{{ title(1) }}'
assert_render('11,12,4', source)
end
def test_set_shadows_capture_at_root_from_inside_loop
source = '{% let n = 1 %}{% let title %}{% for i in (1..2) %}{% set n = n | plus: i %}{% endfor %}{{ n }}{% endlet %}{{ title() }}{{ n }}'
assert_render('41', source)
end
def test_let_shadows_in_loop_and_can_execute_repeatedly
source = '{% let n = 10 %}{% for i in (1..3) %}{% let n = i %}{% set n = n | plus: 1 %}{{ n }}{% endfor %}{{ n }}'
assert_render('23410', source)
end
def test_each_iteration_initializer_reads_outer_binding
source = '{% let n = 10 %}{% for i in (1..2) %}{% let n = n | plus: 1 %}{{ n }},{% endfor %}{{ n }}'
assert_render('11,11,10', source)
end
def test_retained_callbacks_capture_iteration_local_bindings
source = '{% let n = 10 %}{% let first = nil %}{% let second = nil %}{% for i in (1..2) %}{% let n = n | plus: i %}{% let title %}{{ n }}{% endlet %}{% if i == 1 %}{% set first = title %}{% else %}{% set second = title %}{% endif %}{% endfor %}{{ first() }}/{{ second() }}:{{ n }}'
assert_render('11/12:10', source)
end
def test_set_traverses_fresh_iteration_frames_and_persists_at_root
source = '{% let captured = 5 %}{% let title(n) %}{% for i in (1..2) %}{% let step = i %}{% set n = n | plus: step %}{% set missing = missing | plus: step %}{% set captured = captured | plus: step %}{% endfor %}{{ n }}/{{ missing }}/{{ captured }};{% endlet %}{{ title(1) }}{{ title(1) }}{{ captured }}'
assert_equal('4/3/8;4/3/8;5', parse(source).render!({}, strict_variables: true))
end
def test_each_table_row_initializer_reads_outer_binding
source = '{% let n = 10 %}{% tablerow i in (1..2) %}{% let n = n | plus: 1 %}{{ n }}{% endtablerow %}{{ n }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\">11</td><td class=\"col2\">11</td></tr>\n10", source)
end
def test_table_row_callbacks_retain_iteration_local_bindings
source = '{% let first = nil %}{% let second = nil %}{% tablerow i in (1..2) %}{% let n = i %}{% let title %}{{ n }}{% endlet %}{% if i == 1 %}{% set first = title %}{% else %}{% set second = title %}{% endif %}{% endtablerow %}{{ first() }}/{{ second() }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\"></td><td class=\"col2\"></td></tr>\n1/2", source)
end
def test_set_traverses_table_row_frames_to_invocation_root
source = '{% let title(n) %}{% tablerow i in (1..2) %}{% let step = i %}{% set n = n | plus: step %}{% set missing = missing | plus: step %}{% endtablerow %}{{ n }}/{{ missing }}{% endlet %}{{ title(1) }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\"></td><td class=\"col2\"></td></tr>\n4/3", source)
end
def test_empty_loop_branch_has_its_own_let_scope
source = '{% let n = 1 %}{% for i in missing %}{% else %}{% let n = 2 %}{{ n }}{% endfor %}{{ n }}'
assert_render('21', source)
end
def test_table_row_scope_can_shadow_outer_let
source = '{% let n = 10 %}{% tablerow i in (1..2) %}{% let n = i %}{{ n }}{% endtablerow %}{{ n }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n10", source)
end
def test_body_declaration_can_execute_on_every_iteration
assert_render('123', '{% for i in (1..3) %}{% let title %}{{ i }}{% endlet %}{{ title() }}{% endfor %}')
end
def test_set_updates_nearest_owned_let
source = '{% let title(n) %}{% for i in (1..2) %}{% let n = i %}{% set n = n | plus: 10 %}{{ n }},{% endfor %}{{ n }}{% endlet %}{{ title(5) }}'
assert_render('11,12,5', source)
end
def test_nested_closures_have_live_read_only_captures
source = '{% let outer(n) %}{% let inner %}{% set n = n | plus: 10 %}{{ n }}{% endlet %}{% set n = n | plus: 1 %}{{ inner() }}{{ n }}{% endlet %}{{ outer(1) }}{{ outer(2) }}'
assert_render('122133', source)
end
def test_captures_search_all_parents_with_strict_variables
source = '{% let outer %}{% let inner %}{{ title }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('hello', parse(source).render!({ 'title' => 'hello' }, strict_variables: true))
end
def test_repeated_renders_start_with_fresh_bindings
template = parse('{% set n = n | plus: 1 %}{% let title %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title() }}{{ n }}')
2.times { assert_equal('21', template.render!) }
end
def test_repeated_renders_use_current_inputs
template = parse('{% let title = title | upcase %}{% let render_title %}{{ title }}{% endlet %}{{ render_title() }}')
assert_equal('FIRST', template.render!('title' => 'first'))
assert_equal('SECOND', template.render!('title' => 'second'))
end
def test_nil_and_false_bindings_are_owned
source = '{% let title %}{% let n = nil %}{% set n = n | default: 3 %}{% let flag = false %}{% set flag = true %}{{ n }}{{ flag }}{% endlet %}{{ title() }}'
assert_render('3true', source)
end
def test_nested_call_arguments_and_filters
source = '{% let title(text) %}[{{ text }}]{% endlet %}{{ title(title("hello")) | upcase }}'
assert_render('[[HELLO]]', source)
end
def test_calls_in_filter_arguments_conditions_and_dynamic_lookups
source = '{% let key %}title{% endlet %}{% if key() == "title" %}{{ product[key()] | append: key() }}{% endif %}'
assert_render('hellotitle', source, 'product' => { 'title' => 'hello' })
end
def test_calls_in_ranges
assert_render('123', '{% let finish %}3{% endlet %}{% for i in (1..finish()) %}{{ i }}{% endfor %}')
end
def test_callable_ranges_share_loop_continuation
source = '{% let finish %}3{% endlet %}{% for i in (1..finish()) limit: 1 %}{{ i }}{% endfor %}{% for i in ( 1 .. finish( ) ) limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('12', source)
end
def test_callable_dynamic_lookups_share_loop_continuation
source = '{% let key %}items{% endlet %}{% for i in groups[key()] limit: 1 %}{{ i }}{% endfor %}{% for i in groups[ key( ) ] limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('12', source, 'groups' => { 'items' => [1, 2, 3] })
end
def test_callable_collection_loop_names_preserve_syntax
source = '{% let identity(value) %}{{ value }}{% endlet %}{% let key %}items{% endlet %}{% for i in identity("a b") %}{{ forloop.name }}{% endfor %}/{% for i in groups[key()] limit: 1 %}{{ forloop.name }}{% endfor %}/{% for i in (1..identity(3)) limit: 1 %}{{ forloop.name }}{% endfor %}'
assert_render('i-identity("a b")/i-groups[key()]/i-(1..identity(3))', source, 'groups' => { 'items' => [1, 2, 3] })
end
def test_distinct_callable_arguments_have_separate_loop_continuations
source = '{% let finish(value) %}{{ value }}{% endlet %}{% for i in (1..finish(2)) limit: 1 %}{{ i }}{% endfor %}{% for i in (1..finish(3)) limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('11', source)
end
def test_callable_value_can_be_passed_through_let
assert_render('hello', '{% let title %}hello{% endlet %}{% let alias = title %}{{ alias() }}')
end
def test_multiline_liquid
source = <<~LIQUID
{% liquid
let n = 1
let title(step)
set n = n | plus: step
echo n
endlet
echo title(2)
echo n
%}
LIQUID
assert_render("31\n", source)
end
def test_same_scope_redeclarations_are_parse_errors
[
'{% let n = 1 %}{% let n = 2 %}',
'{% let n %}{% endlet %}{% let n = 2 %}',
'{% let title(n) %}{% let n = 1 %}{% endlet %}',
'{% let title %}{% let n = 1 %}{% if false %}{% let n = 2 %}{% endif %}{% endlet %}',
].each do |source|
assert_raises(Liquid::SyntaxError, source) { parse(source) }
end
end
def test_invalid_headers_and_complete_input_validation
[
'{% let %}',
'{% let 1 = 2 %}',
'{% let a.b = 2 %}',
'{% let a == 2 %}',
'{% let a = %}',
'{% let a = 1 junk %}',
'{% let a = 1 | %}',
'{% let a(x) = 2 %}',
'{% let a(x,x) %}{% endlet %}',
'{% let a(,x) %}{% endlet %}',
'{% let a(x,,) %}{% endlet %}',
'{% let a(x) junk %}{% endlet %}',
'{% let a %}',
'{% set %}',
'{% set a.b = 1 %}',
'{% set a == 1 %}',
'{% set a = %}',
'{% set a = 1 junk %}',
'{{ title(,1) }}',
'{{ title(1,,) }}',
'{{ title(1) junk }}',
'{{ title(1 }}',
].each do |source|
assert_raises(Liquid::SyntaxError, source) { parse(source) }
end
end
def test_assign_is_rejected_through_nested_control_flow
[
'{% assign x = 1 %}',
'{% if false %}{% assign x = 1 %}{% endif %}',
'{% for i in (1..2) %}{% unless false %}{% assign x = 1 %}{% endunless %}{% endfor %}',
'{% case n %}{% when 1 %}{% assign x = 1 %}{% endcase %}',
"{% liquid\nif false\nassign x = 1\nendif\n%}",
].each do |body|
error = assert_raises(Liquid::SyntaxError) { parse("{% let title %}#{body}{% endlet %}") }
assert_match('assign is not allowed', error.message)
end
end
def test_assign_still_works_outside_closures
assert_render('12', '{% assign n = 1 %}{% let title %}{{ n }}{% endlet %}{{ title() }}{% assign n = 2 %}{{ title() }}')
end
def test_capture_tag_does_not_mutate_parent_bindings
assert_render('localparent', '{% let n = "parent" %}{% let title %}{% capture n %}local{% endcapture %}{{ n }}{% endlet %}{{ title() }}{{ n }}')
end
def test_counters_in_closures_have_fresh_shadows
source = '{% let n = 5 %}{% let title %}{% increment n %}{{ n }}{% decrement n %}{{ n }}{% endlet %}{{ title() }}{{ title() }}{{ n }}'
assert_render('565556555', source)
end
def test_tags_are_unavailable_in_every_other_mode
[:lax, :warn, :strict, :rigid].each do |mode|
['{% let n = 1 %}', '{% let title %}{% endlet %}', '{% set n = 1 %}'].each do |source|
assert_raises(Liquid::SyntaxError, "#{mode}: #{source}") { Liquid::Template.parse(source, error_mode: mode) }
end
end
end
def test_calls_preserve_legacy_parsing_behavior
[:lax, :warn].each do |mode|
template = Liquid::Template.parse('{{ title(1) }}', error_mode: mode)
assert_equal('', template.render!('title' => 'value'))
end
[:strict, :rigid].each do |mode|
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{{ title(1) }}', error_mode: mode) }
end
end
def test_effective_template_mode_overrides_environment
environment = Liquid::Environment.build(error_mode: :strict)
assert_equal('1', parse('{% let n = 1 %}{{ n }}', environment: environment).render!)
environment = Liquid::Environment.build(error_mode: :strict2)
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{% let n = 1 %}', environment: environment, error_mode: :strict) }
end
def test_effective_partial_mode_enables_feature
environment = Liquid::Environment.build(error_mode: :strict2, file_system: StubFileSystem.new('snippet' => '{% let title %}ok{% endlet %}{{ title() }}'))
template = Liquid::Template.parse('{% render "snippet" %}', environment: environment, error_mode: :strict, include_options_blacklist: [:error_mode])
assert_equal('ok', template.render!)
end
def test_effective_partial_mode_disables_feature
[:lax, :warn, :strict, :rigid].each do |mode|
environment = Liquid::Environment.build(error_mode: mode, file_system: StubFileSystem.new('snippet' => '{% let n = 1 %}'))
template = parse('{% render "snippet" %}', environment: environment, include_options_blacklist: [:error_mode])
assert_raises(Liquid::SyntaxError) { template.render! }
end
end
def test_callable_security
callable = Object.new
def callable.to_liquid
self
end
def callable.call(*)
raise 'must not call arbitrary Ruby objects'
end
[callable, Liquid::Drop.new, 'text', 1, nil].each do |value|
assert_raises(Liquid::ArgumentError) { parse('{{ title(1) }}').render!('title' => value) }
end
end
def test_proc_lookup_is_unchanged_and_receives_no_callable_arguments
values = { 'title' => -> { 'resolved' } }
template = parse('{{ title(1) }}')
assert_raises(Liquid::ArgumentError) { template.render!(values) }
assert_equal('resolved', values['title'])
assert_equal('resolved', parse('{{ title }}').render!(values))
end
def test_proc_captures_keep_existing_lookup_and_memoization
values = { 'title' => -> { 'resolved' } }
assert_render('resolvedresolved', '{% let show %}{{ title }}{% endlet %}{{ show() }}{{ show() }}', values)
assert_equal('resolved', values['title'])
end
def test_nil_proc_captures_are_memoized_before_environment_fallback
values = { 'n' => -> { nil } }
context = Liquid::Context.build(environments: [values, { 'n' => 'fallback' }])
source = '{% let show %}{{ n }}{% endlet %}{{ show() }}/{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
assert_nil(values['n'])
end
def test_nested_proc_captures_receive_the_current_invocation_context
values = { 'n' => ->(context) { context['argument'] } }
source = '{% let outer %}{% let inner(argument) %}{{ n }}{% endlet %}{{ inner("value") }}{% endlet %}{{ outer() }}'
assert_render('value', source, values)
assert_equal('value', values['n'])
end
def test_argument_count_is_validated
['title()', 'title(1,2)'].each do |call|
assert_raises(Liquid::ArgumentError) { parse("{% let title(n) %}{{ n }}{% endlet %}{{ #{call} }}").render! }
end
end
def test_recursion_uses_liquid_runtime_depth_limit
template = parse('{% let title %}{{ title() }}{% endlet %}{{ title() }}')
assert_raises(Liquid::StackLevelError) { template.render! }
end
def test_output_buffer_and_output_limit
template = parse('{% let title %}hello{% endlet %}{{ title() }}{{ title() }}')
output = +'prefix:'
assert_same(output, template.render!({}, output: output))
assert_equal('prefix:hellohello', output)
template.resource_limits.render_length_limit = 9
assert_raises(Liquid::MemoryError) { template.render! }
end
def test_assignment_limits_include_let_set_and_callable_initializers
['{% let n = "hello" %}', '{% set n = "hello" %}', '{% let title %}hello{% endlet %}{% let n = title() %}'].each do |source|
template = parse(source)
template.resource_limits.assign_score_limit = 4
assert_raises(Liquid::MemoryError) { template.render! }
end
end
def test_render_score_limit_includes_callable_body
template = parse('{% let title %}hello{% endlet %}{{ title() }}{{ title() }}')
template.resource_limits.render_score_limit = 4
assert_raises(Liquid::MemoryError) { template.render! }
end
def test_visitor_sees_initializers_arguments_and_bodies_without_captures
template = parse('{% let n = source | plus: step %}{% let title(x) %}{% set n = other %}{{ x }}{% endlet %}{{ title(product.name) }}')
visitor = Liquid::ParseTreeVisitor.for(template.root).add_callback_for(Liquid::VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
expected = ['source', 'step', 'other', 'x', 'title', 'product']
assert_equal(expected, visitor.visit.flatten.compact)
template.render!('source' => 1, 'step' => 2, 'other' => 3, 'product' => { 'name' => 'hello' })
assert_equal(expected, visitor.visit.flatten.compact)
end
def test_visitor_includes_initializer_filter_keyword_arguments
template = parse('{% let n = source | default: fallback, allow_false: flag %}')
visitor = Liquid::ParseTreeVisitor.for(template.root).add_callback_for(Liquid::VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
assert_equal(['source', 'fallback', 'flag'], visitor.visit.flatten.compact)
end
def test_custom_closure_body_parameters_are_writable_and_fresh
with_custom_tag('parameterized', ParameterizedBody) do
source = '{% let value = 10 %}{% parameterized %}{% set value = value | plus: 1 %}{{ value }}{% endparameterized %}{{ value }}'
assert_render('2310', source)
end
end
def test_custom_closure_body_exports_only_direct_child_body_declarations
source = '{% let n = 1 %}{% compose %}{% set n = n | plus: 1 %}{% let value = "hidden" %}{% if true %}{% let nested %}hidden{% endlet %}{% endif %}{% let item(text) %}{{ n }}:{{ text }}{% endlet %}{% endcompose %}{{ n }}'
with_custom_tag('compose', Compose) do
template = parse(source)
assert_equal('2:a|2:a|1', template.render!)
assert_equal('2:a|2:a|1', template.render!)
end
end
def test_custom_closure_bodies_can_nest
source = '{% compose %}{% let item(text) %}{% compose %}{% let item(inner) %}{{ text }}{{ inner }}{% endlet %}{% endcompose %}{% endlet %}{% endcompose %}'
with_custom_tag('compose', Compose) { assert_render('aa|aa||aa|aa||', source) }
end
def test_multiline_liquid_declarations_are_direct_child_exports
source = "{% compose %}{% liquid\nlet item(text)\necho text\nendlet\n%}{% endcompose %}"
with_custom_tag('compose', Compose) { assert_render('a|a|', source) }
end
def test_custom_closure_body_api_requires_strict2
with_custom_tag('compose', Compose) do
[:lax, :warn, :strict, :rigid].each do |mode|
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{% compose %}{% endcompose %}', error_mode: mode) }
end
end
end
def test_custom_closure_body_rejects_nested_assign
with_custom_tag('compose', Compose) do
assert_raises(Liquid::SyntaxError) { parse('{% compose %}{% if false %}{% assign n = 1 %}{% endif %}{% endcompose %}') }
end
end
private
def parse(source, **options)
Liquid::Template.parse(source, **options, error_mode: :strict2)
end
def assert_render(expected, source, values = {})
assert_equal(expected, parse(source).render!(values))
end
class ParameterizedBody < Liquid::Block
def parse(tokens)
@closure_body = parse_closure_body(tokens, parameters: ['value'])
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
body.render_to_output_buffer(context, output, [1])
body.render_to_output_buffer(context, output, [2])
end
end
class Compose < Liquid::Block
def initialize(tag_name, markup, parse_context)
super
@component = Liquid::Template.parse("{{ item('a') }}|", environment: parse_context.environment, error_mode: :strict2)
end
def parse(tokens)
@closure_body = parse_closure_body(tokens)
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
2.times do
exports = body.invoke(context, output: output)
raise Liquid::ArgumentError, 'Unexpected exports' unless exports.keys == ['item']
inner = context.new_isolated_subcontext
inner.merge(exports)
@component.root.render_to_output_buffer(inner, output)
end
output
end
end
end
+91 -11
View File
@@ -31,24 +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_modes(:lax) do
assert(Template.parse("{{|test}}"))
end
with_error_modes(:strict) do
assert_raises(SyntaxError) { Template.parse("{{|test}}") }
assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") }
end
end
def test_supported_parens
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
out = Template.parse("{% if #{markup} %} YES {% endif %}").render({ 'b' => 'bar', 'c' => 'baz' })
assert_equal(' YES ', out)
def test_meaningless_parens_error
with_error_modes(:strict) do
assert_raises(SyntaxError) do
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
Template.parse("{% if #{markup} %} YES {% endif %}")
end
end
end
def test_unexpected_characters_syntax_error
assert_raises(SyntaxError) do
markup = "true && false"
Template.parse("{% if #{markup} %} YES {% endif %}")
with_error_modes(: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_modes(:lax) do
assigns = { 'b' => 'bar', 'c' => 'baz' }
markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false"
assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns)
end
end
def test_unexpected_characters_silently_eat_logic_lax
with_error_modes(: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
@@ -58,6 +92,32 @@ class ParsingQuirksTest < Minitest::Test
end
end
def test_unanchored_filter_arguments
with_error_modes(: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_modes(:lax) do
assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}")
assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}")
end
end
def test_extra_dots_in_ranges
with_error_modes(:lax) do
assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}")
end
end
def test_blank_variable_markup
assert_template_result('', "{{}}")
end
@@ -71,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_modes(: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
+28 -21
View File
@@ -44,32 +44,39 @@ class SecurityTest < Minitest::Test
end
def test_does_not_permanently_add_filters_to_symbol_table
current_symbols = Symbol.all_symbols
assert_no_new_symbols do
# MRI imprecisely marks objects found on the C stack, which can result
# in uninitialized memory being marked. This can even result in the test failing
# deterministically for a given compilation of ruby. Using a separate thread will
# keep these writes of the symbol pointer on a separate stack that will be garbage
# collected after Thread#join.
Thread.new do
test = %( {{ "some_string" | a_bad_filter }} )
Template.parse(test).render!
nil
end.join
# MRI imprecisely marks objects found on the C stack, which can result
# in uninitialized memory being marked. This can even result in the test failing
# deterministically for a given compilation of ruby. Using a separate thread will
# keep these writes of the symbol pointer on a separate stack that will be garbage
# collected after Thread#join.
Thread.new do
test = %( {{ "some_string" | a_bad_filter }} )
Template.parse(test).render!
nil
end.join
GC.start
assert_equal([], Symbol.all_symbols - current_symbols)
GC.start
end
end
def test_does_not_add_drop_methods_to_symbol_table
assert_no_new_symbols do
assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
end
end
def assert_no_new_symbols
# Run once to trigger any first-time initialization which might create some symbols,
# for example autoload or lazy method parsing might create symbols on first execution.
yield
# Ensure no new symbols for further runs, i.e. the code does not leak symbols
current_symbols = Symbol.all_symbols
assigns = { 'drop' => Drop.new }
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
yield
assert_equal([], Symbol.all_symbols - current_symbols)
end
+120
View File
@@ -0,0 +1,120 @@
# frozen_string_literal: true
require 'test_helper'
class SelfDropContextTest < Minitest::Test
include Liquid
def test_self_drop_passed_as_render_param_preserves_original_scope
source = <<~LIQUID
{%- assign var = 42 -%}
{%- assign s = self -%}
{%- render "snippet1", other_self: s -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign var = 43 -%}
{{- other_self.var }}|{{ self.var -}}
LIQUID
}
assert_template_result('42|43', source, partials: partials)
end
def test_self_drop_in_render_without_passing_resolves_inner_scope
source = <<~LIQUID
{%- assign var = 42 -%}
{%- render "snippet1" -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign var = 99 -%}
{{- self.var -}}
LIQUID
}
assert_template_result('99', source, partials: partials)
end
def test_self_drop_passed_to_nested_renders_preserves_each_level
source = <<~LIQUID
{%- assign a = 1 -%}
{%- assign s1 = self -%}
{%- render "snippet1", outer: s1 -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign a = 2 -%}
{%- assign s2 = self -%}
{%- render "snippet2", outer: outer, middle: s2 -%}
LIQUID
'snippet2' => <<~LIQUID,
{%- assign a = 3 -%}
{{- outer.a }}|{{ middle.a }}|{{ self.a -}}
LIQUID
}
assert_template_result('1|2|3', source, partials: partials)
end
def test_self_drop_reflects_variables_assigned_after_creation
source = <<~LIQUID
{%- assign s = self -%}
{%- assign x = 42 %}{{ s.x -}}
LIQUID
assert_template_result('42', source)
end
def test_self_drop_context_setter_is_undefined
context = Context.new
drop = SelfDrop.new(context)
refute(drop.respond_to?(:context=))
assert_template_result('42', '{{ self.x }}', { 'x' => 42 })
end
def test_self_drop_repeated_lookups_compare_equal_for_same_context
context = Context.new
drop = context.find_variable("self")
cached_drop = context.find_variable("self")
assert_same(drop, cached_drop)
assert_equal(drop.object_id, cached_drop.object_id)
assert_equal(drop, cached_drop)
end
def test_assigned_self_drop_compares_equal_to_itself
assert_template_result('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}')
end
def test_distinct_self_assignments_compare_equal_for_same_context
assert_template_result('T', '{% assign a = self %}{% assign b = self %}{% if a == b %}T{% else %}F{% endif %}')
end
def test_bare_self_compares_equal_to_bare_self
assert_template_result('T', '{% if self == self %}T{% else %}F{% endif %}')
end
def test_self_drop_with_strict_variables_does_not_raise_for_defined_var
t = Template.parse('{{ self.x }}')
result = t.render({ 'x' => 42 }, strict_variables: true)
assert_equal('42', result)
end
def test_self_drop_with_strict_variables_returns_nil_for_undefined_var
t = Template.parse('{{ self.x }}')
result = t.render({}, strict_variables: true)
assert_equal('', result)
end
def test_self_drop_can_be_passed_as_bare_drop_to_render
t = Template.parse('{{ self.x }}')
drop = SelfDrop.new(Context.new({ 'x' => 42 }))
result = t.render(drop)
assert_equal('42', result)
end
end
+9
View File
@@ -164,6 +164,13 @@ class StandardFiltersTest < Minitest::Test
assert_equal(['A', 'Z'], @filters.split('A1Z', 1))
end
def test_squish_filter
assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar
\t boo " | squish }})).render)
assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render)
assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render)
end
def test_escape
assert_equal('&lt;strong&gt;', @filters.escape('<strong>'))
assert_equal('1', @filters.escape(1))
@@ -1174,6 +1181,8 @@ class StandardFiltersTest < Minitest::Test
end
def test_all_filters_never_raise_non_liquid_exception
skip("too slow on non-CRuby due to many exceptions") unless RUBY_ENGINE == 'ruby'
test_drop = TestDrop.new(value: "test")
test_drop.context = Context.new
test_enum = TestEnumerable.new
+49 -22
View File
@@ -91,21 +91,26 @@ class CycleTagTest < Minitest::Test
assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message)
end
def test_cycle_tag_unsupported_legacy_quirk
def test_cycle_tag_with_error_mode
# QuotedFragment is more permissive than what Parser#expression allows.
template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}"
template2 = "{% cycle .5: 'a', 'b' %}"
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
with_error_modes(:lax, :strict) do
assert_template_result("b", template1)
assert_template_result("a", template2)
end
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
assert_match(/Liquid syntax error:/, error1.message)
assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message)
end
end
def test_cycle_with_trailing_elements_legacy_syntax
def test_cycle_with_trailing_elements
assignments = "{% assign a = 'A' %}{% assign n = 'N' %}"
template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}"
@@ -114,19 +119,29 @@ class CycleTagTest < Minitest::Test
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) }
with_error_modes(:lax, :strict) do
assert_template_result("a", template1)
assert_template_result("a", template2)
assert_template_result("a", template3)
assert_template_result("N", template4)
assert_template_result("N", template5)
end
expected_error = /Expected end_of_string but found/
with_error_modes(:strict2) do
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) }
error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) }
error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) }
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)
expected_error = /Expected end_of_string but found/
assert_match(expected_error, error1.message)
assert_match(expected_error, error2.message)
assert_match(expected_error, error3.message)
assert_match(expected_error, error4.message)
assert_match(expected_error, error5.message)
end
end
def test_cycle_name_with_invalid_expression
@@ -136,8 +151,14 @@ class CycleTagTest < Minitest::Test
{% endfor %}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_cycle_variable_with_invalid_expression
@@ -147,7 +168,13 @@ class CycleTagTest < Minitest::Test
{% endfor %}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+22
View File
@@ -147,6 +147,28 @@ class IfElseTagTest < Minitest::Test
assert_raises(SyntaxError) { assert_template_result('', '{% if %}') }
end
def test_if_with_custom_condition
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
assert_template_result('yes', %({% if 'bob' contains 'o' %}yes{% endif %}))
assert_template_result('no', %({% if 'bob' contains 'f' %}yes{% else %}no{% endif %}))
ensure
Condition.operators['contains'] = original_op
end
def test_operators_are_ignored_unless_isolated
original_op = Condition.operators['contains']
Condition.operators['contains'] = :[]
assert_template_result(
'yes',
%({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}),
)
ensure
Condition.operators['contains'] = original_op
end
def test_operators_are_whitelisted
assert_raises(SyntaxError) do
assert_template_result('', %({% if 1 or throw or or 1 %}yes{% endif %}))
+95 -16
View File
@@ -204,13 +204,23 @@ 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 %}',
)
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
)
end
with_error_modes(:strict2) do
assert_syntax_error(
'{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% include "snippet" | filter %}',
)
end
end
def test_optional_commas
@@ -291,10 +301,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_modes(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}'))
end
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", 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_modes(:lax) do
assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}'))
end
end
@@ -349,7 +365,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)
@@ -388,21 +404,84 @@ class IncludeTagTest < Minitest::Test
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)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_with_invalid_expression
template = '{% include "snippet" with foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_attribute_with_invalid_expression
template = '{% include "snippet", key: foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_include_for_loop_true_with_for_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' for products %}")
include_node = template.root.nodelist.first
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' keyword")
end
end
def test_include_for_loop_false_with_with_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' with product %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' keyword")
end
end
def test_include_for_loop_false_without_keyword
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'header' %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false when no keyword")
end
end
def test_include_for_loop_with_alias
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' for products as item %}")
include_node = template.root.nodelist.first
assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' with alias")
end
end
def test_include_with_keyword_and_alias
with_error_modes(:lax, :strict, :strict2) do
template = Template.parse("{% include 'product' with products[0] as item %}")
include_node = template.root.nodelist.first
refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' with alias")
end
end
end # IncludeTagTest
@@ -27,4 +27,50 @@ class IncrementTagTest < Minitest::Test
'{%decrement starboard %}',
)
end
def test_increment_strict2_rejects_invalid_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% increment foo bar %}', error_mode: :strict2)
end
end
def test_increment_strict2_rejects_variable_starting_with_number
assert_raises(Liquid::SyntaxError) do
Template.parse('{% increment 11aa %}', error_mode: :strict2)
end
end
def test_increment_strict2_accepts_valid_variable_name
template = Template.parse('{% increment my-var %}', error_mode: :strict2)
assert_equal('0', template.render)
end
def test_decrement_strict2_rejects_invalid_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% decrement foo bar %}', error_mode: :strict2)
end
end
def test_decrement_strict2_rejects_variable_starting_with_number
assert_raises(Liquid::SyntaxError) do
Template.parse('{% decrement 11aa %}', error_mode: :strict2)
end
end
def test_decrement_strict2_accepts_valid_variable_name
template = Template.parse('{% decrement my-var %}', error_mode: :strict2)
assert_equal('-1', template.render)
end
def test_increment_strict2_rejects_empty_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% increment %}', error_mode: :strict2)
end
end
def test_decrement_strict2_rejects_empty_variable_name
assert_raises(Liquid::SyntaxError) do
Template.parse('{% decrement %}', error_mode: :strict2)
end
end
end
+39 -15
View File
@@ -105,13 +105,23 @@ 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 %}',
)
def test_strict2_parsing_errors
with_error_modes(:lax, :strict) do
assert_template_result(
'hello value1 value2',
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' },
)
end
with_error_modes(:strict2) do
assert_syntax_error(
'{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}',
)
assert_syntax_error(
'{% render "snippet" | filter %}',
)
end
end
def test_optional_commas
@@ -164,16 +174,16 @@ class RenderTagTest < Minitest::Test
def test_increment_is_isolated_between_renders
assert_template_result(
'010',
'{% increment %}{% increment %}{% render "incr" %}',
partials: { 'incr' => '{% increment %}' },
'{% increment port %}{% increment port %}{% render "incr" %}',
partials: { 'incr' => '{% increment port %}' },
)
end
def test_decrement_is_isolated_between_renders
assert_template_result(
'-1-2-1',
'{% decrement %}{% decrement %}{% render "decr" %}',
partials: { 'decr' => '{% decrement %}' },
'{% decrement port %}{% decrement port %}{% render "decr" %}',
partials: { 'decr' => '{% decrement port %}' },
)
end
@@ -307,13 +317,27 @@ class RenderTagTest < Minitest::Test
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)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_render_attribute_with_invalid_expression
template = '{% render "snippet", key: foo=>bar %}'
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
with_error_modes(:lax, :strict) do
refute_nil(Template.parse(template))
end
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+102 -32
View File
@@ -188,6 +188,29 @@ 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 %}',
error_mode: :warn,
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}',
error_mode: :warn,
render_errors: true,
)
assert_template_result(
"Liquid error (line 1): invalid integer",
'{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}',
render_errors: true,
error_mode: :warn,
)
end
def test_table_row_handles_interrupts
assert_template_result(
"<tr class=\"row1\">\n<td class=\"col1\"> 1 </td></tr>\n",
@@ -236,7 +259,7 @@ class TableRowTest < Minitest::Test
)
end
def test_tablerow_with_cols_attribute
def test_tablerow_with_cols_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %}
LIQUID
@@ -247,10 +270,12 @@ class TableRowTest < Minitest::Test
<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)
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_limit_attribute
def test_tablerow_with_limit_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %}
LIQUID
@@ -260,10 +285,12 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
assert_template_result(expected, template)
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_offset_attribute
def test_tablerow_with_offset_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %}
LIQUID
@@ -273,10 +300,12 @@ class TableRowTest < Minitest::Test
<td class="col1">3</td><td class="col2">4</td><td class="col3">5</td></tr>
OUTPUT
assert_template_result(expected, template)
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_range_attribute
def test_tablerow_with_range_attribute_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %}
LIQUID
@@ -286,10 +315,12 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
assert_template_result(expected, template)
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_multiple_attributes
def test_tablerow_with_multiple_attributes_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %}
LIQUID
@@ -300,10 +331,12 @@ class TableRowTest < Minitest::Test
<tr class="row2"><td class="col1">4</td><td class="col2">5</td></tr>
OUTPUT
assert_template_result(expected, template)
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_with_variable_collection
def test_tablerow_with_variable_collection_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -314,10 +347,12 @@ class TableRowTest < Minitest::Test
<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] })
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] })
end
end
def test_tablerow_with_dotted_access
def test_tablerow_with_dotted_access_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -328,10 +363,12 @@ class TableRowTest < Minitest::Test
<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] } })
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } })
end
end
def test_tablerow_with_bracketed_access
def test_tablerow_with_bracketed_access_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %}
LIQUID
@@ -341,10 +378,12 @@ class TableRowTest < Minitest::Test
<td class="col1">10</td><td class="col2">20</td></tr>
OUTPUT
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } })
end
end
def test_tablerow_without_attributes
def test_tablerow_without_attributes_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in (1..3) %}{{ i }}{% endtablerow %}
LIQUID
@@ -354,24 +393,30 @@ class TableRowTest < Minitest::Test
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td></tr>
OUTPUT
assert_template_result(expected, template)
with_error_modes(:strict2) do
assert_template_result(expected, template)
end
end
def test_tablerow_without_in_keyword
def test_tablerow_without_in_keyword_in_strict2_mode
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)
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message)
end
end
def test_tablerow_with_multiple_invalid_attributes_reports_first
def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode
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)
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message)
end
end
def test_tablerow_with_empty_collection
def test_tablerow_with_empty_collection_in_strict2_mode
template = <<~LIQUID.chomp
{% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %}
LIQUID
@@ -381,18 +426,43 @@ class TableRowTest < Minitest::Test
</tr>
OUTPUT
assert_template_result(expected, template, { 'empty_array' => [] })
with_error_modes(:strict2) do
assert_template_result(expected, template, { 'empty_array' => [] })
end
end
def test_tablerow_with_invalid_attribute
def test_tablerow_with_invalid_attribute_strict_vs_strict2
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)
expected = <<~OUTPUT
<tr class="row1">
<td class="col1">1</td><td class="col2">2</td><td class="col3">3</td><td class="col4">4</td><td class="col5">5</td></tr>
OUTPUT
with_error_modes(:lax, :strict) do
assert_template_result(expected, template)
end
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Invalid attribute 'invalid_attr'/, error.message)
end
end
def test_tablerow_with_invalid_expression
def test_tablerow_with_invalid_expression_strict_vs_strict2
template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}'
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
with_error_modes(:lax, :strict) do
expected = <<~OUTPUT
<tr class="row1">
</tr>
OUTPUT
assert_template_result(expected, template)
end
with_error_modes(:strict2) do
error = assert_raises(SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+91 -1
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!)
@@ -169,6 +179,86 @@ class TemplateTest < Minitest::Test
assert_equal("すごい", t.render)
end
def test_cumulative_render_score_limit_across_render_tags
file_system = StubFileSystem.new(
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
)
environment = Liquid::Environment.build(file_system: file_system)
# Without cumulative limit, all 5 partials render successfully
t = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
unlimited_output = t.render!
total_cumulative = t.resource_limits.cumulative_render_score
# With cumulative limit set below the total, rendering stops early
t2 = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2
limited_output = t2.render
assert(t2.resource_limits.reached?)
assert_operator(limited_output.length, :<, unlimited_output.length)
end
def test_cumulative_render_score_limit_raises_on_render_bang
file_system = StubFileSystem.new(
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
)
environment = Liquid::Environment.build(file_system: file_system)
t = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
t.resource_limits.cumulative_render_score_limit = 20
assert_raises(Liquid::MemoryError) do
t.render!
end
end
def test_cumulative_assign_score_limit_across_include_tags
file_system = StubFileSystem.new(
'assign_partial' => '{% assign x = "a long string value here" %}',
)
environment = Liquid::Environment.build(file_system: file_system)
# Without cumulative limit, all 5 partials render
t = Template.parse(
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
environment: environment,
)
t.render!
total_cumulative = t.resource_limits.cumulative_assign_score
# With cumulative limit set below the total, rendering stops early
t2 = Template.parse(
'{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}',
environment: environment,
)
t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2
t2.render
assert(t2.resource_limits.reached?)
end
def test_cumulative_render_score_tracks_across_partials_without_limit
file_system = StubFileSystem.new(
'loop' => '{% for a in (1..10) %} foo {% endfor %}',
)
environment = Liquid::Environment.build(file_system: file_system)
t = Template.parse(
'{% render "loop" %}{% render "loop" %}{% render "loop" %}',
environment: environment,
)
t.render!
assert(
t.resource_limits.cumulative_render_score > t.resource_limits.render_score,
"cumulative should exceed per-template score after multiple partials",
)
end
def test_default_resource_limits_unaffected_by_render_with_context
context = Context.new
t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}")
@@ -249,7 +339,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)
+79 -28
View File
@@ -11,24 +11,6 @@ class VariableTest < Minitest::Test
assert_template_result('worked wonderfully', "{{test}}", { 'test' => 'worked wonderfully' })
end
def test_equality
assert_template_result('true', "{{ 5 == 5 }}")
assert_template_result('false', "{{ 5 == 3 }}")
end
def test_comparison
assert_template_result('true', "{{ 5 > 3 }}")
assert_template_result('false', "{{ 5 < 3 }}")
end
def test_expression_piped_into_filter
assert_template_result('TRUE', "{{ 5 == 5 | upcase }}")
end
def test_expression_used_as_filter_argument
assert_template_result('A: TRUE', "{{ 'a: $a' | replace: '$a', 5 == 5 | upcase }}")
end
def test_variable_render_calls_to_liquid
assert_template_result('foobar', '{{ foo }}', { 'foo' => ThingWithToLiquid.new })
end
@@ -73,7 +55,7 @@ class VariableTest < Minitest::Test
def test_expression_with_whitespace_in_square_brackets
assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } })
assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } })
end
def test_ignore_unknown
@@ -153,17 +135,17 @@ class VariableTest < Minitest::Test
end
def test_dynamic_find_var
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_raw_value_variable
assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' })
assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' })
end
def test_dynamic_find_var_with_drop
assert_template_result(
'bar',
'{{ [list[settings.zero]] }}',
'{{ self[list[settings.zero]] }}',
{
'list' => ['foo'],
'settings' => SettingsDrop.new("zero" => 0),
@@ -173,7 +155,7 @@ class VariableTest < Minitest::Test
assert_template_result(
'foo',
'{{ [list[settings.zero]["foo"]] }}',
'{{ self[list[settings.zero]["foo"]] }}',
{
'list' => [{ 'foo' => 'bar' }],
'settings' => SettingsDrop.new("zero" => 0),
@@ -194,33 +176,102 @@ class VariableTest < Minitest::Test
)
end
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
very_long_key = "1234567890" * 100
template_list = [
"{{['#{very_long_key}']}}", # valid
"{{['#{very_long_key}'}}", # missing closing bracket
"{{[['#{very_long_key}']}}", # extra open bracket
]
template_list.each do |template|
Timeout.timeout(1) do
assert_template_result(
'bar',
template,
{
very_long_key => 'bar',
},
error_mode: :lax,
)
end
end
end
def test_filter_with_single_trailing_comma
template = '{{ "hello" | append: "world", }}'
assert_template_result('helloworld', template)
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('helloworld', template)
end
end
def test_multiple_filters_with_trailing_commas
template = '{{ "hello" | append: "1", | append: "2", }}'
assert_template_result('hello12', template)
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('hello12', template)
end
end
def test_filter_with_colon_but_no_arguments
template = '{{ "test" | upcase: }}'
assert_template_result('TEST', template)
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TEST', template)
end
end
def test_filter_chain_with_colon_no_args
template = '{{ "test" | append: "x" | upcase: }}'
assert_template_result('TESTX', template)
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
end
def test_combining_trailing_comma_and_empty_args
template = '{{ "test" | append: "x", | upcase: }}'
assert_template_result('TESTX', template)
with_error_modes(:strict) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/is not a valid expression/, error.message)
end
with_error_modes(:strict2) do
assert_template_result('TESTX', template)
end
end
end
+23 -6
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: Liquid::Environment.default.error_mode, 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,16 @@ module Minitest
Environment.dangerously_override(environment, &blk)
end
def with_error_modes(*modes)
old_mode = Liquid::Environment.default.error_mode
modes.each do |mode|
Liquid::Environment.default.error_mode = mode
yield
end
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)
-146
View File
@@ -1,146 +0,0 @@
# frozen_string_literal: true
require 'test_helper'
class ExecutionSpy
attr_reader :called
attr_accessor :value
def initialize(value)
@called = false
@value = value
end
def to_liquid_value
@called = true
@value
end
def reset
@called = false
end
end
class BinaryExpressionTest < Minitest::Test
include Liquid
def test_simple_comparison_evaluation
assert_eval(false, BinaryExpression.new(5, ">", 5))
assert_eval(true, BinaryExpression.new(5, ">=", 5))
assert_eval(false, BinaryExpression.new(5, "<", 5))
assert_eval(true, BinaryExpression.new(5, "<=", 5))
assert_eval(true, BinaryExpression.new("abcd", "contains", "a"))
end
def test_logical_expression_short_circuiting
spy = ExecutionSpy.new(true)
# false or spy should try spy
assert_eval(true, BinaryExpression.new(false, 'or', spy))
assert_equal(true, spy.called)
spy.reset
# true or spy should not call spy
assert_eval(true, BinaryExpression.new(true, 'or', spy))
assert_equal(false, spy.called)
spy.reset
# true and spy should try spy
assert_eval(true, BinaryExpression.new(true, 'and', spy))
assert_equal(true, spy.called)
spy.reset
# false and spy should not try spy
assert_eval(false, BinaryExpression.new(false, 'and', spy))
assert_equal(false, spy.called)
end
def test_complex_evaluation
# 1 > 2 == 2 > 3
assert_eval(true, BinaryExpression.new(
BinaryExpression.new(1, '>', 2),
'==',
BinaryExpression.new(2, '>', 3),
))
# 1 > 2 != 2 > 3
assert_eval(false, BinaryExpression.new(
BinaryExpression.new(1, '>', 2),
'!=',
BinaryExpression.new(2, '>', 3),
))
# a > 0 == b.prop > 0
assert_eval(
true,
BinaryExpression.new(
BinaryExpression.new(var('a'), '>', 0),
'==',
BinaryExpression.new(var('b.prop'), '>', 0),
),
{ 'a' => 1, 'b' => { 'prop' => 2 } },
)
end
def test_method_literal_equality
empty = MethodLiteral.new(:empty?, '')
# a == empty, empty == a
assert_eval(false, BinaryExpression.new("123", "==", empty))
assert_eval(true, BinaryExpression.new("", "==", empty))
assert_eval(false, BinaryExpression.new(empty, "==", "123"))
assert_eval(true, BinaryExpression.new(empty, "==", ""))
# a does not have .empty?
assert_eval(nil, BinaryExpression.new(1, "==", empty))
assert_eval(nil, BinaryExpression.new(true, "==", empty))
assert_eval(nil, BinaryExpression.new(false, "==", empty))
assert_eval(nil, BinaryExpression.new(nil, "==", empty))
# a != empty
assert_eval(true, BinaryExpression.new("123", "!=", empty))
assert_eval(false, BinaryExpression.new("", "!=", empty))
assert_eval(true, BinaryExpression.new(empty, "!=", "123"))
assert_eval(false, BinaryExpression.new(empty, "!=", ""))
# a does not have .empty?
assert_eval(true, BinaryExpression.new(1, "!=", empty))
assert_eval(true, BinaryExpression.new(true, "!=", empty))
assert_eval(true, BinaryExpression.new(false, "!=", empty))
assert_eval(true, BinaryExpression.new(nil, "!=", empty))
end
def test_method_literal_comparison
empty = MethodLiteral.new(:empty?, '')
['>', '>='].each do |op|
assert_eval(nil, BinaryExpression.new("123", op, empty))
assert_eval(nil, BinaryExpression.new("", op, empty))
assert_eval(nil, BinaryExpression.new(empty, op, "123"))
assert_eval(nil, BinaryExpression.new(empty, op, ""))
end
# Interesting case, contains on strings does include?(right.to_s)
assert_eval(true, BinaryExpression.new("123", "contains", empty))
assert_eval(true, BinaryExpression.new("", "contains", empty))
end
def assert_eval(expected, expr, assigns = {})
actual = expr.evaluate(context(assigns))
message = "Expected '#{expr}' to evaluate to '#{expected}'"
return assert_nil(actual, message) if expected.nil?
assert_equal(expected, actual, message)
end
def var(markup)
Parser.new(markup).variable_lookup
end
def context(assigns = {})
Context.build(outer_scope: assigns)
end
end
+102 -85
View File
@@ -9,6 +9,11 @@ class ConditionUnitTest < Minitest::Test
@context = Liquid::Context.new
end
def test_basic_condition
assert_equal(false, Condition.new(1, '==', 2).evaluate(Context.new))
assert_equal(true, Condition.new(1, '==', 1).evaluate(Context.new))
end
def test_default_operators_evalute_true
assert_evaluates_true(1, '==', 1)
assert_evaluates_true(1, '!=', 2)
@@ -67,17 +72,17 @@ class ConditionUnitTest < Minitest::Test
end
def test_hash_compare_backwards_compatibility
assert_evaluates_nil({}, '>', 2)
assert_evaluates_nil(2, '>', {})
assert_evaluates_false({}, '==', 2)
assert_evaluates_true({ 'a' => 1 }, '==', 'a' => 1)
assert_evaluates_true({ 'a' => 2 }, 'contains', 'a')
assert_nil(Condition.new({}, '>', 2).evaluate(Context.new))
assert_nil(Condition.new(2, '>', {}).evaluate(Context.new))
assert_equal(false, Condition.new({}, '==', 2).evaluate(Context.new))
assert_equal(true, Condition.new({ 'a' => 1 }, '==', 'a' => 1).evaluate(Context.new))
assert_equal(true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate(Context.new))
end
def test_contains_works_on_arrays
@context = Liquid::Context.new
@context['array'] = [1, 2, 3, 4, 5]
array_expr = VariableLookup.parse("array")
array_expr = VariableLookup.new("array")
assert_evaluates_false(array_expr, 'contains', 0)
assert_evaluates_true(array_expr, 'contains', 1)
@@ -91,8 +96,8 @@ class ConditionUnitTest < Minitest::Test
def test_contains_returns_false_for_nil_operands
@context = Liquid::Context.new
assert_evaluates_false(VariableLookup.parse('not_assigned'), 'contains', '0')
assert_evaluates_false(0, 'contains', VariableLookup.parse('not_assigned'))
assert_evaluates_false(VariableLookup.new('not_assigned'), 'contains', '0')
assert_evaluates_false(0, 'contains', VariableLookup.new('not_assigned'))
end
def test_contains_return_false_on_wrong_data_type
@@ -105,64 +110,91 @@ class ConditionUnitTest < Minitest::Test
end
def test_or_condition
false_expr = '1 == 2'
true_expr = '1 == 1'
condition = Condition.new(expression(false_expr))
condition = Condition.new(1, '==', 2)
assert_equal(false, condition.evaluate(Context.new))
condition = Condition.new(expression("#{false_expr} or #{false_expr}"))
condition.or(Condition.new(2, '==', 1))
assert_equal(false, condition.evaluate(Context.new))
condition = Condition.new(expression("#{false_expr} or #{true_expr}"))
assert_equal(true, condition.evaluate(Context.new))
condition.or(Condition.new(1, '==', 1))
condition = Condition.new(expression("#{true_expr} or #{false_expr}"))
assert_equal(true, condition.evaluate(Context.new))
end
def test_and_condition
false_expr = '1 == 2'
true_expr = '1 == 1'
condition = Condition.new(1, '==', 1)
condition = Condition.new(expression(true_expr))
assert_equal(true, condition.evaluate(Context.new))
condition = Condition.new(expression("#{true_expr} and #{false_expr}"))
assert_equal(false, condition.evaluate(Context.new))
condition.and(Condition.new(2, '==', 2))
condition = Condition.new(expression("#{false_expr} and #{true_expr}"))
assert_equal(false, condition.evaluate(Context.new))
condition = Condition.new(expression("#{true_expr} and #{true_expr}"))
assert_equal(true, condition.evaluate(Context.new))
condition.and(Condition.new(2, '==', 1))
assert_equal(false, condition.evaluate(Context.new))
end
def test_should_allow_custom_proc_operator
Condition.operators['starts_with'] = proc { |_cond, left, right| left =~ /^#{right}/ }
assert_evaluates_true('bob', 'starts_with', 'b')
assert_evaluates_false('bob', 'starts_with', 'o')
ensure
Condition.operators.delete('starts_with')
end
def test_left_or_right_may_contain_operators
@context = Liquid::Context.new
@context['one'] = @context['another'] = "gnomeslab-and-or-liquid"
assert_evaluates_true(VariableLookup.parse("one"), '==', VariableLookup.parse("another"))
assert_evaluates_true(VariableLookup.new("one"), '==', VariableLookup.new("another"))
end
def test_parse_expression
environment = Environment.build
def test_default_context_is_deprecated
if Gem::Version.new(Liquid::VERSION) >= Gem::Version.new('6.0.0')
flunk("Condition#evaluate without a context argument is to be removed")
end
_out, err = capture_io do
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."
assert_includes(err.lines.map(&:strip), expected)
end
def test_parse_expression_in_strict_mode
environment = Environment.build(error_mode: :strict)
parse_context = ParseContext.new(environment: environment)
parser = parse_context.new_parser('product.title')
result = parser.expression
result = Condition.parse_expression(parse_context, 'product.title')
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
def test_parser_expression_returns_method_literal_for_blank_and_empty
environment = Environment.build
def test_parse_expression_in_strict2_mode_raises_internal_error
environment = Environment.build(error_mode: :strict2)
parse_context = ParseContext.new(environment: environment)
parser = parse_context.new_parser('blank')
result = parser.expression
assert_instance_of(MethodLiteral, result)
error = assert_raises(Liquid::InternalError) do
Condition.parse_expression(parse_context, 'product.title')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_safe_true_in_strict2_mode
environment = Environment.build(error_mode: :strict2)
parse_context = ParseContext.new(environment: environment)
result = Condition.parse_expression(parse_context, 'product.title', safe: true)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
end
# Tests for blank? comparison without ActiveSupport
@@ -182,99 +214,99 @@ class ConditionUnitTest < Minitest::Test
# 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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.parse('whitespace'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.parse('empty_string'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.parse('empty_array'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.parse('empty_hash'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.parse('nil_value'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_true(VariableLookup.parse('false_value'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.parse('true_value'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.parse('number'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.parse('string'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.parse('array'), '==', blank_literal)
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 = Expression::LITERALS['blank']
blank_literal = Condition.class_variable_get(:@@method_literals)['blank']
assert_evaluates_false(VariableLookup.parse('hash'), '==', blank_literal)
assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal)
end
# Tests for empty? comparison without ActiveSupport
@@ -288,9 +320,9 @@ class ConditionUnitTest < Minitest::Test
# An empty string ("") has length 0, so it's empty.
# Different from blank - empty is a stricter check.
@context['empty_string'] = ''
empty_literal = Expression::LITERALS['empty']
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_true(VariableLookup.parse('empty_string'), '==', empty_literal)
assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal)
end
def test_empty_with_whitespace_string_not_empty
@@ -298,27 +330,27 @@ class ConditionUnitTest < Minitest::Test
# This is the key difference between empty and blank:
# " ".empty? => false, but " ".blank? => true
@context['whitespace'] = ' '
empty_literal = Expression::LITERALS['empty']
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_false(VariableLookup.parse('whitespace'), '==', empty_literal)
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 = Expression::LITERALS['empty']
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_true(VariableLookup.parse('empty_array'), '==', empty_literal)
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 = Expression::LITERALS['empty']
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_true(VariableLookup.parse('empty_hash'), '==', empty_literal)
assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal)
end
def test_nil_is_not_empty
@@ -326,45 +358,30 @@ class ConditionUnitTest < Minitest::Test
# 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 = Expression::LITERALS['empty']
empty_literal = Condition.class_variable_get(:@@method_literals)['empty']
assert_evaluates_false(VariableLookup.parse('nil_value'), '==', empty_literal)
assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal)
end
private
def assert_evaluates_nil(left, op, right)
expr = BinaryExpression.new(left, op, right)
assert_nil(
Condition.new(expr).evaluate(@context),
"Evaluated not nil: #{left.inspect} #{op} #{right.inspect}",
)
end
def assert_evaluates_true(left, op, right)
expr = BinaryExpression.new(left, op, right)
assert(
Condition.new(expr).evaluate(@context),
Condition.new(left, op, right).evaluate(@context),
"Evaluated false: #{left.inspect} #{op} #{right.inspect}",
)
end
def assert_evaluates_false(left, op, right)
expr = BinaryExpression.new(left, op, right)
assert(
!Condition.new(expr).evaluate(@context),
!Condition.new(left, op, right).evaluate(@context),
"Evaluated true: #{left.inspect} #{op} #{right.inspect}",
)
end
def assert_evaluates_argument_error(left, op, right)
assert_raises(Liquid::ArgumentError) do
expr = BinaryExpression.new(left, op, right)
Condition.new(expr).evaluate(@context)
Condition.new(left, op, right).evaluate(@context)
end
end
def expression(markup)
Parser.new(markup).expression
end
end # ConditionTest
+6 -6
View File
@@ -26,17 +26,17 @@ class LexerUnitTest < Minitest::Test
)
end
def test_equality
def test_assignment_and_comparison_tokens
assert_equal(
[[:equality, '=='], [:equality, '<>'], [:equality, '!='], [:end_of_string]],
tokenize('== <> != '),
[[:id, 'n'], [:assign, '='], [:number, '1'], [:comparison, '=='], [:number, '2'], [:end_of_string]],
tokenize('n=1==2'),
)
end
def test_comparison
assert_equal(
[[:comparison, '>'], [:comparison, '>='], [:comparison, '<'], [:comparison, '<='], [:comparison, 'contains'], [:end_of_string]],
tokenize('> >= < <= contains'),
[[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
tokenize('== <> contains '),
)
end
@@ -88,7 +88,7 @@ class LexerUnitTest < Minitest::Test
def test_whitespace
assert_equal(
[[:id, 'five'], [:pipe, '|'], [:equality, '=='], [:end_of_string]],
[[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]],
tokenize("five|\n\t =="),
)
end
+87 -24
View File
@@ -5,56 +5,119 @@ require 'test_helper'
class ParseContextUnitTest < Minitest::Test
include Liquid
def test_parser_expression_with_variable_lookup
parser = parse_context.new_parser('product.title')
result = parser.expression
def test_safe_parse_expression_with_variable_lookup
parser_strict = strict_parse_context.new_parser('product.title')
result_strict = strict_parse_context.safe_parse_expression(parser_strict)
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
parser_strict2 = strict2_parse_context.new_parser('product.title')
result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2)
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
assert_instance_of(VariableLookup, result_strict2)
assert_equal('product', result_strict2.name)
assert_equal(['title'], result_strict2.lookups)
end
def test_parser_expression_raises_syntax_error_for_invalid_expression
parser = parse_context.new_parser('')
def test_safe_parse_expression_raises_syntax_error_for_invalid_expression
parser_strict = strict_parse_context.new_parser('')
parser_strict2 = strict2_parse_context.new_parser('')
error = assert_raises(Liquid::SyntaxError) do
parser.expression
error_strict = assert_raises(Liquid::SyntaxError) do
strict_parse_context.safe_parse_expression(parser_strict)
end
assert_match(/is not a valid expression/, error_strict.message)
error_strict2 = assert_raises(Liquid::SyntaxError) do
strict2_parse_context.safe_parse_expression(parser_strict2)
end
assert_match(/is not a valid expression/, error.message)
assert_match(/is not a valid expression/, error_strict2.message)
end
def test_parse_expression_with_variable_lookup
result = parse_context.new_parser('product.title').expression
result_strict = strict_parse_context.parse_expression('product.title')
assert_instance_of(VariableLookup, result)
assert_equal('product', result.name)
assert_equal(['title'], result.lookups)
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
error = assert_raises(Liquid::InternalError) do
strict2_parse_context.parse_expression('product.title')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parser_expression_advances_parser_pointer
parser = parse_context.new_parser('foo, bar')
def test_parse_expression_with_safe_true
result_strict = strict_parse_context.parse_expression('product.title', safe: true)
# parser.expression consumes "foo"
first_result = parser.expression
assert_instance_of(VariableLookup, result_strict)
assert_equal('product', result_strict.name)
assert_equal(['title'], result_strict.lookups)
result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true)
assert_instance_of(VariableLookup, result_strict2)
assert_equal('product', result_strict2.name)
assert_equal(['title'], result_strict2.lookups)
end
def test_parse_expression_with_empty_string
result_strict = strict_parse_context.parse_expression('')
assert_nil(result_strict)
error = assert_raises(Liquid::InternalError) do
strict2_parse_context.parse_expression('')
end
assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message)
end
def test_parse_expression_with_empty_string_and_safe_true
result_strict = strict_parse_context.parse_expression('', safe: true)
assert_nil(result_strict)
result_strict2 = strict2_parse_context.parse_expression('', safe: true)
assert_nil(result_strict2)
end
def test_safe_parse_expression_advances_parser_pointer
parser = strict2_parse_context.new_parser('foo, bar')
# safe_parse_expression consumes "foo"
first_result = strict2_parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, first_result)
assert_equal('foo', first_result.name)
parser.consume(:comma)
# parser.expression consumes "bar"
second_result = parser.expression
# safe_parse_expression consumes "bar"
second_result = strict2_parse_context.safe_parse_expression(parser)
assert_instance_of(VariableLookup, second_result)
assert_equal('bar', second_result.name)
parser.consume(:end_of_string)
end
def test_parse_expression_with_whitespace_in_strict2_mode
result = strict2_parse_context.parse_expression(' ', safe: true)
assert_nil(result)
end
private
def parse_context
@parse_context ||= ParseContext.new(
environment: Environment.build,
def strict_parse_context
@strict_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict),
)
end
def strict2_parse_context
@strict2_parse_context ||= ParseContext.new(
environment: Environment.build(error_mode: :strict2),
)
end
end
+88 -169
View File
@@ -45,193 +45,112 @@ class ParserUnitTest < Minitest::Test
assert_equal(false, p.look(:number, 1))
end
def test_expression_string
def test_expressions
p = new_parser("hi.there hi?[5].there? hi.there.bob")
assert_equal('hi.there', p.expression_string)
assert_equal('hi?[5].there?', p.expression_string)
assert_equal('hi.there.bob', p.expression_string)
assert_equal('hi.there', p.expression)
assert_equal('hi?[5].there?', p.expression)
assert_equal('hi.there.bob', p.expression)
p = new_parser("567 6.0 'lol' \"wut\"")
assert_equal('567', p.expression_string)
assert_equal('6.0', p.expression_string)
assert_equal("'lol'", p.expression_string)
assert_equal('"wut"', p.expression_string)
end
def test_expression
p = new_parser("hi.there hi?[5].there? hi.there.bob")
v1 = p.expression
v2 = p.expression
v3 = p.expression
assert(v1.is_a?(VariableLookup) && v1.name == 'hi' && v1.lookups[0] == 'there')
assert(v2.is_a?(VariableLookup) && v2.name == 'hi?' && v2.lookups[0] == 5)
assert(v3.is_a?(VariableLookup) && v3.name == 'hi' && v3.lookups[0] == 'there')
p = new_parser("567 6.0 'lol' \"wut\" true false (0..5)")
assert_equal(567, p.expression)
assert_equal(6.0, p.expression)
assert_equal('lol', p.expression)
assert_equal('wut', p.expression)
assert_equal(true, p.expression)
assert_equal(false, p.expression)
assert_equal(0..5, p.expression)
end
def test_logical
p = new_parser("a and b")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('and', expr.operator)
assert_equal('a', expr.left_node.name)
assert_equal('b', expr.right_node.name)
p = new_parser("a and b or c")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('and', expr.operator)
assert_equal('a', expr.left_node.name)
assert_equal('or', expr.right_node.operator)
assert_equal('b', expr.right_node.left_node.name)
assert_equal('c', expr.right_node.right_node.name)
p = new_parser("a == b and c or d")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('and', expr.operator)
assert_equal('==', expr.left_node.operator)
assert_equal('a', expr.left_node.left_node.name)
assert_equal('b', expr.left_node.right_node.name)
assert_equal('or', expr.right_node.operator)
assert_equal('c', expr.right_node.left_node.name)
assert_equal('d', expr.right_node.right_node.name)
end
def test_equality
p = new_parser("a == b")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('==', expr.operator)
assert_equal('a', expr.left_node.name)
assert_equal('b', expr.right_node.name)
# BinaryExpression(==)
# left_node: BinaryExpression(<)
# left_node: 0
# right_node: 5
# right_node: BinaryExpression(>)
# left_node: 6
# right_node: 1
p = new_parser("0 < 5 == 6 > 1")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('==', expr.operator)
assert_equal(0, expr.left_node.left_node)
assert_equal(5, expr.left_node.right_node)
assert_equal(6, expr.right_node.left_node)
assert_equal(1, expr.right_node.right_node)
end
def test_comparison
p = new_parser("a > b")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('>', expr.operator)
assert(expr.left_node.is_a?(VariableLookup))
assert_equal('a', expr.left_node.name)
assert(expr.right_node.is_a?(VariableLookup))
assert_equal('b', expr.right_node.name)
# BinaryExpression(>=)
# left_node: BinaryExpression(>)
# left_node: 10
# right_node: 5
# right_node: 4
p = new_parser("10 > 5 >= 4")
expr = p.expression
assert(expr.is_a?(BinaryExpression))
assert_equal('>=', expr.operator)
assert_equal(10, expr.left_node.left_node)
assert_equal(5, expr.left_node.right_node)
assert_equal(4, expr.right_node)
end
def test_number
p = new_parser('-1 0 1 2.0')
assert_equal(-1, p.number)
assert_equal(0, p.number)
assert_equal(1, p.number)
assert_equal(2.0, p.number)
end
def test_string
p = new_parser("'s1' \"s2\" 'this \"s3\"' \"that 's4'\"")
assert_equal('s1', p.string)
assert_equal('s2', p.string)
assert_equal('this "s3"', p.string)
assert_equal("that 's4'", p.string)
end
def test_unnamed_variable_lookup
p = new_parser('[key].title')
v = p.expression
assert(v.is_a?(VariableLookup))
assert(v.name.is_a?(VariableLookup))
assert_equal('key', v.name.name)
assert_equal('title', v.lookups[0])
end
def test_range_lookup
p = new_parser('(0..5) (a..b)')
assert_equal(0..5, p.expression)
r2 = p.expression
assert(r2.is_a?(RangeLookup))
assert_equal(1..4, r2.evaluate(Context.new({ 'a' => 1, 'b' => 4 })))
assert_equal('567', p.expression)
assert_equal('6.0', p.expression)
assert_equal("'lol'", p.expression)
assert_equal('"wut"', p.expression)
end
def test_ranges
p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)")
assert_equal('(5..7)', p.expression_string)
assert_equal('(1.5..9.6)', p.expression_string)
assert_equal('(young..old)', p.expression_string)
assert_equal('(hi[5].wat..old)', p.expression_string)
assert_equal('(5..7)', p.expression)
assert_equal('(1.5..9.6)', p.expression)
assert_equal('(young..old)', p.expression)
assert_equal('(hi[5].wat..old)', p.expression)
end
def test_groupings_aka_parenthesized_expressions
# without the parens, this would be evaled as a and (b or c)
p = new_parser("(a and b) or c")
expr = p.expression
assert_equal('or', expr.operator)
assert_equal('and', expr.left_node.operator)
assert_equal('a', expr.left_node.left_node.name)
assert_equal('b', expr.left_node.right_node.name)
assert_equal('c', expr.right_node.name)
end
def test_groupings_can_be_used_to_hijack_operation_priority
# without parens would be parsed as `a and (b == c)`
p = new_parser("(a and b) == c")
expr = p.expression
assert_equal('==', expr.operator)
assert_equal('and', expr.left_node.operator)
assert_equal('a', expr.left_node.left_node.name)
assert_equal('b', expr.left_node.right_node.name)
assert_equal('c', expr.right_node.name)
end
def test_argument_string
def test_arguments
p = new_parser("filter: hi.there[5], keyarg: 7")
assert_equal('filter', p.consume(:id))
assert_equal(':', p.consume(:colon))
assert_equal('hi.there[5]', p.argument_string)
assert_equal('hi.there[5]', p.argument)
assert_equal(',', p.consume(:comma))
assert_equal('keyarg: 7', p.argument_string)
assert_equal('keyarg: 7', p.argument)
end
def test_invalid_expression
assert_raises(SyntaxError) do
p = new_parser("==")
p.expression_string
p.expression
end
end
def test_assignment_delimiter
parser = new_parser('title = product.title')
assert_equal('title', parser.consume(:id))
assert_equal('=', parser.consume(:assign))
assert_equal('product.title', parser.expression)
parser.consume(:end_of_string)
end
def test_callable_expression_consumes_only_its_arguments
context = ParseContext.new(error_mode: :strict2)
parser = context.new_parser('title(product, nested(1),), next')
expression = context.safe_parse_expression(parser)
assert_instance_of(CallExpression, expression)
assert_equal('title', expression.target.name)
assert_equal('product', expression.arguments.first.name)
assert_instance_of(CallExpression, expression.arguments.last)
assert_equal([1], expression.arguments.last.arguments)
parser.consume(:comma)
assert_equal('next', parser.consume(:id))
parser.consume(:end_of_string)
end
def test_expression_markup_preserves_callable_syntax_without_consuming_attributes
[
['(1 .. finish( ))', '(1..finish())', RangeLookup],
['groups[ key( ) ].items', 'groups[key()].items', VariableLookup],
['identity( "a b", nested(1) )', 'identity("a b",nested(1))', CallExpression],
].each do |source, expected_markup, expected_class|
parser = ParseContext.new(error_mode: :strict2).new_parser("#{source} limit: 1")
value, markup = parser.expression_with_markup
assert_instance_of(expected_class, value)
assert_equal(expected_markup, markup)
assert_equal('limit', parser.consume(:id))
parser.consume(:colon)
assert_equal('1', parser.consume(:number))
parser.consume(:end_of_string)
end
end
def test_expression_markup_preserves_non_callable_loop_names
['items', 'groups[key].items', '(1..finish)', '"a b"'].each do |source|
value, markup = new_parser(source).expression_with_markup
assert_equal(source, value)
assert_equal(source, markup)
end
end
def test_parameters_consume_parentheses_and_optional_trailing_comma
parser = new_parser('(first, second,)')
assert_equal(['first', 'second'], parser.parameters)
parser.consume(:end_of_string)
assert_equal([], new_parser('()').parameters)
end
def test_callable_grammar_uses_effective_partial_mode
environment = Environment.build(error_mode: :strict)
context = ParseContext.new(environment: environment, error_mode: :strict2, include_options_blacklist: [:error_mode])
assert_instance_of(CallExpression, context.safe_parse_expression(context.new_parser('title()')))
context.partial = true
parser = context.new_parser('title()')
assert_equal('title', parser.expression)
assert_raises(SyntaxError) { parser.consume(:end_of_string) }
end
def test_callable_grammar_is_explicitly_disabled_in_other_modes
[:lax, :warn, :strict, :rigid].each do |mode|
parser = ParseContext.new(error_mode: mode).new_parser('title(1)')
assert_equal('title', parser.expression)
assert_raises(SyntaxError) { parser.consume(:end_of_string) }
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, :strict2].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", "my_partial:strict2"],
context.registers[:cached_partials].keys,
)
end
+91
View File
@@ -0,0 +1,91 @@
# frozen_string_literal: true
require 'test_helper'
class ResourceLimitsUnitTest < Minitest::Test
def test_cumulative_scores_initialize_to_zero
limits = Liquid::ResourceLimits.new({})
assert_equal(0, limits.cumulative_render_score)
assert_equal(0, limits.cumulative_assign_score)
end
def test_cumulative_limits_default_to_nil
limits = Liquid::ResourceLimits.new({})
assert_nil(limits.cumulative_render_score_limit)
assert_nil(limits.cumulative_assign_score_limit)
end
def test_cumulative_limits_configurable_via_hash
limits = Liquid::ResourceLimits.new(
cumulative_render_score_limit: 500,
cumulative_assign_score_limit: 300,
)
assert_equal(500, limits.cumulative_render_score_limit)
assert_equal(300, limits.cumulative_assign_score_limit)
end
def test_cumulative_limits_configurable_via_accessor
limits = Liquid::ResourceLimits.new({})
limits.cumulative_render_score_limit = 500
assert_equal(500, limits.cumulative_render_score_limit)
end
def test_cumulative_scores_survive_reset
limits = Liquid::ResourceLimits.new({})
limits.increment_render_score(10)
limits.increment_assign_score(5)
limits.reset
assert_equal(0, limits.render_score)
assert_equal(0, limits.assign_score)
assert_equal(10, limits.cumulative_render_score)
assert_equal(5, limits.cumulative_assign_score)
end
def test_cumulative_scores_accumulate_across_resets
limits = Liquid::ResourceLimits.new({})
limits.increment_render_score(10)
limits.reset
limits.increment_render_score(20)
limits.reset
limits.increment_render_score(30)
assert_equal(30, limits.render_score)
assert_equal(60, limits.cumulative_render_score)
end
def test_cumulative_render_score_limit_raises
limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25)
limits.increment_render_score(10)
limits.reset
limits.increment_render_score(10)
limits.reset
assert_raises(Liquid::MemoryError) do
limits.increment_render_score(10)
end
assert(limits.reached?)
end
def test_cumulative_assign_score_limit_raises
limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15)
limits.increment_assign_score(8)
limits.reset
assert_raises(Liquid::MemoryError) do
limits.increment_assign_score(8)
end
assert(limits.reached?)
end
def test_per_template_limits_still_work_with_cumulative
limits = Liquid::ResourceLimits.new(
render_score_limit: 50,
cumulative_render_score_limit: 1000,
)
assert_raises(Liquid::MemoryError) do
limits.increment_render_score(51)
end
end
end
+46 -16
View File
@@ -20,9 +20,15 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
with_error_modes(:lax, :strict) do
assert_template_result("one", template)
end
assert_match(/Expected end_of_string but found/, error.message)
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
end
def test_case_when_with_trailing_element
@@ -35,9 +41,15 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
with_error_modes(:lax, :strict) do
assert_template_result("one", template)
end
assert_match(/Expected end_of_string but found/, error.message)
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Expected end_of_string but found/, error.message)
end
end
def test_case_when_with_comma
@@ -50,7 +62,9 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
assert_template_result("one", template)
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
end
def test_case_when_with_or
@@ -63,7 +77,9 @@ class CaseTagUnitTest < Minitest::Test
{%- endcase -%}
LIQUID
assert_template_result("one", template)
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("one", template)
end
end
def test_case_when_empty
@@ -76,12 +92,14 @@ class CaseTagUnitTest < Minitest::Test
{%- 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 })
with_error_modes(:lax, :strict, :strict2) do
assert_template_result("2 or empty", template, { 'x' => 2 })
assert_template_result("2 or empty", template, { 'x' => {} })
assert_template_result("2 or empty", template, { 'x' => [] })
assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } })
assert_template_result("not 2 or empty", template, { 'x' => ['a'] })
assert_template_result("not 2 or empty", template, { 'x' => 4 })
end
end
def test_case_with_invalid_expression
@@ -95,9 +113,15 @@ class CaseTagUnitTest < Minitest::Test
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
with_error_modes(:lax, :strict) do
assert_template_result("one", template, assigns)
end
assert_match(/Unexpected character =/, error.message)
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
def test_case_when_with_invalid_expression
@@ -111,8 +135,14 @@ class CaseTagUnitTest < Minitest::Test
LIQUID
assigns = { 'foo' => { 'bar' => 'baz' } }
error = assert_raises(Liquid::SyntaxError) { Template.parse(template, assigns) }
with_error_modes(:lax, :strict) do
assert_template_result("one", template, assigns)
end
assert_match(/Unexpected character =/, error.message)
with_error_modes(:strict2) do
error = assert_raises(Liquid::SyntaxError) { Template.parse(template) }
assert_match(/Unexpected character =/, error.message)
end
end
end
+80 -40
View File
@@ -7,20 +7,20 @@ class VariableUnitTest < Minitest::Test
def test_variable
var = create_variable('hello')
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
end
def test_filters
var = create_variable('hello | textileze')
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['textileze', []]], var.filters)
var = create_variable('hello | textileze | paragraph')
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['textileze', []], ['paragraph', []]], var.filters)
var = create_variable(%( hello | strftime: '%Y'))
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['strftime', ['%Y']]], var.filters)
var = create_variable(%( 'typo' | link_to: 'Typo', true ))
@@ -44,11 +44,11 @@ class VariableUnitTest < Minitest::Test
assert_equal([['repeat', [3, 3, 3]]], var.filters)
var = create_variable(%( hello | strftime: '%Y, okay?'))
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['strftime', ['%Y, okay?']]], var.filters)
var = create_variable(%( hello | things: "%Y, okay?", 'the other one'))
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['things', ['%Y, okay?', 'the other one']]], var.filters)
end
@@ -60,18 +60,24 @@ class VariableUnitTest < Minitest::Test
def test_filters_without_whitespace
var = create_variable('hello | textileze | paragraph')
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['textileze', []], ['paragraph', []]], var.filters)
var = create_variable('hello|textileze|paragraph')
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
assert_equal([['textileze', []], ['paragraph', []]], var.filters)
var = create_variable("hello|replace:'foo','bar'|textileze")
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
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)
@@ -99,12 +105,14 @@ class VariableUnitTest < Minitest::Test
end
def test_dashes
assert_equal(VariableLookup.parse('foo-bar'), create_variable('foo-bar').name)
assert_equal(VariableLookup.parse('foo-bar-2'), create_variable('foo-bar-2').name)
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_modes(:strict) do
assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') }
assert_raises(Liquid::SyntaxError) { create_variable('-foo') }
assert_raises(Liquid::SyntaxError) { create_variable('2foo') }
end
end
def test_string_with_special_chars
@@ -114,47 +122,79 @@ class VariableUnitTest < Minitest::Test
def test_string_dot
var = create_variable(%( test.test ))
assert_equal(VariableLookup.parse('test.test'), var.name)
assert_equal(VariableLookup.new('test.test'), var.name)
end
def test_filter_with_keyword_arguments
var = create_variable(%( hello | things: greeting: "world", farewell: 'goodbye'))
assert_equal(VariableLookup.parse('hello'), var.name)
assert_equal(VariableLookup.new('hello'), var.name)
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)
# missing argument throws error
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
# missing does not throws error
create_variable(%(n | f1: ,), error_mode: :lax)
create_variable(%(n | f1: ,| f2), error_mode: :lax)
# 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)) }
# arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':'
var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax)
assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters)
# positional and kwargs parsing
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax)
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
# positional and kwargs 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"))
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax)
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
end
# string key throws
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
def test_strict_filter_argument_parsing
with_error_modes(:strict) do
assert_raises(SyntaxError) do
create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ))
end
end
end
def test_strict2_filter_argument_parsing
with_error_modes(:strict2) do
# optional colon
var = create_variable(%(n | f1 | f2:))
assert_equal([['f1', []], ['f2', []]], var.filters)
# missing argument throws error
assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) }
assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) }
# arg requires colon
assert_raises(SyntaxError) { create_variable(%(n | f1 1)) }
# trailing comma doesn't throw
create_variable(%(n | f1: 1, 2, 3, | f2:))
# missing comma throws error
assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) }
# positional and kwargs parsing
var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2))
assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters)
# positional and kwargs mixed
var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c'))
assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters)
# positional and kwargs intermixed (pos1, key1: val1, pos2)
var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"))
assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters)
# string key throws
assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) }
end
end
def test_output_raw_source_of_variable
@@ -163,7 +203,7 @@ class VariableUnitTest < Minitest::Test
end
def test_variable_lookup_interface
lookup = VariableLookup.parse('a.b.c')
lookup = VariableLookup.new('a.b.c')
assert_equal('a', lookup.name)
assert_equal(['b', 'c'], lookup.lookups)
end