Skip to content
Closed

wip4 #2125

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ module Liquid
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"
Expand Down
18 changes: 14 additions & 4 deletions lib/liquid/block.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ def initialize(tag_name, markup, options)
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
Expand Down Expand Up @@ -66,6 +69,13 @@ def block_delimiter

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
Expand Down
33 changes: 33 additions & 0 deletions lib/liquid/call_expression.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 96 additions & 0 deletions lib/liquid/closure.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions lib/liquid/closure_body.rb
Original file line number Diff line number Diff line change
@@ -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
42 changes: 40 additions & 2 deletions lib/liquid/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,40 @@ def new_isolated_subcontext
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
Expand Down Expand Up @@ -206,7 +240,7 @@ def find_variable(key, raise_on_not_found: true)
# 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)
Expand Down Expand Up @@ -262,13 +296,17 @@ def tag_disabled?(tag_name)

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
Expand Down
8 changes: 6 additions & 2 deletions lib/liquid/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ def nodelist
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
Expand All @@ -38,7 +40,9 @@ def unknown_tag(tag, _markup, _tokenizer)
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)
Expand Down
1 change: 1 addition & 0 deletions lib/liquid/expression.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def safe_parse(parser, ss = StringScanner.new(""), cache = nil)

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

Expand Down
3 changes: 3 additions & 0 deletions lib/liquid/lexer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

module Liquid
class Lexer
ASSIGN = [:assign, "="].freeze
CLOSE_ROUND = [:close_round, ")"].freeze
CLOSE_SQUARE = [:close_square, "]"].freeze
COLON = [:colon, ":"].freeze
Expand Down Expand Up @@ -133,6 +134,8 @@ def tokenize(ss)
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
Expand Down
Loading
Loading