mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-30 23:55:12 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5bb4c8eff |
@@ -32,6 +32,7 @@ group :test do
|
||||
end
|
||||
|
||||
group :spec do
|
||||
gem 'liquid-spec', github: 'Shopify/liquid-spec'
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# Recording and replaying renders
|
||||
|
||||
`Liquid::TemplateRecorder` captures successful template renders so they can be
|
||||
replayed without the application's file system or Drop implementations.
|
||||
Recording does not wrap or replace assigns, so the recorded render has the same
|
||||
semantics as a normal render.
|
||||
|
||||
```ruby
|
||||
Liquid::TemplateRecorder.record("render.json") do
|
||||
template = Liquid::Template.parse(source)
|
||||
template.render!(assigns)
|
||||
end
|
||||
|
||||
replayer = Liquid::TemplateRecorder.replay_from("render.json", mode: :verify)
|
||||
replayer.render # raises if the output changed
|
||||
```
|
||||
|
||||
A recording contains the root template, every parsed partial, partial contents,
|
||||
plain Hash/Array values resolved by the template, properties actually read from `Liquid::Drop` objects,
|
||||
filter-call diagnostics, engine options, and the rendered output. Drop instance
|
||||
variables are never inspected. An unsupported Ruby object raises
|
||||
`Liquid::TemplateRecorder::SerializationError` rather than silently producing a
|
||||
recording that cannot be replayed.
|
||||
|
||||
## Storage formats
|
||||
|
||||
A `.json` destination is written atomically after the recording block succeeds.
|
||||
It contains a session with every render performed by the block.
|
||||
|
||||
A `.jsonl` destination is append-only. Each successful top-level render is one
|
||||
compact, self-contained JSON line. This is the recommended format for production
|
||||
sampling: a process failure can lose at most the render being written, writers
|
||||
are serialized with `flock`, and a recording can be replayed by index.
|
||||
|
||||
A destination may instead be any writer object responding to `write(record)`. The
|
||||
writer receives one self-contained recording Hash per successful render. Liquid
|
||||
does not own or close injected writers, so applications can publish records to
|
||||
Kafka, object storage, or another transport without coupling that transport to
|
||||
the recorder.
|
||||
Pass `on_error:` to keep serialization or sink failures out of the render path;
|
||||
the callback receives the error and should not raise.
|
||||
|
||||
```ruby
|
||||
Liquid::TemplateRecorder.record(kafka_writer) do
|
||||
template.render!(assigns)
|
||||
end
|
||||
```
|
||||
|
||||
```ruby
|
||||
Liquid::TemplateRecorder.replay_from("renders.jsonl") # last render
|
||||
Liquid::TemplateRecorder.replay_from("renders.jsonl", index: 0) # first render
|
||||
Liquid::TemplateRecorder.records("renders.jsonl") # inspect all
|
||||
```
|
||||
|
||||
Compression is intentionally separate from the schema. In particular, one
|
||||
long-lived compressed stream makes appending, recovery, and selecting a render
|
||||
harder. Compress rotated `.jsonl` files with the storage system of your choice;
|
||||
a future compressed writer can use one independent frame per record without a
|
||||
schema change.
|
||||
|
||||
Recording sessions are thread-local. Nested sessions in the same thread are
|
||||
rejected. Existing application register names and the one-argument
|
||||
`FileSystem#read_template_file` API remain unchanged.
|
||||
|
||||
## Replay modes
|
||||
|
||||
* `:compute` runs filters normally. Pass application filters with
|
||||
`replayer.render(filters: MyFilters)`.
|
||||
* `:strict` returns each exact recorded filter result and rejects a changed
|
||||
filter sequence. This can replay application-specific or nondeterministic
|
||||
filters without loading their implementations.
|
||||
* `:verify` computes normally and raises when the final output differs.
|
||||
+3
-1
@@ -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"
|
||||
@@ -90,4 +93,3 @@ require 'liquid/partial_cache'
|
||||
require 'liquid/usage'
|
||||
require 'liquid/registers'
|
||||
require 'liquid/template_factory'
|
||||
require "liquid/template_recorder"
|
||||
|
||||
+14
-4
@@ -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
|
||||
|
||||
@@ -84,15 +84,10 @@ module Liquid
|
||||
|
||||
# @api private
|
||||
def self.render_node(context, output, node)
|
||||
recorder = TemplateRecorder.current
|
||||
tag_call = recorder&.begin_tag_render(node, context)
|
||||
output_start = output.length
|
||||
node.render_to_output_buffer(context, output)
|
||||
rescue => exc
|
||||
blank_tag = !node.instance_of?(Variable) && node.blank?
|
||||
rescue_render_node(context, output, node.line_number, exc, blank_tag)
|
||||
ensure
|
||||
recorder&.finish_tag_render(tag_call, output[output_start..]) if output_start
|
||||
end
|
||||
|
||||
# @api private
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
class CallExpression
|
||||
attr_reader :target, :arguments
|
||||
|
||||
def initialize(target, arguments)
|
||||
@target = target.freeze
|
||||
@arguments = arguments.freeze
|
||||
freeze
|
||||
end
|
||||
|
||||
def evaluate(context)
|
||||
context.resource_limits.with_capture do
|
||||
render_to_output_buffer(context, +'')
|
||||
end
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
closure = context.evaluate(@target)
|
||||
raise Liquid::ArgumentError, "Expected a Liquid closure" unless closure.is_a?(Closure)
|
||||
|
||||
arguments = @arguments.map { |argument| context.evaluate(argument) }
|
||||
closure.render_to_output_buffer(context, output, arguments)
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[@node.target] + @node.arguments
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# A render-time value. Captures retain binding hashes, not copies of their values.
|
||||
class Closure
|
||||
def initialize(body, parameters, context)
|
||||
@body = body
|
||||
@parameters = parameters
|
||||
@scopes = context.scopes.dup
|
||||
@environments = context.environments.dup
|
||||
@static_environments = context.static_environments
|
||||
end
|
||||
|
||||
# Render into the supplied buffer and return direct-child body-form let bindings.
|
||||
def invoke(context, arguments = Const::EMPTY_ARRAY, output: +'')
|
||||
invocation = render_invocation(context, arguments, output)
|
||||
@body.exports(invocation)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output, arguments = Const::EMPTY_ARRAY)
|
||||
render_invocation(context, arguments, output)
|
||||
output
|
||||
end
|
||||
|
||||
def to_liquid
|
||||
self
|
||||
end
|
||||
|
||||
def to_s
|
||||
''
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_invocation(context, arguments, output)
|
||||
unless arguments.length == @parameters.length
|
||||
raise Liquid::ArgumentError, "Expected #{@parameters.length} closure arguments, got #{arguments.length}"
|
||||
end
|
||||
|
||||
invocation = context.new_closure_subcontext(@scopes, @environments, @static_environments)
|
||||
@parameters.each_with_index { |name, index| invocation[name] = arguments[index] }
|
||||
@body.render_to_output_buffer(invocation, output)
|
||||
invocation
|
||||
end
|
||||
|
||||
# @api private
|
||||
class CapturedBindings
|
||||
def initialize(scopes, environments, context)
|
||||
@scopes = scopes
|
||||
@environments = environments
|
||||
@context = context
|
||||
@locals = {}
|
||||
end
|
||||
|
||||
def key?(name)
|
||||
local_key?(name) || @environments.any? { |environment| environment.key?(name) }
|
||||
end
|
||||
|
||||
def local_key?(name)
|
||||
@locals.key?(name) || @scopes.any? { |scope| scope.key?(name) } ||
|
||||
@environments.any? { |environment| environment.is_a?(CapturedBindings) && environment.local_key?(name) }
|
||||
end
|
||||
|
||||
def []=(name, value)
|
||||
@locals[name] = value
|
||||
end
|
||||
|
||||
def [](name)
|
||||
find_variable(name, @context, raise_on_not_found: false)
|
||||
end
|
||||
|
||||
def find_variable(name, context, raise_on_not_found: true)
|
||||
return context.lookup_and_evaluate(@locals, name, raise_on_not_found: raise_on_not_found) if @locals.key?(name)
|
||||
|
||||
scope = @scopes.find { |candidate| candidate.key?(name) }
|
||||
return context.lookup_and_evaluate(scope, name, raise_on_not_found: raise_on_not_found) if scope
|
||||
|
||||
find_in_environments(name, context, raise_on_not_found: raise_on_not_found)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_in_environments(name, context, raise_on_not_found:)
|
||||
@environments.each do |environment|
|
||||
if environment.is_a?(CapturedBindings)
|
||||
return environment.find_variable(name, context, raise_on_not_found: raise_on_not_found)
|
||||
end
|
||||
|
||||
value = context.lookup_and_evaluate(environment, name, raise_on_not_found: raise_on_not_found)
|
||||
return value if !value.nil? || context.strict_variables && raise_on_not_found
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
# Parsed syntax shared by let and closure-aware custom blocks.
|
||||
class ClosureBody
|
||||
attr_reader :body
|
||||
|
||||
def initialize(body, parameters = Const::EMPTY_ARRAY)
|
||||
@body = body
|
||||
@parameters = parameters.dup.freeze
|
||||
@exports = body.nodelist.select { |node| node.is_a?(Let) && node.body_form? }.map(&:to).freeze
|
||||
freeze
|
||||
end
|
||||
|
||||
def bind(context)
|
||||
Closure.new(self, @parameters, context)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
@body.render_to_output_buffer(context, output)
|
||||
end
|
||||
|
||||
def exports(context)
|
||||
context.scopes.last.slice(*@exports)
|
||||
end
|
||||
|
||||
def nodelist
|
||||
@body.nodelist
|
||||
end
|
||||
end
|
||||
end
|
||||
+40
-4
@@ -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)
|
||||
@@ -228,8 +262,6 @@ module Liquid
|
||||
|
||||
liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)
|
||||
|
||||
recorder = @registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
|
||||
recorder&.emit_variable_read(key, liquid_variable)
|
||||
liquid_variable
|
||||
end
|
||||
|
||||
@@ -264,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
|
||||
|
||||
@@ -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
-5
@@ -37,15 +37,11 @@ module Liquid
|
||||
|
||||
# called by liquid to invoke a drop
|
||||
def invoke_drop(method_or_key)
|
||||
result = if self.class.invokable?(method_or_key)
|
||||
if self.class.invokable?(method_or_key)
|
||||
send(method_or_key)
|
||||
else
|
||||
liquid_method_missing(method_or_key)
|
||||
end
|
||||
|
||||
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
|
||||
recorder&.emit_drop_read(self, method_or_key, result)
|
||||
result
|
||||
end
|
||||
|
||||
def key?(_name)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -10,8 +10,6 @@ module Liquid
|
||||
|
||||
file_system = context.registers[:file_system]
|
||||
source = file_system.read_template_file(template_name)
|
||||
recorder = context.registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
|
||||
recorder&.emit_file_read(template_name, source)
|
||||
|
||||
parse_context.partial = true
|
||||
|
||||
|
||||
@@ -48,17 +48,13 @@ module Liquid
|
||||
end
|
||||
|
||||
def invoke(method, *args)
|
||||
result = if self.class.invokable?(method)
|
||||
if self.class.invokable?(method)
|
||||
send(method, *args)
|
||||
elsif @context.strict_filters
|
||||
raise Liquid::UndefinedFilter, "undefined filter #{method}"
|
||||
else
|
||||
args.first
|
||||
end
|
||||
|
||||
recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
|
||||
recorder&.emit_filter_call(method, args.first, args.drop(1), result)
|
||||
result
|
||||
rescue ::ArgumentError => e
|
||||
raise Liquid::ArgumentError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
class Let < Block
|
||||
attr_reader :to, :from, :parameters, :closure_body
|
||||
|
||||
def initialize(tag_name, markup, parse_context)
|
||||
super
|
||||
parse_context.require_closures!
|
||||
parser = parse_context.new_parser(markup)
|
||||
@to = parser.consume(:id)
|
||||
if parser.consume?(:assign)
|
||||
raise SyntaxError, "Expected let initializer" if parser.look(:end_of_string)
|
||||
|
||||
@from = Variable.new(parser, parse_context)
|
||||
else
|
||||
@parameters = parser.parameters.freeze
|
||||
parser.consume(:end_of_string)
|
||||
end
|
||||
parse_context.declare_local(@to)
|
||||
end
|
||||
|
||||
def parse(tokens)
|
||||
return unless body_form?
|
||||
|
||||
@closure_body = parse_closure_body(tokens, parameters: @parameters)
|
||||
end
|
||||
|
||||
def body_form?
|
||||
@from.nil?
|
||||
end
|
||||
|
||||
def nodelist
|
||||
body_form? ? super : Const::EMPTY_ARRAY
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
value = body_form? ? @closure_body.bind(context) : @from.render(context)
|
||||
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
|
||||
context[@to] = value
|
||||
output
|
||||
end
|
||||
|
||||
def blank?
|
||||
true
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
@node.body_form? ? [@node.closure_body] : [@node.from]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Liquid
|
||||
class SetTag < Tag
|
||||
attr_reader :to, :from
|
||||
|
||||
def initialize(tag_name, markup, parse_context)
|
||||
super
|
||||
parse_context.require_closures!
|
||||
parser = parse_context.new_parser(markup)
|
||||
@to = parser.consume(:id)
|
||||
parser.consume(:assign)
|
||||
raise SyntaxError, "Expected set initializer" if parser.look(:end_of_string)
|
||||
|
||||
@from = Variable.new(parser, parse_context)
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
value = if context.variable_defined?(@to)
|
||||
@from.render(context)
|
||||
else
|
||||
context.stack(@to => nil) { @from.render(context) }
|
||||
end
|
||||
context.resource_limits.increment_assign_score(Assign.assign_score_of(value))
|
||||
context.set_local(@to, value)
|
||||
output
|
||||
end
|
||||
|
||||
def blank?
|
||||
true
|
||||
end
|
||||
|
||||
class ParseTreeVisitor < Liquid::ParseTreeVisitor
|
||||
def children
|
||||
[@node.from]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -34,6 +34,13 @@ module Liquid
|
||||
parse_with_selected_parser(markup)
|
||||
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.
|
||||
|
||||
+2
-26
@@ -105,7 +105,6 @@ module Liquid
|
||||
|
||||
tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
|
||||
@root = Document.parse(tokenizer, parse_context)
|
||||
@template_recorder_source = source.dup.freeze if defined?(TemplateRecorder) && TemplateRecorder.current
|
||||
self
|
||||
end
|
||||
|
||||
@@ -142,8 +141,6 @@ module Liquid
|
||||
def render(*args)
|
||||
return '' if @root.nil?
|
||||
|
||||
recording_session = TemplateRecorder.current if defined?(TemplateRecorder)
|
||||
recording_assigns = args.first
|
||||
context = case args.first
|
||||
when Liquid::Context
|
||||
c = args.shift
|
||||
@@ -183,13 +180,6 @@ module Liquid
|
||||
context.add_filters(args.pop)
|
||||
end
|
||||
|
||||
recording = recording_session&.begin_render(self, recording_assigns, context)
|
||||
if recording
|
||||
recorder_registers = context.registers.static
|
||||
previous_recorder = recorder_registers[TemplateRecorder::REGISTER_KEY]
|
||||
recorder_registers[TemplateRecorder::REGISTER_KEY] = recording
|
||||
end
|
||||
|
||||
# Retrying a render resets resource usage
|
||||
context.resource_limits.reset
|
||||
|
||||
@@ -202,31 +192,17 @@ module Liquid
|
||||
previous_error_mode = context.registers.static[:template_error_mode]
|
||||
context.registers.static[:template_error_mode] = @error_mode
|
||||
|
||||
rendered_output = nil
|
||||
render_succeeded = false
|
||||
begin
|
||||
# render the nodelist.
|
||||
rendered_output = @root.render_to_output_buffer(context, output || +'')
|
||||
render_succeeded = true
|
||||
rendered_output
|
||||
@root.render_to_output_buffer(context, output || +'')
|
||||
rescue Liquid::MemoryError => e
|
||||
rendered_output = context.handle_error(e)
|
||||
render_succeeded = true
|
||||
rendered_output
|
||||
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
|
||||
if recording
|
||||
if previous_recorder
|
||||
recorder_registers[TemplateRecorder::REGISTER_KEY] = previous_recorder
|
||||
else
|
||||
recorder_registers.delete(TemplateRecorder::REGISTER_KEY)
|
||||
end
|
||||
recording_session.finish_render(recording, rendered_output, context, success: render_succeeded)
|
||||
end
|
||||
@errors = context.errors
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'English'
|
||||
require "digest/sha2"
|
||||
require "json"
|
||||
require "securerandom"
|
||||
require "tempfile"
|
||||
require "time"
|
||||
|
||||
module Liquid
|
||||
# Records complete Liquid renders without changing the objects being rendered.
|
||||
# A .json file contains one session; a .jsonl file is an append-only sequence
|
||||
# of independently replayable renders.
|
||||
class TemplateRecorder
|
||||
FORMAT = "liquid-render"
|
||||
SCHEMA_VERSION = 1
|
||||
REGISTER_KEY = :__liquid_template_recorder
|
||||
REPLAYER_REGISTER_KEY = :__liquid_template_recorder_replayer
|
||||
|
||||
class Error < StandardError; end
|
||||
class ReplayError < Error; end
|
||||
class SerializationError < Error; end
|
||||
|
||||
class << self
|
||||
def record(destination, on_error: nil)
|
||||
raise ArgumentError, "a block is required" unless block_given?
|
||||
|
||||
previous_session = current
|
||||
raise Error, "nested recording sessions are not supported" if previous_session
|
||||
|
||||
session = Session.new(destination, on_error: on_error)
|
||||
Thread.current[thread_key] = session
|
||||
yield
|
||||
ensure
|
||||
if session
|
||||
Thread.current[thread_key] = previous_session
|
||||
session.close if $ERROR_INFO.nil?
|
||||
end
|
||||
end
|
||||
|
||||
def current
|
||||
Thread.current[thread_key]
|
||||
end
|
||||
|
||||
def replay_from(path, mode: :compute, index: -1)
|
||||
records = Store.read(path)
|
||||
raise ReplayError, "recording contains no renders" if records.empty?
|
||||
|
||||
record = records.fetch(index)
|
||||
Replayer.new(record, mode: mode)
|
||||
rescue IndexError
|
||||
raise ReplayError, "render index #{index} does not exist"
|
||||
end
|
||||
|
||||
def records(path)
|
||||
Store.read(path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def thread_key
|
||||
:__liquid_template_recorder_session
|
||||
end
|
||||
end
|
||||
|
||||
class Session
|
||||
def initialize(destination, on_error:)
|
||||
@path = destination.to_s if destination.is_a?(String) || destination.respond_to?(:to_path)
|
||||
@writer = destination unless @path
|
||||
@on_error = on_error
|
||||
@records = []
|
||||
@active = nil
|
||||
@pending_files = {}
|
||||
end
|
||||
|
||||
def begin_render(template, _assigns, context)
|
||||
if @active
|
||||
@active.add_template(template)
|
||||
@active.nesting += 1
|
||||
return @active
|
||||
end
|
||||
|
||||
@active = Render.new(template, context)
|
||||
@pending_files.each { |path, source| @active.emit_file_read(path, source) }
|
||||
@pending_files.clear
|
||||
@active
|
||||
end
|
||||
|
||||
def emit_file_read(path, source)
|
||||
if @active
|
||||
@active.emit_file_read(path, source)
|
||||
else
|
||||
@pending_files[path.to_s] = source
|
||||
end
|
||||
end
|
||||
|
||||
def emit_variable_output(output)
|
||||
@active&.emit_variable_output(output)
|
||||
end
|
||||
|
||||
def begin_tag_render(node, context)
|
||||
@active&.begin_tag_render(node, context)
|
||||
end
|
||||
|
||||
def finish_tag_render(call, output)
|
||||
@active&.finish_tag_render(call, output)
|
||||
end
|
||||
|
||||
def finish_render(render, output, context, success:)
|
||||
return unless render.equal?(@active)
|
||||
|
||||
if render.nesting.positive?
|
||||
render.nesting -= 1
|
||||
return
|
||||
end
|
||||
|
||||
if success
|
||||
begin
|
||||
record = render.finish(output, context)
|
||||
if @writer
|
||||
@writer.write(record)
|
||||
elsif Store.jsonl?(@path)
|
||||
Store.append(@path, record)
|
||||
else
|
||||
@records << record
|
||||
end
|
||||
rescue => error
|
||||
handle_error(error)
|
||||
end
|
||||
end
|
||||
@active = nil
|
||||
end
|
||||
|
||||
def close
|
||||
return if @writer || Store.jsonl?(@path)
|
||||
|
||||
Store.write_session(@path, @records)
|
||||
rescue => error
|
||||
handle_error(error)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_error(error)
|
||||
raise error unless @on_error
|
||||
|
||||
@on_error.call(error)
|
||||
end
|
||||
end
|
||||
|
||||
class Render
|
||||
attr_accessor :nesting
|
||||
|
||||
def initialize(template, _context)
|
||||
@nesting = 0
|
||||
@templates = []
|
||||
@files = {}
|
||||
@filter_calls = []
|
||||
@tag_calls = []
|
||||
@tag_render_depth = 0
|
||||
@variables = {}
|
||||
@variable_outputs = []
|
||||
@drop_values = {}
|
||||
@bindings = {}.compare_by_identity
|
||||
@root_template = template
|
||||
add_template(template)
|
||||
end
|
||||
|
||||
def add_template(template)
|
||||
source = template.instance_variable_get(:@template_recorder_source)
|
||||
return unless source
|
||||
|
||||
entrypoint = template.name
|
||||
digest = Digest::SHA256.hexdigest(source)
|
||||
return if @templates.any? { |item| item["sha256"] == digest && item["entrypoint"] == entrypoint }
|
||||
|
||||
@templates << { "source" => source, "entrypoint" => entrypoint, "sha256" => digest }
|
||||
end
|
||||
|
||||
def emit_variable_output(output)
|
||||
return if @tag_render_depth.positive?
|
||||
|
||||
@variable_outputs << output
|
||||
end
|
||||
|
||||
def begin_tag_render(node, context)
|
||||
name = context.environment.tags.key(node.class)
|
||||
return unless name
|
||||
|
||||
@tag_render_depth += 1
|
||||
return :nested if @tag_render_depth > 1
|
||||
|
||||
call = { "name" => name.to_s, "output" => nil }
|
||||
@tag_calls << call
|
||||
call
|
||||
end
|
||||
|
||||
def finish_tag_render(call, output)
|
||||
return unless call
|
||||
|
||||
@tag_render_depth -= 1
|
||||
call["output"] = output unless call == :nested
|
||||
end
|
||||
|
||||
def emit_variable_read(name, value)
|
||||
path = [name.to_s]
|
||||
@variables[name.to_s] = serialize(value, path, bind: true)
|
||||
rescue SerializationError
|
||||
# Unsupported values must not affect the render being observed.
|
||||
end
|
||||
|
||||
def emit_drop_read(drop, key, value)
|
||||
base = @bindings[drop]
|
||||
return unless base
|
||||
|
||||
path = base + [key.to_s]
|
||||
set_path(@drop_values, path, serialize(value, path, bind: true))
|
||||
rescue SerializationError
|
||||
# Unsupported values must not affect the render being observed.
|
||||
end
|
||||
|
||||
def emit_file_read(path, source)
|
||||
@files[path.to_s] = source.to_s
|
||||
end
|
||||
|
||||
def emit_filter_call(name, input, arguments, output)
|
||||
return if @tag_render_depth.positive?
|
||||
|
||||
@filter_calls << {
|
||||
"name" => name.to_s,
|
||||
"input" => serialize(input, ["filters", @filter_calls.length, "input"]),
|
||||
"arguments" => serialize(arguments, ["filters", @filter_calls.length, "arguments"]),
|
||||
"output" => serialize(output, ["filters", @filter_calls.length, "output"]),
|
||||
}
|
||||
rescue SerializationError
|
||||
# Filter diagnostics must never make an otherwise replayable render fail.
|
||||
end
|
||||
|
||||
def finish(output, context)
|
||||
variables = deep_merge(@variables, @drop_values)
|
||||
source = @root_template.instance_variable_get(:@template_recorder_source)
|
||||
raise Error, "the rendered template was parsed outside the recording block" unless source
|
||||
|
||||
{
|
||||
"format" => FORMAT,
|
||||
"schema_version" => SCHEMA_VERSION,
|
||||
"id" => SecureRandom.uuid,
|
||||
"recorded_at" => Time.now.utc.iso8601,
|
||||
"engine" => {
|
||||
"liquid_version" => Liquid::VERSION,
|
||||
"ruby_version" => RUBY_VERSION,
|
||||
"strict_variables" => !!context.strict_variables,
|
||||
"strict_filters" => !!context.strict_filters,
|
||||
},
|
||||
"template" => @templates.first,
|
||||
"templates" => @templates,
|
||||
"assigns" => variables,
|
||||
"variable_outputs" => @variable_outputs,
|
||||
"file_system" => @files,
|
||||
"filter_calls" => @filter_calls,
|
||||
"tag_calls" => @tag_calls,
|
||||
"output" => output.to_s,
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize(value, path, seen = {}.compare_by_identity, bind: false)
|
||||
case value
|
||||
when nil, true, false, String, Integer, Float
|
||||
value
|
||||
when Symbol
|
||||
value.to_s
|
||||
when Liquid::Drop
|
||||
@bindings[value] ||= path if bind
|
||||
existing = value_at(@drop_values, @bindings[value])
|
||||
existing || {}
|
||||
when Hash
|
||||
raise SerializationError, "circular value at #{format_path(path)}" if seen.key?(value)
|
||||
|
||||
seen[value] = true
|
||||
result = value.each_with_object({}) do |(key, child), hash|
|
||||
string_key = key.to_s
|
||||
hash[string_key] = serialize(child, path + [string_key], seen, bind: bind)
|
||||
end
|
||||
seen.delete(value)
|
||||
result
|
||||
when Array
|
||||
raise SerializationError, "circular value at #{format_path(path)}" if seen.key?(value)
|
||||
|
||||
seen[value] = true
|
||||
result = value.each_with_index.map { |child, index| serialize(child, path + [index], seen, bind: bind) }
|
||||
seen.delete(value)
|
||||
result
|
||||
else
|
||||
raise SerializationError, "cannot record #{value.class} at #{format_path(path)}"
|
||||
end
|
||||
end
|
||||
|
||||
def set_path(root, path, value)
|
||||
return root.replace(value) if path.empty? && value.is_a?(Hash)
|
||||
|
||||
cursor = root
|
||||
path.each_with_index do |segment, index|
|
||||
last = index == path.length - 1
|
||||
if segment.is_a?(Integer)
|
||||
break unless cursor.is_a?(Array)
|
||||
|
||||
end
|
||||
cursor[segment] = last ? value : (cursor[segment] ||= container_for(path[index + 1]))
|
||||
cursor = cursor[segment] unless last
|
||||
end
|
||||
end
|
||||
|
||||
def value_at(root, path)
|
||||
return unless path
|
||||
|
||||
path.reduce(root) { |value, segment| value.respond_to?(:[]) ? value[segment] : nil }
|
||||
end
|
||||
|
||||
def container_for(segment)
|
||||
segment.is_a?(Integer) ? [] : {}
|
||||
end
|
||||
|
||||
def deep_merge(left, right)
|
||||
return right unless left.is_a?(Hash) && right.is_a?(Hash)
|
||||
|
||||
left.merge(right) { |_key, a, b| deep_merge(a, b) }
|
||||
end
|
||||
|
||||
def format_path(path)
|
||||
path.empty? ? "<root>" : path.join(".")
|
||||
end
|
||||
end
|
||||
|
||||
class Store
|
||||
class << self
|
||||
def jsonl?(path)
|
||||
path.end_with?(".jsonl")
|
||||
end
|
||||
|
||||
def append(path, record)
|
||||
line = JSON.generate(record) << "\n"
|
||||
File.open(path, File::WRONLY | File::CREAT | File::APPEND, 0o600) do |file|
|
||||
file.flock(File::LOCK_EX)
|
||||
file.write(line)
|
||||
file.flush
|
||||
end
|
||||
end
|
||||
|
||||
def write_session(path, records)
|
||||
payload = JSON.pretty_generate(
|
||||
"format" => "liquid-recording-session",
|
||||
"schema_version" => SCHEMA_VERSION,
|
||||
"renders" => records,
|
||||
) << "\n"
|
||||
directory = File.dirname(File.expand_path(path))
|
||||
Tempfile.create([".liquid-recording", ".tmp"], directory, mode: File::RDWR, perm: 0o600) do |file|
|
||||
file.write(payload)
|
||||
file.flush
|
||||
file.fsync
|
||||
File.rename(file.path, path)
|
||||
end
|
||||
end
|
||||
|
||||
def read(path)
|
||||
content = File.binread(path)
|
||||
records = if jsonl?(path)
|
||||
read_jsonl(content)
|
||||
else
|
||||
parsed = JSON.parse(content)
|
||||
parsed["renders"] || [parsed]
|
||||
end
|
||||
records.each { |record| validate!(record) }
|
||||
records
|
||||
rescue Errno::ENOENT
|
||||
raise ReplayError, "recording file not found: #{path}"
|
||||
rescue JSON::ParserError => error
|
||||
raise ReplayError, "invalid recording JSON: #{error.message}"
|
||||
end
|
||||
|
||||
def read_jsonl(content)
|
||||
lines = content.lines
|
||||
lines.filter_map.with_index do |line, index|
|
||||
next if line.strip.empty?
|
||||
|
||||
JSON.parse(line)
|
||||
rescue JSON::ParserError
|
||||
last_truncated_line = index == lines.length - 1 && !content.end_with?("\n")
|
||||
raise unless last_truncated_line
|
||||
end
|
||||
end
|
||||
|
||||
def validate!(record)
|
||||
raise ReplayError, "unsupported recording format" unless record["format"] == FORMAT
|
||||
raise ReplayError, "unsupported schema version #{record["schema_version"].inspect}" unless record["schema_version"] == SCHEMA_VERSION
|
||||
|
||||
['template', 'assigns', 'file_system', 'output'].each do |key|
|
||||
raise ReplayError, "recording is missing #{key}" unless record.key?(key)
|
||||
end
|
||||
template = record["template"]
|
||||
expected = Digest::SHA256.hexdigest(template.fetch("source"))
|
||||
raise ReplayError, "template checksum does not match" unless template["sha256"] == expected
|
||||
rescue KeyError, TypeError => error
|
||||
raise ReplayError, "invalid recording schema: #{error.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class MemoryFileSystem
|
||||
def initialize(files)
|
||||
@files = files
|
||||
end
|
||||
|
||||
def read_template_file(path)
|
||||
@files.fetch(path.to_s) { raise FileSystemError, "No such template '#{path}'" }
|
||||
end
|
||||
end
|
||||
|
||||
class Replayer
|
||||
def initialize(record, mode: :compute, environment: nil)
|
||||
@record = record
|
||||
@mode = mode.to_sym
|
||||
@environment = environment
|
||||
unless [:compute, :strict, :verify].include?(@mode)
|
||||
raise ReplayError, "mode must be :compute, :strict, or :verify"
|
||||
end
|
||||
end
|
||||
|
||||
def render(to: nil, filters: nil)
|
||||
@filter_index = 0
|
||||
@tag_index = 0
|
||||
@variable_index = 0
|
||||
parse_options = {}
|
||||
parse_options[:environment] = strict_environment if @mode == :strict
|
||||
template = Liquid::Template.parse(@record.dig("template", "source"), parse_options)
|
||||
registers = { file_system: MemoryFileSystem.new(@record["file_system"]) }
|
||||
registers[REPLAYER_REGISTER_KEY] = self if @mode == :strict && @record.key?("variable_outputs")
|
||||
options = {
|
||||
registers: registers,
|
||||
strict_variables: @record.dig("engine", "strict_variables"),
|
||||
strict_filters: @record.dig("engine", "strict_filters"),
|
||||
}
|
||||
options[:filters] = filters if filters
|
||||
output = template.render!(@record["assigns"], options)
|
||||
if @mode == :strict
|
||||
verify_filter_count! unless @record.key?("variable_outputs")
|
||||
verify_tag_count!
|
||||
verify_variable_count!
|
||||
end
|
||||
if [:strict, :verify].include?(@mode) && output != @record["output"]
|
||||
raise ReplayError, "replayed output does not match the recording"
|
||||
end
|
||||
|
||||
File.binwrite(to, output) if to
|
||||
output
|
||||
end
|
||||
|
||||
def replay_filter(name)
|
||||
call = @record["filter_calls"].fetch(@filter_index) do
|
||||
raise ReplayError, "unexpected filter call #{name}"
|
||||
end
|
||||
if call["name"] != name.to_s
|
||||
raise ReplayError, "expected filter #{call["name"]}, got #{name}"
|
||||
end
|
||||
|
||||
@filter_index += 1
|
||||
JSON.parse(JSON.generate(call["output"]))
|
||||
end
|
||||
|
||||
def replay_variable
|
||||
value = @record.fetch("variable_outputs").fetch(@variable_index) do
|
||||
raise ReplayError, "unexpected variable render"
|
||||
end
|
||||
@variable_index += 1
|
||||
value
|
||||
end
|
||||
|
||||
def replay_tag(name)
|
||||
call = @record.fetch("tag_calls", []).fetch(@tag_index) do
|
||||
raise ReplayError, "unexpected tag call #{name}"
|
||||
end
|
||||
if call["name"] != name.to_s
|
||||
raise ReplayError, "expected tag #{call["name"]}, got #{name}"
|
||||
end
|
||||
|
||||
@tag_index += 1
|
||||
call["output"]
|
||||
end
|
||||
|
||||
def recorded_output
|
||||
@record["output"]
|
||||
end
|
||||
|
||||
def templates
|
||||
@record["templates"]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strict_environment
|
||||
replayer = self
|
||||
strainer = Class.new(Liquid::StrainerTemplate) do
|
||||
define_method(:invoke) do |name, *_args|
|
||||
replayer.replay_filter(name)
|
||||
end
|
||||
end
|
||||
tags = (@environment || Liquid::Environment.default).tags.dup
|
||||
tags&.each do |name, tag_class|
|
||||
tags[name] = replay_tag_class(tag_class, name)
|
||||
end
|
||||
Liquid::Environment.build(tags: tags) do |environment|
|
||||
environment.strainer_template = strainer
|
||||
end
|
||||
end
|
||||
|
||||
def replay_tag_class(tag_class, name)
|
||||
replayer = self
|
||||
Class.new(tag_class) do
|
||||
define_method(:render_to_output_buffer) do |_context, output|
|
||||
output << replayer.replay_tag(name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def verify_variable_count!
|
||||
return unless @record.key?("variable_outputs")
|
||||
|
||||
expected = @record["variable_outputs"].length
|
||||
return if @variable_index == expected
|
||||
|
||||
raise ReplayError, "expected #{expected} variable renders, got #{@variable_index}"
|
||||
end
|
||||
|
||||
def verify_tag_count!
|
||||
expected = @record.fetch("tag_calls", []).length
|
||||
return if @tag_index == expected
|
||||
|
||||
raise ReplayError, "expected #{expected} tag calls, got #{@tag_index}"
|
||||
end
|
||||
|
||||
def verify_filter_count!
|
||||
expected = @record["filter_calls"].length
|
||||
return if @filter_index == expected
|
||||
|
||||
raise ReplayError, "expected #{expected} filter calls, got #{@filter_index}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,14 +110,12 @@ module Liquid
|
||||
end
|
||||
|
||||
def render_to_output_buffer(context, output)
|
||||
if (replayer = context.registers[TemplateRecorder::REPLAYER_REGISTER_KEY])
|
||||
return output << replayer.replay_variable
|
||||
if @name.is_a?(CallExpression) && @filters.empty? && context.global_filter.nil?
|
||||
return @name.render_to_output_buffer(context, output)
|
||||
end
|
||||
|
||||
output_start = output.length
|
||||
obj = render(context)
|
||||
render_obj_to_output(obj, output)
|
||||
TemplateRecorder.current&.emit_variable_output(output[output_start..])
|
||||
output
|
||||
end
|
||||
|
||||
@@ -213,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -268,9 +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
|
||||
expected = ENV["LIQUID_PARSER_MODE"] == "strict2" ? "Liquid error: comparison of Integer with String failed0" : "0"
|
||||
assert_equal(expected, 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
|
||||
|
||||
@@ -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
|
||||
@@ -1,360 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../test_helper"
|
||||
require "tmpdir"
|
||||
|
||||
class TemplateRecorderTest < Minitest::Test
|
||||
class ProductDrop < Liquid::Drop
|
||||
def initialize(title, secret)
|
||||
super()
|
||||
@title = title
|
||||
@secret = secret
|
||||
end
|
||||
|
||||
attr_reader :title
|
||||
|
||||
def details
|
||||
DetailsDrop.new
|
||||
end
|
||||
end
|
||||
|
||||
class DetailsDrop < Liquid::Drop
|
||||
def count
|
||||
3
|
||||
end
|
||||
end
|
||||
|
||||
class LegacyFileSystem
|
||||
attr_reader :reads
|
||||
|
||||
def initialize
|
||||
@reads = []
|
||||
end
|
||||
|
||||
def read_template_file(name)
|
||||
@reads << name
|
||||
"partial={{ product.title }}"
|
||||
end
|
||||
end
|
||||
|
||||
class UnsupportedLiquidValue
|
||||
def to_liquid
|
||||
self
|
||||
end
|
||||
|
||||
def to_s
|
||||
"unsupported"
|
||||
end
|
||||
end
|
||||
|
||||
class WrapperTag < Liquid::Block
|
||||
end
|
||||
|
||||
class MarkerTag < Liquid::Tag
|
||||
def render(_context)
|
||||
"custom"
|
||||
end
|
||||
end
|
||||
|
||||
class CollectingWriter
|
||||
attr_reader :records
|
||||
|
||||
def initialize
|
||||
@records = []
|
||||
end
|
||||
|
||||
def write(record)
|
||||
@records << record
|
||||
end
|
||||
end
|
||||
|
||||
def setup
|
||||
@directory = Dir.mktmpdir
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@directory)
|
||||
end
|
||||
|
||||
def path(name = "recording.json")
|
||||
File.join(@directory, name)
|
||||
end
|
||||
|
||||
def test_records_and_verifies_a_render_without_changing_drop_behavior
|
||||
product = ProductDrop.new("Computed", "must not be recorded")
|
||||
template_source = "{{ product.title }} ({{ product.details.count }})"
|
||||
|
||||
output = Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse(template_source).render!("product" => product)
|
||||
end
|
||||
|
||||
assert_equal("Computed (3)", output)
|
||||
record = Liquid::TemplateRecorder.records(path).first
|
||||
assert_equal({ "title" => "Computed", "details" => { "count" => 3 } }, record.dig("assigns", "product"))
|
||||
refute_includes(File.read(path), "must not be recorded")
|
||||
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :verify).render)
|
||||
end
|
||||
|
||||
def test_preserves_the_legacy_one_argument_file_system_contract
|
||||
file_system = LegacyFileSystem.new
|
||||
environment = Liquid::Environment.build { |env| env.file_system = file_system }
|
||||
|
||||
output = Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("before {% include 'card' %}", environment: environment)
|
||||
.render!("product" => ProductDrop.new("Hat", "secret"))
|
||||
end
|
||||
|
||||
assert_equal("before partial=Hat", output)
|
||||
assert_equal(["card"], file_system.reads)
|
||||
record = Liquid::TemplateRecorder.records(path).first
|
||||
assert_equal({ "card" => "partial={{ product.title }}" }, record["file_system"])
|
||||
assert_equal(2, record["templates"].length)
|
||||
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :verify).render)
|
||||
end
|
||||
|
||||
def test_jsonl_appends_one_self_contained_record_per_render
|
||||
recording = path("renders.jsonl")
|
||||
|
||||
2.times do |sequence|
|
||||
Liquid::TemplateRecorder.record(recording) do
|
||||
Liquid::Template.parse("value={{ value }}").render!("value" => sequence)
|
||||
end
|
||||
end
|
||||
|
||||
assert_equal(2, File.readlines(recording).length)
|
||||
assert_equal(["value=0", "value=1"], Liquid::TemplateRecorder.records(recording).map { |item| item["output"] })
|
||||
assert_equal("value=0", Liquid::TemplateRecorder.replay_from(recording, index: 0).render)
|
||||
assert_equal("value=1", Liquid::TemplateRecorder.replay_from(recording).render)
|
||||
end
|
||||
|
||||
def test_json_session_supports_multiple_renders
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("one={{ value }}").render!("value" => 1)
|
||||
Liquid::Template.new.parse("two={{ value }}").render!("value" => 2)
|
||||
end
|
||||
|
||||
records = Liquid::TemplateRecorder.records(path)
|
||||
assert_equal(["one=1", "two=2"], records.map { |item| item["output"] })
|
||||
assert_equal("two=2", Liquid::TemplateRecorder.replay_from(path).render)
|
||||
end
|
||||
|
||||
def test_failed_recording_does_not_delete_an_existing_json_file
|
||||
File.write(path, "existing")
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
Liquid::TemplateRecorder.record(path) { raise "boom" }
|
||||
end
|
||||
|
||||
assert_equal("existing", File.read(path))
|
||||
end
|
||||
|
||||
def test_render_failure_is_not_written_to_jsonl
|
||||
recording = path("renders.jsonl")
|
||||
|
||||
assert_raises(Liquid::UndefinedVariable) do
|
||||
Liquid::TemplateRecorder.record(recording) do
|
||||
Liquid::Template.parse("{{ missing }}").render!(nil, strict_variables: true)
|
||||
end
|
||||
end
|
||||
|
||||
refute_path_exists(recording)
|
||||
end
|
||||
|
||||
def test_tampered_template_is_rejected
|
||||
Liquid::TemplateRecorder.record(path) { Liquid::Template.parse("safe").render! }
|
||||
session = JSON.parse(File.read(path))
|
||||
session["renders"][0]["template"]["source"] = "changed"
|
||||
File.write(path, JSON.generate(session))
|
||||
|
||||
error = assert_raises(Liquid::TemplateRecorder::ReplayError) do
|
||||
Liquid::TemplateRecorder.replay_from(path)
|
||||
end
|
||||
assert_match(/checksum/, error.message)
|
||||
end
|
||||
|
||||
def test_recordings_are_thread_local
|
||||
paths = [path("a.json"), path("b.json")]
|
||||
ready = Queue.new
|
||||
release = Queue.new
|
||||
threads = Array.new(2) do |index|
|
||||
Thread.new do
|
||||
Liquid::TemplateRecorder.record(paths[index]) do
|
||||
ready << true
|
||||
release.pop
|
||||
Liquid::Template.parse("thread={{ value }}").render!("value" => index)
|
||||
end
|
||||
end
|
||||
end
|
||||
2.times { ready.pop }
|
||||
2.times { release << true }
|
||||
threads.each(&:join)
|
||||
|
||||
assert_equal("thread=0", Liquid::TemplateRecorder.records(paths[0]).first["output"])
|
||||
assert_equal("thread=1", Liquid::TemplateRecorder.records(paths[1]).first["output"])
|
||||
end
|
||||
|
||||
def test_nested_sessions_fail_without_corrupting_outer_session
|
||||
error = nil
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
error = assert_raises(Liquid::TemplateRecorder::Error) do
|
||||
Liquid::TemplateRecorder.record(path("inner.json")) { flunk }
|
||||
end
|
||||
Liquid::Template.parse("outer").render!
|
||||
end
|
||||
|
||||
assert_match(/nested/, error.message)
|
||||
assert_equal("outer", Liquid::TemplateRecorder.replay_from(path).render)
|
||||
end
|
||||
|
||||
def test_supported_render_argument_forms_keep_working
|
||||
filter = Module.new do
|
||||
def decorate(input)
|
||||
"[#{input}]"
|
||||
end
|
||||
end
|
||||
template = nil
|
||||
context = Liquid::Context.new([{ "value" => "context" }])
|
||||
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
template = Liquid::Template.parse("{{ value | decorate }}")
|
||||
assert_equal("[hash]", template.render({ "value" => "hash" }, filter))
|
||||
context.add_filters(filter)
|
||||
assert_equal("[context]", template.render(context))
|
||||
end
|
||||
|
||||
assert_equal(2, Liquid::TemplateRecorder.records(path).length)
|
||||
end
|
||||
|
||||
def test_strict_replay_uses_exact_recorded_filter_outputs
|
||||
filter = Module.new do
|
||||
def external_lookup(_input)
|
||||
"x" * 150
|
||||
end
|
||||
end
|
||||
|
||||
output = Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("{{ key | external_lookup }}").render!({ "key" => "a" }, filter)
|
||||
end
|
||||
|
||||
assert_equal("x" * 150, output)
|
||||
assert_equal(output, Liquid::TemplateRecorder.replay_from(path, mode: :strict).render)
|
||||
assert_equal("a", Liquid::TemplateRecorder.replay_from(path, mode: :compute).render)
|
||||
end
|
||||
|
||||
def test_jsonl_reader_ignores_only_a_truncated_final_record
|
||||
recording = path("renders.jsonl")
|
||||
Liquid::TemplateRecorder.record(recording) { Liquid::Template.parse("complete").render! }
|
||||
File.open(recording, "ab") { |file| file.write('{"format":') }
|
||||
|
||||
assert_equal(["complete"], Liquid::TemplateRecorder.records(recording).map { |item| item["output"] })
|
||||
end
|
||||
|
||||
def test_accepts_a_pluggable_writer
|
||||
writer = CollectingWriter.new
|
||||
|
||||
output = Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("Hello {{ name }}").render!("name" => "Shopify")
|
||||
end
|
||||
|
||||
assert_equal("Hello Shopify", output)
|
||||
assert_equal(["Hello Shopify"], writer.records.map { |record| record["output"] })
|
||||
end
|
||||
|
||||
def test_records_only_variables_resolved_by_the_template
|
||||
unused = Object.new
|
||||
assigns = { "visible" => "yes", "unused" => unused }
|
||||
|
||||
Liquid::TemplateRecorder.record(path) do
|
||||
Liquid::Template.parse("{{ visible }}").render!(assigns)
|
||||
end
|
||||
|
||||
assert_equal({ "visible" => "yes" }, Liquid::TemplateRecorder.records(path).first["assigns"])
|
||||
end
|
||||
|
||||
def test_recording_scope_is_fiber_local
|
||||
writer = CollectingWriter.new
|
||||
ordinary_output = nil
|
||||
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Fiber.new do
|
||||
ordinary_output = Liquid::Template.parse("ordinary").render!
|
||||
end.resume
|
||||
Liquid::Template.parse("recorded").render!
|
||||
end
|
||||
|
||||
assert_equal("ordinary", ordinary_output)
|
||||
assert_equal(["recorded"], writer.records.map { |record| record["output"] })
|
||||
end
|
||||
|
||||
def test_on_error_keeps_recording_failures_out_of_the_render_path
|
||||
writer = Object.new
|
||||
writer.define_singleton_method(:write) { |_record| raise "sink unavailable" }
|
||||
errors = []
|
||||
|
||||
output = Liquid::TemplateRecorder.record(writer, on_error: errors.method(:<<)) do
|
||||
Liquid::Template.parse("still rendered").render!
|
||||
end
|
||||
|
||||
assert_equal("still rendered", output)
|
||||
assert_equal(["sink unavailable"], errors.map(&:message))
|
||||
end
|
||||
|
||||
def test_unsupported_accessed_values_do_not_affect_the_render
|
||||
value = UnsupportedLiquidValue.new
|
||||
writer = CollectingWriter.new
|
||||
|
||||
output = Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("{{ value }}").render!("value" => value)
|
||||
end
|
||||
|
||||
assert_equal(value.to_s, output)
|
||||
assert_equal({}, writer.records.first["assigns"])
|
||||
end
|
||||
|
||||
def test_strict_replay_accepts_the_application_environment
|
||||
environment = Liquid::Environment.build(
|
||||
tags: Liquid::Environment.default.tags.merge("marker" => MarkerTag),
|
||||
)
|
||||
writer = CollectingWriter.new
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("{% marker %}", environment: environment).render!
|
||||
end
|
||||
|
||||
replay = Liquid::TemplateRecorder::Replayer.new(
|
||||
writer.records.first,
|
||||
mode: :strict,
|
||||
environment: environment,
|
||||
)
|
||||
|
||||
assert_equal("custom", replay.render)
|
||||
end
|
||||
|
||||
def test_captures_file_reads_that_happen_before_the_template_starts_rendering
|
||||
writer = CollectingWriter.new
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::TemplateRecorder.current.emit_file_read("card", "Card")
|
||||
file_system = LegacyFileSystem.new
|
||||
Liquid::Template.parse("{% render 'card' %}").render!({}, registers: { file_system: file_system })
|
||||
end
|
||||
|
||||
replay = Liquid::TemplateRecorder::Replayer.new(writer.records.first, mode: :strict)
|
||||
|
||||
assert_equal("partial=", replay.render)
|
||||
end
|
||||
|
||||
def test_strict_replay_skips_nested_custom_tag_calls
|
||||
environment = Liquid::Environment.build(
|
||||
tags: Liquid::Environment.default.tags.merge("wrapper" => WrapperTag, "marker" => MarkerTag),
|
||||
)
|
||||
writer = CollectingWriter.new
|
||||
Liquid::TemplateRecorder.record(writer) do
|
||||
Liquid::Template.parse("{% wrapper %}{% marker %}{% endwrapper %}", environment: environment).render!
|
||||
end
|
||||
|
||||
record = writer.records.first
|
||||
replay = Liquid::TemplateRecorder::Replayer.new(record, mode: :strict, environment: environment)
|
||||
|
||||
assert_equal(["wrapper"], record["tag_calls"].map { |call| call["name"] })
|
||||
assert_equal("custom", replay.render)
|
||||
end
|
||||
end
|
||||
@@ -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]],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user