Add strict2 let closures for Liquid composition

This commit is contained in:
Charles-P. Clermont
2026-09-24 10:10:28 -04:00
parent 807d45a6b3
commit c5bb4c8eff
25 changed files with 1201 additions and 52 deletions
+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
+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
+40 -2
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,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
+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
+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.
+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