diff --git a/README.md b/README.md
index 5066dc65..2f4295ba 100644
--- a/README.md
+++ b/README.md
@@ -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 also recommended that you use it in the template editors of existing apps to give editors better error messages.
+### Strict2 bindings and closures
+
+In `error_mode: :strict2`, `let` binds a value or defines a callable Liquid body:
+
+```liquid
+{% let count = 1 %}
+{% let title(step) %}
+ {% let count = count | plus: step %}
+ {{ count }}
+{% endlet %}
+{{ title(2) }}
+```
+
+Each call has fresh locals and writable parameters. Captures read the latest parent
+bindings. `set name = expression` updates the nearest local binding, or creates a
+shadow at the invocation root when the name is captured or absent. Initializers run
+before installing the new binding. Loops have fresh `let` bindings each iteration
+and can shadow outer names; conditional branches share their surrounding scope.
+Duplicate `let` declarations in one scope
+and `assign` inside closure bodies are parse errors. Calls accept positional
+arguments and invoke only Liquid closures.
+
+Custom `Liquid::Block` subclasses can use the same body rules:
+
+```ruby
+def parse(tokens)
+ @closure_body = parse_closure_body(tokens)
+end
+
+def render_to_output_buffer(context, output)
+ body = @closure_body.bind(context)
+ exports = body.invoke(context, output: output)
+ exports['title']&.render_to_output_buffer(context, output, ['Hello'])
+ output
+end
+```
+
+`invoke` renders into the supplied buffer and returns direct-child body-form `let`
+bindings by name. Value bindings and declarations nested in control flow are not
+exported. Each invocation has fresh state; bind the body at render time rather than
+caching a closure on the parsed tag. For parameterized bodies, pass
+`parameters: ['name']` to `parse_closure_body`, then pass an argument array as the
+second argument to `invoke`. The syntax remains visible to `ParseTreeVisitor`.
+
### Undefined variables and filters
By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method.
diff --git a/lib/liquid.rb b/lib/liquid.rb
index dce08977..fe505da8 100644
--- a/lib/liquid.rb
+++ b/lib/liquid.rb
@@ -58,6 +58,9 @@ require 'liquid/parser_switching'
require 'liquid/tag'
require 'liquid/block'
require 'liquid/parse_tree_visitor'
+require 'liquid/closure_body'
+require 'liquid/closure'
+require 'liquid/call_expression'
require 'liquid/interrupts'
require 'liquid/tags'
require "liquid/environment"
diff --git a/lib/liquid/block.rb b/lib/liquid/block.rb
index 73d86c7b..d9edfd8a 100644
--- a/lib/liquid/block.rb
+++ b/lib/liquid/block.rb
@@ -10,10 +10,13 @@ module Liquid
end
def parse(tokens)
- @body = new_body
- while parse_body(@body, tokens)
- end
- @body.freeze
+ parse_block_body(tokens)
+ end
+
+ # Parse an external block body with closure validation and lexical declarations.
+ def parse_closure_body(tokens, parameters: Const::EMPTY_ARRAY)
+ parse_context.with_closure(parameters) { parse_block_body(tokens) }
+ ClosureBody.new(@body, parameters)
end
# For backwards compatibility
@@ -66,6 +69,13 @@ module Liquid
private
+ def parse_block_body(tokens)
+ @body = new_body
+ while parse_body(@body, tokens)
+ end
+ @body.freeze
+ end
+
# @api public
def new_body
parse_context.new_block_body
diff --git a/lib/liquid/call_expression.rb b/lib/liquid/call_expression.rb
new file mode 100644
index 00000000..112604b1
--- /dev/null
+++ b/lib/liquid/call_expression.rb
@@ -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
diff --git a/lib/liquid/closure.rb b/lib/liquid/closure.rb
new file mode 100644
index 00000000..cde0ad4e
--- /dev/null
+++ b/lib/liquid/closure.rb
@@ -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
diff --git a/lib/liquid/closure_body.rb b/lib/liquid/closure_body.rb
new file mode 100644
index 00000000..803e7944
--- /dev/null
+++ b/lib/liquid/closure_body.rb
@@ -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
diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb
index 30492578..81acccf6 100644
--- a/lib/liquid/context.rb
+++ b/lib/liquid/context.rb
@@ -162,6 +162,40 @@ module Liquid
end
end
+ # @api private
+ def new_closure_subcontext(scopes, environments, static_environments)
+ new_isolated_subcontext.tap do |invocation|
+ captures = Closure::CapturedBindings.new(scopes, environments + static_environments, invocation)
+ invocation.captured_bindings = captures
+ invocation.environments = [captures]
+ invocation.static_environments = static_environments
+ invocation.strict_variables = strict_variables
+ invocation.strict_filters = strict_filters
+ invocation.global_filter = global_filter
+ invocation.template_name = template_name
+ invocation.partial = partial
+ invocation.base_scope_depth = base_scope_depth + @scopes.length
+ end
+ end
+
+ # @api private
+ def with_binding_scope
+ previous_root = @binding_root
+ stack do
+ @binding_root = @scopes.first
+ yield
+ end
+ ensure
+ @binding_root = previous_root
+ end
+
+ # @api private
+ def set_local(name, value)
+ root = @binding_root || @scopes.last
+ scope = @scopes.find { |owned| owned.key?(name) || owned.equal?(root) }
+ scope[name] = value
+ end
+
def clear_instance_assigns
@scopes[0] = {}
end
@@ -206,7 +240,7 @@ module Liquid
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
- fallback_to_self_drop = key == Expression::SELF && index.nil?
+ fallback_to_self_drop = key == Expression::SELF && index.nil? && !@captured_bindings&.local_key?(key)
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
@@ -262,13 +296,17 @@ module Liquid
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
attr_reader :base_scope_depth
def try_variable_find_in_environments(key, raise_on_not_found:)
+ if @captured_bindings
+ return @captured_bindings.find_variable(key, self, raise_on_not_found: raise_on_not_found)
+ end
+
@environments.each do |environment|
found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found)
if !found_variable.nil? || @strict_variables && raise_on_not_found
diff --git a/lib/liquid/document.rb b/lib/liquid/document.rb
index 7742ae5f..29015fd5 100644
--- a/lib/liquid/document.rb
+++ b/lib/liquid/document.rb
@@ -20,7 +20,9 @@ module Liquid
end
def parse(tokenizer, parse_context)
- while parse_body(tokenizer)
+ @local_bindings = parse_context.with_document_scope do
+ while parse_body(tokenizer)
+ end
end
@body.freeze
rescue SyntaxError => e
@@ -38,7 +40,9 @@ module Liquid
end
def render_to_output_buffer(context, output)
- @body.render_to_output_buffer(context, output)
+ return @body.render_to_output_buffer(context, output) unless @local_bindings
+
+ context.with_binding_scope { @body.render_to_output_buffer(context, output) }
end
def render(context)
diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb
index 7e15f85b..1af5545a 100644
--- a/lib/liquid/expression.rb
+++ b/lib/liquid/expression.rb
@@ -36,6 +36,7 @@ module Liquid
def parse(markup, ss = StringScanner.new(""), cache = nil)
return unless markup
+ return markup unless markup.is_a?(String)
markup = markup.strip # markup can be a frozen string
diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb
index f1740dba..f8218bde 100644
--- a/lib/liquid/lexer.rb
+++ b/lib/liquid/lexer.rb
@@ -2,6 +2,7 @@
module Liquid
class Lexer
+ ASSIGN = [:assign, "="].freeze
CLOSE_ROUND = [:close_round, ")"].freeze
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
@@ -133,6 +134,8 @@ module Liquid
if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte])
output << found
ss.scan_byte
+ elsif peeked == "=".ord && ss.peek_byte != "<".ord && ss.peek_byte != ">".ord && ss.peek_byte != "!".ord
+ output << ASSIGN
else
raise_syntax_error(start_pos, ss)
end
diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb
index d956d1c6..447d1002 100644
--- a/lib/liquid/parse_context.rb
+++ b/lib/liquid/parse_context.rb
@@ -38,7 +38,11 @@ module Liquid
def new_parser(input)
@string_scanner.string = input
- Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid)
+ Parser.new(
+ @string_scanner,
+ reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid,
+ allow_calls: @error_mode == :strict2,
+ )
end
def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false)
@@ -60,8 +64,8 @@ module Liquid
# compatibility while raising awareness about strict2 parser standards,
# the safe flag supports API users make a deliberate decision.
#
- # In strict2 mode, markup MUST come from a string returned by the parser
- # (e.g., parser.expression). We're not calling the parser here to
+ # In strict2 mode, markup MUST come from parser.expression, which returns
+ # markup or a parsed callable node. We're not calling the parser here to
# prevent redundant parser overhead.
raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode"
end
@@ -69,6 +73,54 @@ module Liquid
Expression.parse(markup, @string_scanner, @expression_cache)
end
+ def with_document_scope(&block)
+ previous_usage = @uses_local_bindings
+ @uses_local_bindings = false
+ with_lexical_scope(&block)
+ @uses_local_bindings
+ ensure
+ @uses_local_bindings = previous_usage
+ end
+
+ def with_lexical_scope(parameters = Const::EMPTY_ARRAY)
+ previous_scope = @lexical_scope
+ @lexical_scope = parameters.to_h { |name| [name, true] }
+ yield
+ ensure
+ @lexical_scope = previous_scope
+ end
+
+ def local_declarations?
+ @lexical_scope && !@lexical_scope.empty?
+ end
+
+ def declare_local(name)
+ @lexical_scope ||= {}
+ raise SyntaxError, "Duplicate let declaration '#{name}'" if @lexical_scope.key?(name)
+
+ @lexical_scope[name] = true
+ end
+
+ def with_closure(parameters = Const::EMPTY_ARRAY, &block)
+ require_closures!
+ previous_closure = @in_closure
+ @in_closure = true
+ with_lexical_scope(parameters, &block)
+ ensure
+ @in_closure = previous_closure
+ end
+
+ def in_closure?
+ @in_closure
+ end
+
+ def require_closures!
+ @uses_local_bindings = true
+ return if @error_mode == :strict2
+
+ raise SyntaxError, "Closures require strict2 mode"
+ end
+
def partial=(value)
@partial = value
@options = value ? partial_options : @template_options
diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb
index 7a213605..8e11e04c 100644
--- a/lib/liquid/parser.rb
+++ b/lib/liquid/parser.rb
@@ -2,11 +2,12 @@
module Liquid
class Parser
- def initialize(input, reject_bare_brackets: false)
+ def initialize(input, reject_bare_brackets: false, allow_calls: false)
ss = input.is_a?(StringScanner) ? input : StringScanner.new(input)
@tokens = Lexer.tokenize(ss)
@p = 0 # pointer to current location
@reject_bare_brackets = reject_bare_brackets
+ @allow_calls = allow_calls
end
def jump(point)
@@ -51,6 +52,8 @@ module Liquid
token = @tokens[@p]
case token[0]
when :id
+ return callable_or_lookup if @allow_calls
+
str = consume
str << variable_lookups
when :open_square
@@ -69,12 +72,41 @@ module Liquid
consume(:dotdot)
last = expression
consume(:close_round)
+ return RangeLookup.new(Expression.parse(first), Expression.parse(last)) unless first.is_a?(String) && last.is_a?(String)
+
"(#{first}..#{last})"
else
raise SyntaxError, "#{token} is not a valid expression"
end
end
+ def expression_with_markup
+ start = @p
+ # Parsing lookups appends to consumed identifier strings.
+ markup_tokens = @tokens[start...-1].map { |token| token[1].dup }
+ value = expression
+ markup = value.is_a?(String) ? value : markup_tokens.take(@p - start).join
+ [value, markup]
+ end
+
+ def remaining_markup
+ @tokens[@p...-1].map { |token| token[1] }.join(' ')
+ end
+
+ def parameters
+ parameters = []
+ return parameters unless consume?(:open_round)
+
+ unless look(:close_round)
+ parameters << consume(:id)
+ parameters << consume(:id) while consume?(:comma) && !look(:close_round)
+ end
+ consume(:close_round)
+ raise SyntaxError, "Duplicate closure parameter" unless parameters.uniq.length == parameters.length
+
+ parameters
+ end
+
def argument
str = +""
# might be a keyword argument (identifier: expression)
@@ -102,5 +134,46 @@ module Liquid
end
str
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
diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb
index dff7553f..8317c2f0 100644
--- a/lib/liquid/tags.rb
+++ b/lib/liquid/tags.rb
@@ -7,6 +7,8 @@ require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
+require_relative "tags/let"
+require_relative "tags/set"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
@@ -37,6 +39,8 @@ module Liquid
'case' => Case,
'ifchanged' => Ifchanged,
'assign' => Assign,
+ 'let' => Let,
+ 'set' => SetTag,
'for' => For,
'#' => InlineComment,
'break' => Break,
diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb
index 901db40f..1c908114 100644
--- a/lib/liquid/tags/assign.rb
+++ b/lib/liquid/tags/assign.rb
@@ -31,6 +31,8 @@ module Liquid
def initialize(tag_name, markup, parse_context)
super
+ raise SyntaxError, "assign is not allowed in closure bodies" if parse_context.in_closure?
+
parse_with_selected_parser(markup)
end
@@ -65,7 +67,7 @@ module Liquid
def render_to_output_buffer(context, output)
val = @from.render(context)
context.scopes.last[@to] = val
- context.resource_limits.increment_assign_score(assign_score_of(val))
+ context.resource_limits.increment_assign_score(self.class.assign_score_of(val))
output
end
@@ -73,9 +75,8 @@ module Liquid
true
end
- private
-
- def assign_score_of(val)
+ # @api private
+ def self.assign_score_of(val)
if val.instance_of?(String)
val.bytesize
elsif val.instance_of?(Array)
diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb
index cbea85bc..68c2c48c 100644
--- a/lib/liquid/tags/for.rb
+++ b/lib/liquid/tags/for.rb
@@ -38,15 +38,10 @@ module Liquid
end
def parse(tokens)
- if parse_body(@for_block, tokens)
- parse_body(@else_block, tokens)
+ parse_context.with_lexical_scope do
+ parse_loop(tokens)
+ @has_local_declarations = parse_context.local_declarations?
end
- if blank?
- @else_block&.remove_blank_strings
- @for_block.remove_blank_strings
- end
- @else_block&.freeze
- @for_block.freeze
end
def nodelist
@@ -92,10 +87,10 @@ module Liquid
@variable_name = p.consume(:id)
raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in')
- collection_name = p.expression
+ collection_name, collection_markup = p.expression_with_markup
@collection_name = parse_expression(collection_name, safe: true)
- @name = "#{@variable_name}-#{collection_name}"
+ @name = "#{@variable_name}-#{collection_markup}"
@reversed = p.id?('reversed')
while p.look(:comma) || p.look(:id)
@@ -111,6 +106,18 @@ module Liquid
private
+ def parse_loop(tokens)
+ if parse_body(@for_block, tokens)
+ parse_body(@else_block, tokens)
+ end
+ if blank?
+ @else_block&.remove_blank_strings
+ @for_block.remove_blank_strings
+ end
+ @else_block&.freeze
+ @for_block.freeze
+ end
+
def strict2_parse(markup)
strict_parse(markup)
end
@@ -161,7 +168,7 @@ module Liquid
segment.each do |item|
context[@variable_name] = item
- @for_block.render_to_output_buffer(context, output)
+ render_iteration(context, output)
loop_vars.send(:increment!)
# Handle any interrupts if they exist.
@@ -178,6 +185,12 @@ module Liquid
output
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)
case key
when 'offset'
@@ -192,11 +205,10 @@ module Liquid
end
def render_else(context, output)
- if @else_block
- @else_block.render_to_output_buffer(context, output)
- else
- output
- end
+ return output unless @else_block
+ return @else_block.render_to_output_buffer(context, output) unless @has_local_declarations
+
+ context.stack { @else_block.render_to_output_buffer(context, output) }
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
diff --git a/lib/liquid/tags/let.rb b/lib/liquid/tags/let.rb
new file mode 100644
index 00000000..d3ebf6b9
--- /dev/null
+++ b/lib/liquid/tags/let.rb
@@ -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
diff --git a/lib/liquid/tags/set.rb b/lib/liquid/tags/set.rb
new file mode 100644
index 00000000..28a98a21
--- /dev/null
+++ b/lib/liquid/tags/set.rb
@@ -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
diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb
index b69f9148..2591da9a 100644
--- a/lib/liquid/tags/table_row.rb
+++ b/lib/liquid/tags/table_row.rb
@@ -34,6 +34,13 @@ module Liquid
parse_with_selected_parser(markup)
end
+ def parse(tokens)
+ parse_context.with_lexical_scope do
+ super
+ @has_local_declarations = parse_context.local_declarations?
+ end
+ end
+
def strict2_parse(markup)
p = @parse_context.new_parser(markup)
@@ -99,7 +106,11 @@ module Liquid
context[@variable_name] = item
output << "
"
- super
+ if @has_local_declarations
+ context.stack { super }
+ else
+ super
+ end
output << ' | '
# Handle any interrupts if they exist.
diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb
index 12b8d9f2..c5c6081a 100644
--- a/lib/liquid/variable.rb
+++ b/lib/liquid/variable.rb
@@ -25,7 +25,7 @@ module Liquid
include ParserSwitching
def initialize(markup, parse_context)
- @markup = markup
+ @markup = markup.is_a?(Parser) ? markup.remaining_markup : markup
@name = nil
@parse_context = parse_context
@line_number = parse_context.line_number
@@ -42,6 +42,7 @@ module Liquid
end
def markup_context(markup)
+ markup = @markup if markup.is_a?(Parser)
"in \"{{#{markup}}}\""
end
@@ -80,7 +81,7 @@ module Liquid
def strict2_parse(markup)
@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)
@@ -109,6 +110,10 @@ module Liquid
end
def render_to_output_buffer(context, output)
+ if @name.is_a?(CallExpression) && @filters.empty? && context.global_filter.nil?
+ return @name.render_to_output_buffer(context, output)
+ end
+
obj = render(context)
render_obj_to_output(obj, output)
output
@@ -207,7 +212,7 @@ module Liquid
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
- [@node.name] + @node.filters.flatten
+ [@node.name] + @node.filters.flatten.flat_map { |value| value.is_a?(Hash) ? value.values : value }
end
end
end
diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb
index 4fba2a65..4609d624 100644
--- a/lib/liquid/variable_lookup.rb
+++ b/lib/liquid/variable_lookup.rb
@@ -40,6 +40,12 @@ module Liquid
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)
@command_flags & (1 << lookup_index) != 0
end
diff --git a/test/integration/blank_body_error_handling_test.rb b/test/integration/blank_body_error_handling_test.rb
index c428891e..c395f867 100644
--- a/test/integration/blank_body_error_handling_test.rb
+++ b/test/integration/blank_body_error_handling_test.rb
@@ -14,60 +14,60 @@ class BlankBodyErrorHandlingTest < Minitest::Test
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
+ 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)
+ 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)
+ 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' })
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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)
+ 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' })
+ 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)
+ 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
diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb
index 0fda83ca..c13be664 100644
--- a/test/integration/error_handling_test.rb
+++ b/test/integration/error_handling_test.rb
@@ -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
assert_equal("Liquid error: comparison of Integer with String failed0", output)
- output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render
- assert_equal("0", output)
+ source = "{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}"
+ [:lax, :warn, :strict].each do |mode|
+ assert_equal("0", Liquid::Template.parse(source, error_mode: mode).render)
+ end
+ assert_equal("Liquid error: comparison of Integer with String failed0", Liquid::Template.parse(source, error_mode: :strict2).render)
end
def test_syntax_error_is_raised_with_template_name
diff --git a/test/integration/let_closure_test.rb b/test/integration/let_closure_test.rb
new file mode 100644
index 00000000..7f64bf49
--- /dev/null
+++ b/test/integration/let_closure_test.rb
@@ -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("\n| 11 | 11 |
\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("\n | |
\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("\n | |
\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("\n| 1 | 2 |
\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
diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb
index 73eeb739..e692881a 100644
--- a/test/unit/lexer_unit_test.rb
+++ b/test/unit/lexer_unit_test.rb
@@ -26,6 +26,13 @@ class LexerUnitTest < Minitest::Test
)
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
assert_equal(
[[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]],
diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb
index 2c6a3594..910ce3b7 100644
--- a/test/unit/parser_unit_test.rb
+++ b/test/unit/parser_unit_test.rb
@@ -82,6 +82,78 @@ class ParserUnitTest < Minitest::Test
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
def new_parser(str)