Add a new {% render %} tag

Example:

```
// the_count.liquid
{{ number }}! Ah ah ah.

// my_template.liquid
{% for number in range (1..3) %}
  {% render "the_count", number: number %}
{% endfor %}

Output:
1! Ah ah ah.
2! Ah ah ah.
3! Ah ah ah.
```

The `render` tag is a more strict version of the `include` tag. It is
designed to isolate itself from the parent rendering context both by
creating a new scope (which does not inherit the parent scope) and by
only inheriting "static" registers.

Static registers are those that do not hold mutable state which could
affect rendering. This again helps `render`ed templates remain entirely
separate from their calling context.

Unlike `include`, `render` does not permit specifying the target
template using a variable, only a string literal. For example, this
means that `{% render my_dynamic_template %}` is invalid syntax. This
will make it possible to statically analyze the dependencies between
templates without making Turing angry.

Note that the `static_environment` of a rendered template is inherited, unlike
the scope and regular environment. This environment is immutable from within the
template.

An alternate syntax, which mimics the `{% include ... for %}` tag is
currently in design discussion.
This commit is contained in:
Samuel 2019-07-31 11:20:49 -04:00
parent d338ccb9a6
commit 9672ed5285
12 changed files with 291 additions and 519 deletions

View File

@ -74,6 +74,7 @@ require 'liquid/condition'
require 'liquid/utils'
require 'liquid/tokenizer'
require 'liquid/parse_context'
require 'liquid/partial_cache'
# Load all the tags of the standard library
#

View File

@ -12,22 +12,25 @@ module Liquid
#
# context['bob'] #=> nil class Context
class Context
attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers
attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments
attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters
def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_registers: {})
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_registers)
# rubocop:disable Metrics/ParameterLists
def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_registers: {}, static_environments: {})
new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_registers, static_environments)
end
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_registers = {})
@environments = [environments].flatten
@scopes = [(outer_scope || {})]
@registers = registers
@static_registers = static_registers.tap(&:freeze)
@errors = []
@partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_registers = {}, static_environments = {})
@environments = [environments].flatten
@static_environments = [static_environments].flatten.map(&:freeze).freeze
@scopes = [(outer_scope || {})]
@registers = registers
@static_registers = static_registers.freeze
@errors = []
@partial = false
@strict_variables = false
@resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits)
@base_scope_depth = 0
squash_instance_assigns_with_environments
@this_stack_used = false
@ -41,6 +44,7 @@ module Liquid
@filters = []
@global_filter = nil
end
# rubocop:enable Metrics/ParameterLists
def warnings
@warnings ||= []
@ -94,7 +98,7 @@ module Liquid
# Push new local scope on the stack. use <tt>Context#stack</tt> instead
def push(new_scope = {})
@scopes.unshift(new_scope)
raise StackLevelError, "Nesting too deep".freeze if @scopes.length > Block::MAX_DEPTH
check_overflow
end
# Merge a hash of variables in the current local scope
@ -134,13 +138,19 @@ module Liquid
# Creates a new context inheriting resource limits, filters, environment etc.,
# but with an isolated scope.
def new_isolated_subcontext
check_overflow
Context.build(
environments: environments,
resource_limits: resource_limits,
static_environments: static_environments,
static_registers: static_registers
).tap do |subcontext|
subcontext.base_scope_depth = base_scope_depth + 1
subcontext.exception_renderer = exception_renderer
subcontext.add_filters(@filters)
subcontext.filters = @filters
subcontext.strainer = nil
subcontext.errors = errors
subcontext.warnings = warnings
end
end
@ -182,25 +192,13 @@ module Liquid
# This was changed from find() to find_index() because this is a very hot
# path and find_index() is optimized in MRI to reduce object allocation
index = @scopes.find_index { |s| s.key?(key) }
scope = @scopes[index] if index
variable = nil
if scope.nil?
@environments.each do |e|
variable = lookup_and_evaluate(e, key, raise_on_not_found: raise_on_not_found)
# When lookup returned a value OR there is no value but the lookup also did not raise
# then it is the value we are looking for.
if !variable.nil? || @strict_variables && raise_on_not_found
scope = e
break
end
end
variable = if index
lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found)
else
try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found)
end
scope ||= @environments.last || @scopes.last
variable ||= lookup_and_evaluate(scope, key, raise_on_not_found: raise_on_not_found)
variable = variable.to_liquid
variable.context = self if variable.respond_to?(:context=)
@ -221,8 +219,38 @@ module Liquid
end
end
protected
attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters
private
attr_reader :base_scope_depth
def try_variable_find_in_environments(key, raise_on_not_found:)
@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
return found_variable
end
end
@static_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
return found_variable
end
end
nil
end
def check_overflow
raise StackLevelError, "Nesting too deep".freeze if overflow?
end
def overflow?
base_scope_depth + @scopes.length > Block::MAX_DEPTH
end
def internal_error
# raise and catch to set backtrace and cause on exception
raise Liquid::InternalError, 'internal'

