Compare commits

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

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

Add integration coverage for lax/strict suppression, strict2 non-suppression across blank body forms, nonblank bodies, and raised-error behavior.
2026-07-05 18:56:24 +00:00
Guilherme CarreiroandGitHub 7a5e45fc47 Bump to 5.13.0 (#2103) 2026-06-29 17:41:55 +02:00
Ian Ker-SeymerandIan Ker-Seymer 9efca9f718 Use liquid-spec branch for self specs 2026-06-11 23:20:43 -04:00
Ian Ker-SeymerandIan Ker-Seymer 529800f46b Let environment self shadow SelfDrop 2026-06-11 23:20:43 -04:00
Guilherme CarreiroandGitHub 7b368dffb8 Fix SelfDrop equality (#2091) 2026-06-05 10:47:37 +02:00
Guilherme CarreiroandGitHub 742ac3dbf5 Prevent SelfDrop context mutation across render boundaries (#2082) 2026-05-20 09:34:37 +02:00
Ian Ker-SeymerandIan Ker-Seymer 1954a2655c Update liquid-spec adapters 2026-04-28 10:11:38 -04:00
Alok SwamyandGitHub 6d81b1b68c Merge pull request #2077 from Shopify/remove-strict2-from-error-message
Remove "strict2" from bare bracket error message
2026-04-24 16:21:51 -04:00
39 changed files with 1541 additions and 65 deletions
+1 -1
View File
@@ -1 +1 @@
3.4.1
4.0.2
+2 -1
View File
@@ -32,6 +32,7 @@ group :test do
end
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
end
+12
View File
@@ -1,5 +1,17 @@
# 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
* 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]
+44
View File
@@ -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.
+4 -2
View File
@@ -151,6 +151,8 @@ end
desc('run liquid-spec suite across all adapters')
task :spec do
adapters = Dir['./spec/*.rb'].join(',')
sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid"
Dir['./spec/*.rb'].sort.each do |adapter|
puts "=== Running #{adapter} ==="
sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures'
end
end
+3
View File
@@ -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"
+14 -4
View File
@@ -10,10 +10,13 @@ module Liquid
end
def parse(tokens)
@body = new_body
while parse_body(@body, tokens)
end
@body.freeze
parse_block_body(tokens)
end
# Parse an external block body with closure validation and lexical declarations.
def parse_closure_body(tokens, parameters: Const::EMPTY_ARRAY)
parse_context.with_closure(parameters) { parse_block_body(tokens) }
ClosureBody.new(@body, parameters)
end
# For backwards compatibility
@@ -66,6 +69,13 @@ module Liquid
private
def parse_block_body(tokens)
@body = new_body
while parse_body(@body, tokens)
end
@body.freeze
end
# @api public
def new_body
parse_context.new_block_body
+3 -1
View File
@@ -99,7 +99,9 @@ module Liquid
context.handle_error(exc, line_number)
else
error_message = context.handle_error(exc, line_number)
unless blank_tag # conditional for backwards compatibility
error_mode = context.registers.static[:template_error_mode]
suppress_error_text = blank_tag && error_mode != :strict2 && error_mode != :rigid
unless suppress_error_text # blank-tag suppression is kept for backwards compatibility outside strict2
output << error_message
end
end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Liquid
class CallExpression
attr_reader :target, :arguments
def initialize(target, arguments)
@target = target.freeze
@arguments = arguments.freeze
freeze
end
def evaluate(context)
context.resource_limits.with_capture do
render_to_output_buffer(context, +'')
end
end
def render_to_output_buffer(context, output)
closure = context.evaluate(@target)
raise Liquid::ArgumentError, "Expected a Liquid closure" unless closure.is_a?(Closure)
arguments = @arguments.map { |argument| context.evaluate(argument) }
closure.render_to_output_buffer(context, output, arguments)
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.target] + @node.arguments
end
end
end
end
+96
View File
@@ -0,0 +1,96 @@
# frozen_string_literal: true
module Liquid
# A render-time value. Captures retain binding hashes, not copies of their values.
class Closure
def initialize(body, parameters, context)
@body = body
@parameters = parameters
@scopes = context.scopes.dup
@environments = context.environments.dup
@static_environments = context.static_environments
end
# Render into the supplied buffer and return direct-child body-form let bindings.
def invoke(context, arguments = Const::EMPTY_ARRAY, output: +'')
invocation = render_invocation(context, arguments, output)
@body.exports(invocation)
end
def render_to_output_buffer(context, output, arguments = Const::EMPTY_ARRAY)
render_invocation(context, arguments, output)
output
end
def to_liquid
self
end
def to_s
''
end
private
def render_invocation(context, arguments, output)
unless arguments.length == @parameters.length
raise Liquid::ArgumentError, "Expected #{@parameters.length} closure arguments, got #{arguments.length}"
end
invocation = context.new_closure_subcontext(@scopes, @environments, @static_environments)
@parameters.each_with_index { |name, index| invocation[name] = arguments[index] }
@body.render_to_output_buffer(invocation, output)
invocation
end
# @api private
class CapturedBindings
def initialize(scopes, environments, context)
@scopes = scopes
@environments = environments
@context = context
@locals = {}
end
def key?(name)
local_key?(name) || @environments.any? { |environment| environment.key?(name) }
end
def local_key?(name)
@locals.key?(name) || @scopes.any? { |scope| scope.key?(name) } ||
@environments.any? { |environment| environment.is_a?(CapturedBindings) && environment.local_key?(name) }
end
def []=(name, value)
@locals[name] = value
end
def [](name)
find_variable(name, @context, raise_on_not_found: false)
end
def find_variable(name, context, raise_on_not_found: true)
return context.lookup_and_evaluate(@locals, name, raise_on_not_found: raise_on_not_found) if @locals.key?(name)
scope = @scopes.find { |candidate| candidate.key?(name) }
return context.lookup_and_evaluate(scope, name, raise_on_not_found: raise_on_not_found) if scope
find_in_environments(name, context, raise_on_not_found: raise_on_not_found)
end
private
def find_in_environments(name, context, raise_on_not_found:)
@environments.each do |environment|
if environment.is_a?(CapturedBindings)
return environment.find_variable(name, context, raise_on_not_found: raise_on_not_found)
end
value = context.lookup_and_evaluate(environment, name, raise_on_not_found: raise_on_not_found)
return value if !value.nil? || context.strict_variables && raise_on_not_found
end
nil
end
end
end
end
+31
View File
@@ -0,0 +1,31 @@
# frozen_string_literal: true
module Liquid
# Parsed syntax shared by let and closure-aware custom blocks.
class ClosureBody
attr_reader :body
def initialize(body, parameters = Const::EMPTY_ARRAY)
@body = body
@parameters = parameters.dup.freeze
@exports = body.nodelist.select { |node| node.is_a?(Let) && node.body_form? }.map(&:to).freeze
freeze
end
def bind(context)
Closure.new(self, @parameters, context)
end
def render_to_output_buffer(context, output)
@body.render_to_output_buffer(context, output)
end
def exports(context)
context.scopes.last.slice(*@exports)
end
def nodelist
@body.nodelist
end
end
end
+48 -5
View File
@@ -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,16 +240,21 @@ module Liquid
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
# but only when it hasn't been explicitly assigned as a local variable.
return SelfDrop.new(self) if key == Expression::SELF && !index
fallback_to_self_drop = key == Expression::SELF && index.nil? && !@captured_bindings&.local_key?(key)
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
try_variable_find_in_environments(
key,
raise_on_not_found: raise_on_not_found && !fallback_to_self_drop,
)
end
# `self` resolves to a SelfDrop (enabling `self['var']` lookups),
# but only after the normal environment lookup doesn't find a value.
return @self_drop ||= SelfDrop.new(self) if fallback_to_self_drop && variable.nil?
# update variable's context before invoking #to_liquid
variable.context = self if variable.respond_to?(:context=)
@@ -257,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
+6 -2
View File
@@ -20,7 +20,9 @@ module Liquid
end
def parse(tokenizer, parse_context)
while parse_body(tokenizer)
@local_bindings = parse_context.with_document_scope do
while parse_body(tokenizer)
end
end
@body.freeze
rescue SyntaxError => e
@@ -38,7 +40,9 @@ module Liquid
end
def render_to_output_buffer(context, output)
@body.render_to_output_buffer(context, output)
return @body.render_to_output_buffer(context, output) unless @local_bindings
context.with_binding_scope { @body.render_to_output_buffer(context, output) }
end
def render(context)
+1
View File
@@ -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
+3
View File
@@ -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
+55 -3
View File
@@ -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
+74 -1
View File
@@ -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
+20 -4
View File
@@ -16,23 +16,39 @@ module Liquid
# then the local value takes precedence over the `self` object.
# @liquid_access global
class SelfDrop < Drop
def initialize(context)
def initialize(self_context)
super()
@context = context
@self_context = self_context
end
def [](key)
@context.find_variable(key)
@self_context.find_variable(key)
rescue UndefinedVariable
nil
end
def key?(key)
@context.variable_defined?(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
+4
View File
@@ -7,6 +7,8 @@ require_relative "tags/break"
require_relative "tags/inline_comment"
require_relative "tags/for"
require_relative "tags/assign"
require_relative "tags/let"
require_relative "tags/set"
require_relative "tags/ifchanged"
require_relative "tags/case"
require_relative "tags/include"
@@ -37,6 +39,8 @@ module Liquid
'case' => Case,
'ifchanged' => Ifchanged,
'assign' => Assign,
'let' => Let,
'set' => SetTag,
'for' => For,
'#' => InlineComment,
'break' => Break,
+5 -4
View File
@@ -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)
+28 -16
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Liquid
class Let < Block
attr_reader :to, :from, :parameters, :closure_body
def initialize(tag_name, markup, parse_context)
super
parse_context.require_closures!
parser = parse_context.new_parser(markup)
@to = parser.consume(:id)
if parser.consume?(:assign)
raise SyntaxError, "Expected let initializer" if parser.look(:end_of_string)
@from = Variable.new(parser, parse_context)
else
@parameters = parser.parameters.freeze
parser.consume(:end_of_string)
end
parse_context.declare_local(@to)
end
def parse(tokens)
return unless body_form?
@closure_body = parse_closure_body(tokens, parameters: @parameters)
end
def body_form?
@from.nil?
end
def nodelist
body_form? ? super : Const::EMPTY_ARRAY
end
def render_to_output_buffer(context, output)
value = body_form? ? @closure_body.bind(context) : @from.render(context)
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
context[@to] = value
output
end
def blank?
true
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
@node.body_form? ? [@node.closure_body] : [@node.from]
end
end
end
end
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
module Liquid
class SetTag < Tag
attr_reader :to, :from
def initialize(tag_name, markup, parse_context)
super
parse_context.require_closures!
parser = parse_context.new_parser(markup)
@to = parser.consume(:id)
parser.consume(:assign)
raise SyntaxError, "Expected set initializer" if parser.look(:end_of_string)
@from = Variable.new(parser, parse_context)
end
def render_to_output_buffer(context, output)
value = if context.variable_defined?(@to)
@from.render(context)
else
context.stack(@to => nil) { @from.render(context) }
end
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
context.set_local(@to, value)
output
end
def blank?
true
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[@node.from]
end
end
end
end
+12 -1
View File
@@ -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 << "<td class=\"col#{tablerowloop.col}\">"
super
if @has_local_declarations
context.stack { super }
else
super
end
output << '</td>'
# Handle any interrupts if they exist.
+13 -2
View File
@@ -151,8 +151,10 @@ module Liquid
c
when Liquid::Drop
drop = args.shift
drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop = args.shift
c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
drop.context = c if drop.respond_to?(:context=)
c
when Hash
Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment)
when nil
@@ -187,12 +189,20 @@ module Liquid
context.template_name ||= name
previous_error_mode = context.registers.static[:template_error_mode]
context.registers.static[:template_error_mode] = @error_mode
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
rescue Liquid::MemoryError => e
context.handle_error(e)
ensure
if previous_error_mode
context.registers.static[:template_error_mode] = previous_error_mode
else
context.registers.static.delete(:template_error_mode)
end
@errors = context.errors
end
end
@@ -224,6 +234,7 @@ module Liquid
end
@warnings = parse_context.warnings
@error_mode = parse_context.error_mode
parse_context
end
+8 -3
View File
@@ -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
+6
View File
@@ -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
+1 -1
View File
@@ -2,5 +2,5 @@
# frozen_string_literal: true
module Liquid
VERSION = "5.12.0"
VERSION = "5.13.0"
end
+16 -3
View File
@@ -6,14 +6,24 @@
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
# Run core Liquid specs
config.features = [:core]
config.missing_features = [
:activesupport,
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
@@ -28,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+16 -4
View File
@@ -6,15 +6,24 @@
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
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
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
# Force lax mode
options = options.merge(error_mode: :lax)
# Default to lax mode while still honoring specs that explicitly set error_mode.
options = { error_mode: :lax }.merge(options)
ctx[:template] = Liquid::Template.parse(source, **options)
end
@@ -26,9 +35,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+15 -3
View File
@@ -7,14 +7,23 @@
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
# Run core Liquid specs plus ActiveSupport SafeBuffer tests
config.features = [:core, :activesupport]
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
LiquidSpec.compile do |ctx, source, options|
options[:error_mode] ||= :strict
ctx[:template] = Liquid::Template.parse(source, **options)
end
@@ -29,9 +38,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
+14 -2
View File
@@ -13,9 +13,18 @@ end
require 'active_support/all'
require 'liquid'
require_relative 'support/liquid_spec_adapter_helper'
LiquidSpec.configure do |config|
config.features = [:core, :activesupport]
config.missing_features = [
:lax_parsing,
:shopify_filters,
:shopify_includes,
:shopify_blank,
:shopify_error_handling,
:shopify_error_format,
:shopify_string_access,
]
end
# Compile a template string into a Liquid::Template
@@ -33,9 +42,12 @@ LiquidSpec.render do |ctx, assigns, options|
static_environments: assigns,
registers: registers,
rethrow_errors: options[:strict_errors],
resource_limits: LiquidSpecAdapterHelper.resource_limits(options),
)
context.exception_renderer = options[:exception_renderer] if options[:exception_renderer]
ctx[:template].render(context)
LiquidSpecAdapterHelper.with_frozen_time do
ctx[:template].render(context)
end
end
@@ -0,0 +1,24 @@
# frozen_string_literal: true
module LiquidSpecAdapterHelper
extend self
def resource_limits(render_options)
return unless render_options[:resource_limits]
Liquid::ResourceLimits.new({}).tap do |limits|
render_options[:resource_limits].each do |key, value|
limits.public_send(:"#{key}=", value)
end
end
end
def with_frozen_time(&block)
original_tz = ENV['TZ']
ENV['TZ'] = 'UTC'
Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block)
ensure
ENV['TZ'] = original_tz
end
end
@@ -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
+5 -2
View File
@@ -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
+547
View File
@@ -0,0 +1,547 @@
# frozen_string_literal: true
require 'test_helper'
class LetClosureTest < Minitest::Test
def test_value_binding_and_filtered_initializer
assert_render('HELLO:3', "{% let title = product.title | upcase %}{{ title }}:{% let n = 1 | plus: 2 %}{{ n }}", 'product' => { 'title' => 'hello' })
end
def test_initializer_reads_parent_before_installing_shadow
assert_render('231', '{% let n = 1 %}{% let title(step) %}{% let n = n | plus: step %}{{ n }}{% endlet %}{{ title(1) }}{{ title(2) }}{{ n }}')
end
def test_empty_and_parameterless_bodies
assert_render('ok', '{% let empty %}{% endlet %}{% let title() %}ok{% endlet %}{{ empty() }}{{ title() }}')
end
def test_parameters_are_writable_and_fresh
assert_render('23', '{% let title(n,) %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title(1,) }}{{ title(2) }}')
end
def test_live_captures
assert_render('12', '{% let n = 1 %}{% let title %}{{ n }}{% endlet %}{{ title() }}{% set n = 2 %}{{ title() }}')
end
def test_captured_environments_fall_through_nil_values
context = Liquid::Context.build(environments: [{ 'n' => nil }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_environments_keep_false_values
context = Liquid::Context.build(environments: [{ 'n' => false }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('false/false', parse(source).render!(context))
end
def test_captured_environments_use_hash_defaults
context = Liquid::Context.build(environments: Hash.new('fallback'))
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_environments_fall_through_drops
context = Liquid::Context.build(environments: [Liquid::Drop.new, { 'n' => 'fallback' }])
source = '{{ n }}/{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_static_environments_preserve_fallthrough_and_defaults
context = Liquid::Context.build(environments: [], static_environments: [{ 'n' => nil }, Hash.new('fallback')])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
end
def test_captured_local_nil_shadows_environment_values
context = Liquid::Context.build(environments: { 'n' => 'dynamic' }, static_environments: { 'n' => 'static' })
source = '{% let n = nil %}{{ n }}/{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('/', parse(source).render!(context))
end
def test_captured_local_self_nil_does_not_become_a_self_drop
source = '{% let self = nil %}{% let show %}{% if self == nil %}nil{% endif %}{% endlet %}{{ show() }}'
assert_render('nil', source)
end
def test_captured_environments_remain_live_and_read_only
values = { 'n' => 1 }
source = '{% let show %}{{ n }}{% set n = n | plus: 10 %}{{ n }}{% endlet %}{{ show() }}{% increment n %}{{ show() }}'
assert_render('1111212', source, values)
assert_equal(2, values['n'])
end
def test_strict_captured_environments_stop_at_nil
context = Liquid::Context.build(environments: [{ 'n' => nil }, { 'n' => 'fallback' }])
source = '{{ n }}/{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_equal('/', parse(source).render!(context, strict_variables: true))
end
def test_strict_captured_environments_reject_missing_keys_before_fallback
source = '{% let outer %}{% let inner %}{{ n }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
context = Liquid::Context.build(environments: [{}, { 'n' => 'fallback' }])
assert_raises(Liquid::UndefinedVariable) { parse(source).render!(context, strict_variables: true) }
end
def test_strict_captured_environments_reject_hash_defaults
context = Liquid::Context.build(environments: Hash.new('fallback'))
source = '{% let show %}{{ n }}{% endlet %}{{ show() }}'
assert_raises(Liquid::UndefinedVariable) { parse(source).render!(context, strict_variables: true) }
end
def test_strict_captured_self_lookup_can_fall_back_to_self_drop
source = '{% let show %}{{ self["n"] }}{% endlet %}{{ show() }}'
assert_equal('value', parse(source).render!({ 'n' => 'value' }, strict_variables: true))
end
def test_render_in_closures_inherits_only_static_globals
file_system = StubFileSystem.new('snippet' => '{{ g }}/{{ dynamic }}/{{ local }};')
environment = Liquid::Environment.build(file_system: file_system)
context = Liquid::Context.build(environment: environment, environments: { 'dynamic' => 'D' }, static_environments: { 'g' => 'G' })
source = '{% let local = "L" %}{% render "snippet" %}{% let show %}{% render "snippet" %}{% render "snippet", dynamic: dynamic, local: local %}{% endlet %}{{ show() }}'
assert_equal('G//;G//;G/D/L;', parse(source, environment: environment).render!(context))
end
def test_render_in_closures_uses_globals_despite_local_shadows
file_system = StubFileSystem.new('snippet' => '{{ g }}')
environment = Liquid::Environment.build(file_system: file_system)
context = Liquid::Context.build(environment: environment, static_environments: { 'g' => 'G' })
source = '{% render "snippet" %}/{% let g = nil %}{% let outer %}{% let inner %}{{ g }}{% render "snippet" %}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('G/G', parse(source, environment: environment).render!(context))
end
def test_capture_shadows_are_fresh_and_read_only
assert_render('221', '{% let n = 1 %}{% let title %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title() }}{{ title() }}{{ n }}')
end
def test_set_creates_missing_binding_at_invocation_root
source = '{% let title %}{% for i in (1..2) %}{% set n = n | plus: i %}{% endfor %}{{ n }}{% endlet %}{{ title() }}{{ title() }}'
assert_equal('33', parse(source).render!({}, strict_variables: true))
end
def test_set_searches_owned_control_flow_scopes
source = '{% let title(n) %}{% for i in (1..2) %}{% set n = n | plus: i %}{% set i = i | plus: 10 %}{{ i }},{% endfor %}{{ n }}{% endlet %}{{ title(1) }}'
assert_render('11,12,4', source)
end
def test_set_shadows_capture_at_root_from_inside_loop
source = '{% let n = 1 %}{% let title %}{% for i in (1..2) %}{% set n = n | plus: i %}{% endfor %}{{ n }}{% endlet %}{{ title() }}{{ n }}'
assert_render('41', source)
end
def test_let_shadows_in_loop_and_can_execute_repeatedly
source = '{% let n = 10 %}{% for i in (1..3) %}{% let n = i %}{% set n = n | plus: 1 %}{{ n }}{% endfor %}{{ n }}'
assert_render('23410', source)
end
def test_each_iteration_initializer_reads_outer_binding
source = '{% let n = 10 %}{% for i in (1..2) %}{% let n = n | plus: 1 %}{{ n }},{% endfor %}{{ n }}'
assert_render('11,11,10', source)
end
def test_retained_callbacks_capture_iteration_local_bindings
source = '{% let n = 10 %}{% let first = nil %}{% let second = nil %}{% for i in (1..2) %}{% let n = n | plus: i %}{% let title %}{{ n }}{% endlet %}{% if i == 1 %}{% set first = title %}{% else %}{% set second = title %}{% endif %}{% endfor %}{{ first() }}/{{ second() }}:{{ n }}'
assert_render('11/12:10', source)
end
def test_set_traverses_fresh_iteration_frames_and_persists_at_root
source = '{% let captured = 5 %}{% let title(n) %}{% for i in (1..2) %}{% let step = i %}{% set n = n | plus: step %}{% set missing = missing | plus: step %}{% set captured = captured | plus: step %}{% endfor %}{{ n }}/{{ missing }}/{{ captured }};{% endlet %}{{ title(1) }}{{ title(1) }}{{ captured }}'
assert_equal('4/3/8;4/3/8;5', parse(source).render!({}, strict_variables: true))
end
def test_each_table_row_initializer_reads_outer_binding
source = '{% let n = 10 %}{% tablerow i in (1..2) %}{% let n = n | plus: 1 %}{{ n }}{% endtablerow %}{{ n }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\">11</td><td class=\"col2\">11</td></tr>\n10", source)
end
def test_table_row_callbacks_retain_iteration_local_bindings
source = '{% let first = nil %}{% let second = nil %}{% tablerow i in (1..2) %}{% let n = i %}{% let title %}{{ n }}{% endlet %}{% if i == 1 %}{% set first = title %}{% else %}{% set second = title %}{% endif %}{% endtablerow %}{{ first() }}/{{ second() }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\"></td><td class=\"col2\"></td></tr>\n1/2", source)
end
def test_set_traverses_table_row_frames_to_invocation_root
source = '{% let title(n) %}{% tablerow i in (1..2) %}{% let step = i %}{% set n = n | plus: step %}{% set missing = missing | plus: step %}{% endtablerow %}{{ n }}/{{ missing }}{% endlet %}{{ title(1) }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\"></td><td class=\"col2\"></td></tr>\n4/3", source)
end
def test_empty_loop_branch_has_its_own_let_scope
source = '{% let n = 1 %}{% for i in missing %}{% else %}{% let n = 2 %}{{ n }}{% endfor %}{{ n }}'
assert_render('21', source)
end
def test_table_row_scope_can_shadow_outer_let
source = '{% let n = 10 %}{% tablerow i in (1..2) %}{% let n = i %}{{ n }}{% endtablerow %}{{ n }}'
assert_render("<tr class=\"row1\">\n<td class=\"col1\">1</td><td class=\"col2\">2</td></tr>\n10", source)
end
def test_body_declaration_can_execute_on_every_iteration
assert_render('123', '{% for i in (1..3) %}{% let title %}{{ i }}{% endlet %}{{ title() }}{% endfor %}')
end
def test_set_updates_nearest_owned_let
source = '{% let title(n) %}{% for i in (1..2) %}{% let n = i %}{% set n = n | plus: 10 %}{{ n }},{% endfor %}{{ n }}{% endlet %}{{ title(5) }}'
assert_render('11,12,5', source)
end
def test_nested_closures_have_live_read_only_captures
source = '{% let outer(n) %}{% let inner %}{% set n = n | plus: 10 %}{{ n }}{% endlet %}{% set n = n | plus: 1 %}{{ inner() }}{{ n }}{% endlet %}{{ outer(1) }}{{ outer(2) }}'
assert_render('122133', source)
end
def test_captures_search_all_parents_with_strict_variables
source = '{% let outer %}{% let inner %}{{ title }}{% endlet %}{{ inner() }}{% endlet %}{{ outer() }}'
assert_equal('hello', parse(source).render!({ 'title' => 'hello' }, strict_variables: true))
end
def test_repeated_renders_start_with_fresh_bindings
template = parse('{% set n = n | plus: 1 %}{% let title %}{% set n = n | plus: 1 %}{{ n }}{% endlet %}{{ title() }}{{ n }}')
2.times { assert_equal('21', template.render!) }
end
def test_repeated_renders_use_current_inputs
template = parse('{% let title = title | upcase %}{% let render_title %}{{ title }}{% endlet %}{{ render_title() }}')
assert_equal('FIRST', template.render!('title' => 'first'))
assert_equal('SECOND', template.render!('title' => 'second'))
end
def test_nil_and_false_bindings_are_owned
source = '{% let title %}{% let n = nil %}{% set n = n | default: 3 %}{% let flag = false %}{% set flag = true %}{{ n }}{{ flag }}{% endlet %}{{ title() }}'
assert_render('3true', source)
end
def test_nested_call_arguments_and_filters
source = '{% let title(text) %}[{{ text }}]{% endlet %}{{ title(title("hello")) | upcase }}'
assert_render('[[HELLO]]', source)
end
def test_calls_in_filter_arguments_conditions_and_dynamic_lookups
source = '{% let key %}title{% endlet %}{% if key() == "title" %}{{ product[key()] | append: key() }}{% endif %}'
assert_render('hellotitle', source, 'product' => { 'title' => 'hello' })
end
def test_calls_in_ranges
assert_render('123', '{% let finish %}3{% endlet %}{% for i in (1..finish()) %}{{ i }}{% endfor %}')
end
def test_callable_ranges_share_loop_continuation
source = '{% let finish %}3{% endlet %}{% for i in (1..finish()) limit: 1 %}{{ i }}{% endfor %}{% for i in ( 1 .. finish( ) ) limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('12', source)
end
def test_callable_dynamic_lookups_share_loop_continuation
source = '{% let key %}items{% endlet %}{% for i in groups[key()] limit: 1 %}{{ i }}{% endfor %}{% for i in groups[ key( ) ] limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('12', source, 'groups' => { 'items' => [1, 2, 3] })
end
def test_callable_collection_loop_names_preserve_syntax
source = '{% let identity(value) %}{{ value }}{% endlet %}{% let key %}items{% endlet %}{% for i in identity("a b") %}{{ forloop.name }}{% endfor %}/{% for i in groups[key()] limit: 1 %}{{ forloop.name }}{% endfor %}/{% for i in (1..identity(3)) limit: 1 %}{{ forloop.name }}{% endfor %}'
assert_render('i-identity("a b")/i-groups[key()]/i-(1..identity(3))', source, 'groups' => { 'items' => [1, 2, 3] })
end
def test_distinct_callable_arguments_have_separate_loop_continuations
source = '{% let finish(value) %}{{ value }}{% endlet %}{% for i in (1..finish(2)) limit: 1 %}{{ i }}{% endfor %}{% for i in (1..finish(3)) limit: 1 offset: continue %}{{ i }}{% endfor %}'
assert_render('11', source)
end
def test_callable_value_can_be_passed_through_let
assert_render('hello', '{% let title %}hello{% endlet %}{% let alias = title %}{{ alias() }}')
end
def test_multiline_liquid
source = <<~LIQUID
{% liquid
let n = 1
let title(step)
set n = n | plus: step
echo n
endlet
echo title(2)
echo n
%}
LIQUID
assert_render("31\n", source)
end
def test_same_scope_redeclarations_are_parse_errors
[
'{% let n = 1 %}{% let n = 2 %}',
'{% let n %}{% endlet %}{% let n = 2 %}',
'{% let title(n) %}{% let n = 1 %}{% endlet %}',
'{% let title %}{% let n = 1 %}{% if false %}{% let n = 2 %}{% endif %}{% endlet %}',
].each do |source|
assert_raises(Liquid::SyntaxError, source) { parse(source) }
end
end
def test_invalid_headers_and_complete_input_validation
[
'{% let %}',
'{% let 1 = 2 %}',
'{% let a.b = 2 %}',
'{% let a == 2 %}',
'{% let a = %}',
'{% let a = 1 junk %}',
'{% let a = 1 | %}',
'{% let a(x) = 2 %}',
'{% let a(x,x) %}{% endlet %}',
'{% let a(,x) %}{% endlet %}',
'{% let a(x,,) %}{% endlet %}',
'{% let a(x) junk %}{% endlet %}',
'{% let a %}',
'{% set %}',
'{% set a.b = 1 %}',
'{% set a == 1 %}',
'{% set a = %}',
'{% set a = 1 junk %}',
'{{ title(,1) }}',
'{{ title(1,,) }}',
'{{ title(1) junk }}',
'{{ title(1 }}',
].each do |source|
assert_raises(Liquid::SyntaxError, source) { parse(source) }
end
end
def test_assign_is_rejected_through_nested_control_flow
[
'{% assign x = 1 %}',
'{% if false %}{% assign x = 1 %}{% endif %}',
'{% for i in (1..2) %}{% unless false %}{% assign x = 1 %}{% endunless %}{% endfor %}',
'{% case n %}{% when 1 %}{% assign x = 1 %}{% endcase %}',
"{% liquid\nif false\nassign x = 1\nendif\n%}",
].each do |body|
error = assert_raises(Liquid::SyntaxError) { parse("{% let title %}#{body}{% endlet %}") }
assert_match('assign is not allowed', error.message)
end
end
def test_assign_still_works_outside_closures
assert_render('12', '{% assign n = 1 %}{% let title %}{{ n }}{% endlet %}{{ title() }}{% assign n = 2 %}{{ title() }}')
end
def test_capture_tag_does_not_mutate_parent_bindings
assert_render('localparent', '{% let n = "parent" %}{% let title %}{% capture n %}local{% endcapture %}{{ n }}{% endlet %}{{ title() }}{{ n }}')
end
def test_counters_in_closures_have_fresh_shadows
source = '{% let n = 5 %}{% let title %}{% increment n %}{{ n }}{% decrement n %}{{ n }}{% endlet %}{{ title() }}{{ title() }}{{ n }}'
assert_render('565556555', source)
end
def test_tags_are_unavailable_in_every_other_mode
[:lax, :warn, :strict, :rigid].each do |mode|
['{% let n = 1 %}', '{% let title %}{% endlet %}', '{% set n = 1 %}'].each do |source|
assert_raises(Liquid::SyntaxError, "#{mode}: #{source}") { Liquid::Template.parse(source, error_mode: mode) }
end
end
end
def test_calls_preserve_legacy_parsing_behavior
[:lax, :warn].each do |mode|
template = Liquid::Template.parse('{{ title(1) }}', error_mode: mode)
assert_equal('', template.render!('title' => 'value'))
end
[:strict, :rigid].each do |mode|
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{{ title(1) }}', error_mode: mode) }
end
end
def test_effective_template_mode_overrides_environment
environment = Liquid::Environment.build(error_mode: :strict)
assert_equal('1', parse('{% let n = 1 %}{{ n }}', environment: environment).render!)
environment = Liquid::Environment.build(error_mode: :strict2)
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{% let n = 1 %}', environment: environment, error_mode: :strict) }
end
def test_effective_partial_mode_enables_feature
environment = Liquid::Environment.build(error_mode: :strict2, file_system: StubFileSystem.new('snippet' => '{% let title %}ok{% endlet %}{{ title() }}'))
template = Liquid::Template.parse('{% render "snippet" %}', environment: environment, error_mode: :strict, include_options_blacklist: [:error_mode])
assert_equal('ok', template.render!)
end
def test_effective_partial_mode_disables_feature
[:lax, :warn, :strict, :rigid].each do |mode|
environment = Liquid::Environment.build(error_mode: mode, file_system: StubFileSystem.new('snippet' => '{% let n = 1 %}'))
template = parse('{% render "snippet" %}', environment: environment, include_options_blacklist: [:error_mode])
assert_raises(Liquid::SyntaxError) { template.render! }
end
end
def test_callable_security
callable = Object.new
def callable.to_liquid
self
end
def callable.call(*)
raise 'must not call arbitrary Ruby objects'
end
[callable, Liquid::Drop.new, 'text', 1, nil].each do |value|
assert_raises(Liquid::ArgumentError) { parse('{{ title(1) }}').render!('title' => value) }
end
end
def test_proc_lookup_is_unchanged_and_receives_no_callable_arguments
values = { 'title' => -> { 'resolved' } }
template = parse('{{ title(1) }}')
assert_raises(Liquid::ArgumentError) { template.render!(values) }
assert_equal('resolved', values['title'])
assert_equal('resolved', parse('{{ title }}').render!(values))
end
def test_proc_captures_keep_existing_lookup_and_memoization
values = { 'title' => -> { 'resolved' } }
assert_render('resolvedresolved', '{% let show %}{{ title }}{% endlet %}{{ show() }}{{ show() }}', values)
assert_equal('resolved', values['title'])
end
def test_nil_proc_captures_are_memoized_before_environment_fallback
values = { 'n' => -> { nil } }
context = Liquid::Context.build(environments: [values, { 'n' => 'fallback' }])
source = '{% let show %}{{ n }}{% endlet %}{{ show() }}/{{ show() }}'
assert_equal('fallback/fallback', parse(source).render!(context))
assert_nil(values['n'])
end
def test_nested_proc_captures_receive_the_current_invocation_context
values = { 'n' => ->(context) { context['argument'] } }
source = '{% let outer %}{% let inner(argument) %}{{ n }}{% endlet %}{{ inner("value") }}{% endlet %}{{ outer() }}'
assert_render('value', source, values)
assert_equal('value', values['n'])
end
def test_argument_count_is_validated
['title()', 'title(1,2)'].each do |call|
assert_raises(Liquid::ArgumentError) { parse("{% let title(n) %}{{ n }}{% endlet %}{{ #{call} }}").render! }
end
end
def test_recursion_uses_liquid_runtime_depth_limit
template = parse('{% let title %}{{ title() }}{% endlet %}{{ title() }}')
assert_raises(Liquid::StackLevelError) { template.render! }
end
def test_output_buffer_and_output_limit
template = parse('{% let title %}hello{% endlet %}{{ title() }}{{ title() }}')
output = +'prefix:'
assert_same(output, template.render!({}, output: output))
assert_equal('prefix:hellohello', output)
template.resource_limits.render_length_limit = 9
assert_raises(Liquid::MemoryError) { template.render! }
end
def test_assignment_limits_include_let_set_and_callable_initializers
['{% let n = "hello" %}', '{% set n = "hello" %}', '{% let title %}hello{% endlet %}{% let n = title() %}'].each do |source|
template = parse(source)
template.resource_limits.assign_score_limit = 4
assert_raises(Liquid::MemoryError) { template.render! }
end
end
def test_render_score_limit_includes_callable_body
template = parse('{% let title %}hello{% endlet %}{{ title() }}{{ title() }}')
template.resource_limits.render_score_limit = 4
assert_raises(Liquid::MemoryError) { template.render! }
end
def test_visitor_sees_initializers_arguments_and_bodies_without_captures
template = parse('{% let n = source | plus: step %}{% let title(x) %}{% set n = other %}{{ x }}{% endlet %}{{ title(product.name) }}')
visitor = Liquid::ParseTreeVisitor.for(template.root).add_callback_for(Liquid::VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
expected = ['source', 'step', 'other', 'x', 'title', 'product']
assert_equal(expected, visitor.visit.flatten.compact)
template.render!('source' => 1, 'step' => 2, 'other' => 3, 'product' => { 'name' => 'hello' })
assert_equal(expected, visitor.visit.flatten.compact)
end
def test_visitor_includes_initializer_filter_keyword_arguments
template = parse('{% let n = source | default: fallback, allow_false: flag %}')
visitor = Liquid::ParseTreeVisitor.for(template.root).add_callback_for(Liquid::VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc
assert_equal(['source', 'fallback', 'flag'], visitor.visit.flatten.compact)
end
def test_custom_closure_body_parameters_are_writable_and_fresh
with_custom_tag('parameterized', ParameterizedBody) do
source = '{% let value = 10 %}{% parameterized %}{% set value = value | plus: 1 %}{{ value }}{% endparameterized %}{{ value }}'
assert_render('2310', source)
end
end
def test_custom_closure_body_exports_only_direct_child_body_declarations
source = '{% let n = 1 %}{% compose %}{% set n = n | plus: 1 %}{% let value = "hidden" %}{% if true %}{% let nested %}hidden{% endlet %}{% endif %}{% let item(text) %}{{ n }}:{{ text }}{% endlet %}{% endcompose %}{{ n }}'
with_custom_tag('compose', Compose) do
template = parse(source)
assert_equal('2:a|2:a|1', template.render!)
assert_equal('2:a|2:a|1', template.render!)
end
end
def test_custom_closure_bodies_can_nest
source = '{% compose %}{% let item(text) %}{% compose %}{% let item(inner) %}{{ text }}{{ inner }}{% endlet %}{% endcompose %}{% endlet %}{% endcompose %}'
with_custom_tag('compose', Compose) { assert_render('aa|aa||aa|aa||', source) }
end
def test_multiline_liquid_declarations_are_direct_child_exports
source = "{% compose %}{% liquid\nlet item(text)\necho text\nendlet\n%}{% endcompose %}"
with_custom_tag('compose', Compose) { assert_render('a|a|', source) }
end
def test_custom_closure_body_api_requires_strict2
with_custom_tag('compose', Compose) do
[:lax, :warn, :strict, :rigid].each do |mode|
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse('{% compose %}{% endcompose %}', error_mode: mode) }
end
end
end
def test_custom_closure_body_rejects_nested_assign
with_custom_tag('compose', Compose) do
assert_raises(Liquid::SyntaxError) { parse('{% compose %}{% if false %}{% assign n = 1 %}{% endif %}{% endcompose %}') }
end
end
private
def parse(source, **options)
Liquid::Template.parse(source, **options, error_mode: :strict2)
end
def assert_render(expected, source, values = {})
assert_equal(expected, parse(source).render!(values))
end
class ParameterizedBody < Liquid::Block
def parse(tokens)
@closure_body = parse_closure_body(tokens, parameters: ['value'])
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
body.render_to_output_buffer(context, output, [1])
body.render_to_output_buffer(context, output, [2])
end
end
class Compose < Liquid::Block
def initialize(tag_name, markup, parse_context)
super
@component = Liquid::Template.parse("{{ item('a') }}|", environment: parse_context.environment, error_mode: :strict2)
end
def parse(tokens)
@closure_body = parse_closure_body(tokens)
end
def render_to_output_buffer(context, output)
body = @closure_body.bind(context)
2.times do
exports = body.invoke(context, output: output)
raise Liquid::ArgumentError, 'Unexpected exports' unless exports.keys == ['item']
inner = context.new_isolated_subcontext
inner.merge(exports)
@component.root.render_to_output_buffer(inner, output)
end
output
end
end
end
+120
View File
@@ -0,0 +1,120 @@
# frozen_string_literal: true
require 'test_helper'
class SelfDropContextTest < Minitest::Test
include Liquid
def test_self_drop_passed_as_render_param_preserves_original_scope
source = <<~LIQUID
{%- assign var = 42 -%}
{%- assign s = self -%}
{%- render "snippet1", other_self: s -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign var = 43 -%}
{{- other_self.var }}|{{ self.var -}}
LIQUID
}
assert_template_result('42|43', source, partials: partials)
end
def test_self_drop_in_render_without_passing_resolves_inner_scope
source = <<~LIQUID
{%- assign var = 42 -%}
{%- render "snippet1" -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign var = 99 -%}
{{- self.var -}}
LIQUID
}
assert_template_result('99', source, partials: partials)
end
def test_self_drop_passed_to_nested_renders_preserves_each_level
source = <<~LIQUID
{%- assign a = 1 -%}
{%- assign s1 = self -%}
{%- render "snippet1", outer: s1 -%}
LIQUID
partials = {
'snippet1' => <<~LIQUID,
{%- assign a = 2 -%}
{%- assign s2 = self -%}
{%- render "snippet2", outer: outer, middle: s2 -%}
LIQUID
'snippet2' => <<~LIQUID,
{%- assign a = 3 -%}
{{- outer.a }}|{{ middle.a }}|{{ self.a -}}
LIQUID
}
assert_template_result('1|2|3', source, partials: partials)
end
def test_self_drop_reflects_variables_assigned_after_creation
source = <<~LIQUID
{%- assign s = self -%}
{%- assign x = 42 %}{{ s.x -}}
LIQUID
assert_template_result('42', source)
end
def test_self_drop_context_setter_is_undefined
context = Context.new
drop = SelfDrop.new(context)
refute(drop.respond_to?(:context=))
assert_template_result('42', '{{ self.x }}', { 'x' => 42 })
end
def test_self_drop_repeated_lookups_compare_equal_for_same_context
context = Context.new
drop = context.find_variable("self")
cached_drop = context.find_variable("self")
assert_same(drop, cached_drop)
assert_equal(drop.object_id, cached_drop.object_id)
assert_equal(drop, cached_drop)
end
def test_assigned_self_drop_compares_equal_to_itself
assert_template_result('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}')
end
def test_distinct_self_assignments_compare_equal_for_same_context
assert_template_result('T', '{% assign a = self %}{% assign b = self %}{% if a == b %}T{% else %}F{% endif %}')
end
def test_bare_self_compares_equal_to_bare_self
assert_template_result('T', '{% if self == self %}T{% else %}F{% endif %}')
end
def test_self_drop_with_strict_variables_does_not_raise_for_defined_var
t = Template.parse('{{ self.x }}')
result = t.render({ 'x' => 42 }, strict_variables: true)
assert_equal('42', result)
end
def test_self_drop_with_strict_variables_returns_nil_for_undefined_var
t = Template.parse('{{ self.x }}')
result = t.render({}, strict_variables: true)
assert_equal('', result)
end
def test_self_drop_can_be_passed_as_bare_drop_to_render
t = Template.parse('{{ self.x }}')
drop = SelfDrop.new(Context.new({ 'x' => 42 }))
result = t.render(drop)
assert_equal('42', result)
end
end
+7
View File
@@ -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]],
+72
View File
@@ -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)