diff --git a/lib/liquid.rb b/lib/liquid.rb index 770d2f91..0e198bb4 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -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 # diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index a05cdaa4..1b15ca7e 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -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 Context#stack 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' diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 48b3b1d8..c0a9aff7 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -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" diff --git a/lib/liquid/partial_cache.rb b/lib/liquid/partial_cache.rb new file mode 100644 index 00000000..d0b88457 --- /dev/null +++ b/lib/liquid/partial_cache.rb @@ -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 diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 24acf9d9..fd86ee4b 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -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 [ diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 5af12422..95875aa6 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -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 diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index f0f6f107..2e5310b4 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -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 [ diff --git a/test/integration/tags/render_rag_test.rb b/test/integration/tags/render_rag_test.rb deleted file mode 100644 index 2352bc12..00000000 --- a/test/integration/tags/render_rag_test.rb +++ /dev/null @@ -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 - diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 928dc385..a31d0182 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 210333d6..27a24342 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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 diff --git a/test/unit/context_unit_test.rb b/test/unit/context_unit_test.rb index 15457633..9252eb52 100644 --- a/test/unit/context_unit_test.rb +++ b/test/unit/context_unit_test.rb @@ -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 diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb new file mode 100644 index 00000000..29f11449 --- /dev/null +++ b/test/unit/partial_cache_unit_test.rb @@ -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