View File

@ -22,5 +22,6 @@
tag_never_closed: "'%{block_name}' tag was never closed"
meta_syntax_error: "Liquid syntax error: #{e.message}"
table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3"
render: "Syntax error in tag 'render' - Template name must be a quoted string"
argument:
include: "Argument error in tag 'include' - Illegal template name"

View File

@ -0,0 +1,18 @@
module Liquid
class PartialCache
def self.load(template_name, context:, parse_context:)
cached_partials = (context.registers[:cached_partials] ||= {})
cached = cached_partials[template_name]
return cached if cached
file_system = (context.registers[:file_system] ||= Liquid::Template.file_system)
source = file_system.read_template_file(template_name)
parse_context.partial = true
partial = Liquid::Template.parse(source, parse_context)
cached_partials[template_name] = partial
ensure
parse_context.partial = false
end
end
end

View File

@ -46,7 +46,12 @@ module Liquid
template_name = context.evaluate(@template_name_expr)
raise ArgumentError.new(options[:locale].t("errors.argument.include")) unless template_name
partial = load_cached_partial(template_name, context)
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context
)
context_variable_name = template_name.split('/'.freeze).last
variable = if @variable_name_expr
@ -83,35 +88,9 @@ module Liquid
output
end
private
alias_method :parse_context, :options
private :parse_context
def load_cached_partial(template_name, context)
cached_partials = context.registers[:cached_partials] || {}
if cached = cached_partials[template_name]
return cached
end
source = read_template_from_file_system(context)
begin
parse_context.partial = true
partial = Liquid::Template.parse(source, parse_context)
ensure
parse_context.partial = false
end
cached_partials[template_name] = partial
context.registers[:cached_partials] = cached_partials
partial
end
def read_template_from_file_system(context)
file_system = context.registers[:file_system] || Liquid::Template.file_system
file_system.read_template_file(context.evaluate(@template_name_expr))
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[

View File

@ -23,6 +23,7 @@ module Liquid
def render_to_output_buffer(context, output)
value = context.environments.first[@variable] ||= 0
context.environments.first[@variable] = value + 1
output << value.to_s
output
end

View File

@ -1,81 +1,46 @@
module Liquid
#
# TODO: docs
#
class Render < Tag
Syntax = /(#{QuotedFragment}+)/o
Syntax = /(#{QuotedString})#{QuotedFragment}*/o
attr_reader :template_name_expr, :attributes
def initialize(tag_name, markup, options)
super
if markup =~ Syntax
template_name = $1
raise SyntaxError.new(options[:locale].t("errors.syntax.render".freeze)) unless markup =~ Syntax
@template_name_expr = Expression.parse(template_name)
template_name = $1
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = Expression.parse(value)
end
@template_name_expr = Expression.parse(template_name)
else
raise SyntaxError.new(options[:locale].t("errors.syntax.include".freeze))
@attributes = {}
markup.scan(TagAttributes) do |key, value|
@attributes[key] = Expression.parse(value)
end
end
def parse(_tokens)
end
def render_to_output_buffer(context, output)
# Though we evaluate this here we will only ever parse it as a string literal.
template_name = context.evaluate(@template_name_expr)
raise ArgumentError.new(options[:locale].t("errors.argument.include")) unless template_name
partial = load_cached_partial(template_name, context)
partial = PartialCache.load(
template_name,
context: context,
parse_context: parse_context
)
inner_context = Context.new
inner_context = context.new_isolated_subcontext
inner_context.template_name = template_name
inner_context.partial = true
@attributes.each do |key, value|
inner_context[key] = context.evaluate(value)
end
partial.render_to_output_buffer(inner_context, output)
# TODO: Put into a new #isolated_stack method in Context?
inner_context.errors.each { |e| context.errors << e }
output
end
private
alias_method :parse_context, :options
private :parse_context
def load_cached_partial(template_name, context)
cached_partials = context.registers[:cached_partials] || {}
if cached = cached_partials[template_name]
return cached
end
source = read_template_from_file_system(context)
begin
parse_context.partial = true
partial = Liquid::Template.parse(source, parse_context)
ensure
parse_context.partial = false
end
cached_partials[template_name] = partial
context.registers[:cached_partials] = cached_partials
partial
end
def read_template_from_file_system(context)
file_system = context.registers[:file_system] || Liquid::Template.file_system
file_system.read_template_file(context.evaluate(@template_name_expr))
end
class ParseTreeVisitor < Liquid::ParseTreeVisitor
def children
[

View File

@ -1,255 +0,0 @@
require 'test_helper'
class TestFileSystem
def read_template_file(template_path)
case template_path
when "product"
"Product: {{ product.title }} "
when "locale_variables"
"Locale: {{echo1}} {{echo2}}"
when "variant"
"Variant: {{ variant.title }}"
when "nested_template"
"{% include 'header' %} {% include 'body' %} {% include 'footer' %}"
when "body"
"body {% include 'body_detail' %}"
when "nested_product_template"
"Product: {{ nested_product_template.title }} {%include 'details'%} "
when "recursively_nested_template"
"-{% include 'recursively_nested_template' %}"
when "pick_a_source"
"from TestFileSystem"
when 'assignments'
"{% assign foo = 'bar' %}"
when 'break'
"{% break %}"
else
template_path
end
end
end
class OtherFileSystem
def read_template_file(template_path)
'from OtherFileSystem'
end
end
class CountingFileSystem
attr_reader :count
def read_template_file(template_path)
@count ||= 0
@count += 1
'from CountingFileSystem'
end
end
class CustomInclude < Liquid::Tag
Syntax = /(#{Liquid::QuotedFragment}+)(\s+(?:with|for)\s+(#{Liquid::QuotedFragment}+))?/o
def initialize(tag_name, markup, tokens)
markup =~ Syntax
@template_name = $1
super
end
def parse(tokens)
end
def render_to_output_buffer(context, output)
output << @template_name[1..-2]
output
end
end
class IncludeTagTest < Minitest::Test
include Liquid
def setup
Liquid::Template.file_system = TestFileSystem.new
end
def test_include_tag_looks_for_file_system_in_registers_first
assert_equal 'from OtherFileSystem',
Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: OtherFileSystem.new })
end
def test_include_tag_with
assert_template_result "Product: Draft 151cm ",
"{% include 'product' with products[0] %}", "products" => [ { 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' } ]
end
def test_include_tag_with_default_name
assert_template_result "Product: Draft 151cm ",
"{% include 'product' %}", "product" => { 'title' => 'Draft 151cm' }
end
def test_include_tag_for
assert_template_result "Product: Draft 151cm Product: Element 155cm ",
"{% include 'product' for products %}", "products" => [ { 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' } ]
end
def test_include_tag_with_local_variables
assert_template_result "Locale: test123 ", "{% include 'locale_variables' echo1: 'test123' %}"
end
def test_include_tag_with_multiple_local_variables
assert_template_result "Locale: test123 test321",
"{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}"
end
def test_include_tag_with_multiple_local_variables_from_context
assert_template_result "Locale: test123 test321",
"{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}",
'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321' }
end
def test_included_templates_assigns_variables
assert_template_result "bar", "{% include 'assignments' %}{{ foo }}"
end
def test_nested_include_tag
assert_template_result "body body_detail", "{% include 'body' %}"
assert_template_result "header body body_detail footer", "{% include 'nested_template' %}"
end
def test_nested_include_with_variable
assert_template_result "Product: Draft 151cm details ",
"{% include 'nested_product_template' with product %}", "product" => { "title" => 'Draft 151cm' }
assert_template_result "Product: Draft 151cm details Product: Element 155cm details ",
"{% include 'nested_product_template' for products %}", "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }]
end
def test_recursively_included_template_does_not_produce_endless_loop
infinite_file_system = Class.new do
def read_template_file(template_path)
"-{% include 'loop' %}"
end
end
Liquid::Template.file_system = infinite_file_system.new
assert_raises(Liquid::StackLevelError) do
Template.parse("{% include 'loop' %}").render!
end
end
def test_dynamically_choosen_template
assert_template_result "Test123", "{% include template %}", "template" => 'Test123'
assert_template_result "Test321", "{% include template %}", "template" => 'Test321'
assert_template_result "Product: Draft 151cm ", "{% include template for product %}",
"template" => 'product', 'product' => { 'title' => 'Draft 151cm' }
end
def test_include_tag_caches_second_read_of_same_partial
file_system = CountingFileSystem.new
assert_equal 'from CountingFileSystemfrom CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
assert_equal 1, file_system.count
end
def test_include_tag_doesnt_cache_partials_across_renders
file_system = CountingFileSystem.new
assert_equal 'from CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
assert_equal 1, file_system.count
assert_equal 'from CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
assert_equal 2, file_system.count
end
def test_include_tag_within_if_statement
assert_template_result "foo_if_true", "{% if true %}{% include 'foo_if_true' %}{% endif %}"
end
def test_custom_include_tag
original_tag = Liquid::Template.tags['include']
Liquid::Template.tags['include'] = CustomInclude
begin
assert_equal "custom_foo",
Template.parse("{% include 'custom_foo' %}").render!
ensure
Liquid::Template.tags['include'] = original_tag
end
end
def test_custom_include_tag_within_if_statement
original_tag = Liquid::Template.tags['include']
Liquid::Template.tags['include'] = CustomInclude
begin
assert_equal "custom_foo_if_true",
Template.parse("{% if true %}{% include 'custom_foo_if_true' %}{% endif %}").render!
ensure
Liquid::Template.tags['include'] = original_tag
end
end
def test_does_not_add_error_in_strict_mode_for_missing_variable
Liquid::Template.file_system = TestFileSystem.new
a = Liquid::Template.parse(' {% include "nested_template" %}')
a.render!
assert_empty a.errors
end
def test_passing_options_to_included_templates
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(:lax) do
assert_equal 'x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true).render!("template" => '{{ "X" || downcase }}')
end
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale]).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(:lax) do
assert_equal 'x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode]).render!("template" => '{{ "X" || downcase }}')
end
end
def test_render_raise_argument_error_when_template_is_undefined
assert_raises(Liquid::ArgumentError) do
template = Liquid::Template.parse('{% include undefined_variable %}')
template.render!
end
assert_raises(Liquid::ArgumentError) do
template = Liquid::Template.parse('{% include nil %}')
template.render!
end
end
def test_including_via_variable_value
assert_template_result "from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}"
assert_template_result "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page %}", "product" => { 'title' => 'Draft 151cm' }
assert_template_result "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", "foo" => { 'title' => 'Draft 151cm' }
end
def test_including_with_strict_variables
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn)
template.render(nil, strict_variables: true)
assert_equal [], template.errors
end
def test_break_through_include
assert_template_result "1", "{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}"
assert_template_result "1", "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}"
end
end # IncludeTagTest

View File

@ -1,75 +1,69 @@
require 'test_helper'
class StubFileSystem
def initialize(values)
@values = values
end
def read_template_file(template_path)
@values.fetch(template_path)
end
end
class RenderTagTest < Minitest::Test
include Liquid
def test_render_with_no_arguments
Liquid::Template.file_system = StubFileSystem.new('source' => 'rendered content')
assert_template_result 'rendered content', "{% render 'source' %}"
assert_template_result 'rendered content', '{% render "source" %}'
end
def test_render_tag_looks_for_file_system_in_registers_first
file_system = StubFileSystem.new('pick_a_source' => 'from register file system')
assert_equal 'from register file system',
Template.parse("{% render 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
Template.parse('{% render "pick_a_source" %}').render!({}, registers: { file_system: file_system })
end
def test_render_passes_named_arguments_into_inner_scope
Liquid::Template.file_system = StubFileSystem.new('product' => '{{ inner_product.title }}')
assert_template_result 'My Product', "{% render 'product', inner_product: outer_product %}",
assert_template_result 'My Product', '{% render "product", inner_product: outer_product %}',
'outer_product' => { 'title' => 'My Product' }
end
def test_render_accepts_literals_as_arguments
Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ price }}')
assert_template_result '123', "{% render 'snippet', price: 123 %}"
assert_template_result '123', '{% render "snippet", price: 123 %}'
end
def test_render_accepts_multiple_named_arguments
Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ one }} {{ two }}')
assert_template_result '1 2', "{% render 'snippet', one: 1, two: 2 %}"
assert_template_result '1 2', '{% render "snippet", one: 1, two: 2 %}'
end
def test_render_does_inherit_parent_scope_variables
def test_render_does_not_inherit_parent_scope_variables
Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ outer_variable }}')
assert_template_result '', "{% render 'snippet' %}", 'outer_variable' => 'should not be visible'
assert_template_result '', '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}'
end
def test_render_does_not_inherit_variable_with_same_name_as_snippet
Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ snippet }}')
assert_template_result '', "{% render 'snippet' %}", 'snippet' => 'should not be visible'
assert_template_result '', "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}"
end
def test_render_sets_the_correct_template_name_for_errors
Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ unsafe }}')
Liquid::Template.taint_mode = :error
template = Liquid::Template.parse("{% render 'snippet', unsafe: unsafe %}")
template.render('unsafe' => String.new('unsafe').tap(&:taint))
refute_empty template.errors
with_taint_mode :error do
template = Liquid::Template.parse('{% render "snippet", unsafe: unsafe %}')
context = Context.new('unsafe' => (+'unsafe').tap(&:taint))
template.render(context)
assert_equal 'snippet', template.errors.first.template_name
assert_equal [Liquid::TaintedError], template.errors.map(&:class)
assert_equal 'snippet', template.errors.first.template_name
end
end
def test_render_sets_the_correct_template_name_for_warnings
Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ unsafe }}')
Liquid::Template.taint_mode = :warn
template = Liquid::Template.parse("{% render 'snippet', unsafe: unsafe %}")
template.render('unsafe' => String.new('unsafe').tap(&:taint))
refute_empty template.warnings
with_taint_mode :warn do
template = Liquid::Template.parse('{% render "snippet", unsafe: unsafe %}')
context = Context.new('unsafe' => (+'unsafe').tap(&:taint))
template.render(context)
assert_equal 'snippet', template.errors.first.template_name
assert_equal [Liquid::TaintedError], context.warnings.map(&:class)
assert_equal 'snippet', context.warnings.first.template_name
end
end
def test_render_does_not_mutate_parent_scope
@ -79,152 +73,77 @@ class RenderTagTest < Minitest::Test
def test_nested_render_tag
Liquid::Template.file_system = StubFileSystem.new(
'one' => "one {{ render 'two' }}",
'one' => "one {% render 'two' %}",
'two' => 'two'
)
assert_template_result 'one two', "{% include 'one' %}"
assert_template_result 'one two', "{% render 'one' %}"
end
def test_nested_include_with_variable
skip 'To be implemented'
assert_template_result "Product: Draft 151cm details ",
"{% include 'nested_product_template' with product %}", "product" => { "title" => 'Draft 151cm' }
def test_recursively_rendered_template_does_not_produce_endless_loop
Liquid::Template.file_system = StubFileSystem.new('loop' => '{% render "loop" %}')
assert_template_result "Product: Draft 151cm details Product: Element 155cm details ",
"{% include 'nested_product_template' for products %}", "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }]
end
def test_recursively_included_template_does_not_produce_endless_loop
skip 'To be implemented'
infinite_file_system = Class.new do
def read_template_file(template_path)
"-{% include 'loop' %}"
end
end
Liquid::Template.file_system = infinite_file_system.new
assert_raises(Liquid::StackLevelError) do
Template.parse("{% include 'loop' %}").render!
assert_raises Liquid::StackLevelError do
Template.parse('{% render "loop" %}').render!
end
end
def test_dynamically_choosen_template
skip 'To be implemented'
assert_template_result "Test123", "{% include template %}", "template" => 'Test123'
assert_template_result "Test321", "{% include template %}", "template" => 'Test321'
def test_includes_and_renders_count_towards_the_same_recursion_limit
Liquid::Template.file_system = StubFileSystem.new(
'loop_render' => '{% render "loop_include" %}',
'loop_include' => '{% include "loop_render" %}'
)
assert_template_result "Product: Draft 151cm ", "{% include template for product %}",
"template" => 'product', 'product' => { 'title' => 'Draft 151cm' }
assert_raises Liquid::StackLevelError do
Template.parse('{% render "loop_include" %}').render!
end
end
def test_dynamically_choosen_templates_are_not_allowed
Liquid::Template.file_system = StubFileSystem.new('snippet' => 'should not be rendered')
assert_raises Liquid::SyntaxError do
Liquid::Template.parse("{% assign name = 'snippet' %}{% render name %}")
end
end
def test_include_tag_caches_second_read_of_same_partial
skip 'To be implemented'
file_system = CountingFileSystem.new
assert_equal 'from CountingFileSystemfrom CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
assert_equal 1, file_system.count
file_system = StubFileSystem.new('snippet' => 'echo')
assert_equal 'echoecho',
Template.parse('{% render "snippet" %}{% render "snippet" %}')
.render!({}, registers: { file_system: file_system })
assert_equal 1, file_system.file_read_count
end
def test_include_tag_doesnt_cache_partials_across_renders
skip 'To be implemented'
file_system = CountingFileSystem.new
assert_equal 'from CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
assert_equal 1, file_system.count
def test_render_tag_doesnt_cache_partials_across_renders
file_system = StubFileSystem.new('snippet' => 'my message')
assert_equal 'from CountingFileSystem',
Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system })
assert_equal 2, file_system.count
assert_equal 'my message',
Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system })
assert_equal 1, file_system.file_read_count
assert_equal 'my message',
Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system })
assert_equal 2, file_system.file_read_count
end
def test_include_tag_within_if_statement
skip 'To be implemented'
assert_template_result "foo_if_true", "{% if true %}{% include 'foo_if_true' %}{% endif %}"
def test_render_tag_within_if_statement
Liquid::Template.file_system = StubFileSystem.new('snippet' => 'my message')
assert_template_result 'my message', '{% if true %}{% render "snippet" %}{% endif %}'
end
def test_custom_include_tag
skip 'To be implemented'
original_tag = Liquid::Template.tags['include']
Liquid::Template.tags['include'] = CustomInclude
begin
assert_equal "custom_foo",
Template.parse("{% include 'custom_foo' %}").render!
ensure
Liquid::Template.tags['include'] = original_tag
end
def test_break_through_render
Liquid::Template.file_system = StubFileSystem.new('break' => '{% break %}')
assert_template_result '1', '{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}'
assert_template_result '112233', '{% for i in (1..3) %}{{ i }}{% render "break" %}{{ i }}{% endfor %}'
end
def test_custom_include_tag_within_if_statement
skip 'To be implemented'
original_tag = Liquid::Template.tags['include']
Liquid::Template.tags['include'] = CustomInclude
begin
assert_equal "custom_foo_if_true",
Template.parse("{% if true %}{% include 'custom_foo_if_true' %}{% endif %}").render!
ensure
Liquid::Template.tags['include'] = original_tag
end
def test_increment_is_isolated_between_renders
Liquid::Template.file_system = StubFileSystem.new('incr' => '{% increment %}')
assert_template_result '010', '{% increment %}{% increment %}{% render "incr" %}'
end
def test_does_not_add_error_in_strict_mode_for_missing_variable
skip 'To be implemented'
Liquid::Template.file_system = TestFileSystem.new
a = Liquid::Template.parse(' {% include "nested_template" %}')
a.render!
assert_empty a.errors
def test_decrement_is_isolated_between_renders
Liquid::Template.file_system = StubFileSystem.new('decr' => '{% decrement %}')
assert_template_result '-1-2-1', '{% decrement %}{% decrement %}{% render "decr" %}'
end
def test_passing_options_to_included_templates
skip 'To be implemented'
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(:lax) do
assert_equal 'x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true).render!("template" => '{{ "X" || downcase }}')
end
assert_raises(Liquid::SyntaxError) do
Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale]).render!("template" => '{{ "X" || downcase }}')
end
with_error_mode(:lax) do
assert_equal 'x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode]).render!("template" => '{{ "X" || downcase }}')
end
end
def test_render_raise_argument_error_when_template_is_undefined
skip 'To be implemented'
assert_raises(Liquid::ArgumentError) do
template = Liquid::Template.parse('{% include undefined_variable %}')
template.render!
end
assert_raises(Liquid::ArgumentError) do
template = Liquid::Template.parse('{% include nil %}')
template.render!
end
end
def test_including_via_variable_value
skip 'To be implemented'
assert_template_result "from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}"
assert_template_result "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page %}", "product" => { 'title' => 'Draft 151cm' }
assert_template_result "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", "foo" => { 'title' => 'Draft 151cm' }
end
def test_including_with_strict_variables
skip 'To be implemented'
template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn)
template.render(nil, strict_variables: true)
assert_equal [], template.errors
end
def test_break_through_include
skip 'To be implemented'
assert_template_result "1", "{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}"
assert_template_result "1", "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}"
end
end # IncludeTagTest
end

