mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-30 07:35:12 -07:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5bb4c8eff | ||
|
|
807d45a6b3 | ||
|
|
7a5e45fc47 | ||
|
|
9efca9f718 | ||
|
|
529800f46b | ||
|
|
7b368dffb8 | ||
|
|
742ac3dbf5 | ||
|
|
1954a2655c | ||
|
|
6d81b1b68c | ||
|
|
dfddd8f390 | ||
|
|
95ce7e7fa1 | ||
|
|
197d755e0c | ||
|
|
d0c5444db1 | ||
|
|
c99036046e | ||
|
|
a9c85622dd | ||
|
|
9f4d7e78b8 | ||
|
|
0d5c15a03e | ||
|
|
fd68d076dd | ||
|
|
96aa47d13f | ||
|
|
ad70c5c459 | ||
|
|
d824de701c | ||
|
|
346166b600 | ||
|
|
532b439063 | ||
|
|
dd37353cca |
@@ -22,6 +22,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
|
- { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" }
|
||||||
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
|
- { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" }
|
||||||
|
- { ruby: truffleruby, allowed-failure: false }
|
||||||
|
|
||||||
# Head can have failures due to being in development
|
# Head can have failures due to being in development
|
||||||
- { ruby: head, allowed-failure: true }
|
- { ruby: head, allowed-failure: true }
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.4.1
|
4.0.2
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ group :test do
|
|||||||
end
|
end
|
||||||
|
|
||||||
group :spec do
|
group :spec do
|
||||||
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main'
|
# 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
|
gem 'activesupport', require: false
|
||||||
end
|
end
|
||||||
|
|||||||
+12
@@ -1,5 +1,17 @@
|
|||||||
# Liquid Change Log
|
# Liquid Change Log
|
||||||
|
|
||||||
|
## 5.13.0
|
||||||
|
|
||||||
|
* Add TruffleRuby in CI [Benoit Daloze]
|
||||||
|
* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze]
|
||||||
|
* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy]
|
||||||
|
* Add strict2_parse to assign and capture tags by [Alok Swamy]
|
||||||
|
* Add strict2_parse to increment and decrement tags by [Alok Swamy]
|
||||||
|
* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer]
|
||||||
|
* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro]
|
||||||
|
* Fix `SelfDrop` equality [Guilherme Carreiro]
|
||||||
|
* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer]
|
||||||
|
|
||||||
## 5.11.0
|
## 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]
|
* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro]
|
||||||
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
|
* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro]
|
||||||
|
|||||||
@@ -118,6 +118,50 @@ This is useful for doing things like enabling strict mode only in the theme edit
|
|||||||
It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created.
|
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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ end
|
|||||||
|
|
||||||
desc('run liquid-spec suite across all adapters')
|
desc('run liquid-spec suite across all adapters')
|
||||||
task :spec do
|
task :spec do
|
||||||
adapters = Dir['./spec/*.rb'].join(',')
|
Dir['./spec/*.rb'].sort.each do |adapter|
|
||||||
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
|
puts "=== Running #{adapter} ==="
|
||||||
|
sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ require 'liquid/parser_switching'
|
|||||||
require 'liquid/tag'
|
require 'liquid/tag'
|
||||||
require 'liquid/block'
|
require 'liquid/block'
|
||||||
require 'liquid/parse_tree_visitor'
|
require 'liquid/parse_tree_visitor'
|
||||||
|
require 'liquid/closure_body'
|
||||||
|
require 'liquid/closure'
|
||||||
|
require 'liquid/call_expression'
|
||||||
require 'liquid/interrupts'
|
require 'liquid/interrupts'
|
||||||
require 'liquid/tags'
|
require 'liquid/tags'
|
||||||
require "liquid/environment"
|
require "liquid/environment"
|
||||||
@@ -65,6 +68,7 @@ require 'liquid/lexer'
|
|||||||
require 'liquid/parser'
|
require 'liquid/parser'
|
||||||
require 'liquid/i18n'
|
require 'liquid/i18n'
|
||||||
require 'liquid/drop'
|
require 'liquid/drop'
|
||||||
|
require 'liquid/self_drop'
|
||||||
require 'liquid/tablerowloop_drop'
|
require 'liquid/tablerowloop_drop'
|
||||||
require 'liquid/forloop_drop'
|
require 'liquid/forloop_drop'
|
||||||
require 'liquid/extensions'
|
require 'liquid/extensions'
|
||||||
|
|||||||
+14
-4
@@ -10,10 +10,13 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parse(tokens)
|
def parse(tokens)
|
||||||
@body = new_body
|
parse_block_body(tokens)
|
||||||
while parse_body(@body, tokens)
|
end
|
||||||
end
|
|
||||||
@body.freeze
|
# 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
|
end
|
||||||
|
|
||||||
# For backwards compatibility
|
# For backwards compatibility
|
||||||
@@ -66,6 +69,13 @@ module Liquid
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def parse_block_body(tokens)
|
||||||
|
@body = new_body
|
||||||
|
while parse_body(@body, tokens)
|
||||||
|
end
|
||||||
|
@body.freeze
|
||||||
|
end
|
||||||
|
|
||||||
# @api public
|
# @api public
|
||||||
def new_body
|
def new_body
|
||||||
parse_context.new_block_body
|
parse_context.new_block_body
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ module Liquid
|
|||||||
context.handle_error(exc, line_number)
|
context.handle_error(exc, line_number)
|
||||||
else
|
else
|
||||||
error_message = context.handle_error(exc, line_number)
|
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
|
output << error_message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+58
-2
@@ -162,6 +162,40 @@ module Liquid
|
|||||||
end
|
end
|
||||||
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
|
def clear_instance_assigns
|
||||||
@scopes[0] = {}
|
@scopes[0] = {}
|
||||||
end
|
end
|
||||||
@@ -187,6 +221,15 @@ module Liquid
|
|||||||
find_variable(key, raise_on_not_found: false) != nil
|
find_variable(key, raise_on_not_found: false) != nil
|
||||||
end
|
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)
|
def evaluate(object)
|
||||||
object.respond_to?(:evaluate) ? object.evaluate(self) : object
|
object.respond_to?(:evaluate) ? object.evaluate(self) : object
|
||||||
end
|
end
|
||||||
@@ -197,12 +240,21 @@ module Liquid
|
|||||||
# path and find_index() is optimized in MRI to reduce object allocation
|
# path and find_index() is optimized in MRI to reduce object allocation
|
||||||
index = @scopes.find_index { |s| s.key?(key) }
|
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
|
variable = if index
|
||||||
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
|
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
|
||||||
else
|
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
|
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
|
# update variable's context before invoking #to_liquid
|
||||||
variable.context = self if variable.respond_to?(:context=)
|
variable.context = self if variable.respond_to?(:context=)
|
||||||
|
|
||||||
@@ -244,13 +296,17 @@ module Liquid
|
|||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags
|
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags, :environments, :static_environments, :captured_bindings
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
attr_reader :base_scope_depth
|
attr_reader :base_scope_depth
|
||||||
|
|
||||||
def try_variable_find_in_environments(key, raise_on_not_found:)
|
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|
|
@environments.each do |environment|
|
||||||
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
|
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
|
if !found_variable.nil? || @strict_variables && raise_on_not_found
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parse(tokenizer, parse_context)
|
def parse(tokenizer, parse_context)
|
||||||
while parse_body(tokenizer)
|
@local_bindings = parse_context.with_document_scope do
|
||||||
|
while parse_body(tokenizer)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@body.freeze
|
@body.freeze
|
||||||
rescue SyntaxError => e
|
rescue SyntaxError => e
|
||||||
@@ -38,7 +40,9 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_to_output_buffer(context, output)
|
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
|
end
|
||||||
|
|
||||||
def render(context)
|
def render(context)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
class Expression
|
class Expression
|
||||||
|
SELF = 'self'
|
||||||
|
|
||||||
LITERALS = {
|
LITERALS = {
|
||||||
nil => nil,
|
nil => nil,
|
||||||
'nil' => nil,
|
'nil' => nil,
|
||||||
@@ -34,6 +36,7 @@ module Liquid
|
|||||||
|
|
||||||
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
def parse(markup, ss = StringScanner.new(""), cache = nil)
|
||||||
return unless markup
|
return unless markup
|
||||||
|
return markup unless markup.is_a?(String)
|
||||||
|
|
||||||
markup = markup.strip # markup can be a frozen string
|
markup = markup.strip # markup can be a frozen string
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
class Lexer
|
class Lexer
|
||||||
|
ASSIGN = [:assign, "="].freeze
|
||||||
CLOSE_ROUND = [:close_round, ")"].freeze
|
CLOSE_ROUND = [:close_round, ")"].freeze
|
||||||
CLOSE_SQUARE = [:close_square, "]"].freeze
|
CLOSE_SQUARE = [:close_square, "]"].freeze
|
||||||
COLON = [:colon, ":"].freeze
|
COLON = [:colon, ":"].freeze
|
||||||
@@ -133,6 +134,8 @@ module Liquid
|
|||||||
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
|
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
|
||||||
output << found
|
output << found
|
||||||
ss.scan_byte
|
ss.scan_byte
|
||||||
|
elsif peeked == "=".ord && ss.peek_byte != "<".ord && ss.peek_byte != ">".ord && ss.peek_byte != "!".ord
|
||||||
|
output << ASSIGN
|
||||||
else
|
else
|
||||||
raise_syntax_error(start_pos, ss)
|
raise_syntax_error(start_pos, ss)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ module Liquid
|
|||||||
|
|
||||||
def new_parser(input)
|
def new_parser(input)
|
||||||
@string_scanner.string = input
|
@string_scanner.string = input
|
||||||
Parser.new(@string_scanner)
|
Parser.new(
|
||||||
|
@string_scanner,
|
||||||
|
reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid,
|
||||||
|
allow_calls: @error_mode == :strict2,
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
|
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
|
||||||
@@ -60,8 +64,8 @@ module Liquid
|
|||||||
# compatibility while raising awareness about strict2 parser standards,
|
# compatibility while raising awareness about strict2 parser standards,
|
||||||
# the safe flag supports API users make a deliberate decision.
|
# the safe flag supports API users make a deliberate decision.
|
||||||
#
|
#
|
||||||
# In strict2 mode, markup MUST come from a string returned by the parser
|
# In strict2 mode, markup MUST come from parser.expression, which returns
|
||||||
# (e.g., parser.expression). We're not calling the parser here to
|
# markup or a parsed callable node. We're not calling the parser here to
|
||||||
# prevent redundant parser overhead.
|
# prevent redundant parser overhead.
|
||||||
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
|
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
|
||||||
end
|
end
|
||||||
@@ -69,6 +73,54 @@ module Liquid
|
|||||||
Expression.parse(markup, @string_scanner, @expression_cache)
|
Expression.parse(markup, @string_scanner, @expression_cache)
|
||||||
end
|
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)
|
def partial=(value)
|
||||||
@partial = value
|
@partial = value
|
||||||
@options = value ? partial_options : @template_options
|
@options = value ? partial_options : @template_options
|
||||||
|
|||||||
+78
-1
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
class Parser
|
class Parser
|
||||||
def initialize(input)
|
def initialize(input, reject_bare_brackets: false, allow_calls: false)
|
||||||
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
|
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
|
||||||
@tokens = Lexer.tokenize(ss)
|
@tokens = Lexer.tokenize(ss)
|
||||||
@p = 0 # pointer to current location
|
@p = 0 # pointer to current location
|
||||||
|
@reject_bare_brackets = reject_bare_brackets
|
||||||
|
@allow_calls = allow_calls
|
||||||
end
|
end
|
||||||
|
|
||||||
def jump(point)
|
def jump(point)
|
||||||
@@ -50,9 +52,14 @@ module Liquid
|
|||||||
token = @tokens[@p]
|
token = @tokens[@p]
|
||||||
case token[0]
|
case token[0]
|
||||||
when :id
|
when :id
|
||||||
|
return callable_or_lookup if @allow_calls
|
||||||
|
|
||||||
str = consume
|
str = consume
|
||||||
str << variable_lookups
|
str << variable_lookups
|
||||||
when :open_square
|
when :open_square
|
||||||
|
if @reject_bare_brackets
|
||||||
|
raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead"
|
||||||
|
end
|
||||||
str = consume.dup
|
str = consume.dup
|
||||||
str << expression
|
str << expression
|
||||||
str << consume(:close_square)
|
str << consume(:close_square)
|
||||||
@@ -65,12 +72,41 @@ module Liquid
|
|||||||
consume(:dotdot)
|
consume(:dotdot)
|
||||||
last = expression
|
last = expression
|
||||||
consume(:close_round)
|
consume(:close_round)
|
||||||
|
return RangeLookup.new(Expression.parse(first), Expression.parse(last)) unless first.is_a?(String) && last.is_a?(String)
|
||||||
|
|
||||||
"(#{first}..#{last})"
|
"(#{first}..#{last})"
|
||||||
else
|
else
|
||||||
raise SyntaxError, "#{token} is not a valid expression"
|
raise SyntaxError, "#{token} is not a valid expression"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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
|
def argument
|
||||||
str = +""
|
str = +""
|
||||||
# might be a keyword argument (identifier: expression)
|
# might be a keyword argument (identifier: expression)
|
||||||
@@ -98,5 +134,46 @@ module Liquid
|
|||||||
end
|
end
|
||||||
str
|
str
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def callable_or_lookup
|
||||||
|
target = callable_lookups(consume(:id))
|
||||||
|
return target unless consume?(:open_round)
|
||||||
|
|
||||||
|
arguments = []
|
||||||
|
unless look(:close_round)
|
||||||
|
arguments << Expression.parse(expression)
|
||||||
|
arguments << Expression.parse(expression) while consume?(:comma) && !look(:close_round)
|
||||||
|
end
|
||||||
|
consume(:close_round)
|
||||||
|
target = VariableLookup.parse(target) if target.is_a?(String)
|
||||||
|
CallExpression.new(target, arguments)
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Liquid
|
||||||
|
# @liquid_public_docs
|
||||||
|
# @liquid_type object
|
||||||
|
# @liquid_name self
|
||||||
|
# @liquid_summary
|
||||||
|
# Provides access to variables through the current scope chain.
|
||||||
|
# @liquid_description
|
||||||
|
# The `self` object resolves variables through the normal lookup hierarchy
|
||||||
|
# (local > file > global) without exposing filters, interrupts, errors,
|
||||||
|
# or other context internals. It's used when bare bracket notation
|
||||||
|
# (`['variable']`) needs to be replaced with an explicit variable lookup.
|
||||||
|
#
|
||||||
|
# If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`),
|
||||||
|
# then the local value takes precedence over the `self` object.
|
||||||
|
# @liquid_access global
|
||||||
|
class SelfDrop < Drop
|
||||||
|
def initialize(self_context)
|
||||||
|
super()
|
||||||
|
@self_context = self_context
|
||||||
|
end
|
||||||
|
|
||||||
|
def [](key)
|
||||||
|
@self_context.find_variable(key)
|
||||||
|
rescue UndefinedVariable
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def key?(key)
|
||||||
|
@self_context.variable_defined?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_liquid
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def ==(other)
|
||||||
|
other.is_a?(SelfDrop) && other.self_context.equal?(@self_context)
|
||||||
|
end
|
||||||
|
|
||||||
|
alias_method :eql?, :==
|
||||||
|
|
||||||
|
def hash
|
||||||
|
@self_context.object_id.hash
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
attr_reader :self_context
|
||||||
|
|
||||||
|
undef context=
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,10 +8,19 @@ module Liquid
|
|||||||
MAX_I32 = (1 << 31) - 1
|
MAX_I32 = (1 << 31) - 1
|
||||||
private_constant :MAX_I32
|
private_constant :MAX_I32
|
||||||
|
|
||||||
MIN_I64 = -(1 << 63)
|
supports_64bit_indices = begin
|
||||||
MAX_I64 = (1 << 63) - 1
|
[][1 << 33, 1 << 33]
|
||||||
I64_RANGE = MIN_I64..MAX_I64
|
true
|
||||||
private_constant :MIN_I64, :MAX_I64, :I64_RANGE
|
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 = {
|
HTML_ESCAPE = {
|
||||||
'&' => '&',
|
'&' => '&',
|
||||||
@@ -214,11 +223,11 @@ module Liquid
|
|||||||
Utils.to_s(input).slice(offset, length) || ''
|
Utils.to_s(input).slice(offset, length) || ''
|
||||||
end
|
end
|
||||||
rescue RangeError
|
rescue RangeError
|
||||||
if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset)
|
if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset)
|
||||||
raise # unexpected error
|
raise # unexpected error
|
||||||
end
|
end
|
||||||
offset = offset.clamp(I64_RANGE)
|
offset = offset.clamp(INDEX_RANGE)
|
||||||
length = length.clamp(I64_RANGE)
|
length = length.clamp(INDEX_RANGE)
|
||||||
retry
|
retry
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ require_relative "tags/break"
|
|||||||
require_relative "tags/inline_comment"
|
require_relative "tags/inline_comment"
|
||||||
require_relative "tags/for"
|
require_relative "tags/for"
|
||||||
require_relative "tags/assign"
|
require_relative "tags/assign"
|
||||||
|
require_relative "tags/let"
|
||||||
|
require_relative "tags/set"
|
||||||
require_relative "tags/ifchanged"
|
require_relative "tags/ifchanged"
|
||||||
require_relative "tags/case"
|
require_relative "tags/case"
|
||||||
require_relative "tags/include"
|
require_relative "tags/include"
|
||||||
@@ -37,6 +39,8 @@ module Liquid
|
|||||||
'case' => Case,
|
'case' => Case,
|
||||||
'ifchanged' => Ifchanged,
|
'ifchanged' => Ifchanged,
|
||||||
'assign' => Assign,
|
'assign' => Assign,
|
||||||
|
'let' => Let,
|
||||||
|
'set' => SetTag,
|
||||||
'for' => For,
|
'for' => For,
|
||||||
'#' => InlineComment,
|
'#' => InlineComment,
|
||||||
'break' => Break,
|
'break' => Break,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ module Liquid
|
|||||||
# @liquid_syntax_keyword variable_name The name of the variable being created.
|
# @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.
|
# @liquid_syntax_keyword value The value you want to assign to the variable.
|
||||||
class Assign < Tag
|
class Assign < Tag
|
||||||
|
include ParserSwitching
|
||||||
|
|
||||||
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
|
Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om
|
||||||
|
|
||||||
# @api private
|
# @api private
|
||||||
@@ -29,6 +31,12 @@ module Liquid
|
|||||||
|
|
||||||
def initialize(tag_name, markup, parse_context)
|
def initialize(tag_name, markup, parse_context)
|
||||||
super
|
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
|
if markup =~ Syntax
|
||||||
@to = Regexp.last_match(1)
|
@to = Regexp.last_match(1)
|
||||||
@from = Variable.new(Regexp.last_match(2), parse_context)
|
@from = Variable.new(Regexp.last_match(2), parse_context)
|
||||||
@@ -37,10 +45,29 @@ module Liquid
|
|||||||
end
|
end
|
||||||
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)
|
def render_to_output_buffer(context, output)
|
||||||
val = @from.render(context)
|
val = @from.render(context)
|
||||||
context.scopes.last[@to] = val
|
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
|
output
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -48,9 +75,8 @@ module Liquid
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
# @api private
|
||||||
|
def self.assign_score_of(val)
|
||||||
def assign_score_of(val)
|
|
||||||
if val.instance_of?(String)
|
if val.instance_of?(String)
|
||||||
val.bytesize
|
val.bytesize
|
||||||
elsif val.instance_of?(Array)
|
elsif val.instance_of?(Array)
|
||||||
|
|||||||
@@ -20,10 +20,18 @@ module Liquid
|
|||||||
# @liquid_syntax_keyword variable The name of the variable being created.
|
# @liquid_syntax_keyword variable The name of the variable being created.
|
||||||
# @liquid_syntax_keyword value The value you want to assign to the variable.
|
# @liquid_syntax_keyword value The value you want to assign to the variable.
|
||||||
class Capture < Block
|
class Capture < Block
|
||||||
|
include ParserSwitching
|
||||||
|
|
||||||
Syntax = /(#{VariableSignature}+)/o
|
Syntax = /(#{VariableSignature}+)/o
|
||||||
|
|
||||||
|
attr_reader :to
|
||||||
|
|
||||||
def initialize(tag_name, markup, options)
|
def initialize(tag_name, markup, options)
|
||||||
super
|
super
|
||||||
|
parse_with_selected_parser(markup)
|
||||||
|
end
|
||||||
|
|
||||||
|
def lax_parse(markup)
|
||||||
if markup =~ Syntax
|
if markup =~ Syntax
|
||||||
@to = Regexp.last_match(1)
|
@to = Regexp.last_match(1)
|
||||||
else
|
else
|
||||||
@@ -31,6 +39,16 @@ module Liquid
|
|||||||
end
|
end
|
||||||
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)
|
def render_to_output_buffer(context, output)
|
||||||
context.resource_limits.with_capture do
|
context.resource_limits.with_capture do
|
||||||
capture_output = render(context)
|
capture_output = render(context)
|
||||||
|
|||||||
@@ -23,13 +23,29 @@ module Liquid
|
|||||||
# {% decrement variable_name %}
|
# {% decrement variable_name %}
|
||||||
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
|
# @liquid_syntax_keyword variable_name The name of the variable being decremented.
|
||||||
class Decrement < Tag
|
class Decrement < Tag
|
||||||
|
include ParserSwitching
|
||||||
|
|
||||||
attr_reader :variable_name
|
attr_reader :variable_name
|
||||||
|
|
||||||
def initialize(tag_name, markup, options)
|
def initialize(tag_name, markup, options)
|
||||||
super
|
super
|
||||||
|
parse_with_selected_parser(markup)
|
||||||
|
end
|
||||||
|
|
||||||
|
def lax_parse(markup)
|
||||||
@variable_name = markup.strip
|
@variable_name = markup.strip
|
||||||
end
|
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)
|
def render_to_output_buffer(context, output)
|
||||||
counter_environment = context.environments.first
|
counter_environment = context.environments.first
|
||||||
value = counter_environment[@variable_name] || 0
|
value = counter_environment[@variable_name] || 0
|
||||||
|
|||||||
+28
-16
@@ -38,15 +38,10 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parse(tokens)
|
def parse(tokens)
|
||||||
if parse_body(@for_block, tokens)
|
parse_context.with_lexical_scope do
|
||||||
parse_body(@else_block, tokens)
|
parse_loop(tokens)
|
||||||
|
@has_local_declarations = parse_context.local_declarations?
|
||||||
end
|
end
|
||||||
if blank?
|
|
||||||
@else_block&.remove_blank_strings
|
|
||||||
@for_block.remove_blank_strings
|
|
||||||
end
|
|
||||||
@else_block&.freeze
|
|
||||||
@for_block.freeze
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def nodelist
|
def nodelist
|
||||||
@@ -92,10 +87,10 @@ module Liquid
|
|||||||
@variable_name = p.consume(:id)
|
@variable_name = p.consume(:id)
|
||||||
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
|
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
|
||||||
|
|
||||||
collection_name = p.expression
|
collection_name, collection_markup = p.expression_with_markup
|
||||||
@collection_name = parse_expression(collection_name, safe: true)
|
@collection_name = parse_expression(collection_name, safe: true)
|
||||||
|
|
||||||
@name = "#{@variable_name}-#{collection_name}"
|
@name = "#{@variable_name}-#{collection_markup}"
|
||||||
@reversed = p.id?('reversed')
|
@reversed = p.id?('reversed')
|
||||||
|
|
||||||
while p.look(:comma) || p.look(:id)
|
while p.look(:comma) || p.look(:id)
|
||||||
@@ -111,6 +106,18 @@ module Liquid
|
|||||||
|
|
||||||
private
|
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)
|
def strict2_parse(markup)
|
||||||
strict_parse(markup)
|
strict_parse(markup)
|
||||||
end
|
end
|
||||||
@@ -161,7 +168,7 @@ module Liquid
|
|||||||
|
|
||||||
segment.each do |item|
|
segment.each do |item|
|
||||||
context[@variable_name] = item
|
context[@variable_name] = item
|
||||||
@for_block.render_to_output_buffer(context, output)
|
render_iteration(context, output)
|
||||||
loop_vars.send(:increment!)
|
loop_vars.send(:increment!)
|
||||||
|
|
||||||
# Handle any interrupts if they exist.
|
# Handle any interrupts if they exist.
|
||||||
@@ -178,6 +185,12 @@ module Liquid
|
|||||||
output
|
output
|
||||||
end
|
end
|
||||||
|
|
||||||
|
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)
|
def set_attribute(key, expr, safe: false)
|
||||||
case key
|
case key
|
||||||
when 'offset'
|
when 'offset'
|
||||||
@@ -192,11 +205,10 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_else(context, output)
|
def render_else(context, output)
|
||||||
if @else_block
|
return output unless @else_block
|
||||||
@else_block.render_to_output_buffer(context, output)
|
return @else_block.render_to_output_buffer(context, output) unless @has_local_declarations
|
||||||
else
|
|
||||||
output
|
context.stack { @else_block.render_to_output_buffer(context, output) }
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ module Liquid
|
|||||||
class Include < Tag
|
class Include < Tag
|
||||||
prepend Tag::Disableable
|
prepend Tag::Disableable
|
||||||
|
|
||||||
SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
FOR = 'for'
|
||||||
|
SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o
|
||||||
Syntax = SYNTAX
|
Syntax = SYNTAX
|
||||||
|
|
||||||
attr_reader :template_name_expr, :variable_name_expr, :attributes
|
attr_reader :template_name_expr, :variable_name_expr, :attributes
|
||||||
@@ -84,12 +85,18 @@ module Liquid
|
|||||||
alias_method :parse_context, :options
|
alias_method :parse_context, :options
|
||||||
private :parse_context
|
private :parse_context
|
||||||
|
|
||||||
|
def for_loop?
|
||||||
|
@is_for_loop
|
||||||
|
end
|
||||||
|
|
||||||
def strict2_parse(markup)
|
def strict2_parse(markup)
|
||||||
p = @parse_context.new_parser(markup)
|
p = @parse_context.new_parser(markup)
|
||||||
|
|
||||||
@template_name_expr = safe_parse_expression(p)
|
@template_name_expr = safe_parse_expression(p)
|
||||||
@variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with")
|
with_or_for = p.id?("for") || p.id?("with")
|
||||||
|
@variable_name_expr = safe_parse_expression(p) if with_or_for
|
||||||
@alias_name = p.consume(:id) if p.id?("as")
|
@alias_name = p.consume(:id) if p.id?("as")
|
||||||
|
@is_for_loop = (with_or_for == FOR)
|
||||||
|
|
||||||
p.consume?(:comma)
|
p.consume?(:comma)
|
||||||
|
|
||||||
@@ -111,11 +118,13 @@ module Liquid
|
|||||||
def lax_parse(markup)
|
def lax_parse(markup)
|
||||||
if markup =~ SYNTAX
|
if markup =~ SYNTAX
|
||||||
template_name = Regexp.last_match(1)
|
template_name = Regexp.last_match(1)
|
||||||
variable_name = Regexp.last_match(3)
|
with_or_for = Regexp.last_match(3)
|
||||||
|
variable_name = Regexp.last_match(4)
|
||||||
|
|
||||||
@alias_name = Regexp.last_match(5)
|
@alias_name = Regexp.last_match(6)
|
||||||
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
@variable_name_expr = variable_name ? parse_expression(variable_name) : nil
|
||||||
@template_name_expr = parse_expression(template_name)
|
@template_name_expr = parse_expression(template_name)
|
||||||
|
@is_for_loop = (with_or_for == FOR)
|
||||||
@attributes = {}
|
@attributes = {}
|
||||||
|
|
||||||
markup.scan(TagAttributes) do |key, value|
|
markup.scan(TagAttributes) do |key, value|
|
||||||
|
|||||||
@@ -23,13 +23,29 @@ module Liquid
|
|||||||
# {% increment variable_name %}
|
# {% increment variable_name %}
|
||||||
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
|
# @liquid_syntax_keyword variable_name The name of the variable being incremented.
|
||||||
class Increment < Tag
|
class Increment < Tag
|
||||||
|
include ParserSwitching
|
||||||
|
|
||||||
attr_reader :variable_name
|
attr_reader :variable_name
|
||||||
|
|
||||||
def initialize(tag_name, markup, options)
|
def initialize(tag_name, markup, options)
|
||||||
super
|
super
|
||||||
|
parse_with_selected_parser(markup)
|
||||||
|
end
|
||||||
|
|
||||||
|
def lax_parse(markup)
|
||||||
@variable_name = markup.strip
|
@variable_name = markup.strip
|
||||||
end
|
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)
|
def render_to_output_buffer(context, output)
|
||||||
counter_environment = context.environments.first
|
counter_environment = context.environments.first
|
||||||
value = counter_environment[@variable_name] || 0
|
value = counter_environment[@variable_name] || 0
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -34,6 +34,13 @@ module Liquid
|
|||||||
parse_with_selected_parser(markup)
|
parse_with_selected_parser(markup)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parse(tokens)
|
||||||
|
parse_context.with_lexical_scope do
|
||||||
|
super
|
||||||
|
@has_local_declarations = parse_context.local_declarations?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def strict2_parse(markup)
|
def strict2_parse(markup)
|
||||||
p = @parse_context.new_parser(markup)
|
p = @parse_context.new_parser(markup)
|
||||||
|
|
||||||
@@ -99,7 +106,11 @@ module Liquid
|
|||||||
context[@variable_name] = item
|
context[@variable_name] = item
|
||||||
|
|
||||||
output << "<td class=\"col#{tablerowloop.col}\">"
|
output << "<td class=\"col#{tablerowloop.col}\">"
|
||||||
super
|
if @has_local_declarations
|
||||||
|
context.stack { super }
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
output << '</td>'
|
output << '</td>'
|
||||||
|
|
||||||
# Handle any interrupts if they exist.
|
# Handle any interrupts if they exist.
|
||||||
|
|||||||
+13
-2
@@ -151,8 +151,10 @@ module Liquid
|
|||||||
|
|
||||||
c
|
c
|
||||||
when Liquid::Drop
|
when Liquid::Drop
|
||||||
drop = args.shift
|
drop = args.shift
|
||||||
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
||||||
|
drop.context = c if drop.respond_to?(:context=)
|
||||||
|
c
|
||||||
when Hash
|
when Hash
|
||||||
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
|
||||||
when nil
|
when nil
|
||||||
@@ -187,12 +189,20 @@ module Liquid
|
|||||||
|
|
||||||
context.template_name ||= name
|
context.template_name ||= name
|
||||||
|
|
||||||
|
previous_error_mode = context.registers.static[:template_error_mode]
|
||||||
|
context.registers.static[:template_error_mode] = @error_mode
|
||||||
|
|
||||||
begin
|
begin
|
||||||
# render the nodelist.
|
# render the nodelist.
|
||||||
@root.render_to_output_buffer(context, output || +'')
|
@root.render_to_output_buffer(context, output || +'')
|
||||||
rescue Liquid::MemoryError => e
|
rescue Liquid::MemoryError => e
|
||||||
context.handle_error(e)
|
context.handle_error(e)
|
||||||
ensure
|
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
|
@errors = context.errors
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -224,6 +234,7 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
@warnings = parse_context.warnings
|
@warnings = parse_context.warnings
|
||||||
|
@error_mode = parse_context.error_mode
|
||||||
parse_context
|
parse_context
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+12
-3
@@ -25,7 +25,7 @@ module Liquid
|
|||||||
include ParserSwitching
|
include ParserSwitching
|
||||||
|
|
||||||
def initialize(markup, parse_context)
|
def initialize(markup, parse_context)
|
||||||
@markup = markup
|
@markup = markup.is_a?(Parser) ? markup.remaining_markup : markup
|
||||||
@name = nil
|
@name = nil
|
||||||
@parse_context = parse_context
|
@parse_context = parse_context
|
||||||
@line_number = parse_context.line_number
|
@line_number = parse_context.line_number
|
||||||
@@ -37,7 +37,12 @@ module Liquid
|
|||||||
@markup
|
@markup
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def ==(other)
|
||||||
|
self.class == other.class && name == other.name && filters == other.filters
|
||||||
|
end
|
||||||
|
|
||||||
def markup_context(markup)
|
def markup_context(markup)
|
||||||
|
markup = @markup if markup.is_a?(Parser)
|
||||||
"in \"{{#{markup}}}\""
|
"in \"{{#{markup}}}\""
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -76,7 +81,7 @@ module Liquid
|
|||||||
|
|
||||||
def strict2_parse(markup)
|
def strict2_parse(markup)
|
||||||
@filters = []
|
@filters = []
|
||||||
p = @parse_context.new_parser(markup)
|
p = markup.is_a?(Parser) ? markup : @parse_context.new_parser(markup)
|
||||||
|
|
||||||
return if p.look(:end_of_string)
|
return if p.look(:end_of_string)
|
||||||
|
|
||||||
@@ -105,6 +110,10 @@ module Liquid
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render_to_output_buffer(context, output)
|
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)
|
obj = render(context)
|
||||||
render_obj_to_output(obj, output)
|
render_obj_to_output(obj, output)
|
||||||
output
|
output
|
||||||
@@ -203,7 +212,7 @@ module Liquid
|
|||||||
|
|
||||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||||
def children
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ module Liquid
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @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)
|
def lookup_command?(lookup_index)
|
||||||
@command_flags & (1 << lookup_index) != 0
|
@command_flags & (1 << lookup_index) != 0
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Liquid
|
module Liquid
|
||||||
VERSION = "5.12.0"
|
VERSION = "5.13.0"
|
||||||
end
|
end
|
||||||
|
|||||||
+16
-3
@@ -6,14 +6,24 @@
|
|||||||
|
|
||||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||||
require 'liquid'
|
require 'liquid'
|
||||||
|
require_relative 'support/liquid_spec_adapter_helper'
|
||||||
|
|
||||||
LiquidSpec.configure do |config|
|
LiquidSpec.configure do |config|
|
||||||
# Run core Liquid specs
|
config.missing_features = [
|
||||||
config.features = [:core]
|
:activesupport,
|
||||||
|
:lax_parsing,
|
||||||
|
:shopify_filters,
|
||||||
|
:shopify_includes,
|
||||||
|
:shopify_blank,
|
||||||
|
:shopify_error_handling,
|
||||||
|
:shopify_error_format,
|
||||||
|
:shopify_string_access,
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compile a template string into a Liquid::Template
|
# Compile a template string into a Liquid::Template
|
||||||
LiquidSpec.compile do |ctx, source, options|
|
LiquidSpec.compile do |ctx, source, options|
|
||||||
|
options[:error_mode] ||= :strict
|
||||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -28,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
|||||||
static_environments: assigns,
|
static_environments: assigns,
|
||||||
registers: registers,
|
registers: registers,
|
||||||
rethrow_errors: options[:strict_errors],
|
rethrow_errors: options[:strict_errors],
|
||||||
|
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||||
)
|
)
|
||||||
|
|
||||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
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
|
end
|
||||||
|
|||||||
+16
-4
@@ -6,15 +6,24 @@
|
|||||||
|
|
||||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||||
require 'liquid'
|
require 'liquid'
|
||||||
|
require_relative 'support/liquid_spec_adapter_helper'
|
||||||
|
|
||||||
LiquidSpec.configure do |config|
|
LiquidSpec.configure do |config|
|
||||||
config.features = [:core, :lax_parsing]
|
config.missing_features = [
|
||||||
|
:activesupport,
|
||||||
|
:shopify_filters,
|
||||||
|
:shopify_includes,
|
||||||
|
:shopify_blank,
|
||||||
|
:shopify_error_handling,
|
||||||
|
:shopify_error_format,
|
||||||
|
:shopify_string_access,
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compile a template string into a Liquid::Template
|
# Compile a template string into a Liquid::Template
|
||||||
LiquidSpec.compile do |ctx, source, options|
|
LiquidSpec.compile do |ctx, source, options|
|
||||||
# Force lax mode
|
# Default to lax mode while still honoring specs that explicitly set error_mode.
|
||||||
options = options.merge(error_mode: :lax)
|
options = { error_mode: :lax }.merge(options)
|
||||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -26,9 +35,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
|||||||
static_environments: assigns,
|
static_environments: assigns,
|
||||||
registers: registers,
|
registers: registers,
|
||||||
rethrow_errors: options[:strict_errors],
|
rethrow_errors: options[:strict_errors],
|
||||||
|
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||||
)
|
)
|
||||||
|
|
||||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
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
|
end
|
||||||
|
|||||||
@@ -7,14 +7,23 @@
|
|||||||
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
||||||
require 'active_support/all'
|
require 'active_support/all'
|
||||||
require 'liquid'
|
require 'liquid'
|
||||||
|
require_relative 'support/liquid_spec_adapter_helper'
|
||||||
|
|
||||||
LiquidSpec.configure do |config|
|
LiquidSpec.configure do |config|
|
||||||
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
|
config.missing_features = [
|
||||||
config.features = [:core, :activesupport]
|
:lax_parsing,
|
||||||
|
:shopify_filters,
|
||||||
|
:shopify_includes,
|
||||||
|
:shopify_blank,
|
||||||
|
:shopify_error_handling,
|
||||||
|
:shopify_error_format,
|
||||||
|
:shopify_string_access,
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compile a template string into a Liquid::Template
|
# Compile a template string into a Liquid::Template
|
||||||
LiquidSpec.compile do |ctx, source, options|
|
LiquidSpec.compile do |ctx, source, options|
|
||||||
|
options[:error_mode] ||= :strict
|
||||||
ctx[:template] = Liquid::Template.parse(source, **options)
|
ctx[:template] = Liquid::Template.parse(source, **options)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -29,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
|||||||
static_environments: assigns,
|
static_environments: assigns,
|
||||||
registers: registers,
|
registers: registers,
|
||||||
rethrow_errors: options[:strict_errors],
|
rethrow_errors: options[:strict_errors],
|
||||||
|
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||||
)
|
)
|
||||||
|
|
||||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
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
|
end
|
||||||
|
|||||||
@@ -13,9 +13,18 @@ end
|
|||||||
|
|
||||||
require 'active_support/all'
|
require 'active_support/all'
|
||||||
require 'liquid'
|
require 'liquid'
|
||||||
|
require_relative 'support/liquid_spec_adapter_helper'
|
||||||
|
|
||||||
LiquidSpec.configure do |config|
|
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
|
end
|
||||||
|
|
||||||
# Compile a template string into a Liquid::Template
|
# Compile a template string into a Liquid::Template
|
||||||
@@ -33,9 +42,12 @@ LiquidSpec.render do |ctx, assigns, options|
|
|||||||
static_environments: assigns,
|
static_environments: assigns,
|
||||||
registers: registers,
|
registers: registers,
|
||||||
rethrow_errors: options[:strict_errors],
|
rethrow_errors: options[:strict_errors],
|
||||||
|
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
|
||||||
)
|
)
|
||||||
|
|
||||||
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module LiquidSpecAdapterHelper
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def resource_limits(render_options)
|
||||||
|
return unless render_options[:resource_limits]
|
||||||
|
|
||||||
|
Liquid::ResourceLimits.new({}).tap do |limits|
|
||||||
|
render_options[:resource_limits].each do |key, value|
|
||||||
|
limits.public_send(:"#{key}=", value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_frozen_time(&block)
|
||||||
|
original_tz = ENV['TZ']
|
||||||
|
ENV['TZ'] = 'UTC'
|
||||||
|
|
||||||
|
Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block)
|
||||||
|
ensure
|
||||||
|
ENV['TZ'] = original_tz
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -97,6 +97,46 @@ class AssignTest < Minitest::Test
|
|||||||
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
|
assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd'))
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
class ObjectWrapperDrop < Liquid::Drop
|
class ObjectWrapperDrop < Liquid::Drop
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'test_helper'
|
||||||
|
|
||||||
|
class BlankBodyErrorHandlingTest < Minitest::Test
|
||||||
|
COMPARISON_ERROR = 'Liquid error (line 1): comparison of Integer with String failed'
|
||||||
|
INVALID_INTEGER_ERROR = 'Liquid error (line 1): invalid integer'
|
||||||
|
|
||||||
|
def render_inline(source, error_mode:, assigns: {})
|
||||||
|
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render(assigns, render_errors: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_render_raises(source, error_mode:, assigns: {}, message: nil)
|
||||||
|
error = assert_raises(Liquid::ArgumentError) do
|
||||||
|
Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns)
|
||||||
|
end
|
||||||
|
assert_includes(error.message, message) if message
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict
|
||||||
|
[:lax, :strict].each do |mode|
|
||||||
|
assert_equal('', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict
|
||||||
|
[:lax, :strict].each do |mode|
|
||||||
|
assert_equal('', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict
|
||||||
|
[:lax, :strict].each do |mode|
|
||||||
|
assert_equal('', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_blank_if_body_shows_inline_error_text
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_whitespace_if_body_shows_inline_error_text
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_assign_if_body_shows_inline_error_text
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_comment_if_body_shows_inline_error_text
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_capture_if_body_shows_inline_error_text
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_blank_unless_body_shows_inline_error_text
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_strict2_blank_for_body_shows_inline_error_text
|
||||||
|
assert_equal(INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }))
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_nonblank_bodies_show_inline_error_text_in_all_modes
|
||||||
|
[:lax, :strict, :strict2].each do |mode|
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode))
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode))
|
||||||
|
assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_raised_errors_are_not_swallowed_by_blank_if_body
|
||||||
|
[:lax, :strict, :strict2].each do |mode|
|
||||||
|
assert_render_raises('{% if 5 > "x" %}{% endif %}', error_mode: mode, message: 'comparison of Integer with String failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_raised_errors_are_not_swallowed_by_blank_for_body
|
||||||
|
[:lax, :strict, :strict2].each do |mode|
|
||||||
|
assert_render_raises('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }, message: 'invalid integer')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -6,7 +6,11 @@ class CaptureTest < Minitest::Test
|
|||||||
include Liquid
|
include Liquid
|
||||||
|
|
||||||
def test_captures_block_content_in_variable
|
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
|
end
|
||||||
|
|
||||||
def test_capture_with_hyphen_in_variable_name
|
def test_capture_with_hyphen_in_variable_name
|
||||||
@@ -49,4 +53,35 @@ class CaptureTest < Minitest::Test
|
|||||||
t.render!
|
t.render!
|
||||||
assert_equal(9, t.resource_limits.assign_score)
|
assert_equal(9, t.resource_limits.assign_score)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -296,8 +296,8 @@ class ContextTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
def test_access_variable_with_hash_notation
|
def test_access_variable_with_hash_notation
|
||||||
assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" })
|
assert_template_result('baz', '{{ foo }}', { "foo" => "baz" })
|
||||||
assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
|
assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' })
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_access_hashes_with_hash_access_variables
|
def test_access_hashes_with_hash_access_variables
|
||||||
|
|||||||
@@ -268,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
|
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)
|
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
|
source = "{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}"
|
||||||
assert_equal("0", output)
|
[: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
|
end
|
||||||
|
|
||||||
def test_syntax_error_is_raised_with_template_name
|
def test_syntax_error_is_raised_with_template_name
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,32 +44,39 @@ class SecurityTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
def test_does_not_permanently_add_filters_to_symbol_table
|
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
|
GC.start
|
||||||
# in uninitialized memory being marked. This can even result in the test failing
|
end
|
||||||
# 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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_does_not_add_drop_methods_to_symbol_table
|
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
|
current_symbols = Symbol.all_symbols
|
||||||
|
yield
|
||||||
assigns = { 'drop' => Drop.new }
|
|
||||||
assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!)
|
|
||||||
assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!)
|
|
||||||
assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!)
|
|
||||||
|
|
||||||
assert_equal([], Symbol.all_symbols - current_symbols)
|
assert_equal([], Symbol.all_symbols - current_symbols)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1181,6 +1181,8 @@ class StandardFiltersTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
def test_all_filters_never_raise_non_liquid_exception
|
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 = TestDrop.new(value: "test")
|
||||||
test_drop.context = Context.new
|
test_drop.context = Context.new
|
||||||
test_enum = TestEnumerable.new
|
test_enum = TestEnumerable.new
|
||||||
|
|||||||
@@ -105,10 +105,8 @@ class CycleTagTest < Minitest::Test
|
|||||||
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) }
|
||||||
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) }
|
||||||
|
|
||||||
expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/
|
assert_match(/Liquid syntax error:/, error1.message)
|
||||||
|
assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message)
|
||||||
assert_match(expected_error, error1.message)
|
|
||||||
assert_match(expected_error, error2.message)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -439,4 +439,49 @@ class IncludeTagTest < Minitest::Test
|
|||||||
assert_match(/Unexpected character =/, error.message)
|
assert_match(/Unexpected character =/, error.message)
|
||||||
end
|
end
|
||||||
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
|
end # IncludeTagTest
|
||||||
|
|||||||
@@ -27,4 +27,50 @@ class IncrementTagTest < Minitest::Test
|
|||||||
'{%decrement starboard %}',
|
'{%decrement starboard %}',
|
||||||
)
|
)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -174,16 +174,16 @@ class RenderTagTest < Minitest::Test
|
|||||||
def test_increment_is_isolated_between_renders
|
def test_increment_is_isolated_between_renders
|
||||||
assert_template_result(
|
assert_template_result(
|
||||||
'010',
|
'010',
|
||||||
'{% increment %}{% increment %}{% render "incr" %}',
|
'{% increment port %}{% increment port %}{% render "incr" %}',
|
||||||
partials: { 'incr' => '{% increment %}' },
|
partials: { 'incr' => '{% increment port %}' },
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_decrement_is_isolated_between_renders
|
def test_decrement_is_isolated_between_renders
|
||||||
assert_template_result(
|
assert_template_result(
|
||||||
'-1-2-1',
|
'-1-2-1',
|
||||||
'{% decrement %}{% decrement %}{% render "decr" %}',
|
'{% decrement port %}{% decrement port %}{% render "decr" %}',
|
||||||
partials: { 'decr' => '{% decrement %}' },
|
partials: { 'decr' => '{% decrement port %}' },
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class VariableTest < Minitest::Test
|
|||||||
|
|
||||||
def test_expression_with_whitespace_in_square_brackets
|
def test_expression_with_whitespace_in_square_brackets
|
||||||
assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } })
|
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
|
end
|
||||||
|
|
||||||
def test_ignore_unknown
|
def test_ignore_unknown
|
||||||
@@ -135,17 +135,17 @@ class VariableTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
|
|
||||||
def test_dynamic_find_var
|
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
|
end
|
||||||
|
|
||||||
def test_raw_value_variable
|
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
|
end
|
||||||
|
|
||||||
def test_dynamic_find_var_with_drop
|
def test_dynamic_find_var_with_drop
|
||||||
assert_template_result(
|
assert_template_result(
|
||||||
'bar',
|
'bar',
|
||||||
'{{ [list[settings.zero]] }}',
|
'{{ self[list[settings.zero]] }}',
|
||||||
{
|
{
|
||||||
'list' => ['foo'],
|
'list' => ['foo'],
|
||||||
'settings' => SettingsDrop.new("zero" => 0),
|
'settings' => SettingsDrop.new("zero" => 0),
|
||||||
@@ -155,7 +155,7 @@ class VariableTest < Minitest::Test
|
|||||||
|
|
||||||
assert_template_result(
|
assert_template_result(
|
||||||
'foo',
|
'foo',
|
||||||
'{{ [list[settings.zero]["foo"]] }}',
|
'{{ self[list[settings.zero]["foo"]] }}',
|
||||||
{
|
{
|
||||||
'list' => [{ 'foo' => 'bar' }],
|
'list' => [{ 'foo' => 'bar' }],
|
||||||
'settings' => SettingsDrop.new("zero" => 0),
|
'settings' => SettingsDrop.new("zero" => 0),
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class LexerUnitTest < Minitest::Test
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_assignment_and_comparison_tokens
|
||||||
|
assert_equal(
|
||||||
|
[[:id, 'n'], [:assign, '='], [:number, '1'], [:comparison, '=='], [:number, '2'], [:end_of_string]],
|
||||||
|
tokenize('n=1==2'),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def test_comparison
|
def test_comparison
|
||||||
assert_equal(
|
assert_equal(
|
||||||
[[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
|
[[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
|
||||||
|
|||||||
@@ -82,6 +82,78 @@ class ParserUnitTest < Minitest::Test
|
|||||||
end
|
end
|
||||||
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
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def new_parser(str)
|
def new_parser(str)
|
||||||
|
|||||||
Reference in New Issue
Block a user