View File

@ -121,3 +121,17 @@ class ErrorDrop < Liquid::Drop
raise Exception, 'exception'
end
end
class StubFileSystem
attr_reader :file_read_count
def initialize(values)
@file_read_count = 0
@values = values
end
def read_template_file(template_path)
@file_read_count += 1
@values.fetch(template_path)
end
end

View File

@ -468,6 +468,16 @@ class ContextUnitTest < Minitest::Test
assert_equal 'hi filtered', context.apply_global_filter('hi')
end
def test_static_environments_are_read_with_lower_priority_than_environments
context = Context.build(
static_environments: { 'shadowed' => 'static', 'unshadowed' => 'static' },
environments: { 'shadowed' => 'dynamic' }
)
assert_equal 'dynamic', context['shadowed']
assert_equal 'static', context['unshadowed']
end
def test_apply_global_filter_when_no_global_filter_exist
context = Context.new
assert_equal 'hi', context.apply_global_filter('hi')
@ -481,11 +491,11 @@ class ContextUnitTest < Minitest::Test
assert_nil subcontext['my_variable']
end
def test_new_isolated_subcontext_inherits_environment
super_context = Context.new('my_environment_value' => 'my value')
def test_new_isolated_subcontext_inherits_static_environment
super_context = Context.build(static_environments: { 'my_environment_value' => 'my value' })
subcontext = super_context.new_isolated_subcontext
assert_equal 'my value',subcontext['my_environment_value']
assert_equal 'my value', subcontext['my_environment_value']
end
def test_new_isolated_subcontext_inherits_resource_limits
@ -497,7 +507,7 @@ class ContextUnitTest < Minitest::Test
def test_new_isolated_subcontext_inherits_exception_renderer
super_context = Context.new
super_context.exception_renderer = -> (_e) { 'my exception message' }
super_context.exception_renderer = ->(_e) { 'my exception message' }
subcontext = super_context.new_isolated_subcontext
assert_equal 'my exception message', subcontext.handle_error(Liquid::Error.new)
end

View File

@ -0,0 +1,91 @@
require 'test_helper'
class PartialCacheUnitTest < Minitest::Test
def test_uses_the_file_system_register_if_present
context = Liquid::Context.build(
registers: {
file_system: StubFileSystem.new('my_partial' => 'my partial body')
}
)
partial = Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new
)
assert_equal 'my partial body', partial.render
end
def test_reads_from_the_file_system_only_once_per_file
file_system = StubFileSystem.new('my_partial' => 'some partial body')
context = Liquid::Context.build(
registers: { file_system: file_system }
)
2.times do
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new
)
end
assert_equal 1, file_system.file_read_count
end
def test_cache_state_is_stored_per_context
parse_context = Liquid::ParseContext.new
shared_file_system = StubFileSystem.new(
'my_partial' => 'my shared value'
)
context_one = Liquid::Context.build(
registers: {
file_system: shared_file_system
}
)
context_two = Liquid::Context.build(
registers: {
file_system: shared_file_system
}
)
2.times do
Liquid::PartialCache.load(
'my_partial',
context: context_one,
parse_context: parse_context
)
end
Liquid::PartialCache.load(
'my_partial',
context: context_two,
parse_context: parse_context
)
assert_equal 2, shared_file_system.file_read_count
end
def test_cache_is_not_broken_when_a_different_parse_context_is_used
file_system = StubFileSystem.new('my_partial' => 'some partial body')
context = Liquid::Context.build(
registers: { file_system: file_system }
)
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new(my_key: 'value one')
)
Liquid::PartialCache.load(
'my_partial',
context: context,
parse_context: Liquid::ParseContext.new(my_key: 'value two')
)
# Technically what we care about is that the file was parsed twice,
# but measuring file reads is an OK proxy for this.
assert_equal 1, file_system.file_read_count
end
end