From bca01e8944cdf0134508388e29d47639450b8552 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Tue, 6 Sep 2022 17:39:43 -0400 Subject: [PATCH 001/343] Add assert_syntax_error convenience method --- test/test_helper.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_helper.rb b/test/test_helper.rb index de2d534e4..23f0a42a2 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -56,6 +56,10 @@ def assert_match_syntax_error(match, template, error_mode: nil) assert_match(match, exception.message) end + def assert_syntax_error(template, error_mode: nil) + assert_match_syntax_error("", template, error_mode: error_mode) + end + def assert_usage_increment(name, times: 1) old_method = Liquid::Usage.method(:increment) calls = 0 From 34512df8e946a525ca88ca432e2389979e58509d Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Mon, 12 Sep 2022 15:58:32 -0400 Subject: [PATCH 002/343] Use assert_template_result partials option to specify file system state --- test/integration/tags/include_tag_test.rb | 139 +++++++++---------- test/integration/tags/render_tag_test.rb | 154 ++++++++++------------ 2 files changed, 143 insertions(+), 150 deletions(-) diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 3f05b47bf..103851570 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -3,44 +3,13 @@ require 'test_helper' class TestFileSystem - def read_template_file(template_path) - case template_path - when "product" - "Product: {{ product.title }} " - - when "product_alias" - "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' %}" + PARTIALS = { + "nested_template" => "{% include 'header' %} {% include 'body' %} {% include 'footer' %}", + "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 + def read_template_file(template_path) + PARTIALS[template_path] || template_path end end @@ -81,7 +50,11 @@ class IncludeTagTest < Minitest::Test include Liquid def setup - Liquid::Template.file_system = TestFileSystem.new + @default_file_system = Liquid::Template.file_system + end + + def teardown + Liquid::Template.file_system = @default_file_system end def test_include_tag_looks_for_file_system_in_registers_first @@ -92,63 +65,85 @@ def test_include_tag_looks_for_file_system_in_registers_first def test_include_tag_with assert_template_result("Product: Draft 151cm ", "{% include 'product' with products[0] %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { "product" => "Product: {{ product.title }} " }) end def test_include_tag_with_alias assert_template_result("Product: Draft 151cm ", "{% include 'product_alias' with products[0] as product %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { "product_alias" => "Product: {{ product.title }} " }) end def test_include_tag_for_alias assert_template_result("Product: Draft 151cm Product: Element 155cm ", "{% include 'product_alias' for products as product %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { "product_alias" => "Product: {{ product.title }} " }) end def test_include_tag_with_default_name assert_template_result("Product: Draft 151cm ", - "{% include 'product' %}", { "product" => { 'title' => 'Draft 151cm' } }) + "{% include 'product' %}", { "product" => { 'title' => 'Draft 151cm' } }, + partials: { "product" => "Product: {{ product.title }} " }) 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' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { "product" => "Product: {{ product.title }} " }) end def test_include_tag_with_local_variables - assert_template_result("Locale: test123 ", "{% include 'locale_variables' echo1: 'test123' %}") + assert_template_result("Locale: test123 ", "{% include 'locale_variables' echo1: 'test123' %}", + partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }) end def test_include_tag_with_multiple_local_variables assert_template_result("Locale: test123 test321", - "{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}") + "{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}", + partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }) 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' } }) + { 'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321' } }, + partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }) end def test_included_templates_assigns_variables - assert_template_result("bar", "{% include 'assignments' %}{{ foo }}") + assert_template_result("bar", "{% include 'assignments' %}{{ foo }}", + partials: { 'assignments' => "{% assign foo = 'bar' %}" }) end def test_nested_include_tag - assert_template_result("body body_detail", "{% include 'body' %}") + partials = { "body" => "body {% include 'body_detail' %}", "body_detail" => "body_detail" } + assert_template_result("body body_detail", "{% include 'body' %}", partials: partials) - assert_template_result("header body body_detail footer", "{% include 'nested_template' %}") + partials = partials.merge({ + "nested_template" => "{% include 'header' %} {% include 'body' %} {% include 'footer' %}", + "header" => "header", + "footer" => "footer", + }) + assert_template_result("header body body_detail footer", "{% include 'nested_template' %}", partials: partials) end def test_nested_include_with_variable + partials = { + "nested_product_template" => "Product: {{ nested_product_template.title }} {%include 'details'%} ", + "details" => "details", + } + assert_template_result("Product: Draft 151cm details ", - "{% include 'nested_product_template' with product %}", { "product" => { "title" => 'Draft 151cm' } }) + "{% include 'nested_product_template' with product %}", { "product" => { "title" => 'Draft 151cm' } }, + partials: partials) assert_template_result("Product: Draft 151cm details Product: Element 155cm details ", - "{% include 'nested_product_template' for products %}", { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] }) + "{% include 'nested_product_template' for products %}", { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] }, + partials: partials) end def test_recursively_included_template_does_not_produce_endless_loop @@ -166,11 +161,15 @@ def read_template_file(_template_path) 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("Test123", "{% include template %}", { "template" => 'Test123' }, + partials: { "Test123" => "Test123" }) + + assert_template_result("Test321", "{% include template %}", { "template" => 'Test321' }, + partials: { "Test321" => "Test321" }) assert_template_result("Product: Draft 151cm ", "{% include template for product %}", - { "template" => 'product', 'product' => { 'title' => 'Draft 151cm' } }) + { "template" => 'product', 'product' => { 'title' => 'Draft 151cm' } }, + partials: { "product" => "Product: {{ product.title }} " }) end def test_include_tag_caches_second_read_of_same_partial @@ -192,7 +191,8 @@ def test_include_tag_doesnt_cache_partials_across_renders end def test_include_tag_within_if_statement - assert_template_result("foo_if_true", "{% if true %}{% include 'foo_if_true' %}{% endif %}") + assert_template_result("foo_if_true", "{% if true %}{% include 'foo_if_true' %}{% endif %}", + partials: { "foo_if_true" => "foo_if_true" }) end def test_custom_include_tag @@ -226,6 +226,7 @@ def test_does_not_add_error_in_strict_mode_for_missing_variable end def test_passing_options_to_included_templates + Liquid::Template.file_system = TestFileSystem.new assert_raises(Liquid::SyntaxError) do Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}') end @@ -241,27 +242,30 @@ def test_passing_options_to_included_templates 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 + assert_template_result("Liquid error (line 1): Argument error in tag 'include' - Illegal template name", + "{% include undefined_variable %}", render_errors: true) + + assert_template_result("Liquid error (line 1): Argument error in tag 'include' - Illegal template name", + "{% include nil %}", render_errors: true) end def test_including_via_variable_value - assert_template_result("from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}") + assert_template_result("from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}", + partials: { "pick_a_source" => "from TestFileSystem" }) + + partials = { "product" => "Product: {{ product.title }} " } assert_template_result("Product: Draft 151cm ", "{% assign page = 'product' %}{% include page %}", - { "product" => { 'title' => 'Draft 151cm' } }) + { "product" => { 'title' => 'Draft 151cm' } }, + partials: partials) assert_template_result("Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", - { "foo" => { 'title' => 'Draft 151cm' } }) + { "foo" => { 'title' => 'Draft 151cm' } }, + partials: partials) end def test_including_with_strict_variables + Liquid::Template.file_system = StubFileSystem.new({ "simple" => "simple" }) template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn) template.render(nil, strict_variables: true) @@ -270,6 +274,7 @@ def test_including_with_strict_variables 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 %}") + assert_template_result("1", "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}", + partials: { 'break' => "{% break %}" }) end end # IncludeTagTest diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 28e50d1e1..28dbb0de0 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -6,53 +6,52 @@ 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" %}', + partials: { 'source' => 'rendered content' }) 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 })) + assert_template_result('from register file system', '{% render "pick_a_source" %}', + partials: { 'pick_a_source' => 'from register 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 %}', - { 'outer_product' => { 'title' => 'My Product' } }) + { 'outer_product' => { 'title' => 'My Product' } }, + partials: { 'product' => '{{ inner_product.title }}' }) 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 %}', + partials: { 'snippet' => '{{ price }}' }) 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 %}', + partials: { 'snippet' => '{{ one }} {{ two }}' }) end def test_render_does_not_inherit_parent_scope_variables - Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ outer_variable }}') - assert_template_result('', '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}') + assert_template_result('', '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}', + partials: { 'snippet' => '{{ outer_variable }}' }) end def test_render_does_not_inherit_variable_with_same_name_as_snippet - Liquid::Template.file_system = StubFileSystem.new('snippet' => '{{ snippet }}') - assert_template_result('', "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}") + assert_template_result('', "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}", + partials: { 'snippet' => '{{ snippet }}' }) end def test_render_does_not_mutate_parent_scope - Liquid::Template.file_system = StubFileSystem.new('snippet' => '{% assign inner = 1 %}') - assert_template_result('', "{% render 'snippet' %}{{ inner }}") + assert_template_result('', "{% render 'snippet' %}{{ inner }}", + partials: { 'snippet' => '{% assign inner = 1 %}' }) end def test_nested_render_tag - Liquid::Template.file_system = StubFileSystem.new( - 'one' => "one {% render 'two' %}", - 'two' => 'two' - ) - assert_template_result('one two', "{% render 'one' %}") + assert_template_result('one two', "{% render 'one' %}", + partials: { + 'one' => "one {% render 'two' %}", + 'two' => 'two', + }) end def test_recursively_rendered_template_does_not_produce_endless_loop @@ -73,11 +72,7 @@ def test_sub_contexts_count_towards_the_same_recursion_limit 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 + assert_syntax_error("{% assign name = 'snippet' %}{% render name %}") end def test_include_tag_caches_second_read_of_same_partial @@ -101,36 +96,36 @@ def test_render_tag_doesnt_cache_partials_across_renders end 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 %}') + assert_template_result('my message', '{% if true %}{% render "snippet" %}{% endif %}', + partials: { 'snippet' => 'my message' }) 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 %}') + options = { partials: { 'break' => '{% break %}' } } + assert_template_result('1', '{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}', **options) + assert_template_result('112233', '{% for i in (1..3) %}{{ i }}{% render "break" %}{{ i }}{% endfor %}', **options) end def test_increment_is_isolated_between_renders - Liquid::Template.file_system = StubFileSystem.new('incr' => '{% increment %}') - assert_template_result('010', '{% increment %}{% increment %}{% render "incr" %}') + assert_template_result('010', '{% increment %}{% increment %}{% render "incr" %}', + partials: { 'incr' => '{% increment %}' }) end 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" %}') + assert_template_result('-1-2-1', '{% decrement %}{% decrement %}{% render "decr" %}', + partials: { 'decr' => '{% decrement %}' }) end def test_includes_will_not_render_inside_render_tag - Liquid::Template.file_system = StubFileSystem.new( - 'foo' => 'bar', - 'test_include' => '{% include "foo" %}' + assert_template_result( + 'Liquid error (test_include line 1): include usage is not allowed in this context', + '{% render "test_include" %}', + render_errors: true, + partials: { + 'foo' => 'bar', + 'test_include' => '{% include "foo" %}', + } ) - - exc = assert_raises(Liquid::DisabledError) do - Liquid::Template.parse('{% render "test_include" %}').render! - end - assert_equal('Liquid error: include usage is not allowed in this context', exc.message) end def test_includes_will_not_render_inside_nested_sibling_tags @@ -148,74 +143,67 @@ def test_includes_will_not_render_inside_nested_sibling_tags end def test_render_tag_with - Liquid::Template.file_system = StubFileSystem.new( - 'product' => "Product: {{ product.title }} ", - 'product_alias' => "Product: {{ product.title }} ", - ) - assert_template_result("Product: Draft 151cm ", "{% render 'product' with products[0] %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { + 'product' => "Product: {{ product.title }} ", + 'product_alias' => "Product: {{ product.title }} ", + }) end def test_render_tag_with_alias - Liquid::Template.file_system = StubFileSystem.new( - 'product' => "Product: {{ product.title }} ", - 'product_alias' => "Product: {{ product.title }} ", - ) - assert_template_result("Product: Draft 151cm ", "{% render 'product_alias' with products[0] as product %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { + 'product' => "Product: {{ product.title }} ", + 'product_alias' => "Product: {{ product.title }} ", + }) end def test_render_tag_for_alias - Liquid::Template.file_system = StubFileSystem.new( - 'product' => "Product: {{ product.title }} ", - 'product_alias' => "Product: {{ product.title }} ", - ) - assert_template_result("Product: Draft 151cm Product: Element 155cm ", "{% render 'product_alias' for products as product %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { + 'product' => "Product: {{ product.title }} ", + 'product_alias' => "Product: {{ product.title }} ", + }) end def test_render_tag_for - Liquid::Template.file_system = StubFileSystem.new( - 'product' => "Product: {{ product.title }} ", - 'product_alias' => "Product: {{ product.title }} ", - ) - assert_template_result("Product: Draft 151cm Product: Element 155cm ", "{% render 'product' for products %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { + 'product' => "Product: {{ product.title }} ", + 'product_alias' => "Product: {{ product.title }} ", + }) end def test_render_tag_forloop - Liquid::Template.file_system = StubFileSystem.new( - 'product' => "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ", - ) - assert_template_result("Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ", "{% render 'product' for products %}", - { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }) + { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, + partials: { + 'product' => "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ", + }) end def test_render_tag_for_drop - Liquid::Template.file_system = StubFileSystem.new( - 'loop' => "{{ value.foo }}", - ) - assert_template_result("123", - "{% render 'loop' for loop as value %}", { "loop" => TestEnumerable.new }) + "{% render 'loop' for loop as value %}", { "loop" => TestEnumerable.new }, + partials: { + 'loop' => "{{ value.foo }}", + }) end def test_render_tag_with_drop - Liquid::Template.file_system = StubFileSystem.new( - 'loop' => "{{ value }}", - ) - assert_template_result("TestEnumerable", - "{% render 'loop' with loop as value %}", { "loop" => TestEnumerable.new }) + "{% render 'loop' with loop as value %}", { "loop" => TestEnumerable.new }, + partials: { + 'loop' => "{{ value }}", + }) end end From cab08cfe57893d06a26e98fcaccfad69328806ca Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 14 Sep 2022 15:00:57 -0400 Subject: [PATCH 003/343] Stop freezing the static environment hashes to allow Proc memoization (#1623) --- lib/liquid/context.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index eb6ddf9b8..36d3868b6 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -26,7 +26,7 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @environments = [environments] @environments.flatten! - @static_environments = [static_environments].flat_map(&:freeze).freeze + @static_environments = [static_environments].flatten(1).freeze @scopes = [(outer_scope || {})] @registers = registers.is_a?(Registers) ? registers : Registers.new(registers) @errors = [] From ff1c35b986451cca14d9da9b2ef772cbc9c65b24 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 14 Sep 2022 17:03:48 -0400 Subject: [PATCH 004/343] Stop using assert_template_result in some tests depending on language extension (#1622) --- test/integration/blank_test.rb | 2 +- test/integration/standard_filter_test.rb | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/test/integration/blank_test.rb b/test/integration/blank_test.rb index b7ca205db..be6454503 100644 --- a/test/integration/blank_test.rb +++ b/test/integration/blank_test.rb @@ -27,7 +27,7 @@ def wrap(body) def test_new_tags_are_not_blank_by_default with_custom_tag('foobar', FoobarTag) do - assert_template_result(" " * N, wrap_in_for("{% foobar %}")) + assert_equal(" " * N, Liquid::Template.parse(wrap_in_for("{% foobar %}")).render!) end end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 88d525c33..3b2cffa3a 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -469,8 +469,8 @@ def test_sort_calls_to_liquid def test_map_over_proc drop = TestDrop.new(value: "testfoo") p = proc { drop } - templ = '{{ procs | map: "value" }}' - assert_template_result("testfoo", templ, { "procs" => [p] }) + output = Liquid::Template.parse('{{ procs | map: "value" }}').render!({ "procs" => [p] }) + assert_equal("testfoo", output) end def test_map_over_drops_returning_procs @@ -482,12 +482,13 @@ def test_map_over_drops_returning_procs "proc" => -> { "bar" }, }, ] - templ = '{{ drops | map: "proc" }}' - assert_template_result("foobar", templ, { "drops" => drops }) + output = Liquid::Template.parse('{{ drops | map: "proc" }}').render!({ "drops" => drops }) + assert_equal("foobar", output) end def test_map_works_on_enumerables - assert_template_result("123", '{{ foo | map: "foo" }}', { "foo" => TestEnumerable.new }) + output = Liquid::Template.parse('{{ foo | map: "foo" }}').render!({ "foo" => TestEnumerable.new }) + assert_equal("123", output) end def test_map_returns_empty_on_2d_input_array From 456be2f75eddf0547d2036cda5e463841b4336cc Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Thu, 15 Sep 2022 10:08:15 -0400 Subject: [PATCH 005/343] Add a test and improve one for testing break (#1616) --- test/integration/tags/break_tag_test.rb | 4 ++-- test/integration/tags/for_tag_test.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/test/integration/tags/break_tag_test.rb b/test/integration/tags/break_tag_test.rb index a67a8b535..0a02d581d 100644 --- a/test/integration/tags/break_tag_test.rb +++ b/test/integration/tags/break_tag_test.rb @@ -9,8 +9,8 @@ class BreakTagTest < Minitest::Test # block def test_break_with_no_block assigns = { 'i' => 1 } - markup = '{% break %}' - expected = '' + markup = 'before{% break %}after' + expected = 'before' assert_template_result(expected, markup, assigns) end diff --git a/test/integration/tags/for_tag_test.rb b/test/integration/tags/for_tag_test.rb index 68fd8321b..614ebdc19 100644 --- a/test/integration/tags/for_tag_test.rb +++ b/test/integration/tags/for_tag_test.rb @@ -263,6 +263,19 @@ def test_for_with_break assert_template_result(expected, markup, assigns) end + def test_for_with_break_after_nested_loop + source = <<~LIQUID.chomp + {% for i in (1..2) -%} + {% for j in (1..2) -%} + {{ i }}-{{ j }}, + {%- endfor -%} + {% break -%} + {% endfor -%} + after + LIQUID + assert_template_result("1-1,1-2,after", source) + end + def test_for_with_continue assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5] } } From f21d1c5d9d550b765a449c85c0e162673ce3543c Mon Sep 17 00:00:00 2001 From: liamgriffin Date: Wed, 28 Sep 2022 12:24:46 +0100 Subject: [PATCH 006/343] Update escape filter reference summary Added more context/ explanation for escape filter Fixes issue in dev docs https://github.com/Shopify/shopify-dev/pull/26494 --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index ed7136abf..1470243cf 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -73,7 +73,7 @@ def capitalize(input) # @liquid_type filter # @liquid_category string # @liquid_summary - # Escapes a string. + # Escapes special characters in HTML, such as `<>`, `'`, and `&`, and converts characters into escape sequences. The filter doesn't effect characters within the string that don’t have a corresponding escape sequence.". # @liquid_syntax string | escape # @liquid_return [string] def escape(input) From 951be6c1f59247d99f6caf946e529961f7957474 Mon Sep 17 00:00:00 2001 From: Jan Emge-Triebel Date: Fri, 30 Sep 2022 09:07:06 +0000 Subject: [PATCH 007/343] Clarified comment's liquid description --- lib/liquid/tags/comment.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 51cea5c39..9922ee0dd 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -8,7 +8,7 @@ module Liquid # @liquid_summary # Prevents an expression from being rendered or output. # @liquid_description - # Any text inside `comment` tags won't be output, and any Liquid code won't be rendered. + # Any text inside `comment` tags won't be output, and any Liquid code will be parsed, but not executed. # @liquid_syntax # {% comment %} # content From 347a2418c440ac0c73aa9ca1a6bb37d70224bc5e Mon Sep 17 00:00:00 2001 From: Thierry Joyal Date: Mon, 3 Oct 2022 16:52:22 -0400 Subject: [PATCH 008/343] Update homepage url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `http://www.liquidmarkup.org` is `http` `http://www.liquidmarkup.org` redirects to `https://shopify.github.io/liquid/` `https://www.liquidmarkup.org` can’t provide a secure connection (ERR_SSL_PROTOCOL_ERROR) --- liquid.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liquid.gemspec b/liquid.gemspec index 65176776e..684f4c85a 100644 --- a/liquid.gemspec +++ b/liquid.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |s| s.summary = "A secure, non-evaling end user template engine with aesthetic markup." s.authors = ["Tobias Lütke"] s.email = ["tobi@leetsoft.com"] - s.homepage = "http://www.liquidmarkup.org" + s.homepage = "https://shopify.github.io/liquid/" s.license = "MIT" # s.description = "A secure, non-evaling end user template engine with aesthetic markup." From fbab19ac8c64eda118c1f67e27a01cac015b53bf Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 3 Oct 2022 15:57:28 -0300 Subject: [PATCH 009/343] fix tablerow drop's last attribute with missing cols param --- lib/liquid/tags/table_row.rb | 2 +- test/integration/tags/table_row_test.rb | 52 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 6ca528c71..5371f70af 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -51,7 +51,7 @@ def render_to_output_buffer(context, output) collection = Utils.slice_collection(collection, from, to) length = collection.length - cols = context.evaluate(@attributes['cols']).to_i + cols = @attributes['cols'].nil? ? length : context.evaluate(@attributes['cols']).to_i output << "\n" context.stack do diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index a5ecf1406..1f6edda43 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -65,4 +65,56 @@ def test_blank_string_not_iterable "{% tablerow char in characters cols:3 %}I WILL NOT BE OUTPUT{% endtablerow %}", { 'characters' => '' }) end + + def test_tablerow_loop_drop_attributes + template = <<~LIQUID.chomp + {% tablerow i in (1...2) %} + col: {{ tablerowloop.col }} + col0: {{ tablerowloop.col0 }} + col_first: {{ tablerowloop.col_first }} + col_last: {{ tablerowloop.col_last }} + first: {{ tablerowloop.first }} + index: {{ tablerowloop.index }} + index0: {{ tablerowloop.index0 }} + last: {{ tablerowloop.last }} + length: {{ tablerowloop.length }} + rindex: {{ tablerowloop.rindex }} + rindex0: {{ tablerowloop.rindex0 }} + row: {{ tablerowloop.row }} + {% endtablerow %} + LIQUID + + expected_output = <<~OUTPUT + + + col: 1 + col0: 0 + col_first: true + col_last: false + first: true + index: 1 + index0: 0 + last: false + length: 2 + rindex: 2 + rindex0: 1 + row: 1 + + col: 2 + col0: 1 + col_first: false + col_last: true + first: false + index: 2 + index0: 1 + last: true + length: 2 + rindex: 1 + rindex0: 0 + row: 1 + + OUTPUT + + assert_template_result(expected_output, template) + end end From 4f17abfb4a02601e16b387c0fe33b80208a68316 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 9 Sep 2022 11:07:15 -0400 Subject: [PATCH 010/343] Handle truncatewords word length out of range as if no truncation is needed --- lib/liquid/standardfilters.rb | 8 ++++---- test/integration/standard_filter_test.rb | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 1470243cf..21bed3c54 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -6,7 +6,7 @@ module Liquid module StandardFilters - MAX_INT = (1 << 31) - 1 + MAX_I32 = (1 << 31) - 1 HTML_ESCAPE = { '&' => '&', '>' => '>', @@ -239,9 +239,9 @@ def truncatewords(input, words = 15, truncate_string = "...") wordlist = begin input.split(" ", words + 1) rescue RangeError - raise if words + 1 < MAX_INT - # e.g. integer #{words} too big to convert to `int' - raise Liquid::ArgumentError, "integer #{words} too big for truncatewords" + # integer too big for String#split, but we can semantically assume no truncation is needed + return input if words + 1 > MAX_I32 + raise # unexpected error end return input if wordlist.length <= words diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 3b2cffa3a..519a7f3fc 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -227,10 +227,8 @@ def test_truncatewords assert_equal('one two three...', @filters.truncatewords("one two\tthree\nfour", 3)) assert_equal('one two...', @filters.truncatewords("one two three four", 2)) assert_equal('one...', @filters.truncatewords("one two three four", 0)) - exception = assert_raises(Liquid::ArgumentError) do - @filters.truncatewords("one two three four", 1 << 31) - end - assert_equal("Liquid error: integer #{1 << 31} too big for truncatewords", exception.message) + assert_equal('one two three four', @filters.truncatewords("one two three four", 1 << 31)) + assert_equal('one...', @filters.truncatewords("one two three four", -(1 << 32))) end def test_strip_html From 6765d9393861978388baa1d3c763a9d884de10e9 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 9 Sep 2022 11:37:21 -0400 Subject: [PATCH 011/343] Avoid internal errors for large arguments to slice filter Use saturating conversion, which has expected semantics for large integers. --- lib/liquid/standardfilters.rb | 24 ++++++++++++++++++++---- test/integration/standard_filter_test.rb | 8 ++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 21bed3c54..18c79f3cc 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -7,6 +7,13 @@ module Liquid module StandardFilters MAX_I32 = (1 << 31) - 1 + private_constant :MAX_I32 + + MIN_I64 = -(1 << 63) + MAX_I64 = (1 << 63) - 1 + I64_RANGE = MIN_I64..MAX_I64 + private_constant :MIN_I64, :MAX_I64, :I64_RANGE + HTML_ESCAPE = { '&' => '&', '>' => '>', @@ -186,10 +193,19 @@ def slice(input, offset, length = nil) offset = Utils.to_integer(offset) length = length ? Utils.to_integer(length) : 1 - if input.is_a?(Array) - input.slice(offset, length) || [] - else - input.to_s.slice(offset, length) || '' + begin + if input.is_a?(Array) + input.slice(offset, length) || [] + else + input.to_s.slice(offset, length) || '' + end + rescue RangeError + if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset) + raise # unexpected error + end + offset = offset.clamp(I64_RANGE) + length = length.clamp(I64_RANGE) + retry end end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 519a7f3fc..d95f5c9c9 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -109,6 +109,10 @@ def test_slice assert_raises(Liquid::ArgumentError) do @filters.slice('foobar', 0, "") end + assert_equal("", @filters.slice("foobar", 0, -(1 << 64))) + assert_equal("foobar", @filters.slice("foobar", 0, 1 << 63)) + assert_equal("", @filters.slice("foobar", 1 << 63, 6)) + assert_equal("", @filters.slice("foobar", -(1 << 63), 6)) end def test_slice_on_arrays @@ -123,6 +127,10 @@ def test_slice_on_arrays assert_equal(%w(r), @filters.slice(input, -1)) assert_equal(%w(), @filters.slice(input, 100, 10)) assert_equal(%w(), @filters.slice(input, -100, 10)) + assert_equal([], @filters.slice(input, 0, -(1 << 64))) + assert_equal(input, @filters.slice(input, 0, 1 << 63)) + assert_equal([], @filters.slice(input, 1 << 63, 6)) + assert_equal([], @filters.slice(input, -(1 << 63), 6)) end def test_truncate From c0c191cabd1138753c0a04373fc2b7bf47238055 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 9 Sep 2022 11:48:37 -0400 Subject: [PATCH 012/343] Add assertions for truncate filter with large integers --- test/integration/standard_filter_test.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index d95f5c9c9..b633024bd 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -140,6 +140,8 @@ def test_truncate assert_equal('1234567890', @filters.truncate('1234567890')) assert_equal("测试...", @filters.truncate("测试测试测试测试", 5)) assert_equal('12341', @filters.truncate("1234567890", 5, 1)) + assert_equal("foobar", @filters.truncate("foobar", 1 << 63)) + assert_equal("...", @filters.truncate("foobar", -(1 << 63))) end def test_split From 2c2f5826d58286e1eb2b31f2a536c2bb5606ee7a Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 21 Oct 2022 11:43:58 -0400 Subject: [PATCH 013/343] Change increment/decrement tests to avoid relying on input So these can be tested using language tests, without needing support for counter value input, which isn't needed if input is provided through the static environment. --- test/integration/tags/increment_tag_test.rb | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/integration/tags/increment_tag_test.rb b/test/integration/tags/increment_tag_test.rb index 7ad5c3db1..666d2d56c 100644 --- a/test/integration/tags/increment_tag_test.rb +++ b/test/integration/tags/increment_tag_test.rb @@ -6,20 +6,21 @@ class IncrementTagTest < Minitest::Test include Liquid def test_inc - assert_template_result('0', '{%increment port %}', {}) - assert_template_result('0 1', '{%increment port %} {%increment port%}', {}) + assert_template_result('0 1', '{%increment port %} {{ port }}') + assert_template_result(' 0 1 2', '{{port}} {%increment port %} {%increment port%} {{port}}') assert_template_result('0 0 1 2 1', '{%increment port %} {%increment starboard%} ' \ '{%increment port %} {%increment port%} ' \ - '{%increment starboard %}', {}) + '{%increment starboard %}') end def test_dec - assert_template_result('9', '{%decrement port %}', { 'port' => 10 }) - assert_template_result('-1 -2', '{%decrement port %} {%decrement port%}', {}) - assert_template_result('1 5 2 2 5', + assert_template_result('-1 -1', '{%decrement port %} {{ port }}', { 'port' => 10 }) + assert_template_result(' -1 -2 -2', '{{port}} {%decrement port %} {%decrement port%} {{port}}') + assert_template_result('0 1 2 0 3 1 1 3', + '{%increment starboard %} {%increment starboard%} {%increment starboard%} ' \ '{%increment port %} {%increment starboard%} ' \ '{%increment port %} {%decrement port%} ' \ - '{%decrement starboard %}', { 'port' => 1, 'starboard' => 5 }) + '{%decrement starboard %}') end end From a39422feac532c245ceb9d6a15b8ac91d3d73431 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 21 Oct 2022 11:45:29 -0400 Subject: [PATCH 014/343] Use static environment assert_template_result input values To match how we use liquid in practice --- test/test_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 23f0a42a2..052f7091d 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -44,7 +44,7 @@ def assert_template_result( template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym) file_system = StubFileSystem.new(partials) if partials registers = Liquid::Registers.new(file_system: file_system) - context = Liquid::Context.build(environments: assigns, rethrow_errors: !render_errors, registers: registers) + context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers) output = template.render(context) assert_equal(expected, output, message) end From 6ce4ec1011c35dd29ba68bf94dd2686e19e95604 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Tue, 25 Oct 2022 10:21:03 -0400 Subject: [PATCH 015/343] tablerow: Avoid accidental special case for constant nil cols (#1644) It should behave the same as an expression that evaluates to nil --- lib/liquid/tags/table_row.rb | 2 +- test/integration/tags/table_row_test.rb | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 5371f70af..87f3e096c 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -51,7 +51,7 @@ def render_to_output_buffer(context, output) collection = Utils.slice_collection(collection, from, to) length = collection.length - cols = @attributes['cols'].nil? ? length : context.evaluate(@attributes['cols']).to_i + cols = @attributes.key?('cols') ? context.evaluate(@attributes['cols']).to_i : length output << "\n" context.stack do diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 1f6edda43..8556ed2da 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -66,6 +66,20 @@ def test_blank_string_not_iterable { 'characters' => '' }) end + def test_cols_nil_constant_same_as_evaluated_nil_expression + expect = "\n" \ + "false" \ + "false" \ + "\n" + + assert_template_result(expect, + "{% tablerow i in (1..2) cols:nil %}{{ tablerowloop.col_last }}{% endtablerow %}") + + assert_template_result(expect, + "{% tablerow i in (1..2) cols:var %}{{ tablerowloop.col_last }}{% endtablerow %}", + { "var" => nil }) + end + def test_tablerow_loop_drop_attributes template = <<~LIQUID.chomp {% tablerow i in (1...2) %} From b8f05267a9753d2c755e0029ab7cb07eb07497d9 Mon Sep 17 00:00:00 2001 From: Erin Morrissey Date: Tue, 25 Oct 2022 11:53:27 -0700 Subject: [PATCH 016/343] update link paths in docs markup --- lib/liquid/forloop_drop.rb | 2 +- lib/liquid/standardfilters.rb | 2 +- lib/liquid/tablerowloop_drop.rb | 2 +- lib/liquid/tags/break.rb | 2 +- lib/liquid/tags/continue.rb | 2 +- lib/liquid/tags/cycle.rb | 2 +- lib/liquid/tags/decrement.rb | 4 ++-- lib/liquid/tags/echo.rb | 2 +- lib/liquid/tags/for.rb | 4 ++-- lib/liquid/tags/include.rb | 4 ++-- lib/liquid/tags/increment.rb | 4 ++-- lib/liquid/tags/render.rb | 10 +++++----- lib/liquid/tags/table_row.rb | 2 +- lib/liquid/tags/unless.rb | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/liquid/forloop_drop.rb b/lib/liquid/forloop_drop.rb index 9592faa14..c7b65f824 100644 --- a/lib/liquid/forloop_drop.rb +++ b/lib/liquid/forloop_drop.rb @@ -5,7 +5,7 @@ module Liquid # @liquid_type object # @liquid_name forloop # @liquid_summary - # Information about a parent [`for` loop](/api/liquid/tags#for). + # Information about a parent [`for` loop](/api/liquid/tags/for). class ForloopDrop < Drop def initialize(name, length, parentloop) @name = name diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 1470243cf..3119aef84 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -599,7 +599,7 @@ def append(input, string) # @liquid_description # > Note: # > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the - # > [`uniq` filter](/api/liquid/filters#uniq). + # > [`uniq` filter](/api/liquid/filters/uniq). # @liquid_syntax array | concat: array # @liquid_return [array[untyped]] def concat(input, array) diff --git a/lib/liquid/tablerowloop_drop.rb b/lib/liquid/tablerowloop_drop.rb index b24e734a7..554a00ed6 100644 --- a/lib/liquid/tablerowloop_drop.rb +++ b/lib/liquid/tablerowloop_drop.rb @@ -5,7 +5,7 @@ module Liquid # @liquid_type object # @liquid_name tablerowloop # @liquid_summary - # Information about a parent [`tablerow` loop](/api/liquid/tags#tablerow). + # Information about a parent [`tablerow` loop](/api/liquid/tags/tablerow). class TablerowloopDrop < Drop def initialize(length, cols) @length = length diff --git a/lib/liquid/tags/break.rb b/lib/liquid/tags/break.rb index ebdf1177c..70bc9bd75 100644 --- a/lib/liquid/tags/break.rb +++ b/lib/liquid/tags/break.rb @@ -15,7 +15,7 @@ module Liquid # @liquid_category iteration # @liquid_name break # @liquid_summary - # Stops a [`for` loop](/api/liquid/tags#for) from iterating. + # Stops a [`for` loop](/api/liquid/tags/for) from iterating. # @liquid_syntax # {% break %} class Break < Tag diff --git a/lib/liquid/tags/continue.rb b/lib/liquid/tags/continue.rb index 2435899e3..aea0a7270 100644 --- a/lib/liquid/tags/continue.rb +++ b/lib/liquid/tags/continue.rb @@ -6,7 +6,7 @@ module Liquid # @liquid_category iteration # @liquid_name continue # @liquid_summary - # Causes a [`for` loop](/api/liquid/tags#for) to skip to the next iteration. + # Causes a [`for` loop](/api/liquid/tags/for) to skip to the next iteration. # @liquid_syntax # {% continue %} class Continue < Tag diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 3790d0641..19aee5acc 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -6,7 +6,7 @@ module Liquid # @liquid_category iteration # @liquid_name cycle # @liquid_summary - # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/api/liquid/tags#for). + # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/api/liquid/tags/for). # @liquid_description # The `cycle` tag must be used inside a `for` loop. # diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 1a2154d3b..3846e1673 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -12,8 +12,8 @@ module Liquid # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across # [snippets](/themes/architecture#snippets) included in the file. # - # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/api/liquid/tags#assign) - # and [`capture`](/api/liquid/tags#capture). However, `decrement` and [`increment`](/api/liquid/tags#increment) share + # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/api/liquid/tags/assign) + # and [`capture`](/api/liquid/tags/capture). However, `decrement` and [`increment`](/api/liquid/tags/increment) share # variables. # @liquid_syntax # {% decrement variable_name %} diff --git a/lib/liquid/tags/echo.rb b/lib/liquid/tags/echo.rb index 9d3a60ca6..607b1379f 100644 --- a/lib/liquid/tags/echo.rb +++ b/lib/liquid/tags/echo.rb @@ -9,7 +9,7 @@ module Liquid # Outputs an expression. # @liquid_description # Using the `echo` tag is the same as wrapping an expression in curly brackets (`{{` and `}}`). However, unlike the curly - # bracket method, you can use the `echo` tag inside [`liquid` tags](/api/liquid/tags#liquid). + # bracket method, you can use the `echo` tag inside [`liquid` tags](/api/liquid/tags/liquid). # # > Tip: # > You can use [filters](/api/liquid/filters) on expressions inside `echo` tags. diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index ff8c7fc94..7b4ceaeee 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -9,10 +9,10 @@ module Liquid # Renders an expression for every item in an array. # @liquid_description # You can do a maximum of 50 iterations with a `for` loop. If you need to iterate over more than 50 items, then use the - # [`paginate` tag](/api/liquid/tags#paginate) to split the items over multiple pages. + # [`paginate` tag](/api/liquid/tags/paginate) to split the items over multiple pages. # # > Tip: - # > Every `for` loop has an associated [`forloop` object](/api/liquid/objects#forloop) with information about the loop. + # > Every `for` loop has an associated [`forloop` object](/api/liquid/objects/forloop) with information about the loop. # @liquid_syntax # {% for variable in array %} # expression diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index f8fa05375..3fdc84293 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -8,7 +8,7 @@ module Liquid # @liquid_summary # Renders a [snippet](/themes/architecture#snippets). # @liquid_description - # Inside the snippet, you can access and alter variables that are [created](/api/liquid/tags#variable-tags) outside of the + # Inside the snippet, you can access and alter variables that are [created](/api/liquid/tags/variable-tags) outside of the # snippet. # @liquid_syntax # {% include 'filename' %} @@ -16,7 +16,7 @@ module Liquid # @liquid_deprecated # Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain. # - # The `include` tag has been replaced by [`render`](/api/liquid/tags#render). + # The `include` tag has been replaced by [`render`](/api/liquid/tags/render). class Include < Tag prepend Tag::Disableable diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 2965b3b12..67ca24f07 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -12,8 +12,8 @@ module Liquid # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across # [snippets](/themes/architecture#snippets) included in the file. # - # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/api/liquid/tags#assign) - # and [`capture`](/api/liquid/tags#capture). However, `increment` and [`decrement`](/api/liquid/tags#decrement) share + # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/api/liquid/tags/assign) + # and [`capture`](/api/liquid/tags/capture). However, `increment` and [`decrement`](/api/liquid/tags/decrement) share # variables. # @liquid_syntax # {% increment variable_name %} diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 30fd67d8d..ed03353ac 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -8,19 +8,19 @@ module Liquid # @liquid_summary # Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks). # @liquid_description - # Inside snippets and app blocks, you can't directly access variables that are [created](/api/liquid/tags#variable-tags) outside - # of the snippet or app block. However, you can [specify variables as parameters](/api/liquid/tags#render-passing-variables-to-snippets) + # Inside snippets and app blocks, you can't directly access variables that are [created](/api/liquid/tags/variable-tags) outside + # of the snippet or app block. However, you can [specify variables as parameters](/api/liquid/tags/render#render-passing-variables-to-a-snippet) # to pass outside variables to snippets. # # While you can't directly access created variables, you can access global objects, as well as any objects that are # directly accessible outside the snippet or app block. For example, a snippet or app block inside the [product template](/themes/architecture/templates/product) - # can access the [`product` object](/api/liquid/objects#product), and a snippet or app block inside a [section](/themes/architecture/sections) - # can access the [`section` object](/api/liquid/objects#section). + # can access the [`product` object](/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections) + # can access the [`section` object](/api/liquid/objects/section). # # Outside a snippet or app block, you can't access variables created inside the snippet or app block. # # > Note: - # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/api/liquid/tags#include) + # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/api/liquid/tags/include) # > inside the snippet. # @liquid_syntax # {% render 'filename' %} diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 6ca528c71..a9a79e6c0 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -11,7 +11,7 @@ module Liquid # The `tablerow` tag must be wrapped in HTML `` and `
` tags. # # > Tip: - # > Every `tablerow` loop has an associated [`tablerowloop` object](/api/liquid/objects#tablerowloop) with information about the loop. + # > Every `tablerow` loop has an associated [`tablerowloop` object](/api/liquid/objects/tablerowloop) with information about the loop. # @liquid_syntax # {% tablerow variable in array %} # expression diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb index 30c65b0ac..56b9228bb 100644 --- a/lib/liquid/tags/unless.rb +++ b/lib/liquid/tags/unless.rb @@ -11,7 +11,7 @@ module Liquid # Renders an expression unless a specific condition is `true`. # @liquid_description # > Tip: - # > Similar to the [`if` tag](/api/liquid/tags#if), you can use `elsif` to add more conditions to an `unless` tag. + # > Similar to the [`if` tag](/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag. # @liquid_syntax # {% unless condition %} # expression From 96b5325f873808ae8cbfef471e602d6c5bddd115 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 26 Oct 2022 09:21:43 -0400 Subject: [PATCH 017/343] Remove Usage.increment calls that have been shown to in fact be used (#1645) I think these were added for undocumented features to see if they were actually used and they were being used. --- lib/liquid/forloop_drop.rb | 5 +---- lib/liquid/tags/for.rb | 1 - test/integration/tags/for_tag_test.rb | 26 -------------------------- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/lib/liquid/forloop_drop.rb b/lib/liquid/forloop_drop.rb index 9592faa14..4c69a8c38 100644 --- a/lib/liquid/forloop_drop.rb +++ b/lib/liquid/forloop_drop.rb @@ -30,10 +30,7 @@ def initialize(name, length, parentloop) # @liquid_return [forloop] attr_reader :parentloop - def name - Usage.increment('forloop_drop_name') - @name - end + attr_reader :name # @liquid_public_docs # @liquid_summary diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index ff8c7fc94..3ad8ac36d 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -177,7 +177,6 @@ def set_attribute(key, expr) case key when 'offset' @from = if expr == 'continue' - Usage.increment('for_offset_continue') :continue else parse_expression(expr) diff --git a/test/integration/tags/for_tag_test.rb b/test/integration/tags/for_tag_test.rb index 614ebdc19..3855d97b9 100644 --- a/test/integration/tags/for_tag_test.rb +++ b/test/integration/tags/for_tag_test.rb @@ -450,30 +450,4 @@ def test_for_cleans_up_registers assert(context.registers[:for_stack].empty?) end - - def test_instrument_for_offset_continue - assert_usage_increment('for_offset_continue') do - Template.parse('{% for item in items offset:continue %}{{item}}{% endfor %}') - end - - assert_usage_increment('for_offset_continue', times: 0) do - Template.parse('{% for item in items offset:2 %}{{item}}{% endfor %}') - end - end - - def test_instrument_forloop_drop_name - assigns = { 'items' => [1, 2, 3, 4, 5] } - - assert_usage_increment('forloop_drop_name', times: 5) do - Template.parse('{% for item in items %}{{forloop.name}}{% endfor %}').render!(assigns) - end - - assert_usage_increment('forloop_drop_name', times: 0) do - Template.parse('{% for item in items %}{{forloop.index}}{% endfor %}').render!(assigns) - end - - assert_usage_increment('forloop_drop_name', times: 0) do - Template.parse('{% for item in items %}{{item}}{% endfor %}').render!(assigns) - end - end end From 3e8994c258e709b611fd4b2259f4e65cc68a754c Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Thu, 27 Oct 2022 11:13:50 -0400 Subject: [PATCH 018/343] Have subclasses of a tag inherit superclass's disabled tags (#1646) --- lib/liquid/tag.rb | 10 ++++++++-- lib/liquid/tag/disabler.rb | 8 -------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 085c0efc5..2f7bc3016 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -14,12 +14,18 @@ def parse(tag_name, markup, tokenizer, parse_context) end def disable_tags(*tag_names) - @disabled_tags ||= [] - @disabled_tags.concat(tag_names) + tag_names += disabled_tags + define_singleton_method(:disabled_tags) { tag_names } prepend(Disabler) end private :new + + protected + + def disabled_tags + [] + end end def initialize(tag_name, markup, parse_context) diff --git a/lib/liquid/tag/disabler.rb b/lib/liquid/tag/disabler.rb index 210a177d7..852205abc 100644 --- a/lib/liquid/tag/disabler.rb +++ b/lib/liquid/tag/disabler.rb @@ -3,14 +3,6 @@ module Liquid class Tag module Disabler - module ClassMethods - attr_reader :disabled_tags - end - - def self.prepended(base) - base.extend(ClassMethods) - end - def render_to_output_buffer(context, output) context.with_disabled_tags(self.class.disabled_tags) do super From f484b868d08c8153fc397f28e7be193f2dcdf3ff Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 28 Oct 2022 13:55:33 -0400 Subject: [PATCH 019/343] assert_template_result: Avoid using the BlankFileSystem Since it doesn't reflect the real liquid usage that we are trying to test against. --- test/test_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 23f0a42a2..2caa7b320 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -42,7 +42,7 @@ def assert_template_result( message: nil, partials: nil, error_mode: nil, render_errors: false ) template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym) - file_system = StubFileSystem.new(partials) if partials + file_system = StubFileSystem.new(partials || {}) registers = Liquid::Registers.new(file_system: file_system) context = Liquid::Context.build(environments: assigns, rethrow_errors: !render_errors, registers: registers) output = template.render(context) From db8e85ab31e17c4de00a79f919e8b53da5aa7699 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 28 Oct 2022 13:57:17 -0400 Subject: [PATCH 020/343] Prevent an internal error in include tag from non-string template_name which would otherwise happen on `template_name.split('/')` --- lib/liquid/tags/include.rb | 2 +- test/integration/tags/include_tag_test.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 3fdc84293..0664f5c38 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -52,7 +52,7 @@ def parse(_tokens) def render_to_output_buffer(context, output) template_name = context.evaluate(@template_name_expr) - raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name + raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name.is_a?(String) partial = PartialCache.load( template_name, diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 103851570..91f1aeabf 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -249,6 +249,11 @@ def test_render_raise_argument_error_when_template_is_undefined "{% include nil %}", render_errors: true) end + def test_render_raise_argument_error_when_template_is_not_a_string + assert_template_result("Liquid error (line 1): Argument error in tag 'include' - Illegal template name", + "{% include 123 %}", render_errors: true) + end + def test_including_via_variable_value assert_template_result("from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}", partials: { "pick_a_source" => "from TestFileSystem" }) From abed47547c306f35e7aada463e88d60919294efa Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 1 Nov 2022 13:04:41 -0400 Subject: [PATCH 021/343] Fix tags in comment --- lib/liquid/block_body.rb | 12 ++++++++++-- test/integration/tags/standard_tag_test.rb | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index a0d35a790..4674c5f3c 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -109,14 +109,22 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag) end end - private def parse_for_document(tokenizer, parse_context) + private def handle_invalid_tag_token(token, parse_context) + if token.end_with?('%}') + yield token, token + else + BlockBody.raise_missing_tag_terminator(token, parse_context) + end + end + + private def parse_for_document(tokenizer, parse_context, &block) while (token = tokenizer.shift) next if token.empty? case when token.start_with?(TAGSTART) whitespace_handler(token, parse_context) unless token =~ FullToken - BlockBody.raise_missing_tag_terminator(token, parse_context) + return handle_invalid_tag_token(token, parse_context, &block) end tag_name = Regexp.last_match(2) markup = Regexp.last_match(4) diff --git a/test/integration/tags/standard_tag_test.rb b/test/integration/tags/standard_tag_test.rb index 496a7c46c..5ff425a4a 100644 --- a/test/integration/tags/standard_tag_test.rb +++ b/test/integration/tags/standard_tag_test.rb @@ -36,6 +36,8 @@ def test_has_a_block_which_does_nothing assert_template_result('', '{%comment%}{% endif %}{%endcomment%}') assert_template_result('', '{% comment %}{% endwhatever %}{% endcomment %}') assert_template_result('', '{% comment %}{% raw %} {{%%%%}} }} { {% endcomment %} {% comment {% endraw %} {% endcomment %}') + assert_template_result('', '{% comment %}{% " %}{% endcomment %}') + assert_template_result('', '{% comment %}{%%}{% endcomment %}') assert_template_result('foobar', 'foo{%comment%}comment{%endcomment%}bar') assert_template_result('foobar', 'foo{% comment %}comment{% endcomment %}bar') From 1cdd1f0834199900c504ef8c3b2b6bfaffb6c816 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 9 Nov 2022 13:03:33 -0500 Subject: [PATCH 022/343] refactor: Advance tokenizer array offset instead of using Array#shift (#1653) Array#shift would move all the remaining elements of the array, which is slower for larger arrays. --- lib/liquid/tokenizer.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index d3bda7b35..740f85999 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -8,11 +8,15 @@ def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: f @source = source.to_s.to_str @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag + @offset = 0 @tokens = tokenize end def shift - (token = @tokens.shift) || return + token = @tokens[@offset] + return nil unless token + + @offset += 1 if @line_number @line_number += @for_liquid_tag ? 1 : token.count("\n") @@ -31,7 +35,9 @@ def tokenize tokens = @source.split(TemplateParser) # removes the rogue empty element at the beginning of the array - tokens.shift if tokens[0]&.empty? + if tokens[0]&.empty? + @offset += 1 + end tokens end From 667664bb22fb6b1c60e5ca8b42faa2609d5b6b5b Mon Sep 17 00:00:00 2001 From: Shaina Raskas Date: Fri, 11 Nov 2022 15:34:35 -0500 Subject: [PATCH 023/343] clarify inline_comment --- lib/liquid/tags/inline_comment.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/liquid/tags/inline_comment.rb b/lib/liquid/tags/inline_comment.rb index e2cbc138d..594d08ed6 100644 --- a/lib/liquid/tags/inline_comment.rb +++ b/lib/liquid/tags/inline_comment.rb @@ -4,13 +4,10 @@ module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category syntax - # @liquid_name inline_comment + # @liquid_name inline comment # @liquid_summary - # Prevents an expression from being rendered or output. + # Prevents an expression inside of a tag `{% %}` from being rendered or output. # @liquid_description - # Any text inside an `inline_comment` tag won't be rendered or output. - # - # You can create multi-line inline comments. However, each line must begin with a `#`. # @liquid_syntax # {% # content %} # @liquid_syntax_keyword content The content of the comment. From 474315c6bb8c03b6025433b59af0d7d470c71bd3 Mon Sep 17 00:00:00 2001 From: Shaina Raskas Date: Fri, 11 Nov 2022 16:03:52 -0500 Subject: [PATCH 024/343] edits --- lib/liquid/tags/inline_comment.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tags/inline_comment.rb b/lib/liquid/tags/inline_comment.rb index 594d08ed6..b378d7c2d 100644 --- a/lib/liquid/tags/inline_comment.rb +++ b/lib/liquid/tags/inline_comment.rb @@ -4,13 +4,16 @@ module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category syntax - # @liquid_name inline comment + # @liquid_name inline comments # @liquid_summary # Prevents an expression inside of a tag `{% %}` from being rendered or output. # @liquid_description + # You can use inline comment tags to annotate your code, or to temporarily prevent logic in your code from executing. + # + # You can create multi-line inline comments. However, each line in the tag must begin with a `#`, or a syntax error will occur. # @liquid_syntax # {% # content %} - # @liquid_syntax_keyword content The content of the comment. + # @liquid_syntax_keyword content The content of the tag. class InlineComment < Tag def initialize(tag_name, markup, options) super From 8b1b9f649af7c865cd96a09e60e4c09929b31c1a Mon Sep 17 00:00:00 2001 From: Shaina Raskas Date: Fri, 11 Nov 2022 16:21:18 -0500 Subject: [PATCH 025/343] remove separate inline comment tag for IA reasons --- lib/liquid/tags/inline_comment.rb | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/lib/liquid/tags/inline_comment.rb b/lib/liquid/tags/inline_comment.rb index b378d7c2d..493cfddc7 100644 --- a/lib/liquid/tags/inline_comment.rb +++ b/lib/liquid/tags/inline_comment.rb @@ -1,19 +1,6 @@ # frozen_string_literal: true module Liquid - # @liquid_public_docs - # @liquid_type tag - # @liquid_category syntax - # @liquid_name inline comments - # @liquid_summary - # Prevents an expression inside of a tag `{% %}` from being rendered or output. - # @liquid_description - # You can use inline comment tags to annotate your code, or to temporarily prevent logic in your code from executing. - # - # You can create multi-line inline comments. However, each line in the tag must begin with a `#`, or a syntax error will occur. - # @liquid_syntax - # {% # content %} - # @liquid_syntax_keyword content The content of the tag. class InlineComment < Tag def initialize(tag_name, markup, options) super From c2c6cb2b15e237cff534e7e8ca90cdffc9189ac8 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Tue, 29 Nov 2022 09:00:55 -0500 Subject: [PATCH 026/343] Allow commas to separate `for` tag attributes (#1658) For consistency with tags like the `render` tag, where we actually prefer to use commas to separate attributes --- lib/liquid/tags/for.rb | 5 +++-- test/integration/tags/for_tag_test.rb | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index b409d0675..e02b65895 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -98,11 +98,12 @@ def strict_parse(markup) @name = "#{@variable_name}-#{collection_name}" @reversed = p.id?('reversed') - while p.look(:id) && p.look(:colon, 1) + while p.look(:comma) || p.look(:id) + p.consume?(:comma) unless (attribute = p.id?('limit') || p.id?('offset')) raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute") end - p.consume + p.consume(:colon) set_attribute(attribute, p.expression) end p.consume(:end_of_string) diff --git a/test/integration/tags/for_tag_test.rb b/test/integration/tags/for_tag_test.rb index 3855d97b9..b36a5ba2f 100644 --- a/test/integration/tags/for_tag_test.rb +++ b/test/integration/tags/for_tag_test.rb @@ -103,6 +103,7 @@ def test_limiting assert_template_result('1234', '{%for i in array limit:4 %}{{ i }}{%endfor%}', assigns) assert_template_result('3456', '{%for i in array limit:4 offset:2 %}{{ i }}{%endfor%}', assigns) assert_template_result('3456', '{%for i in array limit: 4 offset: 2 %}{{ i }}{%endfor%}', assigns) + assert_template_result('3456', '{%for i in array, limit: 4, offset: 2 %}{{ i }}{%endfor%}', assigns) end def test_limiting_with_invalid_limit From 5cd8a83fa6ad844534e5071b6653b282038f16f8 Mon Sep 17 00:00:00 2001 From: liamgriffin Date: Fri, 6 Jan 2023 11:19:13 +0000 Subject: [PATCH 027/343] Adding more content to divided_by filter Adding more content to divided_by filter, by detailing the behaviour of how different results are generated by different types of divisors. --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 8af5233ce..6528f96d9 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -757,7 +757,7 @@ def times(input, operand) # @liquid_type filter # @liquid_category math # @liquid_summary - # Divides a number by a given number. + # Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float. # @liquid_syntax number | divided_by: number # @liquid_return [number] def divided_by(input, operand) From 936f803a4e0367c0c8a822ac983f95bde93c06ea Mon Sep 17 00:00:00 2001 From: liamgriffin Date: Fri, 6 Jan 2023 11:36:20 +0000 Subject: [PATCH 028/343] Removed trailing space --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 6528f96d9..7450a9384 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -757,7 +757,7 @@ def times(input, operand) # @liquid_type filter # @liquid_category math # @liquid_summary - # Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float. + # Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float. # @liquid_syntax number | divided_by: number # @liquid_return [number] def divided_by(input, operand) From b839deb3a8f673f3d1a40ff591856f90771b4763 Mon Sep 17 00:00:00 2001 From: Ashwin Maroli Date: Wed, 11 Jan 2023 20:50:44 +0530 Subject: [PATCH 029/343] Use cache built into `ruby/setup-ruby` action --- .github/workflows/liquid.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 749eee0a4..c9ab4773f 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -1,5 +1,11 @@ name: Liquid on: [push, pull_request] + +env: + BUNDLE_PATH: "vendor/bundle" + BUNDLE_JOBS: 4 + BUNDLE_RETRY: 3 + jobs: test: runs-on: ubuntu-latest @@ -9,20 +15,16 @@ jobs: - { ruby: 2.7, allowed-failure: false } # minimum supported - { ruby: 3.1, allowed-failure: false } # latest - { ruby: ruby-head, allowed-failure: true } - name: test (${{ matrix.entry.ruby }}) + name: Test Ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@v3 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.entry.ruby }} - - uses: actions/cache@v1 - with: - path: vendor/bundle - key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }} - restore-keys: ${{ runner.os }}-gems- - - run: bundle install --jobs=3 --retry=3 --path=vendor/bundle + bundler-cache: true - run: bundle exec rake continue-on-error: ${{ matrix.entry.allowed-failure }} + memory_profile: runs-on: ubuntu-latest steps: @@ -30,10 +32,5 @@ jobs: - uses: ruby/setup-ruby@v1 with: ruby-version: 2.7 - - uses: actions/cache@v1 - with: - path: vendor/bundle - key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile') }} - restore-keys: ${{ runner.os }}-gems- - - run: bundle install --jobs=3 --retry=3 --path=vendor/bundle + bundler-cache: true - run: bundle exec rake memory_profile:run From 1beb87b4460876b0dc5709bbcc663718acbb0bcc Mon Sep 17 00:00:00 2001 From: Ashwin Maroli Date: Wed, 11 Jan 2023 20:58:17 +0530 Subject: [PATCH 030/343] Bump latest Ruby version in CI matrix --- .github/workflows/liquid.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index c9ab4773f..fc44648f9 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -13,7 +13,7 @@ jobs: matrix: entry: - { ruby: 2.7, allowed-failure: false } # minimum supported - - { ruby: 3.1, allowed-failure: false } # latest + - { ruby: 3.2, allowed-failure: false } # latest - { ruby: ruby-head, allowed-failure: true } name: Test Ruby ${{ matrix.entry.ruby }} steps: From 6a44c1ec772167f612fbb3dcef02bbb5c9682d8f Mon Sep 17 00:00:00 2001 From: Ashwin Maroli Date: Wed, 11 Jan 2023 22:06:47 +0530 Subject: [PATCH 031/343] Do not set `ENV["BUNDLE_PATH"]` --- .github/workflows/liquid.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index fc44648f9..7dc744325 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -2,7 +2,6 @@ name: Liquid on: [push, pull_request] env: - BUNDLE_PATH: "vendor/bundle" BUNDLE_JOBS: 4 BUNDLE_RETRY: 3 From bf711a052170c4b436d20f38882327a03f725916 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 11 Jan 2023 14:05:27 -0500 Subject: [PATCH 032/343] Provide another assertion for to_liquid_value unless tag test (#1672) Since the original one would pass even if to_liquid_value isn't called on the BooleanDrop object. --- test/integration/variable_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index c1fe16f7b..0c42413ce 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -35,6 +35,7 @@ def test_if_tag_calls_to_liquid_value def test_unless_tag_calls_to_liquid_value assert_template_result('', '{% unless foo %}true{% endunless %}', { 'foo' => BooleanDrop.new(true) }) + assert_template_result('true', '{% unless foo %}true{% endunless %}', { 'foo' => BooleanDrop.new(false) }) end def test_case_tag_calls_to_liquid_value From c743936a78df5e658afa4fb9035bc92ffecd532c Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 11 Jan 2023 14:05:50 -0500 Subject: [PATCH 033/343] Instrument usage of bug with iteration of String with offset or 0 limit (#1667) --- lib/liquid/utils.rb | 6 +++++- test/unit/tags/for_tag_unit_test.rb | 30 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 38a406ef2..4ec7d8117 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -16,7 +16,11 @@ def self.slice_collection_using_each(collection, from, to) # Maintains Ruby 1.8.7 String#each behaviour on 1.9 if collection.is_a?(String) - return collection.empty? ? [] : [collection] + return [] if collection.empty? + if from > 0 || to == 0 + Usage.increment("string_slice_bug") + end + return [collection] end return [] unless collection.respond_to?(:each) diff --git a/test/unit/tags/for_tag_unit_test.rb b/test/unit/tags/for_tag_unit_test.rb index 5a52c71c7..cbb68fcb0 100644 --- a/test/unit/tags/for_tag_unit_test.rb +++ b/test/unit/tags/for_tag_unit_test.rb @@ -12,4 +12,34 @@ def test_for_else_nodelist template = Liquid::Template.parse('{% for item in items %}FOR{% else %}ELSE{% endfor %}') assert_equal(['FOR', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end + + def test_for_string_slice_bug_usage + template = Liquid::Template.parse("{% for x in str, offset: 1 %}{{ x }},{% endfor %}") + assert_usage("string_slice_bug") do + assert_equal("abc,", template.render({ "str" => "abc" })) + end + end + + def test_for_string_0_limit_usage + template = Liquid::Template.parse("{% for x in str, limit: 0 %}{{ x }},{% endfor %}") + assert_usage("string_slice_bug") do + assert_equal("abc,", template.render({ "str" => "abc" })) + end + end + + def test_for_string_no_slice_usage + template = Liquid::Template.parse("{% for x in str, offset: 0, limit: 1 %}{{ x }},{% endfor %}") + assert_usage("string_slice_bug", times: 0) do + assert_equal("abc,", template.render({ "str" => "abc" })) + end + end + + private + + def assert_usage(name, times: 1, &block) + count = 0 + result = Liquid::Usage.stub(:increment, ->(n) { count += 1 if n == name }, &block) + assert_equal(times, count) + result + end end From 128b4e35becbd0098602c1c4cf4893286de03f3e Mon Sep 17 00:00:00 2001 From: Jemma Issroff Date: Thu, 12 Jan 2023 15:35:22 -0500 Subject: [PATCH 034/343] Initialize context to nil on the Drop class We believe this will reduce megamorphic exits in YJIT. There are currently 241 separate shapes generated with edge_name "@context" in SFR. Initializing it to nil will significantly reduce this number. --- lib/liquid/drop.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/liquid/drop.rb b/lib/liquid/drop.rb index d4d8950f2..b990630e4 100644 --- a/lib/liquid/drop.rb +++ b/lib/liquid/drop.rb @@ -25,6 +25,10 @@ module Liquid class Drop attr_writer :context + def initialize + @context = nil + end + # Catch all for the method def liquid_method_missing(method) return nil unless @context&.strict_variables From cdb5cb06b280b48f5128c7b0db221a08c4d1ee70 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 13 Jan 2023 15:37:27 -0500 Subject: [PATCH 035/343] Remove == method from drops from testing to_liquid_value Since they could cause tests to pass without to_liquid_value being called on the left side of the equality comparison. --- test/test_helper.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 67900df8f..49b1cc760 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -131,10 +131,6 @@ def initialize(value) @value = value.to_i end - def ==(other) - @value == other - end - def to_s @value.to_s end @@ -150,10 +146,6 @@ def initialize(value) @value = value end - def ==(other) - @value == other - end - def to_liquid_value @value end From 619ed3fcd7e391e4a264372822fc36eecaa62e8d Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Fri, 13 Jan 2023 15:46:12 -0500 Subject: [PATCH 036/343] Add some additional to_liquid_value assertions --- test/integration/variable_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 0c42413ce..c007f1f5e 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -24,13 +24,17 @@ def test_variable_lookup_calls_to_liquid_value def test_if_tag_calls_to_liquid_value assert_template_result('one', '{% if foo == 1 %}one{% endif %}', { 'foo' => IntegerDrop.new('1') }) + assert_template_result('one', '{% if foo == eqv %}one{% endif %}', { 'foo' => IntegerDrop.new(1), 'eqv' => IntegerDrop.new(1) }) assert_template_result('one', '{% if 0 < foo %}one{% endif %}', { 'foo' => IntegerDrop.new('1') }) assert_template_result('one', '{% if foo > 0 %}one{% endif %}', { 'foo' => IntegerDrop.new('1') }) + assert_template_result('one', '{% if b > a %}one{% endif %}', { 'b' => IntegerDrop.new(1), 'a' => IntegerDrop.new(0) }) assert_template_result('true', '{% if foo == true %}true{% endif %}', { 'foo' => BooleanDrop.new(true) }) assert_template_result('true', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(true) }) assert_template_result('', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(false) }) assert_template_result('', '{% if foo == true %}True{% endif %}', { 'foo' => BooleanDrop.new(false) }) + + assert_template_result('one', '{% if a contains x %}one{% endif %}', { 'a' => [1], 'x' => IntegerDrop.new(1) }) end def test_unless_tag_calls_to_liquid_value From 0f11c97623922bfb9f71ed1e9488f7667a5446c0 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 16 Jan 2023 18:51:34 -0400 Subject: [PATCH 037/343] raise invalid integer argument error from tablerow --- lib/liquid/tags/table_row.rb | 17 +++++++-- lib/liquid/utils.rb | 5 ++- test/integration/tags/table_row_test.rb | 46 +++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 2c1d24bf9..f404efd6d 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -45,13 +45,24 @@ def initialize(tag_name, markup, options) def render_to_output_buffer(context, output) (collection = context.evaluate(@collection_name)) || (return '') - from = @attributes.key?('offset') ? context.evaluate(@attributes['offset']).to_i : 0 - to = @attributes.key?('limit') ? from + context.evaluate(@attributes['limit']).to_i : nil + from = if @attributes.key?('offset') + Utils.to_integer(context.evaluate(@attributes['offset']), allow_nil: true) + else + 0 + end + + to = if @attributes.key?('limit') + from + Utils.to_integer(context.evaluate(@attributes['limit']), allow_nil: true) + end collection = Utils.slice_collection(collection, from, to) length = collection.length - cols = @attributes.key?('cols') ? context.evaluate(@attributes['cols']).to_i : length + cols = if @attributes.key?('cols') + Utils.to_integer(context.evaluate(@attributes['cols']), allow_nil: true) + else + length + end output << "\n" context.stack do diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 4ec7d8117..fce8e4c7b 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -39,8 +39,11 @@ def self.slice_collection_using_each(collection, from, to) segments end - def self.to_integer(num) + def self.to_integer(num, allow_nil: false) return num if num.is_a?(Integer) + # with allow_nil param, return 0 which is equal to nil.to_i + return 0 if num.nil? && allow_nil + num = num.to_s begin Integer(num) diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 8556ed2da..5a7d1db23 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -80,6 +80,32 @@ def test_cols_nil_constant_same_as_evaluated_nil_expression { "var" => nil }) end + def test_nil_limit_is_treated_as_zero + expect = "\n" \ + "\n" + + assert_template_result(expect, + "{% tablerow i in (1..2) limit:nil %}{{ i }}{% endtablerow %}") + + assert_template_result(expect, + "{% tablerow i in (1..2) limit:var %}{{ i }}{% endtablerow %}", + { "var" => nil }) + end + + def test_nil_offset_is_treated_as_zero + expect = "\n" \ + "1:false" \ + "2:true" \ + "\n" + + assert_template_result(expect, + "{% tablerow i in (1..2) offset:nil %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}") + + assert_template_result(expect, + "{% tablerow i in (1..2) offset:var %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}", + { "var" => nil }) + end + def test_tablerow_loop_drop_attributes template = <<~LIQUID.chomp {% tablerow i in (1...2) %} @@ -131,4 +157,24 @@ def test_tablerow_loop_drop_attributes assert_template_result(expected_output, template) end + + def test_table_row_renders_correct_error_message_for_invalid_parameters + assert_template_result( + "Liquid error (line 1): invalid integer", + '{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}', + render_errors: true, + ) + + assert_template_result( + "Liquid error (line 1): invalid integer", + '{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}', + render_errors: true, + ) + + assert_template_result( + "Liquid error (line 1): invalid integer", + '{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}', + render_errors: true, + ) + end end From e889a9da0b5e83887f55ee5539c716bb75f5999f Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 17 Jan 2023 17:48:16 -0400 Subject: [PATCH 038/343] use to_i to parse parameters of tablerow tag --- lib/liquid/tags/table_row.rb | 25 +++++++++++-------------- lib/liquid/utils.rb | 5 +---- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index f404efd6d..d52f268a9 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -45,24 +45,13 @@ def initialize(tag_name, markup, options) def render_to_output_buffer(context, output) (collection = context.evaluate(@collection_name)) || (return '') - from = if @attributes.key?('offset') - Utils.to_integer(context.evaluate(@attributes['offset']), allow_nil: true) - else - 0 - end - - to = if @attributes.key?('limit') - from + Utils.to_integer(context.evaluate(@attributes['limit']), allow_nil: true) - end + from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0 + to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil collection = Utils.slice_collection(collection, from, to) length = collection.length - cols = if @attributes.key?('cols') - Utils.to_integer(context.evaluate(@attributes['cols']), allow_nil: true) - else - length - end + cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length output << "\n" context.stack do @@ -93,6 +82,14 @@ def children super + @node.attributes.values + [@node.collection_name] end end + + private + + def to_integer(value) + value.to_i + rescue NoMethodError + raise Liquid::ArgumentError, "invalid integer" + end end Template.register_tag('tablerow', TableRow) diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index fce8e4c7b..4ec7d8117 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -39,11 +39,8 @@ def self.slice_collection_using_each(collection, from, to) segments end - def self.to_integer(num, allow_nil: false) + def self.to_integer(num) return num if num.is_a?(Integer) - # with allow_nil param, return 0 which is equal to nil.to_i - return 0 if num.nil? && allow_nil - num = num.to_s begin Integer(num) From 1aaf6ed0198ba18e1d714f521581a9c921c68635 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 30 Jan 2023 18:32:23 -0400 Subject: [PATCH 039/343] recursively parse brackets on variable lookup --- lib/liquid.rb | 2 +- test/integration/variable_test.rb | 22 ++++++++++++++++++++++ test/test_helper.rb | 11 +++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 5eda04e74..09b0dd808 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -41,7 +41,7 @@ module Liquid AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om - VariableParser = /\[[^\]]+\]|#{VariableSegment}+\??/o + VariableParser = /\[(?:[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index c007f1f5e..192a3fe91 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -135,4 +135,26 @@ def test_dynamic_find_var def test_raw_value_variable assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end + + def test_dynamic_find_var_with_drop + assert_template_result( + 'bar', + '{{ [list[settings.zero]] }}', + { + 'list' => ['foo'], + 'settings' => SettingsDrop.new("zero" => 0), + 'foo' => 'bar', + } + ) + + assert_template_result( + 'foo', + '{{ [list[settings.zero]["foo"]] }}', + { + 'list' => [{ 'foo' => 'bar' }], + 'settings' => SettingsDrop.new("zero" => 0), + 'bar' => 'foo', + } + ) + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 49b1cc760..c1514bb35 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -125,6 +125,17 @@ def to_liquid end end +class SettingsDrop < Liquid::Drop + def initialize(settings) + super() + @settings = settings + end + + def liquid_method_missing(key) + @settings[key] + end +end + class IntegerDrop < Liquid::Drop def initialize(value) super() From dd257b3d663cde982ee98e0283bdcba5408c9a96 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 30 Jan 2023 20:36:14 -0400 Subject: [PATCH 040/343] add an unit test for double nest variable lookup --- test/integration/variable_test.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 192a3fe91..0cb8bddc1 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -157,4 +157,16 @@ def test_dynamic_find_var_with_drop } ) end + + def test_double_nested_variable_lookup + assert_template_result( + 'bar', + '{{ list[list[settings.zero]]["foo"] }}', + { + 'list' => [1, { 'foo' => 'bar' }], + 'settings' => SettingsDrop.new("zero" => 0), + 'bar' => 'foo', + } + ) + end end From 74e505f6fa366767cd998ccbeadea61c1b29a69c Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 1 Feb 2023 13:49:51 +0000 Subject: [PATCH 041/343] Allow raw tag to be used with whitespace control --- lib/liquid/tags/raw.rb | 2 +- test/integration/tags/raw_tag_test.rb | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb index 6ffb6149d..947398b00 100644 --- a/lib/liquid/tags/raw.rb +++ b/lib/liquid/tags/raw.rb @@ -14,7 +14,7 @@ module Liquid # @liquid_syntax_keyword expression The expression to be output without being rendered. class Raw < Block Syntax = /\A\s*\z/ - FullTokenPossiblyInvalid = /\A(.*)#{TagStart}\s*(\w+)\s*(.*)?#{TagEnd}\z/om + FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om def initialize(tag_name, markup, parse_context) super diff --git a/test/integration/tags/raw_tag_test.rb b/test/integration/tags/raw_tag_test.rb index 7ac0b09c1..1aa9bdd2a 100644 --- a/test/integration/tags/raw_tag_test.rb +++ b/test/integration/tags/raw_tag_test.rb @@ -12,6 +12,7 @@ def test_tag_in_raw def test_output_in_raw assert_template_result('{{ test }}', '{% raw %}{{ test }}{% endraw %}') + assert_template_result('{{ test }}', '{%- raw -%}{{ test }}{%- endraw -%}') end def test_open_tag_in_raw From ddc32b7bd8f28e709208245d5fd6a668792f8a48 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 1 Feb 2023 15:52:34 +0000 Subject: [PATCH 042/343] actually test whitespace stripping --- test/integration/tags/raw_tag_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/tags/raw_tag_test.rb b/test/integration/tags/raw_tag_test.rb index 1aa9bdd2a..6d395b5b5 100644 --- a/test/integration/tags/raw_tag_test.rb +++ b/test/integration/tags/raw_tag_test.rb @@ -11,8 +11,8 @@ def test_tag_in_raw end def test_output_in_raw - assert_template_result('{{ test }}', '{% raw %}{{ test }}{% endraw %}') - assert_template_result('{{ test }}', '{%- raw -%}{{ test }}{%- endraw -%}') + # assert_template_result('{{ test }}', '{% raw %}{{ test }}{% endraw %}') + assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <') end def test_open_tag_in_raw From 2b40850e4a9c1fe807982d1f9abda794880f6384 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 1 Feb 2023 19:52:30 -0400 Subject: [PATCH 043/343] fix variable lookup parse timing out with missing closing bracket --- lib/liquid.rb | 2 +- test/integration/variable_test.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 09b0dd808..eba84e140 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -41,7 +41,7 @@ module Liquid AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om - VariableParser = /\[(?:[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o + VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 0cb8bddc1..0667adb9a 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'timeout' class VariableTest < Minitest::Test include Liquid @@ -169,4 +170,30 @@ def test_double_nested_variable_lookup } ) end + + def test_variable_lookup_should_not_hang_with_invalid_syntax + Timeout.timeout(1) do + assert_template_result( + 'bar', + "{{['foo'}}", + { + 'foo' => 'bar', + }, + error_mode: :lax, + ) + end + + very_long_key = "1234567890" * 100 + + Timeout.timeout(1) do + assert_template_result( + 'bar', + "{{['#{very_long_key}'}}", + { + very_long_key => 'bar', + }, + error_mode: :lax, + ) + end + end end From bd9c3802c88473a227afeb3e2ff8a0ad6518e5a0 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 1 Feb 2023 21:15:35 -0400 Subject: [PATCH 044/343] add variable parser timeout unit tests --- test/integration/variable_test.rb | 26 +++++++++++++++++--------- test/unit/regexp_unit_test.rb | 13 +++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 0667adb9a..a66f1a0bd 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -185,15 +185,23 @@ def test_variable_lookup_should_not_hang_with_invalid_syntax very_long_key = "1234567890" * 100 - Timeout.timeout(1) do - assert_template_result( - 'bar', - "{{['#{very_long_key}'}}", - { - very_long_key => 'bar', - }, - error_mode: :lax, - ) + template_list = [ + "{{['#{very_long_key}']}}", # valid + "{{['#{very_long_key}'}}", # missing closing bracket + "{{[['#{very_long_key}']}}", # extra open bracket + ] + + template_list.each do |template| + Timeout.timeout(1) do + assert_template_result( + 'bar', + template, + { + very_long_key => 'bar', + }, + error_mode: :lax, + ) + end end end end diff --git a/test/unit/regexp_unit_test.rb b/test/unit/regexp_unit_test.rb index aad663c1b..877c95e45 100644 --- a/test/unit/regexp_unit_test.rb +++ b/test/unit/regexp_unit_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'timeout' class RegexpUnitTest < Minitest::Test include Liquid @@ -37,10 +38,22 @@ def test_quoted_words_in_the_middle def test_variable_parser assert_equal(['var'], 'var'.scan(VariableParser)) + assert_equal(['[var]'], '[var]'.scan(VariableParser)) assert_equal(['var', 'method'], 'var.method'.scan(VariableParser)) assert_equal(['var', '[method]'], 'var[method]'.scan(VariableParser)) assert_equal(['var', '[method]', '[0]'], 'var[method][0]'.scan(VariableParser)) assert_equal(['var', '["method"]', '[0]'], 'var["method"][0]'.scan(VariableParser)) assert_equal(['var', '[method]', '[0]', 'method'], 'var[method][0].method'.scan(VariableParser)) end + + def test_variable_parser_with_large_input + Timeout.timeout(1) { assert_equal(['[var]'], '[var]'.scan(VariableParser)) } + + very_long_string = "foo" * 1000 + + # valid dynamic lookup + Timeout.timeout(1) { assert_equal(["[#{very_long_string}]"], "[#{very_long_string}]".scan(VariableParser)) } + # invalid dynamic lookup with missing closing bracket + Timeout.timeout(1) { assert_equal([very_long_string], "[#{very_long_string}".scan(VariableParser)) } + end end # RegexpTest From 59c445f0e1c52e88ed7fecaeb58a67cbcbebd9f2 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Thu, 2 Feb 2023 09:31:01 -0500 Subject: [PATCH 045/343] Avoid a couple of duplicate assertions (#1675) --- test/integration/context_test.rb | 2 +- test/integration/tags/if_else_tag_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index 698aaa427..4bd15afcc 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -262,7 +262,7 @@ def test_access_hashes_with_hash_notation assigns = { 'product' => { 'variants' => [{ 'title' => 'draft151cm' }, { 'title' => 'element151cm' }] } } assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns) assert_template_result("element151cm", '{{ product["variants"][1]["title"] }}', assigns) - assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns) + assert_template_result("draft151cm", '{{ product["variants"].first["title"] }}', assigns) assert_template_result("element151cm", '{{ product["variants"].last["title"] }}', assigns) end diff --git a/test/integration/tags/if_else_tag_test.rb b/test/integration/tags/if_else_tag_test.rb index b550ea9e0..fcd57af87 100644 --- a/test/integration/tags/if_else_tag_test.rb +++ b/test/integration/tags/if_else_tag_test.rb @@ -62,7 +62,7 @@ def test_comparison_of_expressions_starting_with_and_or_or def test_if_and assert_template_result(' YES ', '{% if true and true %} YES {% endif %}') assert_template_result('', '{% if false and true %} YES {% endif %}') - assert_template_result('', '{% if false and true %} YES {% endif %}') + assert_template_result('', '{% if true and false %} YES {% endif %}') end def test_hash_miss_generates_false From 84a0289ebc06464bcfc01870626264134b511c14 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 14 Feb 2023 12:24:38 +0100 Subject: [PATCH 046/343] Fix CI (as it installs RuboCop 1.45.1 and fails) (#1686) --- Gemfile | 1 + performance/shopify/database.rb | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 5a12d54a3..29c25b903 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ group :benchmark, :test do end group :test do + gem 'rubocop', '~> 1.44.0' gem 'rubocop-shopify', '~> 2.7.0', require: false gem 'rubocop-performance', require: false diff --git a/performance/shopify/database.rb b/performance/shopify/database.rb index 675f3b343..893c4e9d5 100644 --- a/performance/shopify/database.rb +++ b/performance/shopify/database.rb @@ -49,8 +49,3 @@ def self.tables end end end - -if __FILE__ == $PROGRAM_NAME - p(Database.tables['collections']['frontpage'].keys) - # p Database.tables['blog']['articles'] -end From 940c3a4207f8c99ec5e1aaa9bcfdb60ef836de6f Mon Sep 17 00:00:00 2001 From: Erin Morrissey Date: Thu, 9 Feb 2023 14:17:49 -0800 Subject: [PATCH 047/343] update link paths in docs markup --- README.md | 2 +- lib/liquid/forloop_drop.rb | 2 +- lib/liquid/standardfilters.rb | 8 ++++---- lib/liquid/tablerowloop_drop.rb | 2 +- lib/liquid/tags/assign.rb | 2 +- lib/liquid/tags/break.rb | 2 +- lib/liquid/tags/continue.rb | 2 +- lib/liquid/tags/cycle.rb | 2 +- lib/liquid/tags/decrement.rb | 4 ++-- lib/liquid/tags/echo.rb | 4 ++-- lib/liquid/tags/for.rb | 4 ++-- lib/liquid/tags/include.rb | 4 ++-- lib/liquid/tags/increment.rb | 4 ++-- lib/liquid/tags/render.rb | 10 +++++----- lib/liquid/tags/table_row.rb | 2 +- lib/liquid/tags/unless.rb | 2 +- 16 files changed, 28 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 794e4ecaf..a8fd73c21 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ * [Contributing guidelines](CONTRIBUTING.md) * [Version history](History.md) -* [Liquid documentation from Shopify](https://shopify.dev/api/liquid) +* [Liquid documentation from Shopify](https://shopify.dev/docs/docs/api/liquid) * [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki) * [Website](http://liquidmarkup.org/) diff --git a/lib/liquid/forloop_drop.rb b/lib/liquid/forloop_drop.rb index 1dfa2f3ab..13681b2df 100644 --- a/lib/liquid/forloop_drop.rb +++ b/lib/liquid/forloop_drop.rb @@ -5,7 +5,7 @@ module Liquid # @liquid_type object # @liquid_name forloop # @liquid_summary - # Information about a parent [`for` loop](/api/liquid/tags/for). + # Information about a parent [`for` loop](/docs/api/liquid/tags/for). class ForloopDrop < Drop def initialize(name, length, parentloop) @name = name diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 7450a9384..50be86d71 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -615,7 +615,7 @@ def append(input, string) # @liquid_description # > Note: # > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the - # > [`uniq` filter](/api/liquid/filters/uniq). + # > [`uniq` filter](/docs/api/liquid/filters/uniq). # @liquid_syntax array | concat: array # @liquid_return [array[untyped]] def concat(input, array) @@ -857,9 +857,9 @@ def at_most(input, n) # @liquid_summary # Sets a default value for any variable whose value is one of the following: # - # - [`empty`](/api/liquid/basics#empty) - # - [`false`](/api/liquid/basics#truthy-and-falsy) - # - [`nil`](/api/liquid/basics#nil) + # - [`empty`](/docs/api/liquid/basics#empty) + # - [`false`](/docs/api/liquid/basics#truthy-and-falsy) + # - [`nil`](/docs/api/liquid/basics#nil) # @liquid_syntax variable | default: variable # @liquid_return [untyped] # @liquid_optional_param allow_false [boolean] Whether to use false values instead of the default. diff --git a/lib/liquid/tablerowloop_drop.rb b/lib/liquid/tablerowloop_drop.rb index 554a00ed6..730d623a4 100644 --- a/lib/liquid/tablerowloop_drop.rb +++ b/lib/liquid/tablerowloop_drop.rb @@ -5,7 +5,7 @@ module Liquid # @liquid_type object # @liquid_name tablerowloop # @liquid_summary - # Information about a parent [`tablerow` loop](/api/liquid/tags/tablerow). + # Information about a parent [`tablerow` loop](/docs/api/liquid/tags/tablerow). class TablerowloopDrop < Drop def initialize(length, cols) @length = length diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 7c0abd870..9eff9796e 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -8,7 +8,7 @@ module Liquid # @liquid_summary # Creates a new variable. # @liquid_description - # You can create variables of any [basic type](/api/liquid/basics#types), [object](/api/liquid/objects), or object property. + # You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property. # @liquid_syntax # {% assign variable_name = value %} # @liquid_syntax_keyword variable_name The name of the variable being created. diff --git a/lib/liquid/tags/break.rb b/lib/liquid/tags/break.rb index 70bc9bd75..c2293161d 100644 --- a/lib/liquid/tags/break.rb +++ b/lib/liquid/tags/break.rb @@ -15,7 +15,7 @@ module Liquid # @liquid_category iteration # @liquid_name break # @liquid_summary - # Stops a [`for` loop](/api/liquid/tags/for) from iterating. + # Stops a [`for` loop](/docs/api/liquid/tags/for) from iterating. # @liquid_syntax # {% break %} class Break < Tag diff --git a/lib/liquid/tags/continue.rb b/lib/liquid/tags/continue.rb index aea0a7270..875508384 100644 --- a/lib/liquid/tags/continue.rb +++ b/lib/liquid/tags/continue.rb @@ -6,7 +6,7 @@ module Liquid # @liquid_category iteration # @liquid_name continue # @liquid_summary - # Causes a [`for` loop](/api/liquid/tags/for) to skip to the next iteration. + # Causes a [`for` loop](/docs/api/liquid/tags/for) to skip to the next iteration. # @liquid_syntax # {% continue %} class Continue < Tag diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 19aee5acc..00695265d 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -6,7 +6,7 @@ module Liquid # @liquid_category iteration # @liquid_name cycle # @liquid_summary - # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/api/liquid/tags/for). + # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/docs/api/liquid/tags/for). # @liquid_description # The `cycle` tag must be used inside a `for` loop. # diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 3846e1673..8a99eb94f 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -12,8 +12,8 @@ module Liquid # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across # [snippets](/themes/architecture#snippets) included in the file. # - # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/api/liquid/tags/assign) - # and [`capture`](/api/liquid/tags/capture). However, `decrement` and [`increment`](/api/liquid/tags/increment) share + # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign) + # and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share # variables. # @liquid_syntax # {% decrement variable_name %} diff --git a/lib/liquid/tags/echo.rb b/lib/liquid/tags/echo.rb index 607b1379f..8abb6c2b2 100644 --- a/lib/liquid/tags/echo.rb +++ b/lib/liquid/tags/echo.rb @@ -9,10 +9,10 @@ module Liquid # Outputs an expression. # @liquid_description # Using the `echo` tag is the same as wrapping an expression in curly brackets (`{{` and `}}`). However, unlike the curly - # bracket method, you can use the `echo` tag inside [`liquid` tags](/api/liquid/tags/liquid). + # bracket method, you can use the `echo` tag inside [`liquid` tags](/docs/api/liquid/tags/liquid). # # > Tip: - # > You can use [filters](/api/liquid/filters) on expressions inside `echo` tags. + # > You can use [filters](/docs/api/liquid/filters) on expressions inside `echo` tags. # @liquid_syntax # {% liquid # echo expression diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index e02b65895..9205fa42f 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -9,10 +9,10 @@ module Liquid # Renders an expression for every item in an array. # @liquid_description # You can do a maximum of 50 iterations with a `for` loop. If you need to iterate over more than 50 items, then use the - # [`paginate` tag](/api/liquid/tags/paginate) to split the items over multiple pages. + # [`paginate` tag](/docs/api/liquid/tags/paginate) to split the items over multiple pages. # # > Tip: - # > Every `for` loop has an associated [`forloop` object](/api/liquid/objects/forloop) with information about the loop. + # > Every `for` loop has an associated [`forloop` object](/docs/api/liquid/objects/forloop) with information about the loop. # @liquid_syntax # {% for variable in array %} # expression diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 0664f5c38..f667b9629 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -8,7 +8,7 @@ module Liquid # @liquid_summary # Renders a [snippet](/themes/architecture#snippets). # @liquid_description - # Inside the snippet, you can access and alter variables that are [created](/api/liquid/tags/variable-tags) outside of the + # Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the # snippet. # @liquid_syntax # {% include 'filename' %} @@ -16,7 +16,7 @@ module Liquid # @liquid_deprecated # Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain. # - # The `include` tag has been replaced by [`render`](/api/liquid/tags/render). + # The `include` tag has been replaced by [`render`](/docs/api/liquid/tags/render). class Include < Tag prepend Tag::Disableable diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 67ca24f07..5f7e4f6fa 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -12,8 +12,8 @@ module Liquid # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across # [snippets](/themes/architecture#snippets) included in the file. # - # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/api/liquid/tags/assign) - # and [`capture`](/api/liquid/tags/capture). However, `increment` and [`decrement`](/api/liquid/tags/decrement) share + # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign) + # and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share # variables. # @liquid_syntax # {% increment variable_name %} diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index ed03353ac..967674c12 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -8,19 +8,19 @@ module Liquid # @liquid_summary # Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks). # @liquid_description - # Inside snippets and app blocks, you can't directly access variables that are [created](/api/liquid/tags/variable-tags) outside - # of the snippet or app block. However, you can [specify variables as parameters](/api/liquid/tags/render#render-passing-variables-to-a-snippet) + # Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside + # of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet) # to pass outside variables to snippets. # # While you can't directly access created variables, you can access global objects, as well as any objects that are # directly accessible outside the snippet or app block. For example, a snippet or app block inside the [product template](/themes/architecture/templates/product) - # can access the [`product` object](/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections) - # can access the [`section` object](/api/liquid/objects/section). + # can access the [`product` object](/docs/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections) + # can access the [`section` object](/docs/api/liquid/objects/section). # # Outside a snippet or app block, you can't access variables created inside the snippet or app block. # # > Note: - # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/api/liquid/tags/include) + # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/docs/api/liquid/tags/include) # > inside the snippet. # @liquid_syntax # {% render 'filename' %} diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index d52f268a9..be9a64c44 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -11,7 +11,7 @@ module Liquid # The `tablerow` tag must be wrapped in HTML `` and `
` tags. # # > Tip: - # > Every `tablerow` loop has an associated [`tablerowloop` object](/api/liquid/objects/tablerowloop) with information about the loop. + # > Every `tablerow` loop has an associated [`tablerowloop` object](/docs/api/liquid/objects/tablerowloop) with information about the loop. # @liquid_syntax # {% tablerow variable in array %} # expression diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb index 56b9228bb..74c39587a 100644 --- a/lib/liquid/tags/unless.rb +++ b/lib/liquid/tags/unless.rb @@ -11,7 +11,7 @@ module Liquid # Renders an expression unless a specific condition is `true`. # @liquid_description # > Tip: - # > Similar to the [`if` tag](/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag. + # > Similar to the [`if` tag](/docs/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag. # @liquid_syntax # {% unless condition %} # expression From 9ab688eada7f5b2245998cdff7c9bc06b63a422f Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 15 Feb 2023 08:40:59 +0100 Subject: [PATCH 048/343] Update `rubocop-shopify` (2.7.0 -> 2.12.0) (#1687) --- .rubocop.yml | 11 +- Gemfile | 2 +- example/server/example_servlet.rb | 14 +- lib/liquid/block.rb | 12 +- lib/liquid/condition.rb | 6 +- lib/liquid/context.rb | 2 +- lib/liquid/expression.rb | 5 +- lib/liquid/standardfilters.rb | 2 +- lib/liquid/tags/case.rb | 2 +- lib/liquid/tags/if.rb | 2 +- lib/liquid/tags/include.rb | 2 +- lib/liquid/tags/render.rb | 2 +- lib/liquid/tags/unless.rb | 4 +- performance/shopify/paginate.rb | 4 +- test/integration/assign_test.rb | 25 ++- test/integration/blank_test.rb | 27 ++- test/integration/context_test.rb | 56 +++-- test/integration/error_handling_test.rb | 18 +- test/integration/expression_test.rb | 4 +- test/integration/filter_kwarg_test.rb | 2 +- test/integration/filter_test.rb | 49 +++-- test/integration/standard_filter_test.rb | 20 +- test/integration/tags/for_tag_test.rb | 42 ++-- test/integration/tags/if_else_tag_test.rb | 24 ++- test/integration/tags/include_tag_test.rb | 196 ++++++++++++------ test/integration/tags/increment_tag_test.rb | 12 +- test/integration/tags/raw_tag_test.rb | 6 +- test/integration/tags/render_tag_test.rb | 154 +++++++++----- test/integration/tags/standard_tag_test.rb | 157 +++++++++----- test/integration/tags/table_row_test.rb | 90 +++++--- test/integration/tags/unless_else_tag_test.rb | 6 +- test/integration/template_test.rb | 10 +- test/integration/variable_test.rb | 6 +- test/unit/block_unit_test.rb | 6 +- test/unit/condition_unit_test.rb | 12 +- test/unit/parse_tree_visitor_test.rb | 72 +++---- test/unit/partial_cache_unit_test.rb | 40 ++-- test/unit/strainer_factory_unit_test.rb | 2 +- 38 files changed, 722 insertions(+), 384 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index fec517c55..f917cc52d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,6 +12,7 @@ Performance: AllCops: TargetRubyVersion: 2.7 NewCops: disable + SuggestExtensions: false Exclude: - 'vendor/bundle/**/*' @@ -19,6 +20,10 @@ Naming/MethodName: Exclude: - 'example/server/liquid_servlet.rb' -# Backport https://github.com/Shopify/ruby-style-guide/pull/258 -Layout/BeginEndAlignment: - Enabled: true +Style/ClassMethodsDefinitions: + Enabled: false + +# liquid filter calls were being mistaken to be calls on arrays +Style/ConcatArrayLiterals: + Exclude: + - 'test/integration/standard_filter_test.rb' diff --git a/Gemfile b/Gemfile index 29c25b903..31561a9e0 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,7 @@ end group :test do gem 'rubocop', '~> 1.44.0' - gem 'rubocop-shopify', '~> 2.7.0', require: false + gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false platform :mri, :truffleruby do diff --git a/example/server/example_servlet.rb b/example/server/example_servlet.rb index b09e3bb7e..d2d4fd6a7 100644 --- a/example/server/example_servlet.rb +++ b/example/server/example_servlet.rb @@ -30,14 +30,18 @@ def products private def products_list - [{ 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, - { 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' }, - { 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' }] + [ + { 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, + { 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' }, + { 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' } + ] end def more_products_list - [{ 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' }, - { 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' }] + [ + { 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' }, + { 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' } + ] end def description diff --git a/lib/liquid/block.rb b/lib/liquid/block.rb index dc22a4772..73d86c7bd 100644 --- a/lib/liquid/block.rb +++ b/lib/liquid/block.rb @@ -36,13 +36,17 @@ def unknown_tag(tag_name, _markup, _tokenizer) # @api private def self.raise_unknown_tag(tag, block_name, block_delimiter, parse_context) if tag == 'else' - raise SyntaxError, parse_context.locale.t("errors.syntax.unexpected_else", - block_name: block_name) + raise SyntaxError, parse_context.locale.t( + "errors.syntax.unexpected_else", + block_name: block_name, + ) elsif tag.start_with?('end') - raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_delimiter", + raise SyntaxError, parse_context.locale.t( + "errors.syntax.invalid_delimiter", tag: tag, block_name: block_name, - block_delimiter: block_delimiter) + block_delimiter: block_delimiter, + ) else raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag) end diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index 595f1713b..c1476dd4c 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -159,8 +159,10 @@ def deprecated_default_context class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ - @node.left, @node.right, - @node.child_condition, @node.attachment + @node.left, + @node.right, + @node.child_condition, + @node.attachment ].compact end end diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 36d3868b6..5b1a01ee1 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -144,7 +144,7 @@ def new_isolated_subcontext self.class.build( resource_limits: resource_limits, static_environments: static_environments, - registers: Registers.new(registers) + registers: Registers.new(registers), ).tap do |subcontext| subcontext.base_scope_depth = base_scope_depth + 1 subcontext.exception_renderer = exception_renderer diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index fcdd4d8ae..a1426732c 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -3,7 +3,10 @@ module Liquid class Expression LITERALS = { - nil => nil, 'nil' => nil, 'null' => nil, '' => nil, + nil => nil, + 'nil' => nil, + 'null' => nil, + '' => nil, 'true' => true, 'false' => false, 'blank' => '', diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 50be86d71..53662fef1 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -25,7 +25,7 @@ module StandardFilters STRIP_HTML_BLOCKS = Regexp.union( %r{}m, //m, - %r{}m + %r{}m, ) STRIP_HTML_TAGS = /<.*?>/m diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 910dd6eb5..4fe4cd98c 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -77,7 +77,7 @@ def render_to_output_buffer(context, output) end result = Liquid::Utils.to_liquid_value( - block.evaluate(context) + block.evaluate(context), ) if result diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index d15173f8d..a5cc84697 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -53,7 +53,7 @@ def unknown_tag(tag, markup, tokens) def render_to_output_buffer(context, output) @blocks.each do |block| result = Liquid::Utils.to_liquid_value( - block.evaluate(context) + block.evaluate(context), ) if result diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index f667b9629..5b3ad8e01 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -57,7 +57,7 @@ def render_to_output_buffer(context, output) partial = PartialCache.load( template_name, context: context, - parse_context: parse_context + parse_context: parse_context, ) context_variable_name = @alias_name || template_name.split('/').last diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 967674c12..0dc7215d0 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -69,7 +69,7 @@ def render_tag(context, output) partial = PartialCache.load( template_name, context: context, - parse_context: parse_context + parse_context: parse_context, ) context_variable_name = @alias_name || template_name.split('/').last diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb index 74c39587a..2fc79db56 100644 --- a/lib/liquid/tags/unless.rb +++ b/lib/liquid/tags/unless.rb @@ -23,7 +23,7 @@ def render_to_output_buffer(context, output) # First condition is interpreted backwards ( if not ) first_block = @blocks.first result = Liquid::Utils.to_liquid_value( - first_block.evaluate(context) + first_block.evaluate(context), ) unless result @@ -33,7 +33,7 @@ def render_to_output_buffer(context, output) # After the first condition unless works just like if @blocks[1..-1].each do |block| result = Liquid::Utils.to_liquid_value( - block.evaluate(context) + block.evaluate(context), ) if result diff --git a/performance/shopify/paginate.rb b/performance/shopify/paginate.rb index fb5033571..3154d1838 100644 --- a/performance/shopify/paginate.rb +++ b/performance/shopify/paginate.rb @@ -45,8 +45,8 @@ def render_to_output_buffer(context, output) pagination['items'] = collection_size pagination['pages'] = page_count - 1 - pagination['previous'] = link('« Previous', current_page - 1) unless 1 >= current_page - pagination['next'] = link('Next »', current_page + 1) unless page_count <= current_page + 1 + pagination['previous'] = link('« Previous', current_page - 1) if 1 < current_page + pagination['next'] = link('Next »', current_page + 1) if page_count > current_page + 1 pagination['parts'] = [] hellip_break = false diff --git a/test/integration/assign_test.rb b/test/integration/assign_test.rb index ba795fbbf..fdb6c99ca 100644 --- a/test/integration/assign_test.rb +++ b/test/integration/assign_test.rb @@ -14,19 +14,25 @@ def test_assign_with_hyphen_in_variable_name end def test_assigned_variable - assert_template_result('.foo.', + assert_template_result( + '.foo.', '{% assign foo = values %}.{{ foo[0] }}.', - { 'values' => %w(foo bar baz) }) + { 'values' => %w(foo bar baz) }, + ) - assert_template_result('.bar.', + assert_template_result( + '.bar.', '{% assign foo = values %}.{{ foo[1] }}.', - { 'values' => %w(foo bar baz) }) + { 'values' => %w(foo bar baz) }, + ) end def test_assign_with_filter - assert_template_result('.bar.', + assert_template_result( + '.bar.', '{% assign foo = values | split: "," %}.{{ foo[1] }}.', - { 'values' => "foo,bar,baz" }) + { 'values' => "foo,bar,baz" }, + ) end def test_assign_syntax_error @@ -34,8 +40,11 @@ def test_assign_syntax_error end def test_assign_uses_error_mode - assert_match_syntax_error("Expected dotdot but found pipe in ", - "{% assign foo = ('X' | downcase) %}", error_mode: :strict) + assert_match_syntax_error( + "Expected dotdot but found pipe in ", + "{% assign foo = ('X' | downcase) %}", + error_mode: :strict, + ) assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax) end diff --git a/test/integration/blank_test.rb b/test/integration/blank_test.rb index be6454503..9705d3b55 100644 --- a/test/integration/blank_test.rb +++ b/test/integration/blank_test.rb @@ -57,9 +57,11 @@ def test_captures_are_blank def test_nested_blocks_are_blank_but_only_if_all_children_are assert_template_result("", wrap(wrap(" "))) - assert_template_result("\n but this is not " * (N + 1), + assert_template_result( + "\n but this is not " * (N + 1), wrap('{% if true %} {% comment %} this is blank {% endcomment %} {% endif %} - {% if true %} but this is not {% endif %}')) + {% if true %} but this is not {% endif %}'), + ) end def test_assigns_are_blank @@ -89,12 +91,21 @@ def test_raw_is_not_blank end def test_include_is_blank - assert_template_result("foobar" * (N + 1), wrap("{% include 'foobar' %}"), - partials: { 'foobar' => 'foobar' }) - assert_template_result(" foobar " * (N + 1), wrap("{% include ' foobar ' %}"), - partials: { ' foobar ' => ' foobar ' }) - assert_template_result(" " * (N + 1), wrap(" {% include ' ' %} "), - partials: { ' ' => ' ' }) + assert_template_result( + "foobar" * (N + 1), + wrap("{% include 'foobar' %}"), + partials: { 'foobar' => 'foobar' }, + ) + assert_template_result( + " foobar " * (N + 1), + wrap("{% include ' foobar ' %}"), + partials: { ' foobar ' => ' foobar ' }, + ) + assert_template_result( + " " * (N + 1), + wrap(" {% include ' ' %} "), + partials: { ' ' => ' ' }, + ) end def test_case_is_blank diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index 4bd15afcc..a25362a67 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -121,14 +121,23 @@ def test_scoping end def test_length_query - assert_template_result("true", "{% if numbers.size == 4 %}true{% endif %}", - { "numbers" => [1, 2, 3, 4] }) + assert_template_result( + "true", + "{% if numbers.size == 4 %}true{% endif %}", + { "numbers" => [1, 2, 3, 4] }, + ) - assert_template_result("true", "{% if numbers.size == 4 %}true{% endif %}", - { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } }) + assert_template_result( + "true", + "{% if numbers.size == 4 %}true{% endif %}", + { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } }, + ) - assert_template_result("true", "{% if numbers.size == 1000 %}true{% endif %}", - { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } }) + assert_template_result( + "true", + "{% if numbers.size == 1000 %}true{% endif %}", + { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } }, + ) end def test_hyphenated_variable @@ -228,12 +237,14 @@ def test_recoursive_array_notation end def test_hash_to_array_transition - assigns = { 'colors' => { - 'Blue' => ['003366', '336699', '6699CC', '99CCFF'], - 'Green' => ['003300', '336633', '669966', '99CC99'], - 'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'], - 'Red' => ['660000', '993333', 'CC6666', 'FF9999'], - } } + assigns = { + 'colors' => { + 'Blue' => ['003366', '336699', '6699CC', '99CCFF'], + 'Green' => ['003300', '336633', '669966', '99CC99'], + 'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'], + 'Red' => ['660000', '993333', 'CC6666', 'FF9999'], + }, + } assert_template_result("003366", "{{ colors.Blue[0] }}", assigns) assert_template_result("FF9999", "{{ colors.Red[3] }}", assigns) @@ -410,10 +421,12 @@ def test_lambda_is_called_once def test_nested_lambda_is_called_once @global = 0 - @context['callcount'] = { "lambda" => proc { - @global += 1 - @global.to_s - } } + @context['callcount'] = { + "lambda" => proc { + @global += 1 + @global.to_s + }, + } assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda']) @@ -423,10 +436,11 @@ def test_nested_lambda_is_called_once def test_lambda_in_array_is_called_once @global = 0 - @context['callcount'] = [1, 2, proc { - @global += 1 - @global.to_s - }, 4, 5] + p = proc { + @global += 1 + @global.to_s + } + @context['callcount'] = [1, 2, p, 4, 5] assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]']) @@ -473,7 +487,7 @@ def test_apply_global_filter def test_static_environments_are_read_with_lower_priority_than_environments context = Context.build( static_environments: { 'shadowed' => 'static', 'unshadowed' => 'static' }, - environments: { 'shadowed' => 'dynamic' } + environments: { 'shadowed' => 'dynamic' }, ) assert_equal('dynamic', context['shadowed']) diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index b7ee624de..4affac236 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -105,7 +105,8 @@ def test_with_line_numbers_adds_numbers_to_parser_errors_with_whitespace_trim end def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors - template = Liquid::Template.parse(' + template = Liquid::Template.parse( + ' foobar {% if 1 =! 2 %}ok{% endif %} @@ -113,15 +114,19 @@ def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors bla ', error_mode: :warn, - line_numbers: true) + line_numbers: true, + ) - assert_equal(['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'], - template.warnings.map(&:message)) + assert_equal( + ['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'], + template.warnings.map(&:message), + ) end def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors err = assert_raises(SyntaxError) do - Liquid::Template.parse(' + Liquid::Template.parse( + ' foobar {% if 1 =! 2 %}ok{% endif %} @@ -129,7 +134,8 @@ def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors bla ', error_mode: :strict, - line_numbers: true) + line_numbers: true, + ) end assert_equal('Liquid syntax error (line 4): Unexpected character = in "1 =! 2"', err.message) diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 47734484d..80f6f4173 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -32,11 +32,11 @@ def test_range assert_match_syntax_error( "Liquid syntax error (line 1): Invalid expression type 'false' in range expression", - "{{ (false..true) }}" + "{{ (false..true) }}", ) assert_match_syntax_error( "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", - "{{ ((1..2)..3) }}" + "{{ ((1..2)..3) }}", ) end diff --git a/test/integration/filter_kwarg_test.rb b/test/integration/filter_kwarg_test.rb index 2bd3cbdfc..b41f6d679 100644 --- a/test/integration/filter_kwarg_test.rb +++ b/test/integration/filter_kwarg_test.rb @@ -17,7 +17,7 @@ def test_can_parse_data_kwargs with_global_filter(KwargFilter) do assert_equal( "data-src='src' data-widths='100, 200'", - Template.parse("{{ 'img' | html_tag: data-src: 'src', data-widths: '100, 200' }}").render(nil, nil) + Template.parse("{{ 'img' | html_tag: data-src: 'src', data-widths: '100, 200' }}").render(nil, nil), ) end end diff --git a/test/integration/filter_test.rb b/test/integration/filter_test.rb index 80d652d07..133b57b3b 100644 --- a/test/integration/filter_test.rb +++ b/test/integration/filter_test.rb @@ -68,22 +68,34 @@ def test_join def test_sort assert_template_result("1 2 3 4", "{{numbers | sort | join}}", { "numbers" => [2, 1, 4, 3] }) - assert_template_result("alphabetic as expected", "{{words | sort | join}}", - { "words" => ['expected', 'as', 'alphabetic'] }) + assert_template_result( + "alphabetic as expected", + "{{words | sort | join}}", + { "words" => ['expected', 'as', 'alphabetic'] }, + ) assert_template_result("3", "{{value | sort}}", { "value" => 3 }) assert_template_result('are flower', "{{arrays | sort | join}}", { 'arrays' => ['flower', 'are'] }) - assert_template_result("Expected case sensitive", "{{case_sensitive | sort | join}}", - { "case_sensitive" => ["sensitive", "Expected", "case"] }) + assert_template_result( + "Expected case sensitive", + "{{case_sensitive | sort | join}}", + { "case_sensitive" => ["sensitive", "Expected", "case"] }, + ) end def test_sort_natural # Test strings - assert_template_result("Assert case Insensitive", "{{words | sort_natural | join}}", - { "words" => ["case", "Assert", "Insensitive"] }) + assert_template_result( + "Assert case Insensitive", + "{{words | sort_natural | join}}", + { "words" => ["case", "Assert", "Insensitive"] }, + ) # Test hashes - assert_template_result("A b C", "{{hashes | sort_natural: 'a' | map: 'a' | join}}", - { "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] }) + assert_template_result( + "A b C", + "{{hashes | sort_natural: 'a' | map: 'a' | join}}", + { "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] }, + ) # Test objects @context['objects'] = [TestObject.new('A'), TestObject.new('b'), TestObject.new('C')] @@ -92,12 +104,18 @@ def test_sort_natural def test_compact # Test strings - assert_template_result("a b c", "{{words | compact | join}}", - { "words" => ['a', nil, 'b', nil, 'c'] }) + assert_template_result( + "a b c", + "{{words | compact | join}}", + { "words" => ['a', nil, 'b', nil, 'c'] }, + ) # Test hashes - assert_template_result("A C", "{{hashes | compact: 'a' | map: 'a' | join}}", - { "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] }) + assert_template_result( + "A C", + "{{hashes | compact: 'a' | map: 'a' | join}}", + { "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] }, + ) # Test objects @context['objects'] = [TestObject.new('A'), TestObject.new(nil), TestObject.new('C')] @@ -109,8 +127,11 @@ def test_strip_html end def test_strip_html_ignore_comments_with_html - assert_template_result("bla blub", "{{ var | strip_html }}", - { "var" => "bla blub" }) + assert_template_result( + "bla blub", + "{{ var | strip_html }}", + { "var" => "bla blub" }, + ) end def test_capitalize diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index b633024bd..bc8c6a4ae 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -188,7 +188,7 @@ def test_base64_decode def test_base64_url_safe_encode assert_equal( 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8', - @filters.base64_url_safe_encode('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|') + @filters.base64_url_safe_encode('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|'), ) assert_equal('', @filters.base64_url_safe_encode(nil)) end @@ -196,7 +196,7 @@ def test_base64_url_safe_encode def test_base64_url_safe_decode assert_equal( 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|', - @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8') + @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8'), ) exception = assert_raises(Liquid::ArgumentError) do @filters.base64_url_safe_decode("invalidbase64") @@ -230,7 +230,7 @@ def test_truncatewords assert_equal('one two three', @filters.truncatewords('one two three')) assert_equal( 'Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”...', - @filters.truncatewords('Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.', 15) + @filters.truncatewords('Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.', 15), ) assert_equal("测试测试测试测试", @filters.truncatewords('测试测试测试测试', 5)) assert_equal('one two1', @filters.truncatewords("one two three", 2, 1)) @@ -433,8 +433,11 @@ def test_legacy_reverse_hash def test_map assert_equal([1, 2, 3, 4], @filters.map([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], 'a')) - assert_template_result('abc', "{{ ary | map:'foo' | map:'bar' }}", - { 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] }) + assert_template_result( + 'abc', + "{{ ary | map:'foo' | map:'bar' }}", + { 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] }, + ) end def test_map_doesnt_call_arbitrary_stuff @@ -458,8 +461,11 @@ def test_map_calls_context= end def test_map_on_hashes - assert_template_result("4217", '{{ thing | map: "foo" | map: "bar" }}', - { "thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] } }) + assert_template_result( + "4217", + '{{ thing | map: "foo" | map: "bar" }}', + { "thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] } }, + ) end def test_legacy_map_on_hashes_with_dynamic_key diff --git a/test/integration/tags/for_tag_test.rb b/test/integration/tags/for_tag_test.rb index b36a5ba2f..5b81e1109 100644 --- a/test/integration/tags/for_tag_test.rb +++ b/test/integration/tags/for_tag_test.rb @@ -73,9 +73,11 @@ def test_for_with_variable def test_for_helpers assigns = { 'array' => [1, 2, 3] } - assert_template_result(' 1/3 2/3 3/3 ', + assert_template_result( + ' 1/3 2/3 3/3 ', '{%for item in array%} {{forloop.index}}/{{forloop.length}} {%endfor%}', - assigns) + assigns, + ) assert_template_result(' 1 2 3 ', '{%for item in array%} {{forloop.index}} {%endfor%}', assigns) assert_template_result(' 0 1 2 ', '{%for item in array%} {{forloop.index0}} {%endfor%}', assigns) assert_template_result(' 2 1 0 ', '{%for item in array%} {{forloop.rindex0}} {%endfor%}', assigns) @@ -86,9 +88,11 @@ def test_for_helpers def test_for_and_if assigns = { 'array' => [1, 2, 3] } - assert_template_result('+--', + assert_template_result( + '+--', '{%for item in array%}{% if forloop.first %}+{% else %}-{% endif %}{%endfor%}', - assigns) + assigns, + ) end def test_for_else @@ -325,15 +329,20 @@ def test_for_tag_string # ruby 1.9.3 no longer supports .each on String though we mimic # the functionality for backwards compatibility - assert_template_result('test string', + assert_template_result( + 'test string', '{%for val in string%}{{val}}{%endfor%}', - { 'string' => "test string" }) + { 'string' => "test string" }, + ) - assert_template_result('test string', + assert_template_result( + 'test string', '{%for val in string limit:1%}{{val}}{%endfor%}', - { 'string' => "test string" }) + { 'string' => "test string" }, + ) - assert_template_result('val-string-1-1-0-1-0-true-true-test string', + assert_template_result( + 'val-string-1-1-0-1-0-true-true-test string', '{%for val in string%}' \ '{{forloop.name}}-' \ '{{forloop.index}}-' \ @@ -344,23 +353,28 @@ def test_for_tag_string '{{forloop.first}}-' \ '{{forloop.last}}-' \ '{{val}}{%endfor%}', - { 'string' => "test string" }) + { 'string' => "test string" }, + ) end def test_for_parentloop_references_parent_loop - assert_template_result('1.1 1.2 1.3 2.1 2.2 2.3 ', + assert_template_result( + '1.1 1.2 1.3 2.1 2.2 2.3 ', '{% for inner in outer %}{% for k in inner %}' \ '{{ forloop.parentloop.index }}.{{ forloop.index }} ' \ '{% endfor %}{% endfor %}', - { 'outer' => [[1, 1, 1], [1, 1, 1]] }) + { 'outer' => [[1, 1, 1], [1, 1, 1]] }, + ) end def test_for_parentloop_nil_when_not_present - assert_template_result('.1 .2 ', + assert_template_result( + '.1 .2 ', '{% for inner in outer %}' \ '{{ forloop.parentloop.index }}.{{ forloop.index }} ' \ '{% endfor %}', - { 'outer' => [[1, 1, 1], [1, 1, 1]] }) + { 'outer' => [[1, 1, 1], [1, 1, 1]] }, + ) end def test_inner_for_over_empty_input diff --git a/test/integration/tags/if_else_tag_test.rb b/test/integration/tags/if_else_tag_test.rb index fcd57af87..3f04f2b8d 100644 --- a/test/integration/tags/if_else_tag_test.rb +++ b/test/integration/tags/if_else_tag_test.rb @@ -7,8 +7,10 @@ class IfElseTagTest < Minitest::Test def test_if assert_template_result(' ', ' {% if false %} this text should not go into the output {% endif %} ') - assert_template_result(' this text should go into the output ', - ' {% if true %} this text should go into the output {% endif %} ') + assert_template_result( + ' this text should go into the output ', + ' {% if true %} this text should go into the output {% endif %} ', + ) assert_template_result(' you rock ?', '{% if false %} you suck {% endif %} {% if true %} you rock {% endif %}?') end @@ -51,12 +53,16 @@ def test_comparison_of_strings_containing_and_or_or def test_comparison_of_expressions_starting_with_and_or_or assigns = { 'order' => { 'items_count' => 0 }, 'android' => { 'name' => 'Roy' } } - assert_template_result("YES", + assert_template_result( + "YES", "{% if android.name == 'Roy' %}YES{% endif %}", - assigns) - assert_template_result("YES", + assigns, + ) + assert_template_result( + "YES", "{% if order.items_count == 0 %}YES{% endif %}", - assigns) + assigns, + ) end def test_if_and @@ -155,8 +161,10 @@ def test_operators_are_ignored_unless_isolated original_op = Condition.operators['contains'] Condition.operators['contains'] = :[] - assert_template_result('yes', - %({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %})) + assert_template_result( + 'yes', + %({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}), + ) ensure Condition.operators['contains'] = original_op end diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 91f1aeabf..442ab3950 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -58,65 +58,88 @@ def teardown 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 })) + 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 ", + assert_template_result( + "Product: Draft 151cm ", "{% include 'product' with products[0] %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, - partials: { "product" => "Product: {{ product.title }} " }) + partials: { "product" => "Product: {{ product.title }} " }, + ) end def test_include_tag_with_alias - assert_template_result("Product: Draft 151cm ", + assert_template_result( + "Product: Draft 151cm ", "{% include 'product_alias' with products[0] as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, - partials: { "product_alias" => "Product: {{ product.title }} " }) + partials: { "product_alias" => "Product: {{ product.title }} " }, + ) end def test_include_tag_for_alias - assert_template_result("Product: Draft 151cm Product: Element 155cm ", + assert_template_result( + "Product: Draft 151cm Product: Element 155cm ", "{% include 'product_alias' for products as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, - partials: { "product_alias" => "Product: {{ product.title }} " }) + partials: { "product_alias" => "Product: {{ product.title }} " }, + ) end def test_include_tag_with_default_name - assert_template_result("Product: Draft 151cm ", - "{% include 'product' %}", { "product" => { 'title' => 'Draft 151cm' } }, - partials: { "product" => "Product: {{ product.title }} " }) + assert_template_result( + "Product: Draft 151cm ", + "{% include 'product' %}", + { "product" => { 'title' => 'Draft 151cm' } }, + partials: { "product" => "Product: {{ product.title }} " }, + ) end def test_include_tag_for - assert_template_result("Product: Draft 151cm Product: Element 155cm ", + assert_template_result( + "Product: Draft 151cm Product: Element 155cm ", "{% include 'product' for products %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, - partials: { "product" => "Product: {{ product.title }} " }) + partials: { "product" => "Product: {{ product.title }} " }, + ) end def test_include_tag_with_local_variables - assert_template_result("Locale: test123 ", "{% include 'locale_variables' echo1: 'test123' %}", - partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }) + assert_template_result( + "Locale: test123 ", + "{% include 'locale_variables' echo1: 'test123' %}", + partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }, + ) end def test_include_tag_with_multiple_local_variables - assert_template_result("Locale: test123 test321", + assert_template_result( + "Locale: test123 test321", "{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}", - partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }) + partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }, + ) end def test_include_tag_with_multiple_local_variables_from_context - assert_template_result("Locale: test123 test321", + assert_template_result( + "Locale: test123 test321", "{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}", { 'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321' } }, - partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }) + partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }, + ) end def test_included_templates_assigns_variables - assert_template_result("bar", "{% include 'assignments' %}{{ foo }}", - partials: { 'assignments' => "{% assign foo = 'bar' %}" }) + assert_template_result( + "bar", + "{% include 'assignments' %}{{ foo }}", + partials: { 'assignments' => "{% assign foo = 'bar' %}" }, + ) end def test_nested_include_tag @@ -137,13 +160,19 @@ def test_nested_include_with_variable "details" => "details", } - assert_template_result("Product: Draft 151cm details ", - "{% include 'nested_product_template' with product %}", { "product" => { "title" => 'Draft 151cm' } }, - partials: partials) + assert_template_result( + "Product: Draft 151cm details ", + "{% include 'nested_product_template' with product %}", + { "product" => { "title" => 'Draft 151cm' } }, + partials: partials, + ) - assert_template_result("Product: Draft 151cm details Product: Element 155cm details ", - "{% include 'nested_product_template' for products %}", { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] }, - partials: partials) + assert_template_result( + "Product: Draft 151cm details Product: Element 155cm details ", + "{% include 'nested_product_template' for products %}", + { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] }, + partials: partials, + ) end def test_recursively_included_template_does_not_produce_endless_loop @@ -161,46 +190,68 @@ def read_template_file(_template_path) end def test_dynamically_choosen_template - assert_template_result("Test123", "{% include template %}", { "template" => 'Test123' }, - partials: { "Test123" => "Test123" }) - - assert_template_result("Test321", "{% include template %}", { "template" => 'Test321' }, - partials: { "Test321" => "Test321" }) - - assert_template_result("Product: Draft 151cm ", "{% include template for product %}", + assert_template_result( + "Test123", + "{% include template %}", + { "template" => 'Test123' }, + partials: { "Test123" => "Test123" }, + ) + + assert_template_result( + "Test321", + "{% include template %}", + { "template" => 'Test321' }, + partials: { "Test321" => "Test321" }, + ) + + assert_template_result( + "Product: Draft 151cm ", + "{% include template for product %}", { "template" => 'product', 'product' => { 'title' => 'Draft 151cm' } }, - partials: { "product" => "Product: {{ product.title }} " }) + partials: { "product" => "Product: {{ product.title }} " }, + ) 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( + '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( + '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( + '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 %}", - partials: { "foo_if_true" => "foo_if_true" }) + assert_template_result( + "foo_if_true", + "{% if true %}{% include 'foo_if_true' %}{% endif %}", + partials: { "foo_if_true" => "foo_if_true" }, + ) 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!) + assert_equal( + "custom_foo", + Template.parse("{% include 'custom_foo' %}").render!, + ) ensure Liquid::Template.tags['include'] = original_tag end @@ -210,8 +261,10 @@ 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!) + 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 @@ -242,31 +295,49 @@ def test_passing_options_to_included_templates end def test_render_raise_argument_error_when_template_is_undefined - assert_template_result("Liquid error (line 1): Argument error in tag 'include' - Illegal template name", - "{% include undefined_variable %}", render_errors: true) + assert_template_result( + "Liquid error (line 1): Argument error in tag 'include' - Illegal template name", + "{% include undefined_variable %}", + render_errors: true, + ) - assert_template_result("Liquid error (line 1): Argument error in tag 'include' - Illegal template name", - "{% include nil %}", render_errors: true) + assert_template_result( + "Liquid error (line 1): Argument error in tag 'include' - Illegal template name", + "{% include nil %}", + render_errors: true, + ) end def test_render_raise_argument_error_when_template_is_not_a_string - assert_template_result("Liquid error (line 1): Argument error in tag 'include' - Illegal template name", - "{% include 123 %}", render_errors: true) + assert_template_result( + "Liquid error (line 1): Argument error in tag 'include' - Illegal template name", + "{% include 123 %}", + render_errors: true, + ) end def test_including_via_variable_value - assert_template_result("from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}", - partials: { "pick_a_source" => "from TestFileSystem" }) + assert_template_result( + "from TestFileSystem", + "{% assign page = 'pick_a_source' %}{% include page %}", + partials: { "pick_a_source" => "from TestFileSystem" }, + ) partials = { "product" => "Product: {{ product.title }} " } - assert_template_result("Product: Draft 151cm ", "{% assign page = 'product' %}{% include page %}", + assert_template_result( + "Product: Draft 151cm ", + "{% assign page = 'product' %}{% include page %}", { "product" => { 'title' => 'Draft 151cm' } }, - partials: partials) + partials: partials, + ) - assert_template_result("Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", + assert_template_result( + "Product: Draft 151cm ", + "{% assign page = 'product' %}{% include page for foo %}", { "foo" => { 'title' => 'Draft 151cm' } }, - partials: partials) + partials: partials, + ) end def test_including_with_strict_variables @@ -279,7 +350,10 @@ def test_including_with_strict_variables 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 %}", - partials: { 'break' => "{% break %}" }) + assert_template_result( + "1", + "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}", + partials: { 'break' => "{% break %}" }, + ) end end # IncludeTagTest diff --git a/test/integration/tags/increment_tag_test.rb b/test/integration/tags/increment_tag_test.rb index 666d2d56c..1ad94254e 100644 --- a/test/integration/tags/increment_tag_test.rb +++ b/test/integration/tags/increment_tag_test.rb @@ -8,19 +8,23 @@ class IncrementTagTest < Minitest::Test def test_inc assert_template_result('0 1', '{%increment port %} {{ port }}') assert_template_result(' 0 1 2', '{{port}} {%increment port %} {%increment port%} {{port}}') - assert_template_result('0 0 1 2 1', + assert_template_result( + '0 0 1 2 1', '{%increment port %} {%increment starboard%} ' \ '{%increment port %} {%increment port%} ' \ - '{%increment starboard %}') + '{%increment starboard %}', + ) end def test_dec assert_template_result('-1 -1', '{%decrement port %} {{ port }}', { 'port' => 10 }) assert_template_result(' -1 -2 -2', '{{port}} {%decrement port %} {%decrement port%} {{port}}') - assert_template_result('0 1 2 0 3 1 1 3', + assert_template_result( + '0 1 2 0 3 1 1 3', '{%increment starboard %} {%increment starboard%} {%increment starboard%} ' \ '{%increment port %} {%increment starboard%} ' \ '{%increment port %} {%decrement port%} ' \ - '{%decrement starboard %}') + '{%decrement starboard %}', + ) end end diff --git a/test/integration/tags/raw_tag_test.rb b/test/integration/tags/raw_tag_test.rb index 6d395b5b5..7e8181294 100644 --- a/test/integration/tags/raw_tag_test.rb +++ b/test/integration/tags/raw_tag_test.rb @@ -6,8 +6,10 @@ class RawTagTest < Minitest::Test include Liquid def test_tag_in_raw - assert_template_result('{% comment %} test {% endcomment %}', - '{% raw %}{% comment %} test {% endcomment %}{% endraw %}') + assert_template_result( + '{% comment %} test {% endcomment %}', + '{% raw %}{% comment %} test {% endcomment %}{% endraw %}', + ) end def test_output_in_raw diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 28dbb0de0..f9ffc2f33 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -6,52 +6,79 @@ class RenderTagTest < Minitest::Test include Liquid def test_render_with_no_arguments - assert_template_result('rendered content', '{% render "source" %}', - partials: { 'source' => 'rendered content' }) + assert_template_result( + 'rendered content', + '{% render "source" %}', + partials: { 'source' => 'rendered content' }, + ) end def test_render_tag_looks_for_file_system_in_registers_first - assert_template_result('from register file system', '{% render "pick_a_source" %}', - partials: { 'pick_a_source' => 'from register file system' }) + assert_template_result( + 'from register file system', + '{% render "pick_a_source" %}', + partials: { 'pick_a_source' => 'from register file system' }, + ) end def test_render_passes_named_arguments_into_inner_scope - 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' } }, - partials: { 'product' => '{{ inner_product.title }}' }) + partials: { 'product' => '{{ inner_product.title }}' }, + ) end def test_render_accepts_literals_as_arguments - assert_template_result('123', '{% render "snippet", price: 123 %}', - partials: { 'snippet' => '{{ price }}' }) + assert_template_result( + '123', + '{% render "snippet", price: 123 %}', + partials: { 'snippet' => '{{ price }}' }, + ) end def test_render_accepts_multiple_named_arguments - assert_template_result('1 2', '{% render "snippet", one: 1, two: 2 %}', - partials: { 'snippet' => '{{ one }} {{ two }}' }) + assert_template_result( + '1 2', + '{% render "snippet", one: 1, two: 2 %}', + partials: { 'snippet' => '{{ one }} {{ two }}' }, + ) end def test_render_does_not_inherit_parent_scope_variables - assert_template_result('', '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}', - partials: { 'snippet' => '{{ outer_variable }}' }) + assert_template_result( + '', + '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}', + partials: { 'snippet' => '{{ outer_variable }}' }, + ) end def test_render_does_not_inherit_variable_with_same_name_as_snippet - assert_template_result('', "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}", - partials: { 'snippet' => '{{ snippet }}' }) + assert_template_result( + '', + "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}", + partials: { 'snippet' => '{{ snippet }}' }, + ) end def test_render_does_not_mutate_parent_scope - assert_template_result('', "{% render 'snippet' %}{{ inner }}", - partials: { 'snippet' => '{% assign inner = 1 %}' }) + assert_template_result( + '', + "{% render 'snippet' %}{{ inner }}", + partials: { 'snippet' => '{% assign inner = 1 %}' }, + ) end def test_nested_render_tag - assert_template_result('one two', "{% render 'one' %}", + assert_template_result( + 'one two', + "{% render 'one' %}", partials: { 'one' => "one {% render 'two' %}", 'two' => 'two', - }) + }, + ) end def test_recursively_rendered_template_does_not_produce_endless_loop @@ -77,27 +104,36 @@ def test_dynamically_choosen_templates_are_not_allowed def test_include_tag_caches_second_read_of_same_partial file_system = StubFileSystem.new('snippet' => 'echo') - assert_equal('echoecho', + assert_equal( + 'echoecho', Template.parse('{% render "snippet" %}{% render "snippet" %}') - .render!({}, registers: { file_system: file_system })) + .render!({}, registers: { file_system: file_system }), + ) assert_equal(1, file_system.file_read_count) end def test_render_tag_doesnt_cache_partials_across_renders file_system = StubFileSystem.new('snippet' => 'my message') - assert_equal('my message', - Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system })) + 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( + 'my message', + Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }), + ) assert_equal(2, file_system.file_read_count) end def test_render_tag_within_if_statement - assert_template_result('my message', '{% if true %}{% render "snippet" %}{% endif %}', - partials: { 'snippet' => 'my message' }) + assert_template_result( + 'my message', + '{% if true %}{% render "snippet" %}{% endif %}', + partials: { 'snippet' => 'my message' }, + ) end def test_break_through_render @@ -107,13 +143,19 @@ def test_break_through_render end def test_increment_is_isolated_between_renders - assert_template_result('010', '{% increment %}{% increment %}{% render "incr" %}', - partials: { 'incr' => '{% increment %}' }) + assert_template_result( + '010', + '{% increment %}{% increment %}{% render "incr" %}', + partials: { 'incr' => '{% increment %}' }, + ) end def test_decrement_is_isolated_between_renders - assert_template_result('-1-2-1', '{% decrement %}{% decrement %}{% render "decr" %}', - partials: { 'decr' => '{% decrement %}' }) + assert_template_result( + '-1-2-1', + '{% decrement %}{% decrement %}{% render "decr" %}', + partials: { 'decr' => '{% decrement %}' }, + ) end def test_includes_will_not_render_inside_render_tag @@ -124,7 +166,7 @@ def test_includes_will_not_render_inside_render_tag partials: { 'foo' => 'bar', 'test_include' => '{% include "foo" %}', - } + }, ) end @@ -138,72 +180,88 @@ def test_includes_will_not_render_inside_nested_sibling_tags 'nested_render_with_sibling_include' => '{% render "test_include" %}{% include "foo" %}', 'test_include' => '{% include "foo" %}', }, - render_errors: true + render_errors: true, ) end def test_render_tag_with - assert_template_result("Product: Draft 151cm ", + assert_template_result( + "Product: Draft 151cm ", "{% render 'product' with products[0] %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", - }) + }, + ) end def test_render_tag_with_alias - assert_template_result("Product: Draft 151cm ", + assert_template_result( + "Product: Draft 151cm ", "{% render 'product_alias' with products[0] as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", - }) + }, + ) end def test_render_tag_for_alias - assert_template_result("Product: Draft 151cm Product: Element 155cm ", + assert_template_result( + "Product: Draft 151cm Product: Element 155cm ", "{% render 'product_alias' for products as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", - }) + }, + ) end def test_render_tag_for - assert_template_result("Product: Draft 151cm Product: Element 155cm ", + assert_template_result( + "Product: Draft 151cm Product: Element 155cm ", "{% render 'product' for products %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", - }) + }, + ) end def test_render_tag_forloop - assert_template_result("Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ", + assert_template_result( + "Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ", "{% render 'product' for products %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ", - }) + }, + ) end def test_render_tag_for_drop - assert_template_result("123", - "{% render 'loop' for loop as value %}", { "loop" => TestEnumerable.new }, + assert_template_result( + "123", + "{% render 'loop' for loop as value %}", + { "loop" => TestEnumerable.new }, partials: { 'loop' => "{{ value.foo }}", - }) + }, + ) end def test_render_tag_with_drop - assert_template_result("TestEnumerable", - "{% render 'loop' with loop as value %}", { "loop" => TestEnumerable.new }, + assert_template_result( + "TestEnumerable", + "{% render 'loop' with loop as value %}", + { "loop" => TestEnumerable.new }, partials: { 'loop' => "{{ value }}", - }) + }, + ) end end diff --git a/test/integration/tags/standard_tag_test.rb b/test/integration/tags/standard_tag_test.rb index 5ff425a4a..6c297d407 100644 --- a/test/integration/tags/standard_tag_test.rb +++ b/test/integration/tags/standard_tag_test.rb @@ -6,8 +6,10 @@ class StandardTagTest < Minitest::Test include Liquid def test_no_transform - assert_template_result('this text should come out of the template without change...', - 'this text should come out of the template without change...') + assert_template_result( + 'this text should come out of the template without change...', + 'this text should come out of the template without change...', + ) assert_template_result('blah', 'blah') assert_template_result('', '') @@ -20,8 +22,10 @@ def test_no_transform end def test_has_a_block_which_does_nothing - assert_template_result(%(the comment block should be removed .. right?), - %(the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?)) + assert_template_result( + %(the comment block should be removed .. right?), + %(the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?), + ) assert_template_result('', '{%comment%}{%endcomment%}') assert_template_result('', '{%comment%}{% endcomment %}') @@ -64,61 +68,81 @@ def test_assign_with_colon_and_spaces def test_capture assigns = { 'var' => 'content' } - assert_template_result('content foo content foo ', + assert_template_result( + 'content foo content foo ', '{{ var2 }}{% capture var2 %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', - assigns) + assigns, + ) end def test_capture_detects_bad_syntax assert_raises(SyntaxError) do - assert_template_result('content foo content foo ', + assert_template_result( + 'content foo content foo ', '{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', - { 'var' => 'content' }) + { 'var' => 'content' }, + ) end end def test_case assigns = { 'condition' => 2 } - assert_template_result(' its 2 ', + assert_template_result( + ' its 2 ', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', - assigns) + assigns, + ) assigns = { 'condition' => 1 } - assert_template_result(' its 1 ', + assert_template_result( + ' its 1 ', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', - assigns) + assigns, + ) assigns = { 'condition' => 3 } - assert_template_result('', + assert_template_result( + '', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', - assigns) + assigns, + ) assigns = { 'condition' => "string here" } - assert_template_result(' hit ', + assert_template_result( + ' hit ', '{% case condition %}{% when "string here" %} hit {% endcase %}', - assigns) + assigns, + ) assigns = { 'condition' => "bad string here" } - assert_template_result('', + assert_template_result( + '', '{% case condition %}{% when "string here" %} hit {% endcase %}',\ - assigns) + assigns, + ) end def test_case_with_else assigns = { 'condition' => 5 } - assert_template_result(' hit ', + assert_template_result( + ' hit ', '{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', - assigns) + assigns, + ) assigns = { 'condition' => 6 } - assert_template_result(' else ', + assert_template_result( + ' else ', '{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', - assigns) + assigns, + ) assigns = { 'condition' => 6 } - assert_template_result(' else ', + assert_template_result( + ' else ', '{% case condition %} {% when 5 %} hit {% else %} else {% endcase %}', - assigns) + assigns, + ) end def test_case_on_size @@ -131,47 +155,67 @@ def test_case_on_size end def test_case_on_size_with_else - assert_template_result('else', + assert_template_result( + 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', - { 'a' => [] }) + { 'a' => [] }, + ) - assert_template_result('1', + assert_template_result( + '1', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', - { 'a' => [1] }) + { 'a' => [1] }, + ) - assert_template_result('2', + assert_template_result( + '2', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', - { 'a' => [1, 1] }) + { 'a' => [1, 1] }, + ) - assert_template_result('else', + assert_template_result( + 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', - { 'a' => [1, 1, 1] }) + { 'a' => [1, 1, 1] }, + ) - assert_template_result('else', + assert_template_result( + 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', - { 'a' => [1, 1, 1, 1] }) + { 'a' => [1, 1, 1, 1] }, + ) - assert_template_result('else', + assert_template_result( + 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', - { 'a' => [1, 1, 1, 1, 1] }) + { 'a' => [1, 1, 1, 1, 1] }, + ) end def test_case_on_length_with_else - assert_template_result('else', + assert_template_result( + 'else', '{% case a.empty? %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', - {}) + {}, + ) - assert_template_result('false', + assert_template_result( + 'false', '{% case false %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', - {}) + {}, + ) - assert_template_result('true', + assert_template_result( + 'true', '{% case true %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', - {}) + {}, + ) - assert_template_result('else', + assert_template_result( + 'else', '{% case NULL %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', - {}) + {}, + ) end def test_assign_from_case @@ -253,24 +297,33 @@ def test_cycle assert_template_result('one two one', '{%cycle "one", "two"%} {%cycle "one", "two"%} {%cycle "one", "two"%}') - assert_template_result('text-align: left text-align: right', - '{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}') + assert_template_result( + 'text-align: left text-align: right', + '{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}', + ) end def test_multiple_cycles - assert_template_result('1 2 1 1 2 3 1', - '{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}') + assert_template_result( + '1 2 1 1 2 3 1', + '{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}', + ) end def test_multiple_named_cycles - assert_template_result('one one two two one one', - '{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}') + assert_template_result( + 'one one two two one one', + '{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}', + ) end def test_multiple_named_cycles_with_names_from_context assigns = { "var1" => 1, "var2" => 2 } - assert_template_result('one one two two one one', - '{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', assigns) + assert_template_result( + 'one one two two one one', + '{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', + assigns, + ) end def test_size_of_array diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 5a7d1db23..24fd53444 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -18,52 +18,70 @@ def each(&block) end def test_table_row - assert_template_result("\n 1 2 3 \n 4 5 6 \n", + assert_template_result( + "\n 1 2 3 \n 4 5 6 \n", '{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}', - { 'numbers' => [1, 2, 3, 4, 5, 6] }) + { 'numbers' => [1, 2, 3, 4, 5, 6] }, + ) - assert_template_result("\n\n", + assert_template_result( + "\n\n", '{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}', - { 'numbers' => [] }) + { 'numbers' => [] }, + ) end def test_table_row_with_different_cols - assert_template_result("\n 1 2 3 4 5 \n 6 \n", + assert_template_result( + "\n 1 2 3 4 5 \n 6 \n", '{% tablerow n in numbers cols:5%} {{n}} {% endtablerow %}', - { 'numbers' => [1, 2, 3, 4, 5, 6] }) + { 'numbers' => [1, 2, 3, 4, 5, 6] }, + ) end def test_table_col_counter - assert_template_result("\n12\n12\n12\n", + assert_template_result( + "\n12\n12\n12\n", '{% tablerow n in numbers cols:2%}{{tablerowloop.col}}{% endtablerow %}', - { 'numbers' => [1, 2, 3, 4, 5, 6] }) + { 'numbers' => [1, 2, 3, 4, 5, 6] }, + ) end def test_quoted_fragment - assert_template_result("\n 1 2 3 \n 4 5 6 \n", + assert_template_result( + "\n 1 2 3 \n 4 5 6 \n", "{% tablerow n in collections.frontpage cols:3%} {{n}} {% endtablerow %}", - { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } }) - assert_template_result("\n 1 2 3 \n 4 5 6 \n", + { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } }, + ) + assert_template_result( + "\n 1 2 3 \n 4 5 6 \n", "{% tablerow n in collections['frontpage'] cols:3%} {{n}} {% endtablerow %}", - { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } }) + { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } }, + ) end def test_enumerable_drop - assert_template_result("\n 1 2 3 \n 4 5 6 \n", + assert_template_result( + "\n 1 2 3 \n 4 5 6 \n", '{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}', - { 'numbers' => ArrayDrop.new([1, 2, 3, 4, 5, 6]) }) + { 'numbers' => ArrayDrop.new([1, 2, 3, 4, 5, 6]) }, + ) end def test_offset_and_limit - assert_template_result("\n 1 2 3 \n 4 5 6 \n", + assert_template_result( + "\n 1 2 3 \n 4 5 6 \n", '{% tablerow n in numbers cols:3 offset:1 limit:6%} {{n}} {% endtablerow %}', - { 'numbers' => [0, 1, 2, 3, 4, 5, 6, 7] }) + { 'numbers' => [0, 1, 2, 3, 4, 5, 6, 7] }, + ) end def test_blank_string_not_iterable - assert_template_result("\n\n", + assert_template_result( + "\n\n", "{% tablerow char in characters cols:3 %}I WILL NOT BE OUTPUT{% endtablerow %}", - { 'characters' => '' }) + { 'characters' => '' }, + ) end def test_cols_nil_constant_same_as_evaluated_nil_expression @@ -72,24 +90,32 @@ def test_cols_nil_constant_same_as_evaluated_nil_expression "false" \ "\n" - assert_template_result(expect, - "{% tablerow i in (1..2) cols:nil %}{{ tablerowloop.col_last }}{% endtablerow %}") + assert_template_result( + expect, + "{% tablerow i in (1..2) cols:nil %}{{ tablerowloop.col_last }}{% endtablerow %}", + ) - assert_template_result(expect, + assert_template_result( + expect, "{% tablerow i in (1..2) cols:var %}{{ tablerowloop.col_last }}{% endtablerow %}", - { "var" => nil }) + { "var" => nil }, + ) end def test_nil_limit_is_treated_as_zero expect = "\n" \ "\n" - assert_template_result(expect, - "{% tablerow i in (1..2) limit:nil %}{{ i }}{% endtablerow %}") + assert_template_result( + expect, + "{% tablerow i in (1..2) limit:nil %}{{ i }}{% endtablerow %}", + ) - assert_template_result(expect, + assert_template_result( + expect, "{% tablerow i in (1..2) limit:var %}{{ i }}{% endtablerow %}", - { "var" => nil }) + { "var" => nil }, + ) end def test_nil_offset_is_treated_as_zero @@ -98,12 +124,16 @@ def test_nil_offset_is_treated_as_zero "2:true" \ "\n" - assert_template_result(expect, - "{% tablerow i in (1..2) offset:nil %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}") + assert_template_result( + expect, + "{% tablerow i in (1..2) offset:nil %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}", + ) - assert_template_result(expect, + assert_template_result( + expect, "{% tablerow i in (1..2) offset:var %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}", - { "var" => nil }) + { "var" => nil }, + ) end def test_tablerow_loop_drop_attributes diff --git a/test/integration/tags/unless_else_tag_test.rb b/test/integration/tags/unless_else_tag_test.rb index 174fb7901..0bbdb2b9c 100644 --- a/test/integration/tags/unless_else_tag_test.rb +++ b/test/integration/tags/unless_else_tag_test.rb @@ -7,8 +7,10 @@ class UnlessElseTagTest < Minitest::Test def test_unless assert_template_result(' ', ' {% unless true %} this text should not go into the output {% endunless %} ') - assert_template_result(' this text should go into the output ', - ' {% unless false %} this text should go into the output {% endunless %} ') + assert_template_result( + ' this text should go into the output ', + ' {% unless false %} this text should go into the output {% endunless %} ', + ) assert_template_result(' you rock ?', '{% unless true %} you suck {% endunless %} {% unless false %} you rock {% endunless %}?') end diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index d3f635645..d22254892 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -93,10 +93,12 @@ def test_lambda_is_called_once_from_persistent_assigns_over_multiple_parses_and_ def test_lambda_is_called_once_from_custom_assigns_over_multiple_parses_and_renders t = Template.new - assigns = { 'number' => -> { - @global ||= 0 - @global += 1 - } } + assigns = { + 'number' => -> { + @global ||= 0 + @global += 1 + }, + } assert_equal('1', t.parse("{{number}}").render!(assigns)) assert_equal('1', t.parse("{{number}}").render!(assigns)) assert_equal('1', t.render!(assigns)) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index a66f1a0bd..42b38e33b 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -145,7 +145,7 @@ def test_dynamic_find_var_with_drop 'list' => ['foo'], 'settings' => SettingsDrop.new("zero" => 0), 'foo' => 'bar', - } + }, ) assert_template_result( @@ -155,7 +155,7 @@ def test_dynamic_find_var_with_drop 'list' => [{ 'foo' => 'bar' }], 'settings' => SettingsDrop.new("zero" => 0), 'bar' => 'foo', - } + }, ) end @@ -167,7 +167,7 @@ def test_double_nested_variable_lookup 'list' => [1, { 'foo' => 'bar' }], 'settings' => SettingsDrop.new("zero" => 0), 'bar' => 'foo', - } + }, ) end diff --git a/test/unit/block_unit_test.rb b/test/unit/block_unit_test.rb index 42093ef18..81b79d853 100644 --- a/test/unit/block_unit_test.rb +++ b/test/unit/block_unit_test.rb @@ -35,8 +35,10 @@ def test_variable_middle def test_variable_many_embedded_fragments template = Liquid::Template.parse(" {{funk}} {{so}} {{brother}} ") assert_equal(7, template.root.nodelist.size) - assert_equal([String, Variable, String, Variable, String, Variable, String], - block_types(template.root.nodelist)) + assert_equal( + [String, Variable, String, Variable, String, Variable, String], + block_types(template.root.nodelist), + ) end def test_with_block diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index 8fcc45103..d10cbb528 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -164,13 +164,17 @@ def test_default_context_is_deprecated private def assert_evaluates_true(left, op, right) - assert(Condition.new(left, op, right).evaluate(@context), - "Evaluated false: #{left} #{op} #{right}") + assert( + Condition.new(left, op, right).evaluate(@context), + "Evaluated false: #{left} #{op} #{right}", + ) end def assert_evaluates_false(left, op, right) - assert(!Condition.new(left, op, right).evaluate(@context), - "Evaluated true: #{left} #{op} #{right}") + assert( + !Condition.new(left, op, right).evaluate(@context), + "Evaluated true: #{left} #{op} #{right}", + ) end def assert_evaluates_argument_error(left, op, right) diff --git a/test/unit/parse_tree_visitor_test.rb b/test/unit/parse_tree_visitor_test.rb index 71a902e19..6d946f225 100644 --- a/test/unit/parse_tree_visitor_test.rb +++ b/test/unit/parse_tree_visitor_test.rb @@ -8,245 +8,245 @@ class ParseTreeVisitorTest < Minitest::Test def test_variable assert_equal( ["test"], - visit(%({{ test }})) + visit(%({{ test }})), ) end def test_varible_with_filter assert_equal( ["test", "infilter"], - visit(%({{ test | split: infilter }})) + visit(%({{ test | split: infilter }})), ) end def test_dynamic_variable assert_equal( ["test", "inlookup"], - visit(%({{ test[inlookup] }})) + visit(%({{ test[inlookup] }})), ) end def test_echo assert_equal( ["test"], - visit(%({% echo test %})) + visit(%({% echo test %})), ) end def test_if_condition assert_equal( ["test"], - visit(%({% if test %}{% endif %})) + visit(%({% if test %}{% endif %})), ) end def test_complex_if_condition assert_equal( ["test"], - visit(%({% if 1 == 1 and 2 == test %}{% endif %})) + visit(%({% if 1 == 1 and 2 == test %}{% endif %})), ) end def test_if_body assert_equal( ["test"], - visit(%({% if 1 == 1 %}{{ test }}{% endif %})) + visit(%({% if 1 == 1 %}{{ test }}{% endif %})), ) end def test_unless_condition assert_equal( ["test"], - visit(%({% unless test %}{% endunless %})) + visit(%({% unless test %}{% endunless %})), ) end def test_complex_unless_condition assert_equal( ["test"], - visit(%({% unless 1 == 1 and 2 == test %}{% endunless %})) + visit(%({% unless 1 == 1 and 2 == test %}{% endunless %})), ) end def test_unless_body assert_equal( ["test"], - visit(%({% unless 1 == 1 %}{{ test }}{% endunless %})) + visit(%({% unless 1 == 1 %}{{ test }}{% endunless %})), ) end def test_elsif_condition assert_equal( ["test"], - visit(%({% if 1 == 1 %}{% elsif test %}{% endif %})) + visit(%({% if 1 == 1 %}{% elsif test %}{% endif %})), ) end def test_complex_elsif_condition assert_equal( ["test"], - visit(%({% if 1 == 1 %}{% elsif 1 == 1 and 2 == test %}{% endif %})) + visit(%({% if 1 == 1 %}{% elsif 1 == 1 and 2 == test %}{% endif %})), ) end def test_elsif_body assert_equal( ["test"], - visit(%({% if 1 == 1 %}{% elsif 2 == 2 %}{{ test }}{% endif %})) + visit(%({% if 1 == 1 %}{% elsif 2 == 2 %}{{ test }}{% endif %})), ) end def test_else_body assert_equal( ["test"], - visit(%({% if 1 == 1 %}{% else %}{{ test }}{% endif %})) + visit(%({% if 1 == 1 %}{% else %}{{ test }}{% endif %})), ) end def test_case_left assert_equal( ["test"], - visit(%({% case test %}{% endcase %})) + visit(%({% case test %}{% endcase %})), ) end def test_case_condition assert_equal( ["test"], - visit(%({% case 1 %}{% when test %}{% endcase %})) + visit(%({% case 1 %}{% when test %}{% endcase %})), ) end def test_case_when_body assert_equal( ["test"], - visit(%({% case 1 %}{% when 2 %}{{ test }}{% endcase %})) + visit(%({% case 1 %}{% when 2 %}{{ test }}{% endcase %})), ) end def test_case_else_body assert_equal( ["test"], - visit(%({% case 1 %}{% else %}{{ test }}{% endcase %})) + visit(%({% case 1 %}{% else %}{{ test }}{% endcase %})), ) end def test_for_in assert_equal( ["test"], - visit(%({% for x in test %}{% endfor %})) + visit(%({% for x in test %}{% endfor %})), ) end def test_for_limit assert_equal( ["test"], - visit(%({% for x in (1..5) limit: test %}{% endfor %})) + visit(%({% for x in (1..5) limit: test %}{% endfor %})), ) end def test_for_offset assert_equal( ["test"], - visit(%({% for x in (1..5) offset: test %}{% endfor %})) + visit(%({% for x in (1..5) offset: test %}{% endfor %})), ) end def test_for_body assert_equal( ["test"], - visit(%({% for x in (1..5) %}{{ test }}{% endfor %})) + visit(%({% for x in (1..5) %}{{ test }}{% endfor %})), ) end def test_for_range assert_equal( ["test"], - visit(%({% for x in (1..test) %}{% endfor %})) + visit(%({% for x in (1..test) %}{% endfor %})), ) end def test_tablerow_in assert_equal( ["test"], - visit(%({% tablerow x in test %}{% endtablerow %})) + visit(%({% tablerow x in test %}{% endtablerow %})), ) end def test_tablerow_limit assert_equal( ["test"], - visit(%({% tablerow x in (1..5) limit: test %}{% endtablerow %})) + visit(%({% tablerow x in (1..5) limit: test %}{% endtablerow %})), ) end def test_tablerow_offset assert_equal( ["test"], - visit(%({% tablerow x in (1..5) offset: test %}{% endtablerow %})) + visit(%({% tablerow x in (1..5) offset: test %}{% endtablerow %})), ) end def test_tablerow_body assert_equal( ["test"], - visit(%({% tablerow x in (1..5) %}{{ test }}{% endtablerow %})) + visit(%({% tablerow x in (1..5) %}{{ test }}{% endtablerow %})), ) end def test_cycle assert_equal( ["test"], - visit(%({% cycle test %})) + visit(%({% cycle test %})), ) end def test_assign assert_equal( ["test"], - visit(%({% assign x = test %})) + visit(%({% assign x = test %})), ) end def test_capture assert_equal( ["test"], - visit(%({% capture x %}{{ test }}{% endcapture %})) + visit(%({% capture x %}{{ test }}{% endcapture %})), ) end def test_include assert_equal( ["test"], - visit(%({% include test %})) + visit(%({% include test %})), ) end def test_include_with assert_equal( ["test"], - visit(%({% include "hai" with test %})) + visit(%({% include "hai" with test %})), ) end def test_include_for assert_equal( ["test"], - visit(%({% include "hai" for test %})) + visit(%({% include "hai" for test %})), ) end def test_render_with assert_equal( ["test"], - visit(%({% render "hai" with test %})) + visit(%({% render "hai" with test %})), ) end def test_render_for assert_equal( ["test"], - visit(%({% render "hai" for test %})) + visit(%({% render "hai" for test %})), ) end @@ -257,7 +257,7 @@ def test_preserve_tree_structure ["test", []], ["xs", []], ]]], - traversal(%({% for x in xs offset: test %}{{ other }}{% endfor %})).visit + traversal(%({% for x in xs offset: test %}{{ other }}{% endfor %})).visit, ) end diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb index b0a2483bb..6bf8c5cdd 100644 --- a/test/unit/partial_cache_unit_test.rb +++ b/test/unit/partial_cache_unit_test.rb @@ -7,13 +7,13 @@ 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 + parse_context: Liquid::ParseContext.new, ) assert_equal('my partial body', partial.render) @@ -22,14 +22,14 @@ def test_uses_the_file_system_register_if_present 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 } + registers: { file_system: file_system }, ) 2.times do Liquid::PartialCache.load( 'my_partial', context: context, - parse_context: Liquid::ParseContext.new + parse_context: Liquid::ParseContext.new, ) end @@ -39,31 +39,31 @@ def test_reads_from_the_file_system_only_once_per_file def test_cache_state_is_stored_per_context parse_context = Liquid::ParseContext.new shared_file_system = StubFileSystem.new( - 'my_partial' => 'my shared value' + '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 + parse_context: parse_context, ) end Liquid::PartialCache.load( 'my_partial', context: context_two, - parse_context: parse_context + parse_context: parse_context, ) assert_equal(2, shared_file_system.file_read_count) @@ -72,18 +72,18 @@ def test_cache_state_is_stored_per_context 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 } + registers: { file_system: file_system }, ) Liquid::PartialCache.load( 'my_partial', context: context, - parse_context: Liquid::ParseContext.new(my_key: 'value one') + 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') + parse_context: Liquid::ParseContext.new(my_key: 'value two'), ) # Technically what we care about is that the file was parsed twice, @@ -95,13 +95,13 @@ def test_uses_default_template_factory_when_no_template_factory_found_in_registe 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 + parse_context: Liquid::ParseContext.new, ) assert_equal('my partial body', partial.render) @@ -113,13 +113,13 @@ def test_uses_template_factory_register_if_present registers: { file_system: StubFileSystem.new('my_partial' => 'my partial body'), template_factory: template_factory, - } + }, ) partial = Liquid::PartialCache.load( 'my_partial', context: context, - parse_context: Liquid::ParseContext.new + parse_context: Liquid::ParseContext.new, ) assert_equal('my partial body', partial.render) @@ -129,12 +129,12 @@ def test_uses_template_factory_register_if_present def test_cache_state_is_shared_for_subcontexts parse_context = Liquid::ParseContext.new shared_file_system = StubFileSystem.new( - 'my_partial' => 'my shared value' + 'my_partial' => 'my shared value', ) context = Liquid::Context.build( registers: Liquid::Registers.new( file_system: shared_file_system, - ) + ), ) subcontext = context.new_isolated_subcontext @@ -144,13 +144,13 @@ def test_cache_state_is_shared_for_subcontexts Liquid::PartialCache.load( 'my_partial', context: context, - parse_context: parse_context + parse_context: parse_context, ) Liquid::PartialCache.load( 'my_partial', context: subcontext, - parse_context: parse_context + parse_context: parse_context, ) end diff --git a/test/unit/strainer_factory_unit_test.rb b/test/unit/strainer_factory_unit_test.rb index 43eafc1d4..c78d422b6 100644 --- a/test/unit/strainer_factory_unit_test.rb +++ b/test/unit/strainer_factory_unit_test.rb @@ -50,7 +50,7 @@ def test_stainer_argument_error_contains_backtrace assert_match( /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, - exception.message + exception.message, ) source = AccessScopeFilters.instance_method(:public_filter).source_location assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1]) From a75517e2c7450b45284bbb942a31380d43fb6bd0 Mon Sep 17 00:00:00 2001 From: Robertas Godelis Date: Wed, 15 Feb 2023 18:27:36 +0200 Subject: [PATCH 049/343] Fix broken documentantion link in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8fd73c21..39e501937 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ * [Contributing guidelines](CONTRIBUTING.md) * [Version history](History.md) -* [Liquid documentation from Shopify](https://shopify.dev/docs/docs/api/liquid) +* [Liquid documentation from Shopify](https://shopify.dev/docs/api/liquid) * [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki) * [Website](http://liquidmarkup.org/) @@ -111,4 +111,4 @@ template.render!({ 'x' => 1}, { strict_variables: true }) To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you. -Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns. \ No newline at end of file +Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns. From e650dc419521c402df5ddefdb2d3ac10e0159ac9 Mon Sep 17 00:00:00 2001 From: Dylan Thacker-Smith Date: Wed, 22 Feb 2023 12:48:18 -0500 Subject: [PATCH 050/343] Revert "Instrument usage of bug with iteration of String with offset or 0 limit (#1667)" (#1677) This reverts commit c743936a78df5e658afa4fb9035bc92ffecd532c. --- lib/liquid/utils.rb | 6 +----- test/unit/tags/for_tag_unit_test.rb | 30 ----------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 4ec7d8117..38a406ef2 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -16,11 +16,7 @@ def self.slice_collection_using_each(collection, from, to) # Maintains Ruby 1.8.7 String#each behaviour on 1.9 if collection.is_a?(String) - return [] if collection.empty? - if from > 0 || to == 0 - Usage.increment("string_slice_bug") - end - return [collection] + return collection.empty? ? [] : [collection] end return [] unless collection.respond_to?(:each) diff --git a/test/unit/tags/for_tag_unit_test.rb b/test/unit/tags/for_tag_unit_test.rb index cbb68fcb0..5a52c71c7 100644 --- a/test/unit/tags/for_tag_unit_test.rb +++ b/test/unit/tags/for_tag_unit_test.rb @@ -12,34 +12,4 @@ def test_for_else_nodelist template = Liquid::Template.parse('{% for item in items %}FOR{% else %}ELSE{% endfor %}') assert_equal(['FOR', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end - - def test_for_string_slice_bug_usage - template = Liquid::Template.parse("{% for x in str, offset: 1 %}{{ x }},{% endfor %}") - assert_usage("string_slice_bug") do - assert_equal("abc,", template.render({ "str" => "abc" })) - end - end - - def test_for_string_0_limit_usage - template = Liquid::Template.parse("{% for x in str, limit: 0 %}{{ x }},{% endfor %}") - assert_usage("string_slice_bug") do - assert_equal("abc,", template.render({ "str" => "abc" })) - end - end - - def test_for_string_no_slice_usage - template = Liquid::Template.parse("{% for x in str, offset: 0, limit: 1 %}{{ x }},{% endfor %}") - assert_usage("string_slice_bug", times: 0) do - assert_equal("abc,", template.render({ "str" => "abc" })) - end - end - - private - - def assert_usage(name, times: 1, &block) - count = 0 - result = Liquid::Usage.stub(:increment, ->(n) { count += 1 if n == name }, &block) - assert_equal(times, count) - result - end end From 0fe4a5d1445cbcb1b4d043f99c37e3ba9f27040a Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 27 Feb 2023 10:54:09 -0400 Subject: [PATCH 051/343] render error message with actual template path --- lib/liquid/tags/include.rb | 10 ++++++++-- lib/liquid/tags/render.rb | 8 +++++++- test/integration/tags/include_tag_test.rb | 13 +++++++++++++ test/integration/tags/render_tag_test.rb | 13 +++++++++++++ test/test_helper.rb | 22 ++++++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 5b3ad8e01..d451a6360 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -70,9 +70,15 @@ def render_to_output_buffer(context, output) old_template_name = context.template_name old_partial = context.partial + begin - context.template_name = template_name - context.partial = true + context.template_name = if Template.file_system.respond_to?(:actual_template_name) + Template.file_system.actual_template_name(template_name).delete_suffix('.liquid') + else + template_name + end + + context.partial = true context.stack do @attributes.each do |key, value| context[key] = context.evaluate(value) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 0dc7215d0..a29c36229 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,7 +76,13 @@ def render_tag(context, output) render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext - inner_context.template_name = template_name + + inner_context.template_name = if Template.file_system.respond_to?(:actual_template_name) + Template.file_system.actual_template_name(template_name).delete_suffix('.liquid') + else + template_name + end + inner_context.partial = true inner_context['forloop'] = forloop if forloop diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 442ab3950..245acf0f8 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -356,4 +356,17 @@ def test_break_through_include partials: { 'break' => "{% break %}" }, ) end + + def test_include_tag_renders_actual_template_name_for_error + original_file_system = Liquid::Template.file_system + + Liquid::Template.file_system = MemoryFileSystem.new( + '/some/path/snippets/foo.liquid' => "{{ foo.standard_error }}", + ) + + template = Liquid::Template.parse("{% include 'foo' with errors %}", line_numbers: true) + assert_equal('Liquid error (/some/path/snippets/foo line 1): standard error', template.render('errors' => ErrorDrop.new)) + ensure + Liquid::Template.file_system = original_file_system + end end # IncludeTagTest diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index f9ffc2f33..c26d22581 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -264,4 +264,17 @@ def test_render_tag_with_drop }, ) end + + def test_render_tag_renders_actual_template_name_for_error + original_file_system = Liquid::Template.file_system + + Liquid::Template.file_system = MemoryFileSystem.new( + '/some/path/snippets/foo.liquid' => "{{ foo.standard_error }}", + ) + + template = Liquid::Template.parse("{% render 'foo' with errors %}", line_numbers: true) + assert_equal('Liquid error (/some/path/snippets/foo line 1): standard error', template.render('errors' => ErrorDrop.new)) + ensure + Liquid::Template.file_system = original_file_system + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index c1514bb35..ed55ec409 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -214,3 +214,25 @@ def for(_template_name) Liquid::Template.new end end + +class MemoryFileSystem + def initialize(values) + # values is a hash of template_path => template_source + @snippets = {} + values.each do |file_path, source| + key = file_path.split('/').last.delete_suffix(".liquid") + @snippets[key] = { + source: source, + actual_template_name: file_path, + } + end + end + + def read_template_file(template_name) + @snippets[template_name][:source] + end + + def actual_template_name(template_name) + @snippets[template_name][:actual_template_name] + end +end From 428c66ffacb8836f448196e5b397d98aa52a1bd1 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 27 Feb 2023 16:12:40 -0400 Subject: [PATCH 052/343] add name attribute to Template for more flexibility to set context's template name --- lib/liquid/partial_cache.rb | 7 +++++++ lib/liquid/tags/include.rb | 6 +----- lib/liquid/tags/render.rb | 6 +----- lib/liquid/template.rb | 2 +- test/integration/tags/render_tag_test.rb | 6 ++++-- test/test_helper.rb | 2 +- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/liquid/partial_cache.rb b/lib/liquid/partial_cache.rb index 56e108050..7f94f76bb 100644 --- a/lib/liquid/partial_cache.rb +++ b/lib/liquid/partial_cache.rb @@ -16,6 +16,13 @@ def self.load(template_name, context:, parse_context:) template = template_factory.for(template_name) partial = template.parse(source, parse_context) + + partial.name ||= if context.registers[:file_system]&.respond_to?(:actual_template_name) + context.registers[:file_system].actual_template_name(template_name) + else + template_name + end + cached_partials[template_name] = partial ensure parse_context.partial = false diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index d451a6360..98f64fdbc 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -72,11 +72,7 @@ def render_to_output_buffer(context, output) old_partial = context.partial begin - context.template_name = if Template.file_system.respond_to?(:actual_template_name) - Template.file_system.actual_template_name(template_name).delete_suffix('.liquid') - else - template_name - end + context.template_name = partial.name if partial.name context.partial = true context.stack do diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index a29c36229..4f2dfafa5 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -77,11 +77,7 @@ def render_tag(context, output) render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext - inner_context.template_name = if Template.file_system.respond_to?(:actual_template_name) - Template.file_system.actual_template_name(template_name).delete_suffix('.liquid') - else - template_name - end + inner_context.template_name = partial.name if partial.name inner_context.partial = true inner_context['forloop'] = forloop if forloop diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 39bde8338..e20ec1e0f 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -15,7 +15,7 @@ module Liquid # template.render('user_name' => 'bob') # class Template - attr_accessor :root + attr_accessor :root, :name attr_reader :resource_limits, :warnings class TagRegistry diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index c26d22581..f24a5ef96 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -268,12 +268,14 @@ def test_render_tag_with_drop def test_render_tag_renders_actual_template_name_for_error original_file_system = Liquid::Template.file_system - Liquid::Template.file_system = MemoryFileSystem.new( + context = Liquid::Context.new('errors' => ErrorDrop.new) + + context.registers[:file_system] = MemoryFileSystem.new( '/some/path/snippets/foo.liquid' => "{{ foo.standard_error }}", ) template = Liquid::Template.parse("{% render 'foo' with errors %}", line_numbers: true) - assert_equal('Liquid error (/some/path/snippets/foo line 1): standard error', template.render('errors' => ErrorDrop.new)) + assert_equal('Liquid error (/some/path/snippets/foo line 1): standard error', template.render(context)) ensure Liquid::Template.file_system = original_file_system end diff --git a/test/test_helper.rb b/test/test_helper.rb index ed55ec409..840b888a8 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -233,6 +233,6 @@ def read_template_file(template_name) end def actual_template_name(template_name) - @snippets[template_name][:actual_template_name] + @snippets[template_name][:actual_template_name].delete_suffix(".liquid") end end From 24dceef552179f2efb0acefcfa3e03203614e7c5 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 27 Feb 2023 20:48:41 -0400 Subject: [PATCH 053/343] set context's template_name with template's name --- lib/liquid/partial_cache.rb | 7 +---- lib/liquid/tags/include.rb | 4 +-- lib/liquid/tags/render.rb | 4 +-- test/integration/tags/include_tag_test.rb | 26 +++++++++++------- test/integration/tags/render_tag_test.rb | 28 +++++++++++-------- test/test_helper.rb | 33 +++++------------------ test/unit/partial_cache_unit_test.rb | 18 +++++++++++++ 7 files changed, 63 insertions(+), 57 deletions(-) diff --git a/lib/liquid/partial_cache.rb b/lib/liquid/partial_cache.rb index 7f94f76bb..1ef527887 100644 --- a/lib/liquid/partial_cache.rb +++ b/lib/liquid/partial_cache.rb @@ -16,12 +16,7 @@ def self.load(template_name, context:, parse_context:) template = template_factory.for(template_name) partial = template.parse(source, parse_context) - - partial.name ||= if context.registers[:file_system]&.respond_to?(:actual_template_name) - context.registers[:file_system].actual_template_name(template_name) - else - template_name - end + partial.name ||= template_name cached_partials[template_name] = partial ensure diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 98f64fdbc..7b9685eeb 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -72,9 +72,9 @@ def render_to_output_buffer(context, output) old_partial = context.partial begin - context.template_name = partial.name if partial.name - + context.template_name = partial.name context.partial = true + context.stack do @attributes.each do |key, value| context[key] = context.evaluate(value) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 4f2dfafa5..b9ae58ea0 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,9 +76,7 @@ def render_tag(context, output) render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext - - inner_context.template_name = partial.name if partial.name - + inner_context.template_name = partial.name inner_context.partial = true inner_context['forloop'] = forloop if forloop diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 245acf0f8..b86d40eee 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -357,16 +357,24 @@ def test_break_through_include ) end - def test_include_tag_renders_actual_template_name_for_error - original_file_system = Liquid::Template.file_system - - Liquid::Template.file_system = MemoryFileSystem.new( - '/some/path/snippets/foo.liquid' => "{{ foo.standard_error }}", + def test_render_tag_renders_error_with_template_name + assert_template_result( + 'Liquid error (foo line 1): standard error', + "{% include 'foo' with errors %}", + { 'errors' => ErrorDrop.new }, + partials: { 'foo' => '{{ foo.standard_error }}' }, + render_errors: true, ) + end - template = Liquid::Template.parse("{% include 'foo' with errors %}", line_numbers: true) - assert_equal('Liquid error (/some/path/snippets/foo line 1): standard error', template.render('errors' => ErrorDrop.new)) - ensure - Liquid::Template.file_system = original_file_system + def test_render_tag_renders_error_with_template_name_from_template_factory + assert_template_result( + 'Liquid error (some/path/foo line 1): standard error', + "{% include 'foo' with errors %}", + { 'errors' => ErrorDrop.new }, + partials: { 'foo' => '{{ foo.standard_error }}' }, + template_factory: StubTemplateFactory.new, + render_errors: true, + ) end end # IncludeTagTest diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index f24a5ef96..01485cfa2 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -265,18 +265,24 @@ def test_render_tag_with_drop ) end - def test_render_tag_renders_actual_template_name_for_error - original_file_system = Liquid::Template.file_system - - context = Liquid::Context.new('errors' => ErrorDrop.new) - - context.registers[:file_system] = MemoryFileSystem.new( - '/some/path/snippets/foo.liquid' => "{{ foo.standard_error }}", + def test_render_tag_renders_error_with_template_name + assert_template_result( + 'Liquid error (foo line 1): standard error', + "{% render 'foo' with errors %}", + { 'errors' => ErrorDrop.new }, + partials: { 'foo' => '{{ foo.standard_error }}' }, + render_errors: true, ) + end - template = Liquid::Template.parse("{% render 'foo' with errors %}", line_numbers: true) - assert_equal('Liquid error (/some/path/snippets/foo line 1): standard error', template.render(context)) - ensure - Liquid::Template.file_system = original_file_system + def test_render_tag_renders_error_with_template_name_from_template_factory + assert_template_result( + 'Liquid error (some/path/foo line 1): standard error', + "{% render 'foo' with errors %}", + { 'errors' => ErrorDrop.new }, + partials: { 'foo' => '{{ foo.standard_error }}' }, + template_factory: StubTemplateFactory.new, + render_errors: true, + ) end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 840b888a8..34d85aeb5 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -39,11 +39,12 @@ module Assertions def assert_template_result( expected, template, assigns = {}, - message: nil, partials: nil, error_mode: nil, render_errors: false + message: nil, partials: nil, error_mode: nil, render_errors: false, + template_factory: nil ) template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym) file_system = StubFileSystem.new(partials || {}) - registers = Liquid::Registers.new(file_system: file_system) + registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory) context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers) output = template.render(context) assert_equal(expected, output, message) @@ -209,30 +210,10 @@ def initialize @count = 0 end - def for(_template_name) + def for(template_name) @count += 1 - Liquid::Template.new - end -end - -class MemoryFileSystem - def initialize(values) - # values is a hash of template_path => template_source - @snippets = {} - values.each do |file_path, source| - key = file_path.split('/').last.delete_suffix(".liquid") - @snippets[key] = { - source: source, - actual_template_name: file_path, - } - end - end - - def read_template_file(template_name) - @snippets[template_name][:source] - end - - def actual_template_name(template_name) - @snippets[template_name][:actual_template_name].delete_suffix(".liquid") + template = Liquid::Template.new + template.name = "some/path/" + template_name + template end end diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb index 6bf8c5cdd..6a20efbb6 100644 --- a/test/unit/partial_cache_unit_test.rb +++ b/test/unit/partial_cache_unit_test.rb @@ -156,4 +156,22 @@ def test_cache_state_is_shared_for_subcontexts assert_equal(1, shared_file_system.file_read_count) end + + def test_uses_template_name_from_template_factory + template_factory = StubTemplateFactory.new + context = Liquid::Context.build( + registers: { + file_system: StubFileSystem.new('my_partial' => 'my partial body'), + template_factory: template_factory, + }, + ) + + partial = Liquid::PartialCache.load( + 'my_partial', + context: context, + parse_context: Liquid::ParseContext.new, + ) + + assert_equal('some/path/my_partial', partial.name) + end end From 1d97389fb051e7c4c6d67c33392f6f319333e56d Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 2 Mar 2023 15:26:20 -0400 Subject: [PATCH 054/343] render template name with Liquid Syntax errors --- lib/liquid/partial_cache.rb | 8 ++- lib/liquid/template.rb | 2 + test/integration/error_handling_test.rb | 77 +++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/lib/liquid/partial_cache.rb b/lib/liquid/partial_cache.rb index 1ef527887..783073615 100644 --- a/lib/liquid/partial_cache.rb +++ b/lib/liquid/partial_cache.rb @@ -15,7 +15,13 @@ def self.load(template_name, context:, parse_context:) template_factory = context.registers[:template_factory] template = template_factory.for(template_name) - partial = template.parse(source, parse_context) + begin + partial = template.parse(source, parse_context) + rescue Liquid::Error => e + e.template_name = template&.name || template_name + raise e + end + partial.name ||= template_name cached_partials[template_name] = partial diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index e20ec1e0f..30742dd6f 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -189,6 +189,8 @@ def render(*args) @profiler = context.profiler = Liquid::Profiler.new end + context.template_name ||= name + begin # render the nodelist. @root.render_to_output_buffer(context, output || +'') diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index 4affac236..69ce8297e 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -263,4 +263,81 @@ def test_bug_compatible_silencing_of_errors_in_blank_nodes output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render assert_equal("0", output) end + + def test_syntax_error_is_raised_with_template_name + file_system = StubFileSystem.new("snippet" => "1\n2\n{{ 1") + + context = Liquid::Context.build( + registers: { file_system: file_system }, + ) + + template = Template.parse( + '{% render "snippet" %}', + line_numbers: true, + ) + template.name = "template/index" + + assert_equal( + "Liquid syntax error (snippet line 3): Variable '{{' was not properly terminated with regexp: /\\}\\}/", + template.render(context), + ) + end + + def test_syntax_error_is_raised_with_template_name_from_template_factory + file_system = StubFileSystem.new("snippet" => "1\n2\n{{ 1") + + context = Liquid::Context.build( + registers: { + file_system: file_system, + template_factory: StubTemplateFactory.new, + }, + ) + + template = Template.parse( + '{% render "snippet" %}', + line_numbers: true, + ) + template.name = "template/index" + + assert_equal( + "Liquid syntax error (some/path/snippet line 3): Variable '{{' was not properly terminated with regexp: /\\}\\}/", + template.render(context), + ) + end + + def test_error_is_raised_during_parse_with_template_name + depth = Liquid::Block::MAX_DEPTH + 1 + code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth + + template = Template.parse("{% render 'snippet' %}", line_numbers: true) + + context = Liquid::Context.build( + registers: { + file_system: StubFileSystem.new("snippet" => code), + template_factory: StubTemplateFactory.new, + }, + ) + + assert_equal("Liquid error (some/path/snippet line 1): Nesting too deep", template.render(context)) + end + + def test_internal_error_is_raised_with_template_name + template = Template.new + template.parse( + "{% render 'snippet' %}", + line_numbers: true, + ) + template.name = "template/index" + + context = Liquid::Context.build( + registers: { + file_system: StubFileSystem.new({}), + }, + ) + + assert_equal( + "Liquid error (template/index line 1): internal", + template.render(context), + ) + end end From 80bc7ffdf204909d713601ce9c23b3096de385a9 Mon Sep 17 00:00:00 2001 From: Jeffrey Loppert Date: Wed, 10 May 2023 14:47:16 -0400 Subject: [PATCH 055/343] Update Liquid Documentation from Shopify URL Current url https://shopify.dev/docs/docs/api/liquid 404s. Updated to https://shopify.dev/docs/api/liquid --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8fd73c21..39e501937 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ * [Contributing guidelines](CONTRIBUTING.md) * [Version history](History.md) -* [Liquid documentation from Shopify](https://shopify.dev/docs/docs/api/liquid) +* [Liquid documentation from Shopify](https://shopify.dev/docs/api/liquid) * [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki) * [Website](http://liquidmarkup.org/) @@ -111,4 +111,4 @@ template.render!({ 'x' => 1}, { strict_variables: true }) To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you. -Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns. \ No newline at end of file +Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns. From 649cca1349e16f58ef60c6973e3b720bc0630fa1 Mon Sep 17 00:00:00 2001 From: Guillaume Malette Date: Fri, 19 May 2023 11:29:22 -0400 Subject: [PATCH 056/343] [trim] Fix trimming at end of raw block when specified in the opening tag --- lib/liquid/tags/raw.rb | 1 + test/integration/tags/raw_tag_test.rb | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb index 947398b00..7f3dec9b1 100644 --- a/lib/liquid/tags/raw.rb +++ b/lib/liquid/tags/raw.rb @@ -26,6 +26,7 @@ def parse(tokens) @body = +'' while (token = tokens.shift) if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2) + parse_context.trim_whitespace = (token[-3] == WhitespaceControl) @body << Regexp.last_match(1) if Regexp.last_match(1) != "" return end diff --git a/test/integration/tags/raw_tag_test.rb b/test/integration/tags/raw_tag_test.rb index 7e8181294..3629f39c2 100644 --- a/test/integration/tags/raw_tag_test.rb +++ b/test/integration/tags/raw_tag_test.rb @@ -13,8 +13,9 @@ def test_tag_in_raw end def test_output_in_raw - # assert_template_result('{{ test }}', '{% raw %}{{ test }}{% endraw %}') assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <') + assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <") + assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <") end def test_open_tag_in_raw From af66bc8a5f9ae29fa8121c418675d43f3f755a03 Mon Sep 17 00:00:00 2001 From: Adam Klingbaum Date: Thu, 15 Jun 2023 18:57:57 +0000 Subject: [PATCH 057/343] Add `sum` filter to standard filters --- lib/liquid/standardfilters.rb | 25 +++++++++++ test/integration/standard_filter_test.rb | 54 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 53662fef1..aee430344 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -869,6 +869,31 @@ def default(input, default_value = '', options = {}) false_check || (input.respond_to?(:empty?) && input.empty?) ? default_value : input end + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Returns the sum of all elements in an array. + # @liquid_syntax array | sum + # @liquid_return [number] + def sum(input, property = nil) + ary = InputIterator.new(input, context) + + if ary.empty? + 0 + elsif property.nil? + ary.sum do |item| + Utils.to_number(item) + end + else + ary.sum do |item| + item.respond_to?(:[]) ? Utils.to_number(item[property]) : 0 + rescue TypeError + raise_property_error(property) + end + end + end + private attr_reader :context diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index bc8c6a4ae..dd15ff4e9 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -928,6 +928,60 @@ def test_where_no_target_value assert_equal([{ "foo" => true }, { "foo" => "for sure" }], @filters.where(input, "foo")) end + def test_sum_with_all_numbers + input = [1, 2] + + assert_equal(3, @filters.sum(input)) + assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do + @filters.sum(input, "quantity") + end + end + + def test_sum_with_numeric_strings + input = [1, 2, "3", "4"] + + assert_equal(10, @filters.sum(input)) + assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do + @filters.sum(input, "quantity") + end + end + + def test_sum_with_nested_arrays + input = [1, [2, [3, 4]]] + + assert_equal(10, @filters.sum(input)) + assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do + @filters.sum(input, "quantity") + end + end + + def test_sum_with_indexable_map_values + input = [{ "quantity" => 1 }, { "quantity" => 2, "weight" => 3 }, { "weight" => 4 }] + + assert_equal(0, @filters.sum(input)) + assert_equal(3, @filters.sum(input, "quantity")) + assert_equal(7, @filters.sum(input, "weight")) + assert_equal(0, @filters.sum(input, "subtotal")) + end + + def test_sum_with_indexable_non_map_values + input = [1, [2], "foo", { "quantity" => 3 }] + + assert_equal(3, @filters.sum(input)) + assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do + @filters.sum(input, "quantity") + end + end + + def test_sum_with_unindexable_values + input = [1, true, nil, { "quantity" => 2 }] + + assert_equal(1, @filters.sum(input)) + assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do + @filters.sum(input, "quantity") + end + end + private def with_timezone(tz) From 98ce25cb402e74f708f724ef43b3d680511b1450 Mon Sep 17 00:00:00 2001 From: Adam Klingbaum Date: Thu, 22 Jun 2023 16:25:11 +0000 Subject: [PATCH 058/343] Ensure `sum` filter calls `to_liquid` --- lib/liquid/standardfilters.rb | 25 +++++++++++++----------- test/integration/standard_filter_test.rb | 12 ++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index aee430344..d97643045 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -878,19 +878,22 @@ def default(input, default_value = '', options = {}) # @liquid_return [number] def sum(input, property = nil) ary = InputIterator.new(input, context) + return 0 if ary.empty? - if ary.empty? - 0 - elsif property.nil? - ary.sum do |item| - Utils.to_number(item) - end - else - ary.sum do |item| - item.respond_to?(:[]) ? Utils.to_number(item[property]) : 0 - rescue TypeError - raise_property_error(property) + values_for_sum = ary.map do |item| + if property.nil? + item + elsif item.respond_to?(:[]) + item[property] + else + 0 end + rescue TypeError + raise_property_error(property) + end + + InputIterator.new(values_for_sum, context).sum do |item| + Utils.to_number(item) end end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index dd15ff4e9..b2405f7f8 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -982,6 +982,18 @@ def test_sum_with_unindexable_values end end + def test_sum_without_property_calls_to_liquid + t = TestThing.new + Liquid::Template.parse('{{ foo | sum }}').render("foo" => [t]) + assert(t.foo > 0) + end + + def test_sum_with_property_calls_to_liquid_on_property_values + t = TestThing.new + Liquid::Template.parse('{{ foo | sum: "quantity" }}').render("foo" => [{ "quantity" => t }]) + assert(t.foo > 0) + end + private def with_timezone(tz) From dd7bbf26bccaeedd33dc7237abd6d84cdaaaa859 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 23 Jun 2023 18:13:53 -0300 Subject: [PATCH 059/343] include error mode to partial cache key --- lib/liquid/partial_cache.rb | 5 +++-- test/unit/partial_cache_unit_test.rb | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/liquid/partial_cache.rb b/lib/liquid/partial_cache.rb index 783073615..f49d14d90 100644 --- a/lib/liquid/partial_cache.rb +++ b/lib/liquid/partial_cache.rb @@ -4,7 +4,8 @@ module Liquid class PartialCache def self.load(template_name, context:, parse_context:) cached_partials = context.registers[:cached_partials] - cached = cached_partials[template_name] + cache_key = "#{template_name}:#{parse_context.error_mode}" + cached = cached_partials[cache_key] return cached if cached file_system = context.registers[:file_system] @@ -24,7 +25,7 @@ def self.load(template_name, context:, parse_context:) partial.name ||= template_name - cached_partials[template_name] = partial + cached_partials[cache_key] = partial ensure parse_context.partial = false end diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb index 6a20efbb6..b081afe52 100644 --- a/test/unit/partial_cache_unit_test.rb +++ b/test/unit/partial_cache_unit_test.rb @@ -174,4 +174,27 @@ def test_uses_template_name_from_template_factory assert_equal('some/path/my_partial', partial.name) end + + def test_includes_error_mode_into_template_cache + template_factory = StubTemplateFactory.new + context = Liquid::Context.build( + registers: { + file_system: StubFileSystem.new('my_partial' => 'my partial body'), + template_factory: template_factory, + }, + ) + + [:lax, :warn, :strict].each do |error_mode| + Liquid::PartialCache.load( + 'my_partial', + context: context, + parse_context: Liquid::ParseContext.new(error_mode: error_mode), + ) + end + + assert_equal( + ["my_partial:lax", "my_partial:warn", "my_partial:strict"], + context.registers[:cached_partials].keys, + ) + end end From 9a77e3e923068dc2d59e669b7887923b0b208535 Mon Sep 17 00:00:00 2001 From: Michelle Vinci Date: Wed, 5 Jul 2023 08:44:06 -0700 Subject: [PATCH 060/343] update desc --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index d97643045..661235a9d 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -69,7 +69,7 @@ def upcase(input) # @liquid_type filter # @liquid_category string # @liquid_summary - # Capitalizes the first word in a string. + # Capitalizes the first word in a string and downcases the remaining characters. # @liquid_syntax string | capitalize # @liquid_return [string] def capitalize(input) From 10e0fb795e0eea4e97853164a65869b77944e949 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Thu, 6 Jul 2023 10:45:12 +0100 Subject: [PATCH 061/343] Allow liquid tag inside liquid tag --- lib/liquid/block_body.rb | 3 +++ test/integration/tags/liquid_tag_test.rb | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 4674c5f3c..42d372bb8 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -45,6 +45,9 @@ def freeze end tag_name = Regexp.last_match(1) markup = Regexp.last_match(2) + if tag_name == "liquid" + next + end unless (tag = registered_tags[tag_name]) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed diff --git a/test/integration/tags/liquid_tag_test.rb b/test/integration/tags/liquid_tag_test.rb index 17ce00195..d10c49a6e 100644 --- a/test/integration/tags/liquid_tag_test.rb +++ b/test/integration/tags/liquid_tag_test.rb @@ -113,4 +113,15 @@ def test_liquid_tag_in_raw {% raw %}{% liquid echo 'test' %}{% endraw %} LIQUID end + + def test_nested_liquid_tags + assert_template_result('good', <<~LIQUID) + {%- liquid + liquid + if true + echo "good" + endif + -%} + LIQUID + end end From 7f2cf1fe67936fbfac027a69fd5e381926174023 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Fri, 7 Jul 2023 11:20:34 +0100 Subject: [PATCH 062/343] Recursively parse for liquid tag --- lib/liquid/block_body.rb | 7 +++++-- test/integration/tags/liquid_tag_test.rb | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 42d372bb8..33a6a99f5 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -45,9 +45,12 @@ def freeze end tag_name = Regexp.last_match(1) markup = Regexp.last_match(2) - if tag_name == "liquid" - next + + if tag_name == 'liquid' + parse_context.line_number -= 1 + next parse_liquid_tag(markup, parse_context) end + unless (tag = registered_tags[tag_name]) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed diff --git a/test/integration/tags/liquid_tag_test.rb b/test/integration/tags/liquid_tag_test.rb index d10c49a6e..c6694fa70 100644 --- a/test/integration/tags/liquid_tag_test.rb +++ b/test/integration/tags/liquid_tag_test.rb @@ -124,4 +124,26 @@ def test_nested_liquid_tags -%} LIQUID end + + def test_nested_liquid_tags_on_same_line + assert_template_result('good', <<~LIQUID) + {%- liquid liquid liquid echo "good" -%} + LIQUID + end + + def test_nested_liquid_liquid_is_not_skipped_if_used_in_non_tag_position + assert_template_result('liquid', <<~LIQUID, { 'liquid' => 'liquid' }) + {%- liquid liquid liquid echo liquid -%} + LIQUID + end + + def test_next_liquid_with_unclosed_if_tag + assert_match_syntax_error("Liquid syntax error (line 2): 'if' tag was never closed", <<~LIQUID) + {%- liquid + liquid if true + echo "good" + endif + -%} + LIQUID + end end From 21d6197533688bf51bbe13cfd6cc3663ed23cf38 Mon Sep 17 00:00:00 2001 From: Marco Rudilosso Date: Wed, 19 Jul 2023 15:22:12 +0000 Subject: [PATCH 063/343] Call to_liquid_value when short circuiting conditions --- lib/liquid/condition.rb | 4 ++-- test/integration/variable_test.rb | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index c1476dd4c..d35ba3011 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -69,9 +69,9 @@ def evaluate(context = deprecated_default_context) case condition.child_relation when :or - break if result + break if Liquid::Utils.to_liquid_value(result) when :and - break unless result + break unless Liquid::Utils.to_liquid_value(result) else break end diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 42b38e33b..22f2b64e8 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -34,6 +34,7 @@ def test_if_tag_calls_to_liquid_value assert_template_result('', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(false) }) assert_template_result('', '{% if foo == true %}True{% endif %}', { 'foo' => BooleanDrop.new(false) }) + assert_template_result('', '{% if foo and true %}SHOULD NOT HAPPEN{% endif %}', { 'foo' => BooleanDrop.new(false) }) assert_template_result('one', '{% if a contains x %}one{% endif %}', { 'a' => [1], 'x' => IntegerDrop.new(1) }) end From 2c5d2be1936b3631aaba0246a23ec880cf1e158d Mon Sep 17 00:00:00 2001 From: James Prior Date: Fri, 30 Jun 2023 17:05:35 +0100 Subject: [PATCH 064/343] Don't render `sum` filter results in scientific notation. --- lib/liquid/standardfilters.rb | 3 ++- test/integration/standard_filter_test.rb | 31 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 661235a9d..6f27a87a0 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -892,9 +892,10 @@ def sum(input, property = nil) raise_property_error(property) end - InputIterator.new(values_for_sum, context).sum do |item| + result = InputIterator.new(values_for_sum, context).sum do |item| Utils.to_number(item) end + result.is_a?(BigDecimal) ? result.to_f : result end private diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index b2405f7f8..ba2790a5d 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -946,6 +946,21 @@ def test_sum_with_numeric_strings end end + def test_sum_with_floats + input = [0.1, 0.2, 0.3] + assert_equal(0.6, @filters.sum(input)) + end + + def test_sum_with_negative_float + input = [0.1, 0.2, -0.3] + assert_equal(0.0, @filters.sum(input)) + end + + def test_sum_with_float_strings + input = [0.1, "0.2", "0.3"] + assert_equal(0.6, @filters.sum(input)) + end + def test_sum_with_nested_arrays input = [1, [2, [3, 4]]] @@ -994,6 +1009,22 @@ def test_sum_with_property_calls_to_liquid_on_property_values assert(t.foo > 0) end + def test_render_sum_of_floats + assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, 0.2, 0.3]), "0.6") + end + + def test_render_sum_of_negative_floats + assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, -0.2, 0.3]), "0.2") + end + + def test_render_sum_negative_float_result + assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, -0.2, -0.3]), "-0.4") + end + + def test_render_sum_float_zero_result + assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, 0.2, -0.3]), "0.0") + end + private def with_timezone(tz) From de6d15a73e651035db49e5ec778b350c3aef5abe Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 25 Jul 2023 17:41:42 +0100 Subject: [PATCH 065/343] Test sum floats from properties. --- test/integration/standard_filter_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index ba2790a5d..87c0aedf6 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1025,6 +1025,22 @@ def test_render_sum_float_zero_result assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, 0.2, -0.3]), "0.0") end + def test_render_sum_with_floats_and_indexable_map_values + input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }] + assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => input), "0") + assert_equal(Liquid::Template.parse('{{ foo | sum: "quantity" }}').render("foo" => input), "1.2") + assert_equal(Liquid::Template.parse('{{ foo | sum: "weight" }}').render("foo" => input), "0.1") + assert_equal(Liquid::Template.parse('{{ foo | sum: "subtotal" }}').render("foo" => input), "0") + end + + def test_sum_with_floats_and_indexable_map_values + input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }] + assert_equal(0, @filters.sum(input)) + assert_equal(1.2, @filters.sum(input, "quantity")) + assert_equal(0.1, @filters.sum(input, "weight")) + assert_equal(0, @filters.sum(input, "subtotal")) + end + private def with_timezone(tz) From 75e7725f57342a3a174748bfe37f4623eb3674f6 Mon Sep 17 00:00:00 2001 From: Adam Klingbaum Date: Tue, 8 Aug 2023 16:15:48 +0000 Subject: [PATCH 066/343] Streamline new tests for `sum` filter --- lib/liquid/standardfilters.rb | 1 + test/integration/standard_filter_test.rb | 55 ++++++++++-------------- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 6f27a87a0..0fcd55e58 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -895,6 +895,7 @@ def sum(input, property = nil) result = InputIterator.new(values_for_sum, context).sum do |item| Utils.to_number(item) end + result.is_a?(BigDecimal) ? result.to_f : result end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 87c0aedf6..e945ad253 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -946,21 +946,6 @@ def test_sum_with_numeric_strings end end - def test_sum_with_floats - input = [0.1, 0.2, 0.3] - assert_equal(0.6, @filters.sum(input)) - end - - def test_sum_with_negative_float - input = [0.1, 0.2, -0.3] - assert_equal(0.0, @filters.sum(input)) - end - - def test_sum_with_float_strings - input = [0.1, "0.2", "0.3"] - assert_equal(0.6, @filters.sum(input)) - end - def test_sum_with_nested_arrays input = [1, [2, [3, 4]]] @@ -1009,36 +994,40 @@ def test_sum_with_property_calls_to_liquid_on_property_values assert(t.foo > 0) end - def test_render_sum_of_floats - assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, 0.2, 0.3]), "0.6") - end - - def test_render_sum_of_negative_floats - assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, -0.2, 0.3]), "0.2") + def test_sum_of_floats + input = [0.1, 0.2, 0.3] + assert_equal(0.6, @filters.sum(input)) + assert_template_result("0.6", "{{ input | sum }}", { "input" => input }) end - def test_render_sum_negative_float_result - assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, -0.2, -0.3]), "-0.4") + def test_sum_of_negative_floats + input = [0.1, 0.2, -0.3] + assert_equal(0.0, @filters.sum(input)) + assert_template_result("0.0", "{{ input | sum }}", { "input" => input }) end - def test_render_sum_float_zero_result - assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => [0.1, 0.2, -0.3]), "0.0") + def test_sum_with_float_strings + input = [0.1, "0.2", "0.3"] + assert_equal(0.6, @filters.sum(input)) + assert_template_result("0.6", "{{ input | sum }}", { "input" => input }) end - def test_render_sum_with_floats_and_indexable_map_values - input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }] - assert_equal(Liquid::Template.parse('{{ foo | sum }}').render("foo" => input), "0") - assert_equal(Liquid::Template.parse('{{ foo | sum: "quantity" }}').render("foo" => input), "1.2") - assert_equal(Liquid::Template.parse('{{ foo | sum: "weight" }}').render("foo" => input), "0.1") - assert_equal(Liquid::Template.parse('{{ foo | sum: "subtotal" }}').render("foo" => input), "0") + def test_sum_resulting_in_negative_float + input = [0.1, -0.2, -0.3] + assert_equal(-0.4, @filters.sum(input)) + assert_template_result("-0.4", "{{ input | sum }}", { "input" => input }) end def test_sum_with_floats_and_indexable_map_values input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }] - assert_equal(0, @filters.sum(input)) + assert_equal(0.0, @filters.sum(input)) assert_equal(1.2, @filters.sum(input, "quantity")) assert_equal(0.1, @filters.sum(input, "weight")) - assert_equal(0, @filters.sum(input, "subtotal")) + assert_equal(0.0, @filters.sum(input, "subtotal")) + assert_template_result("0", "{{ input | sum }}", { "input" => input }) + assert_template_result("1.2", "{{ input | sum: 'quantity' }}", { "input" => input }) + assert_template_result("0.1", "{{ input | sum: 'weight' }}", { "input" => input }) + assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input }) end private From cc47fa8f03f923c9e924a39e11cc45c9e9cc2f51 Mon Sep 17 00:00:00 2001 From: Guillaume Malette Date: Fri, 29 Sep 2023 09:39:59 -0400 Subject: [PATCH 067/343] [base64] Respect string encoding of input in base64_decode filters --- lib/liquid/standardfilters.rb | 19 ++++++++++++++++-- test/integration/standard_filter_test.rb | 25 ++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 661235a9d..41cae30fa 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -29,6 +29,19 @@ module StandardFilters ) STRIP_HTML_TAGS = /<.*?>/m + class << self + def try_coerce_encoding(input, encoding:) + original_encoding = input.encoding + if input.encoding != encoding + input.force_encoding(encoding) + if !input.valid_encoding? + input.force_encoding(original_encoding) + end + end + input + end + end + # @liquid_public_docs # @liquid_type filter # @liquid_category array @@ -150,7 +163,8 @@ def base64_encode(input) # @liquid_syntax string | base64_decode # @liquid_return [string] def base64_decode(input) - Base64.strict_decode64(input.to_s) + input = input.to_s + StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid base64 provided to base64_decode" end @@ -174,7 +188,8 @@ def base64_url_safe_encode(input) # @liquid_syntax string | base64_url_safe_decode # @liquid_return [string] def base64_url_safe_decode(input) - Base64.urlsafe_decode64(input.to_s) + input = input.to_s + StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index b2405f7f8..7e7c34861 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -176,7 +176,17 @@ def test_base64_encode end def test_base64_decode - assert_equal('one two three', @filters.base64_decode('b25lIHR3byB0aHJlZQ==')) + decoded = @filters.base64_decode('b25lIHR3byB0aHJlZQ==') + assert_equal('one two three', decoded) + assert_equal(Encoding::UTF_8, decoded.encoding) + + decoded = @filters.base64_decode('4pyF') + assert_equal('✅', decoded) + assert_equal(Encoding::UTF_8, decoded.encoding) + + decoded = @filters.base64_decode("/w==") + assert_equal(Encoding::ASCII_8BIT, decoded.encoding) + assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded) exception = assert_raises(Liquid::ArgumentError) do @filters.base64_decode("invalidbase64") @@ -194,10 +204,21 @@ def test_base64_url_safe_encode end def test_base64_url_safe_decode + decoded = @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8') assert_equal( 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|', - @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8'), + decoded, ) + assert_equal(Encoding::UTF_8, decoded.encoding) + + decoded = @filters.base64_url_safe_decode('4pyF') + assert_equal('✅', decoded) + assert_equal(Encoding::UTF_8, decoded.encoding) + + decoded = @filters.base64_url_safe_decode("_w==") + assert_equal(Encoding::ASCII_8BIT, decoded.encoding) + assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded) + exception = assert_raises(Liquid::ArgumentError) do @filters.base64_url_safe_decode("invalidbase64") end From 33e1a8ffbc2f397237ac37ab36f233cf1f18bdbf Mon Sep 17 00:00:00 2001 From: Guillaume Malette Date: Fri, 29 Sep 2023 09:50:42 -0400 Subject: [PATCH 068/343] fix style --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 41cae30fa..a2dbeb39d 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -34,7 +34,7 @@ def try_coerce_encoding(input, encoding:) original_encoding = input.encoding if input.encoding != encoding input.force_encoding(encoding) - if !input.valid_encoding? + unless input.valid_encoding? input.force_encoding(original_encoding) end end From ceb7a4237f4b24d9ec3ca13a8e55981ac252a014 Mon Sep 17 00:00:00 2001 From: Ates Goral Date: Mon, 16 Oct 2023 10:56:33 -0400 Subject: [PATCH 069/343] Update CI status badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39e501937..76cfa3c0e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://api.travis-ci.org/Shopify/liquid.svg?branch=master)](http://travis-ci.org/Shopify/liquid) +[![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml) [![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid) # Liquid template engine From 407a8e5b0f7fa0cfeeb24d59700f7b069df324aa Mon Sep 17 00:00:00 2001 From: Ieva Grazuleviciute Date: Fri, 20 Oct 2023 11:42:54 +0300 Subject: [PATCH 070/343] Fix for custom tag rendering --- lib/liquid/tag.rb | 3 ++- test/unit/tag_unit_test.rb | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 2f7bc3016..8303a7233 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -54,7 +54,8 @@ def render(_context) # of the `render_to_output_buffer` method will become the default and the `render` # method will be removed. def render_to_output_buffer(context, output) - output << render(context) + render_result = render(context) + output << render_result if render_result output end diff --git a/test/unit/tag_unit_test.rb b/test/unit/tag_unit_test.rb index b814b1b72..7f9b490c9 100644 --- a/test/unit/tag_unit_test.rb +++ b/test/unit/tag_unit_test.rb @@ -20,4 +20,13 @@ def test_tag_name_should_return_name_of_the_tag tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new) assert_equal('some_tag', tag.tag_name) end + + class CustomTag < Liquid::Tag + def render(_context); end + end + + def test_tag_render_to_output_buffer_nil_value + custom_tag = CustomTag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new) + assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string")) + end end From dbf0aa8cd58593ebc6942d47df42cfbc80f060d1 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 6 Nov 2023 17:56:11 -0400 Subject: [PATCH 071/343] don't parse nodes inside a comment tag Co-authored-by: Alex Coco --- lib/liquid/tags/comment.rb | 53 ++++++++++++++++ test/unit/tags/comment_tag_unit_test.rb | 82 +++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 test/unit/tags/comment_tag_unit_test.rb diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 9922ee0dd..b60ef01b6 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -25,6 +25,59 @@ def unknown_tag(_tag, _markup, _tokens) def blank? true end + + private + + def parse_body(body, tokens) + if parse_context.depth >= MAX_DEPTH + raise StackLevelError, "Nesting too deep" + end + + parse_context.depth += 1 + comment_tag_depth = 1 + + begin + # Consume tokens without creating child nodes. + # The children tag doesn't require to be a valid Liquid except the comment and raw tag. + # The child comment and raw tag must be closed. + while (token = tokens.send(:shift)) + tag_name_match = BlockBody::FullToken.match(token) + + next if tag_name_match.nil? + + tag_name = tag_name_match[2] + + if tag_name == "raw" + # raw tags are required to be closed + parse_raw_tag_body(tokens) + next + end + + if tag_name_match[2] == "comment" + comment_tag_depth += 1 + next + elsif tag_name_match[2] == "endcomment" + comment_tag_depth -= 1 + + return false if comment_tag_depth.zero? + end + end + + raise_tag_never_closed(block_name) + ensure + parse_context.depth -= 1 + end + + false + end + + def parse_raw_tag_body(tokens) + while (token = tokens.send(:shift)) + return if token =~ Raw::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2) + end + + raise_tag_never_closed("raw") + end end Template.register_tag('comment', Comment) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb new file mode 100644 index 000000000..457d224ad --- /dev/null +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CommentTagUnitTest < Minitest::Test + def test_does_not_parse_nodes_inside_a_comment + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% if true %} + {% if ... %} + {%- for ? -%} + {% while true %} + {% + unless if + %} + {% endcase %} + {% endcomment %} + LIQUID + + assert_equal("", template.render) + end + + def test_child_comment_tags_need_to_be_closed + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% comment %} + {% comment %}{% endcomment %} + {% endcomment %} + {% endcomment %} + LIQUID + + assert_equal("", template.render) + + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% comment %} + {% comment %} + {% endcomment %} + {% endcomment %} + LIQUID + end + end + + def test_child_raw_tags_need_to_be_closed + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% raw %} + {% endcomment %} + {% endraw %} + {% endcomment %} + LIQUID + + assert_equal("", template.render) + + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% raw %} + {% endcomment %} + {% endcomment %} + LIQUID + end + end + + def test_error_line_number_is_correct + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% if true %} + {% endcomment %} + {{ errors.standard_error }} + LIQUID + + output = template.render('errors' => ErrorDrop.new) + expected = <<~TEXT.chomp + + Liquid error (line 4): standard error + TEXT + + assert_equal(expected, output) + end +end From eada2b65a2aad7eb291e10cfdb1242f767c395cc Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 8 Nov 2023 11:13:13 -0400 Subject: [PATCH 072/343] clean up comment tag body parsing Co-authored-by: Peter Zhu --- lib/liquid/tags/comment.rb | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index b60ef01b6..5c7315e0e 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -47,16 +47,12 @@ def parse_body(body, tokens) tag_name = tag_name_match[2] - if tag_name == "raw" - # raw tags are required to be closed + case tag_name + when "raw" parse_raw_tag_body(tokens) - next - end - - if tag_name_match[2] == "comment" + when "comment" comment_tag_depth += 1 - next - elsif tag_name_match[2] == "endcomment" + when "endcomment" comment_tag_depth -= 1 return false if comment_tag_depth.zero? From 2abf52d5465004a30dc303a280838de862b84388 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 8 Nov 2023 11:23:00 -0400 Subject: [PATCH 073/343] add a quirky comment tag unit test --- test/unit/tags/comment_tag_unit_test.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index 457d224ad..f201e2777 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -20,6 +20,16 @@ def test_does_not_parse_nodes_inside_a_comment assert_equal("", template.render) end + def test_allows_incomplete_tags_inside_a_comment + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% comment %} + {% assign foo = "1" + {% endcomment %} + LIQUID + + assert_equal("", template.render) + end + def test_child_comment_tags_need_to_be_closed template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) {% comment %} From a681e73aec7768b7a50778882781e1a9aee1eedc Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 8 Nov 2023 16:45:11 -0400 Subject: [PATCH 074/343] refactor comment tag unit test to use test helper --- test/unit/tags/comment_tag_unit_test.rb | 31 ++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index f201e2777..8093cd42f 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -4,7 +4,7 @@ class CommentTagUnitTest < Minitest::Test def test_does_not_parse_nodes_inside_a_comment - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + assert_template_result("", <<~LIQUID.chomp) {% comment %} {% if true %} {% if ... %} @@ -16,22 +16,31 @@ def test_does_not_parse_nodes_inside_a_comment {% endcase %} {% endcomment %} LIQUID - - assert_equal("", template.render) end def test_allows_incomplete_tags_inside_a_comment - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + assert_template_result("", <<~LIQUID.chomp) {% comment %} {% assign foo = "1" {% endcomment %} LIQUID - assert_equal("", template.render) + assert_template_result("", <<~LIQUID.chomp) + {% comment %} + {% comment %} + {% invalid + {% endcomment %} + {% endcomment %} + LIQUID + + assert_template_result("", <<~LIQUID.chomp) + {% comment %} + {% {{ {%- endcomment %} + LIQUID end def test_child_comment_tags_need_to_be_closed - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + assert_template_result("", <<~LIQUID.chomp) {% comment %} {% comment %} {% comment %}{% endcomment %} @@ -39,10 +48,8 @@ def test_child_comment_tags_need_to_be_closed {% endcomment %} LIQUID - assert_equal("", template.render) - assert_raises(Liquid::SyntaxError) do - Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + assert_template_result("", <<~LIQUID.chomp) {% comment %} {% comment %} {% comment %} @@ -53,7 +60,7 @@ def test_child_comment_tags_need_to_be_closed end def test_child_raw_tags_need_to_be_closed - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + assert_template_result("", <<~LIQUID.chomp) {% comment %} {% raw %} {% endcomment %} @@ -61,10 +68,8 @@ def test_child_raw_tags_need_to_be_closed {% endcomment %} LIQUID - assert_equal("", template.render) - assert_raises(Liquid::SyntaxError) do - Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + Liquid::Template.parse(<<~LIQUID.chomp) {% comment %} {% raw %} {% endcomment %} From e180535784b0375340dfc3fd7696d117f9f2a2ec Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 8 Nov 2023 16:47:24 -0400 Subject: [PATCH 075/343] allow incomplete tags inside a comment tag --- lib/liquid/block_body.rb | 1 + lib/liquid/tags/comment.rb | 4 ++-- lib/liquid/tags/raw.rb | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 33a6a99f5..61096de80 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -6,6 +6,7 @@ module Liquid class BlockBody LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om + FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om WhitespaceOrNothing = /\A\s*\z/ TAGSTART = "{%" diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 5c7315e0e..df7b294c3 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -41,7 +41,7 @@ def parse_body(body, tokens) # The children tag doesn't require to be a valid Liquid except the comment and raw tag. # The child comment and raw tag must be closed. while (token = tokens.send(:shift)) - tag_name_match = BlockBody::FullToken.match(token) + tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) next if tag_name_match.nil? @@ -69,7 +69,7 @@ def parse_body(body, tokens) def parse_raw_tag_body(tokens) while (token = tokens.send(:shift)) - return if token =~ Raw::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2) + return if token =~ BlockBody::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2) end raise_tag_never_closed("raw") diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb index 7f3dec9b1..02ee2b3cb 100644 --- a/lib/liquid/tags/raw.rb +++ b/lib/liquid/tags/raw.rb @@ -14,7 +14,6 @@ module Liquid # @liquid_syntax_keyword expression The expression to be output without being rendered. class Raw < Block Syntax = /\A\s*\z/ - FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om def initialize(tag_name, markup, parse_context) super @@ -25,7 +24,7 @@ def initialize(tag_name, markup, parse_context) def parse(tokens) @body = +'' while (token = tokens.shift) - if token =~ FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2) + if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2) parse_context.trim_whitespace = (token[-3] == WhitespaceControl) @body << Regexp.last_match(1) if Regexp.last_match(1) != "" return From 41f65173b0ac8f2e18a5d9e15e50f4c934f3f20a Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 9 Nov 2023 16:26:49 -0400 Subject: [PATCH 076/343] fix parsing comment block body inside a liquid tag --- lib/liquid/tags/comment.rb | 26 +++++++++++++++++-------- test/unit/tags/comment_tag_unit_test.rb | 13 +++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index df7b294c3..060a0293c 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -28,7 +28,7 @@ def blank? private - def parse_body(body, tokens) + def parse_body(body, tokenizer) if parse_context.depth >= MAX_DEPTH raise StackLevelError, "Nesting too deep" end @@ -40,16 +40,26 @@ def parse_body(body, tokens) # Consume tokens without creating child nodes. # The children tag doesn't require to be a valid Liquid except the comment and raw tag. # The child comment and raw tag must be closed. - while (token = tokens.send(:shift)) - tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) + while (token = tokenizer.send(:shift)) + tag_name = if tokenizer.for_liquid_tag + next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing) - next if tag_name_match.nil? + tag_name_match = BlockBody::LiquidTagToken.match(token) - tag_name = tag_name_match[2] + next if tag_name_match.nil? + + tag_name_match[1] + else + tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) + + next if tag_name_match.nil? + + tag_name_match[2] + end case tag_name when "raw" - parse_raw_tag_body(tokens) + parse_raw_tag_body(tokenizer) when "comment" comment_tag_depth += 1 when "endcomment" @@ -67,8 +77,8 @@ def parse_body(body, tokens) false end - def parse_raw_tag_body(tokens) - while (token = tokens.send(:shift)) + def parse_raw_tag_body(tokenizer) + while (token = tokenizer.send(:shift)) return if token =~ BlockBody::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2) end diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index 8093cd42f..64f96f3a1 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -3,6 +3,19 @@ require 'test_helper' class CommentTagUnitTest < Minitest::Test + def test_comment_inside_liquid_tag + assert_template_result("", <<~LIQUID.chomp) + {% liquid + if 1 != 1 + comment + else + echo 123 + endcomment + endif + %} + LIQUID + end + def test_does_not_parse_nodes_inside_a_comment assert_template_result("", <<~LIQUID.chomp) {% comment %} From 6dafc19b6d9186a2d0ee2f330f9f349567150ee8 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Nov 2023 15:42:54 -0400 Subject: [PATCH 077/343] fix parsing comment tag delimiter with extra strings --- lib/liquid/tags/comment.rb | 5 +++++ test/unit/tags/comment_tag_unit_test.rb | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 060a0293c..d6f855711 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -15,6 +15,8 @@ module Liquid # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block + TagDelimiter = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(endcomment)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om + def render_to_output_buffer(_context, output) output end @@ -49,6 +51,9 @@ def parse_body(body, tokenizer) next if tag_name_match.nil? tag_name_match[1] + elsif TagDelimiter.match?(token) + # aggressively match comment delimiter first + "endcomment" else tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index 64f96f3a1..11d2fd152 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -107,4 +107,18 @@ def test_error_line_number_is_correct assert_equal(expected, output) end + + def test_comment_tag_delimiter_with_extra_strings + assert_template_result( + '', + <<~LIQUID.chomp, + {% comment %} + {% comment %} + {% endcomment + {% if true %} + {% endif %} + {% endcomment %} + LIQUID + ) + end end From 6a5ebb0e856cc481ee64f802e4caf940a9d90488 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Sat, 11 Nov 2023 12:19:44 -0400 Subject: [PATCH 078/343] fix parsing nested comment tag with extra strings --- lib/liquid/tags/comment.rb | 9 ++++++--- test/unit/tags/comment_tag_unit_test.rb | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index d6f855711..0446685ba 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -15,7 +15,7 @@ module Liquid # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block - TagDelimiter = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(endcomment)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om + TAG_DELIMITER = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(endcomment)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om def render_to_output_buffer(_context, output) output @@ -51,9 +51,12 @@ def parse_body(body, tokenizer) next if tag_name_match.nil? tag_name_match[1] - elsif TagDelimiter.match?(token) - # aggressively match comment delimiter first + elsif TAG_DELIMITER.match?(token) + # aggressively match comment delimiter "endcomment" + elsif token =~ BlockBody::FullToken && Regexp.last_match(2) == "comment" + # aggressively match comment tag + "comment" else tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index 11d2fd152..9f812b71b 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -121,4 +121,18 @@ def test_comment_tag_delimiter_with_extra_strings LIQUID ) end + + def test_nested_comment_tag_with_extra_strings + assert_template_result( + '', + <<~LIQUID.chomp, + {% comment %} + {% comment + {% assign foo = 1 %} + {% endcomment + {% assign foo = 1 %} + {% endcomment %} + LIQUID + ) + end end From bf0f79f36c784934b3de5efaff0ee3bc113930e4 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 30 Nov 2023 13:16:55 -0400 Subject: [PATCH 079/343] fix parsing comment tag delimiter --- lib/liquid/tags/comment.rb | 2 +- test/unit/tags/comment_tag_unit_test.rb | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 0446685ba..465a363fb 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -15,7 +15,7 @@ module Liquid # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block - TAG_DELIMITER = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(endcomment)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om + TAG_DELIMITER = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(endcomment)\s*(\s.*)?#{WhitespaceControl}?#{TagEnd}\z/om def render_to_output_buffer(_context, output) output diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index 9f812b71b..09da018ec 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -135,4 +135,24 @@ def test_nested_comment_tag_with_extra_strings LIQUID ) end + + def test_ignores_delimiter_with_extra_strings + assert_template_result( + '', + <<~LIQUID.chomp, + {% if true %} + {% comment %} + {% commentXXXXX %}wut{% endcommentXXXXX %} + {% endcomment %} + {% endif %} + LIQUID + ) + end + + def test_delimiter_can_have_extra_strings + assert_template_result('', "{% comment %}123{% endcomment xyz %}") + assert_template_result('', "{% comment %}123{% endcomment\txyz %}") + assert_template_result('', "{% comment %}123{% endcomment\nxyz %}") + assert_template_result('', "{% comment %}123{% endcomment\n xyz endcomment %}") + end end From 24d461a9e34ac36b0e629af36d5e33e0be52d428 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 1 Dec 2023 16:04:03 -0400 Subject: [PATCH 080/343] implement whitespace control to comment tag --- lib/liquid/tags/comment.rb | 5 ++++- test/unit/tags/comment_tag_unit_test.rb | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 465a363fb..153705106 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -73,7 +73,10 @@ def parse_body(body, tokenizer) when "endcomment" comment_tag_depth -= 1 - return false if comment_tag_depth.zero? + if comment_tag_depth.zero? + parse_context.trim_whitespace = (token[-3] == WhitespaceControl) + return false + end end end diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index 09da018ec..a8bfa4e82 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -155,4 +155,15 @@ def test_delimiter_can_have_extra_strings assert_template_result('', "{% comment %}123{% endcomment\nxyz %}") assert_template_result('', "{% comment %}123{% endcomment\n xyz endcomment %}") end + + def test_with_whitespace_control + assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%}Hello!") + assert_template_result("Hello!", "{%- comment -%}123{%- endcomment -%} Hello!") + assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%} Hello!") + + assert_template_result("Hello!", <<~LIQUID.chomp) + {%- comment %}Whitespace control!{% endcomment -%} + Hello! + LIQUID + end end From c618ac1c9fa1194de31b1804742b3f5a1b037508 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 12 Dec 2023 16:23:37 -0400 Subject: [PATCH 081/343] fix parsing comment tag with extra string --- lib/liquid/tags/comment.rb | 11 +++-------- test/unit/tags/comment_tag_unit_test.rb | 1 + 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 153705106..792c0860a 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -15,8 +15,6 @@ module Liquid # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block - TAG_DELIMITER = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(endcomment)\s*(\s.*)?#{WhitespaceControl}?#{TagEnd}\z/om - def render_to_output_buffer(_context, output) output end @@ -51,12 +49,9 @@ def parse_body(body, tokenizer) next if tag_name_match.nil? tag_name_match[1] - elsif TAG_DELIMITER.match?(token) - # aggressively match comment delimiter - "endcomment" - elsif token =~ BlockBody::FullToken && Regexp.last_match(2) == "comment" - # aggressively match comment tag - "comment" + elsif token =~ BlockBody::FullToken && Regexp.last_match(2) == "comment" || Regexp.last_match(2) == "endcomment" + # aggressively match comment tag or comment tag delimiter + Regexp.last_match(2) else tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index a8bfa4e82..e4f8b1491 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -154,6 +154,7 @@ def test_delimiter_can_have_extra_strings assert_template_result('', "{% comment %}123{% endcomment\txyz %}") assert_template_result('', "{% comment %}123{% endcomment\nxyz %}") assert_template_result('', "{% comment %}123{% endcomment\n xyz endcomment %}") + assert_template_result('', "{%comment}{% assign a = 1 %}{%endcomment}{% endif %}") end def test_with_whitespace_control From 3c5ad7db61ae323e842b34fa0de65482f108bfde Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Dec 2023 13:55:28 -0400 Subject: [PATCH 082/343] don't allow invalid syntax inside comment tag --- lib/liquid/tags/comment.rb | 10 ++---- test/unit/tags/comment_tag_unit_test.rb | 42 ++++++++++++++++++------- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 792c0860a..7b83c3ee6 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -49,15 +49,9 @@ def parse_body(body, tokenizer) next if tag_name_match.nil? tag_name_match[1] - elsif token =~ BlockBody::FullToken && Regexp.last_match(2) == "comment" || Regexp.last_match(2) == "endcomment" - # aggressively match comment tag or comment tag delimiter - Regexp.last_match(2) else - tag_name_match = BlockBody::FullTokenPossiblyInvalid.match(token) - - next if tag_name_match.nil? - - tag_name_match[2] + token =~ BlockBody::FullToken + Regexp.last_match(2) end case tag_name diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index e4f8b1491..d3ce4cb96 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -31,25 +31,45 @@ def test_does_not_parse_nodes_inside_a_comment LIQUID end - def test_allows_incomplete_tags_inside_a_comment - assert_template_result("", <<~LIQUID.chomp) + def test_allows_unclosed_tags + assert_template_result('', <<~LIQUID.chomp) {% comment %} - {% assign foo = "1" + {% if true %} {% endcomment %} LIQUID + end - assert_template_result("", <<~LIQUID.chomp) + def test_block_body_must_be_valid + assert_template_result('', <<~LIQUID.chomp) {% comment %} - {% comment %} - {% invalid - {% endcomment %} + {% assign a = 123 {% comment %} {% endcomment %} LIQUID - assert_template_result("", <<~LIQUID.chomp) - {% comment %} - {% {{ {%- endcomment %} - LIQUID + assert_raises(Liquid::SyntaxError) do + assert_template_result("", <<~LIQUID.chomp) + {% comment %} + {% assign foo = "1" + {% endcomment %} + LIQUID + end + + assert_raises(Liquid::SyntaxError) do + assert_template_result("", <<~LIQUID.chomp) + {% comment %} + {% comment %} + {% invalid + {% endcomment %} + {% endcomment %} + LIQUID + end + + assert_raises(Liquid::SyntaxError) do + assert_template_result("", <<~LIQUID.chomp) + {% comment %} + {% {{ {%- endcomment %} + LIQUID + end end def test_child_comment_tags_need_to_be_closed From fc9c3386822fe37d81a509631a9717b03eb964ef Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Dec 2023 17:08:26 -0400 Subject: [PATCH 083/343] refactor: rename comment tag unit test --- test/unit/tags/comment_tag_unit_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index d3ce4cb96..b5bc48fa1 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -39,7 +39,7 @@ def test_allows_unclosed_tags LIQUID end - def test_block_body_must_be_valid + def test_open_tags_in_comment assert_template_result('', <<~LIQUID.chomp) {% comment %} {% assign a = 123 {% comment %} From 4924822c88ddba897114c6afffd2432fa9d21fa9 Mon Sep 17 00:00:00 2001 From: Mamoru TASAKA Date: Fri, 15 Dec 2023 15:09:57 +0900 Subject: [PATCH 084/343] change: make nil_safe_casecmp judge compatible for nil-nil comparison Ruby returns 0 (not nil) for nil <=> nil, i.e. nil and nil are judged as equal for comparison, and so returns nil_safe_compare . ref: https://github.com/Shopify/liquid/pull/1476 To make the behavior of nil_safe_casecmp consistent with nil_safe_compare , change nil_safe_casecmp so that comparison between nil <=> nil return 0 (equal). Also change testsuite to reflect this change. Fixes #1759 . --- lib/liquid/standardfilters.rb | 2 ++ test/integration/standard_filter_test.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 7a92c6753..3b0ecf15d 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -944,6 +944,8 @@ def nil_safe_compare(a, b) def nil_safe_casecmp(a, b) if !a.nil? && !b.nil? a.to_s.casecmp(b.to_s) + elsif a.nil? && b.nil? + 0 else a.nil? ? 1 : -1 end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 28c006cb9..0564404c3 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -331,8 +331,8 @@ def test_sort_natural_when_property_is_sometimes_missing_puts_nils_last { "price" => "1", "handle" => "gamma" }, { "price" => 2, "handle" => "epsilon" }, { "price" => "4", "handle" => "alpha" }, - { "handle" => "delta" }, { "handle" => "beta" }, + { "handle" => "delta" }, ] assert_equal(expectation, @filters.sort_natural(input, "price")) end From a3c837687e5e6d800aed7f4f6d29b25f7155c275 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 5 Jan 2024 10:17:37 -0400 Subject: [PATCH 085/343] don't reset Liquid tag's whitespace control from comment tag --- lib/liquid/tags/comment.rb | 8 ++++---- test/unit/tags/comment_tag_unit_test.rb | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 7b83c3ee6..798355d54 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -61,11 +61,11 @@ def parse_body(body, tokenizer) comment_tag_depth += 1 when "endcomment" comment_tag_depth -= 1 + end - if comment_tag_depth.zero? - parse_context.trim_whitespace = (token[-3] == WhitespaceControl) - return false - end + if comment_tag_depth.zero? + parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag + return false end end diff --git a/test/unit/tags/comment_tag_unit_test.rb b/test/unit/tags/comment_tag_unit_test.rb index b5bc48fa1..56618b720 100644 --- a/test/unit/tags/comment_tag_unit_test.rb +++ b/test/unit/tags/comment_tag_unit_test.rb @@ -187,4 +187,16 @@ def test_with_whitespace_control Hello! LIQUID end + + def test_dont_override_liquid_tag_whitespace_control + assert_template_result("Hello!World!", <<~LIQUID.chomp) + Hello! + {%- liquid + comment + this is inside a liquid tag + endcomment + -%} + World! + LIQUID + end end From 369a6c55e349e8785f1c5964f65db3258b210688 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 5 Jan 2024 17:53:29 -0400 Subject: [PATCH 086/343] check template UTF8 validity before parsing --- lib/liquid/locales/en.yml | 1 + lib/liquid/template.rb | 5 +++++ test/integration/template_test.rb | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 7e232de42..f33c61cec 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -15,6 +15,7 @@ include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]" inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character" invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" + invalid_template_encoding: "Invalid template encoding" render: "Syntax error in tag 'render' - Template name must be a quoted string" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" tag_never_closed: "'%{block_name}' tag was never closed" diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 30742dd6f..de06a6fa4 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -107,6 +107,11 @@ def initialize # Returns self for easy chaining def parse(source, options = {}) parse_context = configure_options(options) + + unless source.valid_encoding? + raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_template_encoding") + end + tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1) @root = Document.parse(tokenizer, parse_context) self diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index d22254892..2ed1a6fa8 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -337,4 +337,16 @@ def to_s assert_equal("x=2", output) assert_instance_of(String, output) end + + def test_raises_error_with_invalid_utf8 + e = assert_raises(SyntaxError) do + Template.parse(<<~LIQUID) + {% comment %} + \xC0 + {% endcomment %} + LIQUID + end + + assert_equal('Liquid syntax error: Invalid template encoding', e.message) + end end From 730ad3684a0480c97d0c22a1f907d16215d82aee Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 10 Jan 2024 16:32:08 -0400 Subject: [PATCH 087/343] allow nil template source --- lib/liquid/template.rb | 2 +- test/integration/template_test.rb | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index de06a6fa4..3776415d2 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -108,7 +108,7 @@ def initialize def parse(source, options = {}) parse_context = configure_options(options) - unless source.valid_encoding? + if source.is_a?(String) && !source.valid_encoding? raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_template_encoding") end diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index 2ed1a6fa8..08ec76489 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -349,4 +349,9 @@ def test_raises_error_with_invalid_utf8 assert_equal('Liquid syntax error: Invalid template encoding', e.message) end + + def test_allows_nil_as_source + template = Template.parse(nil) + assert_equal('', template.render) + end end From 6a0fe3f7e30a9c8d4aea1efd5266580d1b8cbdf5 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 10 Jan 2024 16:50:31 -0400 Subject: [PATCH 088/343] convert template source to string to ensure encoding validity --- lib/liquid/template.rb | 3 ++- lib/liquid/tokenizer.rb | 2 +- test/integration/template_test.rb | 7 ++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 3776415d2..e27ca922a 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -107,8 +107,9 @@ def initialize # Returns self for easy chaining def parse(source, options = {}) parse_context = configure_options(options) + source = source.to_s.to_str - if source.is_a?(String) && !source.valid_encoding? + unless source.valid_encoding? raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_template_encoding") end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 740f85999..1b3984cab 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -5,7 +5,7 @@ class Tokenizer attr_reader :line_number, :for_liquid_tag def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) - @source = source.to_s.to_str + @source = source @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag @offset = 0 diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index 08ec76489..c2b63eb51 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -350,8 +350,9 @@ def test_raises_error_with_invalid_utf8 assert_equal('Liquid syntax error: Invalid template encoding', e.message) end - def test_allows_nil_as_source - template = Template.parse(nil) - assert_equal('', template.render) + def test_allows_non_string_values_as_source + assert_equal('', Template.parse(nil).render) + assert_equal('1', Template.parse(1).render) + assert_equal('true', Template.parse(true).render) end end From 7b25b770af3f7ca81cbb4a760d1a037ce801753d Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 12 Jan 2024 12:17:20 -0400 Subject: [PATCH 089/343] add new TemplateEncodingError --- lib/liquid/errors.rb | 31 ++++++++++++++++--------------- lib/liquid/template.rb | 2 +- test/integration/template_test.rb | 4 ++-- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/liquid/errors.rb b/lib/liquid/errors.rb index 832341d76..086fabb19 100644 --- a/lib/liquid/errors.rb +++ b/lib/liquid/errors.rb @@ -40,19 +40,20 @@ def message_prefix end end - ArgumentError = Class.new(Error) - ContextError = Class.new(Error) - FileSystemError = Class.new(Error) - StandardError = Class.new(Error) - SyntaxError = Class.new(Error) - StackLevelError = Class.new(Error) - MemoryError = Class.new(Error) - ZeroDivisionError = Class.new(Error) - FloatDomainError = Class.new(Error) - UndefinedVariable = Class.new(Error) - UndefinedDropMethod = Class.new(Error) - UndefinedFilter = Class.new(Error) - MethodOverrideError = Class.new(Error) - DisabledError = Class.new(Error) - InternalError = Class.new(Error) + ArgumentError = Class.new(Error) + ContextError = Class.new(Error) + FileSystemError = Class.new(Error) + StandardError = Class.new(Error) + SyntaxError = Class.new(Error) + StackLevelError = Class.new(Error) + MemoryError = Class.new(Error) + ZeroDivisionError = Class.new(Error) + FloatDomainError = Class.new(Error) + UndefinedVariable = Class.new(Error) + UndefinedDropMethod = Class.new(Error) + UndefinedFilter = Class.new(Error) + MethodOverrideError = Class.new(Error) + DisabledError = Class.new(Error) + InternalError = Class.new(Error) + TemplateEncodingError = Class.new(Error) end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index de06a6fa4..1fd9fd63c 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -109,7 +109,7 @@ def parse(source, options = {}) parse_context = configure_options(options) unless source.valid_encoding? - raise SyntaxError, parse_context.locale.t("errors.syntax.invalid_template_encoding") + raise TemplateEncodingError, parse_context.locale.t("errors.syntax.invalid_template_encoding") end tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1) diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index 2ed1a6fa8..2341bf4eb 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -339,7 +339,7 @@ def to_s end def test_raises_error_with_invalid_utf8 - e = assert_raises(SyntaxError) do + e = assert_raises(TemplateEncodingError) do Template.parse(<<~LIQUID) {% comment %} \xC0 @@ -347,6 +347,6 @@ def test_raises_error_with_invalid_utf8 LIQUID end - assert_equal('Liquid syntax error: Invalid template encoding', e.message) + assert_equal('Liquid error: Invalid template encoding', e.message) end end From 1b2b62964e1c272740676b63496193fa01417aaa Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Tue, 30 Jan 2024 19:03:05 -0500 Subject: [PATCH 090/343] Allow for binary comparison of incompatible strings --- lib/liquid/condition.rb | 3 +++ test/unit/condition_unit_test.rb | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index d35ba3011..e5c321dca 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -24,6 +24,9 @@ class Condition # :nodoc: else false end + rescue Encoding::CompatibilityError + # "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal + left.b.include?(right.b) end, } diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index d10cbb528..acc71dc4e 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -55,6 +55,11 @@ def test_contains_works_on_strings assert_evaluates_false('bob', 'contains', '---') end + def test_contains_binary_encoding_compatibility_with_utf8 + assert_evaluates_true('🙈'.b, 'contains', '🙈') + assert_evaluates_true('🙈', 'contains', '🙈'.b) + end + def test_invalid_comparation_operator assert_evaluates_argument_error(1, '~~', 0) end @@ -166,14 +171,14 @@ def test_default_context_is_deprecated def assert_evaluates_true(left, op, right) assert( Condition.new(left, op, right).evaluate(@context), - "Evaluated false: #{left} #{op} #{right}", + "Evaluated false: #{left.inspect} #{op} #{right.inspect}", ) end def assert_evaluates_false(left, op, right) assert( !Condition.new(left, op, right).evaluate(@context), - "Evaluated true: #{left} #{op} #{right}", + "Evaluated true: #{left.inspect} #{op} #{right.inspect}", ) end From a5e5fab82a8f52f7045c329c15c3663ed25820a0 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 24 Jan 2024 15:24:57 -0400 Subject: [PATCH 091/343] update variable's context before invoking its to_liquid --- lib/liquid/context.rb | 8 +++++-- test/integration/context_test.rb | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 5b1a01ee1..80720a450 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -197,10 +197,14 @@ def find_variable(key, raise_on_not_found: true) try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found) end - variable = variable.to_liquid + # update variable's context before invoking #to_liquid variable.context = self if variable.respond_to?(:context=) - variable + liquid_variable = variable.to_liquid + + liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=) + + liquid_variable end def lookup_and_evaluate(obj, key, raise_on_not_found: true) diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index a25362a67..03b33cf58 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -36,6 +36,24 @@ def to_liquid end end +class ProductsDrop < Liquid::Drop + def initialize(products) + @products = products + end + + def size + @products.size + end + + def to_liquid + if @context["forloop"] + @products.first(@context["forloop"].length) + else + @products + end + end +end + class CategoryDrop < Liquid::Drop attr_accessor :category, :context @@ -635,6 +653,25 @@ def test_context_always_uses_static_registers assert_equal(:my_value, c.registers[:my_register]) end + def test_variable_to_liquid_returns_contextual_drop + context = { + "products" => ProductsDrop.new(["A", "B", "C", "D", "E"]), + } + + template = Liquid::Template.parse(<<~LIQUID) + {%- for i in (1..3) -%} + for_loop_products_count: {{ products | size }} + {% endfor %} + + unscoped_products_count: {{ products | size }} + LIQUID + + result = template.render(context) + + assert_includes(result, "for_loop_products_count: 3") + assert_includes(result, "unscoped_products_count: 5") + end + private def assert_no_object_allocations From 4f35b0bc665ef488aeb9c959056a4135c5cb219b Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 12 Feb 2024 16:49:52 -0400 Subject: [PATCH 092/343] update liquid-c --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 31561a9e0..b9b79f4f4 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,6 @@ group :test do gem 'rubocop-performance', require: false platform :mri, :truffleruby do - gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'master' + gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'main' end end From 56a0b7c42bb987b512da5ccb825a9cec7b670918 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 21 Mar 2024 15:48:54 -0400 Subject: [PATCH 093/343] Bump to v5.5.0 --- .github/workflows/liquid.yml | 5 +++-- History.md | 4 ++++ lib/liquid/version.rb | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 7dc744325..8bda0aa1b 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -12,7 +12,8 @@ jobs: matrix: entry: - { ruby: 2.7, allowed-failure: false } # minimum supported - - { ruby: 3.2, allowed-failure: false } # latest + - { ruby: 3.2, allowed-failure: false } + - { ruby: 3.3, allowed-failure: false } # latest - { ruby: ruby-head, allowed-failure: true } name: Test Ruby ${{ matrix.entry.ruby }} steps: @@ -30,6 +31,6 @@ jobs: - uses: actions/checkout@v3 - uses: ruby/setup-ruby@v1 with: - ruby-version: 2.7 + ruby-version: 3.3 bundler-cache: true - run: bundle exec rake memory_profile:run diff --git a/History.md b/History.md index 65e29243e..7d94db602 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,9 @@ # Liquid Change Log +## 5.5.0 2024-03-21 + +Please reference the GitHub release for more information. + ## 5.4.0 2022-07-29 ### Breaking Changes diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 7fdbe9b76..9a567060e 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.4.0" + VERSION = "5.5.0" end From 8596bb2e38b10cbd6d4933d17e838290c0123a19 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Fri, 5 Apr 2024 10:21:49 -0400 Subject: [PATCH 094/343] Commit a .ruby-version --- .gitignore | 1 - .ruby-version | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .ruby-version diff --git a/.gitignore b/.gitignore index 90bf6dc1c..c101fd033 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ pkg *.rbc .rvmrc -.ruby-version Gemfile.lock .bundle .byebug_history diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..15a279981 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.0 From fc4f19471eb629cc48aa873d0918d18cc291443f Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Fri, 5 Apr 2024 10:12:42 -0400 Subject: [PATCH 095/343] Update rubocop to 1.61.0 --- .gitignore | 1 - Gemfile | 2 +- Gemfile.lock | 73 +++++++++++++++++++++++++++++++++++++++++++ lib/liquid/context.rb | 2 +- lib/liquid/tags/if.rb | 2 +- 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 Gemfile.lock diff --git a/.gitignore b/.gitignore index c101fd033..9029bf307 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,5 @@ pkg *.rbc .rvmrc -Gemfile.lock .bundle .byebug_history diff --git a/Gemfile b/Gemfile index b9b79f4f4..b5881c4a2 100644 --- a/Gemfile +++ b/Gemfile @@ -18,7 +18,7 @@ group :benchmark, :test do end group :test do - gem 'rubocop', '~> 1.44.0' + gem 'rubocop', '~> 1.61.0' gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..66ff47345 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,73 @@ +GIT + remote: https://github.com/Shopify/liquid-c.git + revision: 5a786af7284df55e013ea20551c4b688d02e8326 + ref: main + specs: + liquid-c (4.2.0) + liquid (>= 5.0.1) + +PATH + remote: . + specs: + liquid (5.5.0) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + benchmark-ips (2.13.0) + json (2.7.2) + language_server-protocol (3.17.0.3) + memory_profiler (1.0.1) + minitest (5.22.3) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + racc (1.7.3) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.0) + rexml (3.2.6) + rubocop (1.61.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-shopify (2.12.0) + rubocop (~> 1.44) + ruby-progressbar (1.13.0) + stackprof (0.2.26) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.5.0) + +PLATFORMS + ruby + +DEPENDENCIES + benchmark-ips + liquid! + liquid-c! + memory_profiler + minitest + rake (~> 13.0) + rubocop (~> 1.61.0) + rubocop-performance + rubocop-shopify (~> 2.12.0) + stackprof + terminal-table + +BUNDLED WITH + 2.5.7 diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 80720a450..e712b4cc8 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -27,7 +27,7 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @environments.flatten! @static_environments = [static_environments].flatten(1).freeze - @scopes = [(outer_scope || {})] + @scopes = [outer_scope || {}] @registers = registers.is_a?(Registers) ? registers : Registers.new(registers) @errors = [] @partial = false diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index a5cc84697..92ed8aa3b 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -111,7 +111,7 @@ def strict_parse(markup) def parse_binary_comparisons(p) condition = parse_comparison(p) first_condition = condition - while (op = (p.id?('and') || p.id?('or'))) + while (op = p.id?('and') || p.id?('or')) child_condition = parse_comparison(p) condition.send(op, child_condition) condition = child_condition From 2b4810006ba68cc5376316ee464049159a842487 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Fri, 5 Apr 2024 10:12:56 -0400 Subject: [PATCH 096/343] Remove TargetRubyVersion in RuboCop config reads from required_ruby_version in RuboCop 1.61+ --- .rubocop.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index f917cc52d..8aebe5d81 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,7 +10,6 @@ Performance: Enabled: true AllCops: - TargetRubyVersion: 2.7 NewCops: disable SuggestExtensions: false Exclude: From 36c7fc8e079d8326658f0bfb35cd1770333a8310 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Fri, 5 Apr 2024 10:33:06 -0400 Subject: [PATCH 097/343] Remove ruby version definition in CI/CD it will be read from .ruby-version --- .github/workflows/liquid.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 8bda0aa1b..c06a05af8 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -31,6 +31,5 @@ jobs: - uses: actions/checkout@v3 - uses: ruby/setup-ruby@v1 with: - ruby-version: 3.3 bundler-cache: true - run: bundle exec rake memory_profile:run From 88d013c8da9e9ced2d7f90c8246e21404f33822d Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Fri, 5 Apr 2024 10:33:27 -0400 Subject: [PATCH 098/343] Use latest compatible Bundler of each Ruby version run --- .github/workflows/liquid.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index c06a05af8..63d966621 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -22,6 +22,7 @@ jobs: with: ruby-version: ${{ matrix.entry.ruby }} bundler-cache: true + bundler: latest - run: bundle exec rake continue-on-error: ${{ matrix.entry.allowed-failure }} From d36937d17f18e076412cef4a1a5acbe418ce9dd0 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Tue, 25 Jun 2024 11:44:12 +0200 Subject: [PATCH 099/343] Add named? method to Cycle class for checking if the cycle is named --- lib/liquid/tags/cycle.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 00695265d..2514bf3ba 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -26,14 +26,20 @@ def initialize(tag_name, markup, options) when NamedSyntax @variables = variables_from_string(Regexp.last_match(2)) @name = parse_expression(Regexp.last_match(1)) + @is_named = true when SimpleSyntax @variables = variables_from_string(markup) @name = @variables.to_s + @is_named = !@name.match?(/\w+:0x\h{8}/) else raise SyntaxError, options[:locale].t("errors.syntax.cycle") end end + def named? + @is_named + end + def render_to_output_buffer(context, output) context.registers[:cycle] ||= {} From d6ca569e8a33604681a1c7da3449906df92bffbb Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Tue, 16 Jul 2024 15:55:49 -0400 Subject: [PATCH 100/343] Require `base64` --- Gemfile | 2 ++ Gemfile.lock | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Gemfile b/Gemfile index b5881c4a2..509316c1a 100644 --- a/Gemfile +++ b/Gemfile @@ -7,6 +7,8 @@ end gemspec +gem "base64" + group :benchmark, :test do gem 'benchmark-ips' gem 'memory_profiler' diff --git a/Gemfile.lock b/Gemfile.lock index 66ff47345..cbfda8d4d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,6 +15,7 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) + base64 (0.2.0) benchmark-ips (2.13.0) json (2.7.2) language_server-protocol (3.17.0.3) @@ -57,6 +58,7 @@ PLATFORMS ruby DEPENDENCIES + base64 benchmark-ips liquid! liquid-c! From a0411e09277ddba0e12167d56cc2d6a52847ca91 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Mon, 22 Jul 2024 16:40:42 -0400 Subject: [PATCH 101/343] Bump to v5.5.1 --- Gemfile.lock | 2 +- lib/liquid/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index cbfda8d4d..a2860ce65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,7 +9,7 @@ GIT PATH remote: . specs: - liquid (5.5.0) + liquid (5.5.1) GEM remote: https://rubygems.org/ diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 9a567060e..6f833394f 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.5.0" + VERSION = "5.5.1" end From fb6634f454e8c352a6653784107850c8843daf5a Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 7 Aug 2024 15:00:44 -0400 Subject: [PATCH 102/343] Add concept of `Liquid::Environment` (#1815) * Add concept of `Liquid::World` * Rename `World` to `Environment` --- README.md | 48 ++++++- lib/liquid.rb | 21 +-- lib/liquid/block_body.rb | 8 +- lib/liquid/const.rb | 8 ++ lib/liquid/context.rb | 15 +- lib/liquid/deprecations.rb | 22 +++ lib/liquid/environment.rb | 159 ++++++++++++++++++++++ lib/liquid/parse_context.rb | 7 +- lib/liquid/standardfilters.rb | 2 - lib/liquid/strainer_factory.rb | 36 ++--- lib/liquid/tag.rb | 3 + lib/liquid/tags.rb | 47 +++++++ lib/liquid/tags/assign.rb | 2 - lib/liquid/tags/break.rb | 2 - lib/liquid/tags/capture.rb | 2 - lib/liquid/tags/case.rb | 2 - lib/liquid/tags/comment.rb | 2 - lib/liquid/tags/continue.rb | 2 - lib/liquid/tags/cycle.rb | 2 - lib/liquid/tags/decrement.rb | 2 - lib/liquid/tags/echo.rb | 2 - lib/liquid/tags/for.rb | 2 - lib/liquid/tags/if.rb | 2 - lib/liquid/tags/ifchanged.rb | 2 - lib/liquid/tags/include.rb | 2 - lib/liquid/tags/increment.rb | 2 - lib/liquid/tags/inline_comment.rb | 2 - lib/liquid/tags/raw.rb | 2 - lib/liquid/tags/render.rb | 2 - lib/liquid/tags/table_row.rb | 2 - lib/liquid/tags/unless.rb | 2 - lib/liquid/template.rb | 107 +++++++-------- lib/liquid/template/tag_registry.rb | 44 ++++++ test/integration/error_handling_test.rb | 27 ++-- test/integration/tags/include_tag_test.rb | 11 +- test/test_helper.rb | 42 ++---- test/unit/strainer_template_unit_test.rb | 5 +- test/unit/template_unit_test.rb | 31 ++--- 38 files changed, 459 insertions(+), 222 deletions(-) create mode 100644 lib/liquid/const.rb create mode 100644 lib/liquid/deprecations.rb create mode 100644 lib/liquid/environment.rb create mode 100644 lib/liquid/tags.rb create mode 100644 lib/liquid/template/tag_registry.rb diff --git a/README.md b/README.md index 76cfa3c0e..e1df18ba7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,47 @@ For standard use you can just pass it the content of a file and call render with @template.render('name' => 'tobi') # => "hi tobi" ``` +### Concept of Environments + +In Liquid, a "Environment" is a scoped environment that encapsulates custom tags, filters, and other configurations. This allows you to define and isolate different sets of functionality for different contexts, avoiding global overrides that can lead to conflicts and unexpected behavior. + +By using environments, you can: + +1. **Encapsulate Logic**: Keep the logic for different parts of your application separate. +2. **Avoid Conflicts**: Prevent custom tags and filters from clashing with each other. +3. **Improve Maintainability**: Make it easier to manage and understand the scope of customizations. +4. **Enhance Security**: Limit the availability of certain tags and filters to specific contexts. + +We encourage the use of Environments over globally overriding things because it promotes better software design principles such as modularity, encapsulation, and separation of concerns. + +Here's an example of how you can define and use Environments in Liquid: + +```ruby +user_environment = Liquid::Environment.build do |environment| + environment.register_tag("renderobj", RenderObjTag) +end + +Liquid::Template.parse(<<~LIQUID, environment: user_environment) + {% renderobj src: "path/to/model.obj" %} +LIQUID +``` + +In this example, `RenderObjTag` is a custom tag that is only available within the `user_environment`. + +Similarly, you can define another environment for a different context, such as email templates: + +```ruby +email_environment = Liquid::Environment.build do |environment| + environment.register_tag("unsubscribe_footer", UnsubscribeFooter) +end + +Liquid::Template.parse(<<~LIQUID, environment: email_environment) + {% unsubscribe_footer %} +LIQUID +``` + +By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. + ### Error Modes Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted. @@ -62,9 +103,10 @@ Liquid also comes with a stricter parser that can be used when editing templates when templates are invalid. You can enable this new parser like this: ```ruby -Liquid::Template.error_mode = :strict # Raises a SyntaxError when invalid syntax is used -Liquid::Template.error_mode = :warn # Adds strict errors to template.errors but continues as normal -Liquid::Template.error_mode = :lax # The default mode, accepts almost anything. +Liquid::Environment.default.error_mode = :strict +Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used +Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal +Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. ``` If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: diff --git a/lib/liquid.rb b/lib/liquid.rb index eba84e140..6f071a8a6 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -50,7 +50,18 @@ module Liquid end require "liquid/version" +require "liquid/deprecations" +require "liquid/const" +require "liquid/template/tag_registry" +require 'liquid/standardfilters' +require 'liquid/file_system' +require 'liquid/parser_switching' +require 'liquid/tag' +require 'liquid/block' require 'liquid/parse_tree_visitor' +require 'liquid/interrupts' +require 'liquid/tags' +require "liquid/environment" require 'liquid/lexer' require 'liquid/parser' require 'liquid/i18n' @@ -64,20 +75,14 @@ module Liquid require 'liquid/strainer_factory' require 'liquid/expression' require 'liquid/context' -require 'liquid/parser_switching' require 'liquid/tag' -require 'liquid/tag/disabler' -require 'liquid/tag/disableable' -require 'liquid/block' require 'liquid/block_body' require 'liquid/document' require 'liquid/variable' require 'liquid/variable_lookup' require 'liquid/range_lookup' -require 'liquid/file_system' require 'liquid/resource_limits' require 'liquid/template' -require 'liquid/standardfilters' require 'liquid/condition' require 'liquid/utils' require 'liquid/tokenizer' @@ -86,7 +91,3 @@ module Liquid require 'liquid/usage' require 'liquid/registers' require 'liquid/template_factory' - -# Load all the tags of the standard library -# -Dir["#{__dir__}/liquid/tags/*.rb"].each { |f| require f } diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 61096de80..ec40e2129 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -52,7 +52,7 @@ def freeze next parse_liquid_tag(markup, parse_context) end - unless (tag = registered_tags[tag_name]) + unless (tag = parse_context.environment.tag_for_name(tag_name)) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed return yield tag_name, markup @@ -147,7 +147,7 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag) next end - unless (tag = registered_tags[tag_name]) + unless (tag = parse_context.environment.tag_for_name(tag_name)) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed return yield tag_name, markup @@ -262,9 +262,5 @@ def raise_missing_tag_terminator(token, parse_context) def raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context) end - - def registered_tags - Template.tags - end end end diff --git a/lib/liquid/const.rb b/lib/liquid/const.rb new file mode 100644 index 000000000..607f51a5b --- /dev/null +++ b/lib/liquid/const.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Liquid + module Const + EMPTY_HASH = {}.freeze + EMPTY_ARRAY = [].freeze + end +end diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index e712b4cc8..011235644 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -15,14 +15,15 @@ module Liquid # context['bob'] #=> nil class Context class Context 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 + attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :environment # rubocop:disable Metrics/ParameterLists - def self.build(environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) + def self.build(environment: Environment.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, &block) end - def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}) + def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default) + @environment = environment @environments = [environments] @environments.flatten! @@ -32,7 +33,7 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @errors = [] @partial = false @strict_variables = false - @resource_limits = resource_limits || ResourceLimits.new(Template.default_resource_limits) + @resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits) @base_scope_depth = 0 @interrupts = [] @filters = [] @@ -40,10 +41,10 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @disabled_tags = {} @registers.static[:cached_partials] ||= {} - @registers.static[:file_system] ||= Liquid::Template.file_system + @registers.static[:file_system] ||= environment.file_system @registers.static[:template_factory] ||= Liquid::TemplateFactory.new - self.exception_renderer = Template.default_exception_renderer + self.exception_renderer = environment.exception_renderer if rethrow_errors self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA end @@ -60,7 +61,7 @@ def warnings end def strainer - @strainer ||= StrainerFactory.create(self, @filters) + @strainer ||= @environment.create_strainer(self, @filters) end # Adds filters to this context. diff --git a/lib/liquid/deprecations.rb b/lib/liquid/deprecations.rb new file mode 100644 index 000000000..e1ec628f4 --- /dev/null +++ b/lib/liquid/deprecations.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "set" + +module Liquid + class Deprecations + class << self + attr_accessor :warned + + Deprecations.warned = Set.new + + def warn(name, alternative) + return if warned.include?(name) + + warned << name + + caller_location = caller_locations(2, 1).first + Warning.warn("[DEPRECATION] #{name} is deprecated. Use #{alternative} instead. Called from #{caller_location}\n") + end + end + end +end diff --git a/lib/liquid/environment.rb b/lib/liquid/environment.rb new file mode 100644 index 000000000..fdd404fcd --- /dev/null +++ b/lib/liquid/environment.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Liquid + # The Environment is the container for all configuration options of Liquid, such as + # the registered tags, filters, and the default error mode. + class Environment + # The default error mode for all templates. This can be overridden on a + # per-template basis. + attr_accessor :error_mode + + # The tags that are available to use in the template. + attr_accessor :tags + + # The strainer template which is used to store filters that are available to + # use in templates. + attr_accessor :strainer_template + + # The exception renderer that is used to render exceptions that are raised + # when rendering a template + attr_accessor :exception_renderer + + # The default file system that is used to load templates from. + attr_accessor :file_system + + # The default resource limits that are used to limit the resources that a + # template can consume. + attr_accessor :default_resource_limits + + class << self + # Creates a new environment instance. + # + # @param tags [Hash] The tags that are available to use in + # the template. + # @param file_system The default file system that is used + # to load templates from. + # @param error_mode [Symbol] The default error mode for all templates + # (either :strict, :warn, or :lax). + # @param exception_renderer [Proc] The exception renderer that is used to + # render exceptions. + # @yieldparam environment [Environment] The environment instance that is being built. + # @return [Environment] The new environment instance. + def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil) + ret = new + ret.tags = Template::TagRegistry.new(tags) if tags + ret.file_system = file_system if file_system + ret.error_mode = error_mode if error_mode + ret.exception_renderer = exception_renderer if exception_renderer + yield ret if block_given? + ret.freeze + end + + # Returns the default environment instance. + # + # @return [Environment] The default environment instance. + def default + @default ||= new + end + + # Sets the default environment instance for the duration of the block + # + # @param environment [Environment] The environment instance to use as the default for the + # duration of the block. + # @yield + # @return [Object] The return value of the block. + def dangerously_override(environment) + original_default = @default + @default = environment + yield + ensure + @default = original_default + end + end + + # Initializes a new environment instance. + # @api private + def initialize + @tags = Template::TagRegistry.new(Tags::STANDARD_TAGS) + @error_mode = :lax + @strainer_template = Class.new(StrainerTemplate).tap do |klass| + klass.add_filter(StandardFilters) + end + @exception_renderer = ->(exception) { exception } + @file_system = BlankFileSystem.new + @default_resource_limits = Const::EMPTY_HASH + @strainer_template_class_cache = {} + end + + # Registers a new tag with the environment. + # + # @param name [String] The name of the tag. + # @param klass [Liquid::Tag] The class that implements the tag. + # @return [void] + def register_tag(name, klass) + @tags[name] = klass + end + + # Registers a new filter with the environment. + # + # @param filter [Module] The module that contains the filter methods. + # @return [void] + def register_filter(filter) + @strainer_template_class_cache.clear + @strainer_template.add_filter(filter) + end + + # Registers multiple filters with this environment. + # + # @param filters [Array] The modules that contain the filter methods. + # @return [self] + def register_filters(filters) + @strainer_template_class_cache.clear + filters.each { |f| @strainer_template.add_filter(f) } + self + end + + # Creates a new strainer instance with the given filters, caching the result + # for faster lookup. + # + # @param context [Liquid::Context] The context that the strainer will be + # used in. + # @param filters [Array] The filters that the strainer will have + # access to. + # @return [Liquid::Strainer] The new strainer instance. + def create_strainer(context, filters = Const::EMPTY_ARRAY) + return @strainer_template.new(context) if filters.empty? + + strainer_template = @strainer_template_class_cache[filters] ||= begin + klass = Class.new(@strainer_template) + filters.each { |f| klass.add_filter(f) } + klass + end + + strainer_template.new(context) + end + + # Returns the names of all the filter methods that are available to use in + # the strainer template. + # + # @return [Array] The names of all the filter methods. + def filter_method_names + @strainer_template.filter_method_names + end + + # Returns the tag class for the given tag name. + # + # @param name [String] The name of the tag. + # @return [Liquid::Tag] The tag class. + def tag_for_name(name) + @tags[name] + end + + def freeze + @tags.freeze + # TODO: freeze the tags, currently this is not possible because of liquid-c + # @strainer_template.freeze + super + end + end +end diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 87570ad52..7bd5418d5 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -3,9 +3,10 @@ module Liquid class ParseContext attr_accessor :locale, :line_number, :trim_whitespace, :depth - attr_reader :partial, :warnings, :error_mode + attr_reader :partial, :warnings, :error_mode, :environment - def initialize(options = {}) + def initialize(options = Const::EMPTY_HASH) + @environment = options.fetch(:environment, Environment.default) @template_options = options ? options.dup : {} @locale = @template_options[:locale] ||= I18n.new @@ -35,7 +36,7 @@ def partial=(value) @partial = value @options = value ? partial_options : @template_options - @error_mode = @options[:error_mode] || Template.error_mode + @error_mode = @options[:error_mode] || @environment.error_mode end def partial_options diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 3b0ecf15d..6f1afaca6 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -1001,6 +1001,4 @@ def each end end end - - Template.register_filter(StandardFilters) end diff --git a/lib/liquid/strainer_factory.rb b/lib/liquid/strainer_factory.rb index bb51ef062..869b09ad3 100644 --- a/lib/liquid/strainer_factory.rb +++ b/lib/liquid/strainer_factory.rb @@ -5,37 +5,19 @@ module Liquid module StrainerFactory extend self - def add_global_filter(filter) - strainer_class_cache.clear - GlobalCache.add_filter(filter) + def add_global_filter(filter, environment = Environment.default) + Deprecations.warn("StrainerFactory.add_global_filter", "Environment#register_filter") + environment.register_filter(filter) end - def create(context, filters = []) - strainer_from_cache(filters).new(context) + def create(context, filters = Const::EMPTY_ARRAY, environment = Environment.default) + Deprecations.warn("StrainerFactory.create", "StrainerFactory.create_strainer") + environment.create_strainer(context, filters) end - def global_filter_names - GlobalCache.filter_method_names - end - - GlobalCache = Class.new(StrainerTemplate) - - private - - def strainer_from_cache(filters) - if filters.empty? - GlobalCache - else - strainer_class_cache[filters] ||= begin - klass = Class.new(GlobalCache) - filters.each { |f| klass.add_filter(f) } - klass - end - end - end - - def strainer_class_cache - @strainer_class_cache ||= {} + def global_filter_names(environment = Environment.default) + Deprecations.warn("StrainerFactory.global_filter_names", "Environment#filter_method_names") + Environment.strainer_template.filter_method_names end end end diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 8303a7233..9ca97d0f3 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require 'liquid/tag/disabler' +require 'liquid/tag/disableable' + module Liquid class Tag attr_reader :nodelist, :tag_name, :line_number, :parse_context diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb new file mode 100644 index 000000000..916a63bd5 --- /dev/null +++ b/lib/liquid/tags.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "tags/table_row" +require_relative "tags/echo" +require_relative "tags/if" +require_relative "tags/break" +require_relative "tags/inline_comment" +require_relative "tags/for" +require_relative "tags/assign" +require_relative "tags/ifchanged" +require_relative "tags/case" +require_relative "tags/include" +require_relative "tags/continue" +require_relative "tags/capture" +require_relative "tags/decrement" +require_relative "tags/unless" +require_relative "tags/increment" +require_relative "tags/comment" +require_relative "tags/raw" +require_relative "tags/render" +require_relative "tags/cycle" + +module Liquid + module Tags + STANDARD_TAGS = { + 'cycle' => Cycle, + 'render' => Render, + 'raw' => Raw, + 'comment' => Comment, + 'increment' => Increment, + 'unless' => Unless, + 'decrement' => Decrement, + 'capture' => Capture, + 'continue' => Continue, + 'include' => Include, + 'case' => Case, + 'ifchanged' => Ifchanged, + 'assign' => Assign, + 'for' => For, + '#' => InlineComment, + 'break' => Break, + 'if' => If, + 'echo' => Echo, + 'tablerow' => TableRow, + }.freeze + end +end diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 9eff9796e..702831781 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -72,6 +72,4 @@ def children end end end - - Template.register_tag('assign', Assign) end diff --git a/lib/liquid/tags/break.rb b/lib/liquid/tags/break.rb index c2293161d..cec640074 100644 --- a/lib/liquid/tags/break.rb +++ b/lib/liquid/tags/break.rb @@ -26,6 +26,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('break', Break) end diff --git a/lib/liquid/tags/capture.rb b/lib/liquid/tags/capture.rb index 1b9f28193..59aa62e7f 100644 --- a/lib/liquid/tags/capture.rb +++ b/lib/liquid/tags/capture.rb @@ -39,6 +39,4 @@ def blank? true end end - - Template.register_tag('capture', Capture) end diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 4fe4cd98c..6b67601fe 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -123,6 +123,4 @@ def children end end end - - Template.register_tag('case', Case) end diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 798355d54..659108e37 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -85,6 +85,4 @@ def parse_raw_tag_body(tokenizer) raise_tag_never_closed("raw") end end - - Template.register_tag('comment', Comment) end diff --git a/lib/liquid/tags/continue.rb b/lib/liquid/tags/continue.rb index 875508384..48e235186 100644 --- a/lib/liquid/tags/continue.rb +++ b/lib/liquid/tags/continue.rb @@ -17,6 +17,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('continue', Continue) end diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 2514bf3ba..06d788de3 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -78,6 +78,4 @@ def children end end end - - Template.register_tag('cycle', Cycle) end diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 8a99eb94f..677166621 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -35,6 +35,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('decrement', Decrement) end diff --git a/lib/liquid/tags/echo.rb b/lib/liquid/tags/echo.rb index 8abb6c2b2..2c45c9cb6 100644 --- a/lib/liquid/tags/echo.rb +++ b/lib/liquid/tags/echo.rb @@ -36,6 +36,4 @@ def children end end end - - Template.register_tag('echo', Echo) end diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 9205fa42f..0dda3081d 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -201,6 +201,4 @@ def children end end end - - Template.register_tag('for', For) end diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 92ed8aa3b..bcf250a11 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -135,6 +135,4 @@ def children end end end - - Template.register_tag('if', If) end diff --git a/lib/liquid/tags/ifchanged.rb b/lib/liquid/tags/ifchanged.rb index dd3be532a..50397794d 100644 --- a/lib/liquid/tags/ifchanged.rb +++ b/lib/liquid/tags/ifchanged.rb @@ -14,6 +14,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('ifchanged', Ifchanged) end diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 7b9685eeb..b4f1be137 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -110,6 +110,4 @@ def children end end end - - Template.register_tag('include', Include) end diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 5f7e4f6fa..c650e5ac5 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -35,6 +35,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('increment', Increment) end diff --git a/lib/liquid/tags/inline_comment.rb b/lib/liquid/tags/inline_comment.rb index 493cfddc7..3aa7c50f9 100644 --- a/lib/liquid/tags/inline_comment.rb +++ b/lib/liquid/tags/inline_comment.rb @@ -25,6 +25,4 @@ def blank? true end end - - Template.register_tag('#', InlineComment) end diff --git a/lib/liquid/tags/raw.rb b/lib/liquid/tags/raw.rb index 02ee2b3cb..2721c4a2d 100644 --- a/lib/liquid/tags/raw.rb +++ b/lib/liquid/tags/raw.rb @@ -56,6 +56,4 @@ def ensure_valid_markup(tag_name, markup, parse_context) end end end - - Template.register_tag('raw', Raw) end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index b9ae58ea0..3615b1b35 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -108,6 +108,4 @@ def children end end end - - Template.register_tag('render', Render) end diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index be9a64c44..f7e8cd72d 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -91,6 +91,4 @@ def to_integer(value) raise Liquid::ArgumentError, "invalid integer" end end - - Template.register_tag('tablerow', TableRow) end diff --git a/lib/liquid/tags/unless.rb b/lib/liquid/tags/unless.rb index 2fc79db56..a6faa6f93 100644 --- a/lib/liquid/tags/unless.rb +++ b/lib/liquid/tags/unless.rb @@ -44,6 +44,4 @@ def render_to_output_buffer(context, output) output end end - - Template.register_tag('unless', Unless) end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index f6369d1a4..9fb4089a4 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -18,89 +18,78 @@ class Template attr_accessor :root, :name attr_reader :resource_limits, :warnings - class TagRegistry - include Enumerable + attr_reader :profiler - def initialize - @tags = {} - @cache = {} + class << self + # Sets how strict the parser should be. + # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. + # :warn is the default and will give deprecation warnings when invalid syntax is used. + # :strict will enforce correct syntax. + def error_mode=(mode) + Deprecations.warn("Template.error_mode=", "Environment#error_mode=") + Environment.default.error_mode = mode end - def [](tag_name) - return nil unless @tags.key?(tag_name) - return @cache[tag_name] if Liquid.cache_classes - - lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o } + def error_mode + Environment.default.error_mode end - def []=(tag_name, klass) - @tags[tag_name] = klass.name - @cache[tag_name] = klass + def default_exception_renderer=(renderer) + Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=") + Environment.default.exception_renderer = renderer end - def delete(tag_name) - @tags.delete(tag_name) - @cache.delete(tag_name) + def default_exception_renderer + Environment.default.exception_renderer end - def each(&block) - @tags.each(&block) + def file_system=(file_system) + Deprecations.warn("Template.file_system=", "Environment#file_system=") + Environment.default.file_system = file_system end - private - - def lookup_class(name) - Object.const_get(name) + def file_system + Environment.default.file_system end - end - - attr_reader :profiler - class << self - # Sets how strict the parser should be. - # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. - # :warn is the default and will give deprecation warnings when invalid syntax is used. - # :strict will enforce correct syntax. - attr_accessor :error_mode - Template.error_mode = :lax - - attr_accessor :default_exception_renderer - Template.default_exception_renderer = lambda do |exception| - exception + def tags + Environment.default.tags end - attr_accessor :file_system - Template.file_system = BlankFileSystem.new - - attr_accessor :tags - Template.tags = TagRegistry.new - private :tags= - def register_tag(name, klass) - tags[name.to_s] = klass + Deprecations.warn("Template.register_tag", "Environment#register_tag") + Environment.default.register_tag(name, klass) end # Pass a module with filter methods which should be available # to all liquid views. Good for registering the standard library def register_filter(mod) - StrainerFactory.add_global_filter(mod) + Deprecations.warn("Template.register_filter", "Environment#register_filter") + Environment.default.register_filter(mod) end - attr_accessor :default_resource_limits - Template.default_resource_limits = {} - private :default_resource_limits= + private def default_resource_limits=(limits) + Deprecations.warn("Template.default_resource_limits=", "Environment#default_resource_limits=") + Environment.default.default_resource_limits = limits + end + + def default_resource_limits + Environment.default.default_resource_limits + end # creates a new Template object from liquid source code # To enable profiling, pass in profile: true as an option. # See Liquid::Profiler for more information def parse(source, options = {}) - new.parse(source, options) + environment = options[:environment] || Environment.default + new(environment: environment).parse(source, options) end end - def initialize + def initialize(environment: Environment.default) + @environment = environment @rethrow_errors = false - @resource_limits = ResourceLimits.new(Template.default_resource_limits) + @resource_limits = ResourceLimits.new(environment.default_resource_limits) end # Parse source code. @@ -162,11 +151,11 @@ def render(*args) c when Liquid::Drop drop = args.shift - drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits) + drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) when Hash - Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits) + Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) when nil - Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits) + Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) else raise ArgumentError, "Expected Hash or Liquid::Context as parameter" end @@ -226,8 +215,14 @@ def configure_options(options) @options = options @profiling = profiling @line_numbers = options[:line_numbers] || @profiling - parse_context = options.is_a?(ParseContext) ? options : ParseContext.new(options) - @warnings = parse_context.warnings + parse_context = if options.is_a?(ParseContext) + options + else + opts = options.key?(:environment) ? options : options.merge(environment: @environment) + ParseContext.new(opts) + end + + @warnings = parse_context.warnings parse_context end diff --git a/lib/liquid/template/tag_registry.rb b/lib/liquid/template/tag_registry.rb new file mode 100644 index 000000000..d055ad2e0 --- /dev/null +++ b/lib/liquid/template/tag_registry.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Liquid + class Template + class TagRegistry + include Enumerable + + def initialize(tags = nil) + @tags = {} + @cache = {} + tags.each { |tag_name, klass| self[tag_name] = klass } + Deprecations.warn("Template::TagRegistry", "Use a Environment instance with zeitwerk") + end + + def [](tag_name) + return nil unless @tags.key?(tag_name) + return @cache[tag_name] if Liquid.cache_classes + + lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o } + end + + def delete(tag_name) + Deprecations.warn("Template::TagRegistry#delete", "Use a Environment instance with immutable tags") + @tags.delete(tag_name) + @cache.delete(tag_name) + end + + def []=(tag_name, klass) + @tags[tag_name] = klass.name + @cache[tag_name] = klass + end + + def each(&block) + @tags.each(&block) + end + + private + + def lookup_class(name) + Object.const_get(name) + end + end + end +end diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index 69ce8297e..c19e75243 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -219,6 +219,21 @@ def test_setting_default_exception_renderer Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer end + def test_setting_exception_renderer_on_environment + exceptions = [] + exception_renderer = ->(e) do + exceptions << e + '' + end + + environment = Liquid::Environment.build(exception_renderer: exception_renderer) + template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: environment) + output = template.render('errors' => ErrorDrop.new) + + assert_equal('This is a runtime error: ', output) + assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) + end + def test_exception_renderer_exposing_non_liquid_error template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true) exceptions = [] @@ -242,16 +257,10 @@ def read_template_file(_template_path) end def test_included_template_name_with_line_numbers - old_file_system = Liquid::Template.file_system - - begin - Liquid::Template.file_system = TestFileSystem.new + environment = Liquid::Environment.build(file_system: TestFileSystem.new) + template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true, environment: environment) + page = template.render('errors' => ErrorDrop.new) - template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true) - page = template.render('errors' => ErrorDrop.new) - ensure - Liquid::Template.file_system = old_file_system - end assert_equal("Argument error:\nLiquid error (product line 1): argument error", page) assert_equal("product", template.errors.first.template_name) end diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index b86d40eee..ad801f69a 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -49,14 +49,6 @@ def render_to_output_buffer(_context, output) class IncludeTagTest < Minitest::Test include Liquid - def setup - @default_file_system = Liquid::Template.file_system - end - - def teardown - Liquid::Template.file_system = @default_file_system - end - def test_include_tag_looks_for_file_system_in_registers_first assert_equal( 'from OtherFileSystem', @@ -214,9 +206,10 @@ def test_dynamically_choosen_template def test_include_tag_caches_second_read_of_same_partial file_system = CountingFileSystem.new + environment = Liquid::Environment.build(file_system: file_system) assert_equal( 'from CountingFileSystemfrom CountingFileSystem', - Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }), + Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}", environment: environment).render!({}, registers: { file_system: file_system }), ) assert_equal(1, file_system.count) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 34d85aeb5..e5c88fb29 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -42,10 +42,11 @@ def assert_template_result( message: nil, partials: nil, error_mode: nil, render_errors: false, template_factory: nil ) - template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym) file_system = StubFileSystem.new(partials || {}) + environment = Liquid::Environment.build(file_system: file_system) + template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment) registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory) - context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers) + context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment) output = template.render(context) assert_equal(expected, output, message) end @@ -78,22 +79,12 @@ def assert_usage_increment(name, times: 1) assert_equal(times, calls, "Number of calls to Usage.increment with #{name.inspect}") end - def with_global_filter(*globals) - original_global_cache = Liquid::StrainerFactory::GlobalCache - Liquid::StrainerFactory.send(:remove_const, :GlobalCache) - Liquid::StrainerFactory.const_set(:GlobalCache, Class.new(Liquid::StrainerTemplate)) - - globals.each do |global| - Liquid::Template.register_filter(global) - end - Liquid::StrainerFactory.send(:strainer_class_cache).clear - begin - yield - ensure - Liquid::StrainerFactory.send(:remove_const, :GlobalCache) - Liquid::StrainerFactory.const_set(:GlobalCache, original_global_cache) - Liquid::StrainerFactory.send(:strainer_class_cache).clear + def with_global_filter(*globals, &blk) + environment = Liquid::Environment.build do |w| + w.register_filters(globals) end + + Environment.dangerously_override(environment, &blk) end def with_error_mode(mode) @@ -104,18 +95,11 @@ def with_error_mode(mode) Liquid::Template.error_mode = old_mode end - def with_custom_tag(tag_name, tag_class) - old_tag = Liquid::Template.tags[tag_name] - begin - Liquid::Template.register_tag(tag_name, tag_class) - yield - ensure - if old_tag - Liquid::Template.tags[tag_name] = old_tag - else - Liquid::Template.tags.delete(tag_name) - end - end + def with_custom_tag(tag_name, tag_class, &block) + environment = Liquid::Environment.default.dup + environment.register_tag(tag_name, tag_class) + + Environment.dangerously_override(environment, &block) end end end diff --git a/test/unit/strainer_template_unit_test.rb b/test/unit/strainer_template_unit_test.rb index fd6a5a3ca..18b9f499b 100644 --- a/test/unit/strainer_template_unit_test.rb +++ b/test/unit/strainer_template_unit_test.rb @@ -58,8 +58,9 @@ def public_filter def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method with_global_filter do - strainer = Context.new.strainer - strainer.class.add_filter(PublicMethodOverrideFilter) + context = Context.new + context.add_filters([PublicMethodOverrideFilter]) + strainer = context.strainer assert(strainer.class.send(:filter_methods).include?('public_filter')) end end diff --git a/test/unit/template_unit_test.rb b/test/unit/template_unit_test.rb index b75669470..f44cd1538 100644 --- a/test/unit/template_unit_test.rb +++ b/test/unit/template_unit_test.rb @@ -36,7 +36,6 @@ def test_with_cache_classes_tags_returns_the_same_class assert(Template.tags['custom'].equal?(original_klass)) ensure Object.send(:remove_const, :CustomTag) - Template.tags.delete('custom') Liquid.cache_classes = original_cache_setting end @@ -46,36 +45,26 @@ def test_without_cache_classes_tags_reloads_the_class original_klass = Class.new Object.send(:const_set, :CustomTag, original_klass) - Template.register_tag('custom', CustomTag) - - Object.send(:remove_const, :CustomTag) + with_custom_tag('custom', CustomTag) do + Object.send(:remove_const, :CustomTag) - new_klass = Class.new - Object.send(:const_set, :CustomTag, new_klass) + new_klass = Class.new + Object.send(:const_set, :CustomTag, new_klass) - assert(Template.tags['custom'].equal?(new_klass)) + assert(Template.tags['custom'].equal?(new_klass)) + end ensure Object.send(:remove_const, :CustomTag) - Template.tags.delete('custom') Liquid.cache_classes = original_cache_setting end class FakeTag; end - def test_tags_delete - Template.register_tag('fake', FakeTag) - assert_equal(FakeTag, Template.tags['fake']) - - Template.tags.delete('fake') - assert_nil(Template.tags['fake']) - end - def test_tags_can_be_looped_over - Template.register_tag('fake', FakeTag) - result = Template.tags.map { |name, klass| [name, klass] } - assert(result.include?(["fake", "TemplateUnitTest::FakeTag"])) - ensure - Template.tags.delete('fake') + with_custom_tag('fake', FakeTag) do + result = Template.tags.map { |name, klass| [name, klass] } + assert(result.include?(["fake", "TemplateUnitTest::FakeTag"])) + end end class TemplateSubclass < Liquid::Template From 9067e5167a944647c65ee7abf320f8285abec47b Mon Sep 17 00:00:00 2001 From: Alex Coco Date: Thu, 15 Aug 2024 11:17:01 -0400 Subject: [PATCH 103/343] Handle interrupts in table row --- lib/liquid/tags/table_row.rb | 6 ++++ test/integration/tags/table_row_test.rb | 48 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index f7e8cd72d..d59791ce1 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -65,6 +65,12 @@ def render_to_output_buffer(context, output) super output << '' + # Handle any interrupts if they exist. + if context.interrupt? + interrupt = context.pop_interrupt + break if interrupt.is_a?(BreakInterrupt) + end + if tablerowloop.col_last && !tablerowloop.last output << "\n" end diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 24fd53444..ddc0877df 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -207,4 +207,52 @@ def test_table_row_renders_correct_error_message_for_invalid_parameters render_errors: true, ) end + + def test_table_row_handles_interrupts + assert_template_result( + "\n 1 \n", + '{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}', + ) + + assert_template_result( + "\n 1 2 \n 3 \n", + '{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}', + ) + end + + def test_table_row_does_not_leak_interrupts + template = <<~LIQUID + {% for i in (1..2) -%} + {% for j in (1..2) -%} + {% tablerow k in (1..3) %}{% break %}{% endtablerow -%} + loop j={{ j }} + {% endfor -%} + loop i={{ i }} + {% endfor -%} + after loop + LIQUID + + expected = <<~STR + + + loop j=1 + + + loop j=2 + loop i=1 + + + loop j=1 + + + loop j=2 + loop i=2 + after loop + STR + + assert_template_result( + expected, + template, + ) + end end From b233b3d0810641b60be3c8cbdffc26ff5b247631 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 15 Aug 2024 16:12:16 -0400 Subject: [PATCH 104/343] Bump to v5.6.0.alpha (#1819) --- Gemfile.lock | 2 +- lib/liquid/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a2860ce65..61acc767d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -9,7 +9,7 @@ GIT PATH remote: . specs: - liquid (5.5.1) + liquid (5.6.0.alpha) GEM remote: https://rubygems.org/ diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 6f833394f..98fa9ac62 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.5.1" + VERSION = "5.6.0.alpha" end From b3553787c8c3b426b3c386c5b595f29ffd61627a Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 23 Oct 2024 14:15:33 -0400 Subject: [PATCH 105/343] Speed up the lexer for Ruby 3.4+ (#1832) * Speed up lexing * Bump msrv to 3.0 (from 2.7) * Normalize test for ruby-head compat * Fix bug when parsing negative numbers --- .github/workflows/liquid.yml | 4 +- .gitignore | 1 + .ruby-version | 2 +- Gemfile.lock | 75 ----------- Rakefile | 8 ++ lib/liquid/lexer.rb | 156 ++++++++++++++++++++++- lib/liquid/parser.rb | 2 +- liquid.gemspec | 5 +- performance/benchmark.rb | 1 + performance/unit/lexer_benchmark.rb | 62 +++++++++ test/integration/standard_filter_test.rb | 2 +- test/unit/lexer_unit_test.rb | 10 ++ 12 files changed, 246 insertions(+), 82 deletions(-) delete mode 100644 Gemfile.lock create mode 100644 performance/unit/lexer_benchmark.rb diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 63d966621..cb1087695 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -11,10 +11,10 @@ jobs: strategy: matrix: entry: - - { ruby: 2.7, allowed-failure: false } # minimum supported + - { ruby: 3.0, allowed-failure: false } # minimum supported - { ruby: 3.2, allowed-failure: false } - { ruby: 3.3, allowed-failure: false } # latest - - { ruby: ruby-head, allowed-failure: true } + - { ruby: ruby-head, allowed-failure: false } name: Test Ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 9029bf307..29d622d4b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ pkg .rvmrc .bundle .byebug_history +Gemfile.lock diff --git a/.ruby-version b/.ruby-version index 15a279981..a0891f563 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.0 +3.3.4 diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 61acc767d..000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,75 +0,0 @@ -GIT - remote: https://github.com/Shopify/liquid-c.git - revision: 5a786af7284df55e013ea20551c4b688d02e8326 - ref: main - specs: - liquid-c (4.2.0) - liquid (>= 5.0.1) - -PATH - remote: . - specs: - liquid (5.6.0.alpha) - -GEM - remote: https://rubygems.org/ - specs: - ast (2.4.2) - base64 (0.2.0) - benchmark-ips (2.13.0) - json (2.7.2) - language_server-protocol (3.17.0.3) - memory_profiler (1.0.1) - minitest (5.22.3) - parallel (1.24.0) - parser (3.3.0.5) - ast (~> 2.4.1) - racc - racc (1.7.3) - rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.0) - rexml (3.2.6) - rubocop (1.61.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.2) - parser (>= 3.3.0.4) - rubocop-performance (1.19.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-shopify (2.12.0) - rubocop (~> 1.44) - ruby-progressbar (1.13.0) - stackprof (0.2.26) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - unicode-display_width (2.5.0) - -PLATFORMS - ruby - -DEPENDENCIES - base64 - benchmark-ips - liquid! - liquid-c! - memory_profiler - minitest - rake (~> 13.0) - rubocop (~> 1.61.0) - rubocop-performance - rubocop-shopify (~> 2.12.0) - stackprof - terminal-table - -BUNDLED WITH - 2.5.7 diff --git a/Rakefile b/Rakefile index 889fc722e..82588eda1 100755 --- a/Rakefile +++ b/Rakefile @@ -81,6 +81,14 @@ namespace :benchmark do task :strict do ruby "./performance/benchmark.rb strict" end + + desc "Run unit benchmarks" + task :unit do + Dir["./performance/unit/*_benchmark.rb"].each do |file| + puts "🧪 Running #{file}" + ruby file + end + end end namespace :profile do diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 4ce2bc7b9..9c392fad5 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true require "strscan" + module Liquid - class Lexer + class Lexer1 SPECIALS = { '|' => :pipe, '.' => :dot, @@ -58,4 +59,157 @@ def tokenize @output << [:end_of_string] end end + + class Lexer2 + CLOSE_ROUND = [:close_round, ")"].freeze + CLOSE_SQUARE = [:close_square, "]"].freeze + COLON = [:colon, ":"].freeze + COMMA = [:comma, ","].freeze + COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze + COMPARISON_CONTAINS = [:comparison, "contains"].freeze + COMPARISON_EQUAL = [:comparison, "=="].freeze + COMPARISON_GREATER_THAN = [:comparison, ">"].freeze + COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze + COMPARISON_LESS_THAN = [:comparison, "<"].freeze + COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze + COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze + CONTAINS = /contains(?=\s)/ + DASH = [:dash, "-"].freeze + DOT = [:dot, "."].freeze + DOTDOT = [:dotdot, ".."].freeze + DOT_ORD = ".".ord + DOUBLE_STRING_LITERAL = /"[^\"]*"/ + EOS = [:end_of_string].freeze + IDENTIFIER = /[a-zA-Z_][\w-]*\??/ + NUMBER_LITERAL = /-?\d+(\.\d+)?/ + OPEN_ROUND = [:open_round, "("].freeze + OPEN_SQUARE = [:open_square, "["].freeze + PIPE = [:pipe, "|"].freeze + QUESTION = [:question, "?"].freeze + RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze + SINGLE_STRING_LITERAL = /'[^\']*'/ + WHITESPACE_OR_NOTHING = /\s*/ + + COMPARISON_JUMP_TABLE = [].tap do |table| + table["=".ord] = [].tap do |sub_table| + sub_table["=".ord] = COMPARISON_EQUAL + sub_table.freeze + end + table["!".ord] = [].tap do |sub_table| + sub_table["=".ord] = COMPARISION_NOT_EQUAL + sub_table.freeze + end + table["<".ord] = [].tap do |sub_table| + sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL + sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT + RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN } + sub_table.freeze + end + table[">".ord] = [].tap do |sub_table| + sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL + RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN } + sub_table.freeze + end + table.freeze + end + + NEXT_MATCHER_JUMP_TABLE = [].tap do |table| + "a".upto("z") do |c| + table[c.ord] = [:id, IDENTIFIER].freeze + table[c.upcase.ord] = [:id, IDENTIFIER].freeze + end + table["_".ord] = [:id, IDENTIFIER].freeze + + "0".upto("9") do |c| + table[c.ord] = [:number, NUMBER_LITERAL].freeze + end + table["-".ord] = [:number, NUMBER_LITERAL].freeze + + table["'".ord] = [:string, SINGLE_STRING_LITERAL].freeze + table["\"".ord] = [:string, DOUBLE_STRING_LITERAL].freeze + table.freeze + end + + SPECIAL_TABLE = [].tap do |table| + table["|".ord] = PIPE + table[".".ord] = DOT + table[":".ord] = COLON + table[",".ord] = COMMA + table["[".ord] = OPEN_SQUARE + table["]".ord] = CLOSE_SQUARE + table["(".ord] = OPEN_ROUND + table[")".ord] = CLOSE_ROUND + table["?".ord] = QUESTION + table["-".ord] = DASH + end + + NUMBER_TABLE = [].tap do |table| + "0".upto("9") do |c| + table[c.ord] = true + end + table.freeze + end + + def initialize(input) + @ss = StringScanner.new(input) + end + + # rubocop:disable Metrics/BlockNesting + def tokenize + @output = [] + + until @ss.eos? + @ss.skip(WHITESPACE_OR_NOTHING) + + break if @ss.eos? + + peeked = @ss.peek_byte + + if (special = SPECIAL_TABLE[peeked]) + @ss.scan_byte + # Special case for ".." + if special == DOT && @ss.peek_byte == DOT_ORD + @ss.scan_byte + @output << DOTDOT + elsif special == DASH + # Special case for negative numbers + if NUMBER_TABLE[@ss.peek_byte] + @ss.pos -= 1 + @output << [:number, @ss.scan(NUMBER_LITERAL)] + else + @output << special + end + else + @output << special + end + elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) + @ss.scan_byte + if (found = sub_table[@ss.peek_byte]) + @output << found + @ss.scan_byte + else + raise SyntaxError, "Unexpected character #{peeked.chr}" + end + else + type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] + + if type && (t = @ss.scan(pattern)) + # Special case for "contains" + @output << if type == :id && t == "contains" + COMPARISON_CONTAINS + else + [type, t] + end + else + raise SyntaxError, "Unexpected character #{peeked.chr}" + end + end + end + # rubocop:enable Metrics/BlockNesting + + @output << EOS + end + end + + Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1 end diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 609601ac4..8560fc58e 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -53,7 +53,7 @@ def expression str = consume str << variable_lookups when :open_square - str = consume + str = consume.dup str << expression str << consume(:close_square) str << variable_lookups diff --git a/liquid.gemspec b/liquid.gemspec index 65176776e..7b8c68f9f 100644 --- a/liquid.gemspec +++ b/liquid.gemspec @@ -17,7 +17,7 @@ Gem::Specification.new do |s| s.license = "MIT" # s.description = "A secure, non-evaling end user template engine with aesthetic markup." - s.required_ruby_version = ">= 2.7.0" + s.required_ruby_version = ">= 3.0.0" s.required_rubygems_version = ">= 1.3.7" s.metadata['allowed_push_host'] = 'https://rubygems.org' @@ -28,6 +28,9 @@ Gem::Specification.new do |s| s.require_path = "lib" + s.add_dependency("strscan") + s.add_dependency("bigdecimal") + s.add_development_dependency('rake', '~> 13.0') s.add_development_dependency('minitest') end diff --git a/performance/benchmark.rb b/performance/benchmark.rb index 8bf231340..da31aa0c6 100644 --- a/performance/benchmark.rb +++ b/performance/benchmark.rb @@ -3,6 +3,7 @@ require 'benchmark/ips' require_relative 'theme_runner' +RubyVM::YJIT.enable if defined?(RubyVM::YJIT) Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first profiler = ThemeRunner.new diff --git a/performance/unit/lexer_benchmark.rb b/performance/unit/lexer_benchmark.rb new file mode 100644 index 000000000..2faa1f686 --- /dev/null +++ b/performance/unit/lexer_benchmark.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "benchmark/ips" + +# benchmark liquid lexing + +require 'liquid' + +RubyVM::YJIT.enable + +EXPRESSIONS = [ + "foo[1..2].baz", + "12.0", + "foo.bar.based", + "21 - 62", + "foo.bar.baz", + "foo > 12", + "foo < 12", + "foo <= 12", + "foo >= 12", + "foo <> 12", + "foo == 12", + "foo != 12", + "foo contains 12", + "foo contains 'bar'", + "foo != 'bar'", + "'foo' contains 'bar'", + '234089', + "foo | default: -1", +] + +EXPRESSIONS.each do |expr| + lexer_1_result = Liquid::Lexer1.new(expr).tokenize + lexer_2_result = Liquid::Lexer2.new(expr).tokenize + + next if lexer_1_result == lexer_2_result + + warn "Lexer1 and Lexer2 results are different for expression: #{expr}" + warn "expected: #{lexer_1_result}" + warn "got: #{lexer_2_result}" + abort +end + +Benchmark.ips do |x| + x.config(time: 10, warmup: 5) + + x.report("Liquid::Lexer1#tokenize") do + EXPRESSIONS.each do |expr| + l = Liquid::Lexer1.new(expr) + l.tokenize + end + end + + x.report("Liquid::Lexer2#tokenize") do + EXPRESSIONS.each do |expr| + l = Liquid::Lexer2.new(expr) + l.tokenize + end + end + + x.compare! +end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 0564404c3..eae4a1c9c 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -32,7 +32,7 @@ def initialize(value:) attr_reader :value def registers - { @value => @context.registers[@value] } + "{#{@value.inspect}=>#{@context.registers[@value].inspect}}" end end diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index ad4a5bd3e..363740b42 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -50,4 +50,14 @@ def test_unexpected_character Lexer.new("%").tokenize end end + + def test_negative_numbers + tokens = Lexer.new("foo | default: -1").tokenize + assert_equal([[:id, 'foo'], [:pipe, '|'], [:id, 'default'], [:colon, ":"], [:number, '-1'], [:end_of_string]], tokens) + end + + def test_greater_than_two_digits + tokens = Lexer.new("foo > 12").tokenize + assert_equal([[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]], tokens) + end end From 8d7ed706f406d6c8220a4b4975a9eecc5abd95d1 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Sat, 19 Oct 2024 17:57:00 -0700 Subject: [PATCH 106/343] Update liquid docs for named parameters The YARD liquid gem now supports specifying named parameters. For the core liquid tags and filters this is the only object I could find that needed to be updated. --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 6f1afaca6..ffaceb32f 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -877,7 +877,7 @@ def at_most(input, n) # - [`nil`](/docs/api/liquid/basics#nil) # @liquid_syntax variable | default: variable # @liquid_return [untyped] - # @liquid_optional_param allow_false [boolean] Whether to use false values instead of the default. + # @liquid_optional_param allow_false: [boolean] Whether to use false values instead of the default. def default(input, default_value = '', options = {}) options = {} unless options.is_a?(Hash) false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input) From cb162195529655c40e0db82b13eeedd062a1745d Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 22 Oct 2024 17:06:20 -0300 Subject: [PATCH 107/343] faster BlockBody variable matching --- lib/liquid/block_body.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index ec40e2129..964758b65 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -246,10 +246,16 @@ def render_node(context, output, node) end def create_variable(token, parse_context) - if token =~ ContentOfVariable - markup = Regexp.last_match(1) + if token.end_with?("}}") + i = 2 + i = 3 if token[i] == "-" + parse_end = token.length - 3 + parse_end -= 1 if token[parse_end] == "-" + + markup = token[i..parse_end] return Variable.new(markup, parse_context) end + BlockBody.raise_missing_variable_terminator(token, parse_context) end From fb6ac72520c20b8e225da2f4853aff1f7a1a59dc Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 25 Oct 2024 15:29:24 -0300 Subject: [PATCH 108/343] use byteslice to create Variable BlockBody --- lib/liquid/block_body.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 964758b65..816c2bc62 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -251,8 +251,9 @@ def create_variable(token, parse_context) i = 3 if token[i] == "-" parse_end = token.length - 3 parse_end -= 1 if token[parse_end] == "-" + markup_end = parse_end - i + 1 + markup = markup_end <= 0 ? "" : token.byteslice(i, markup_end) - markup = token[i..parse_end] return Variable.new(markup, parse_context) end From 6c13805a608936c0be80acdea9b4612c2219777b Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 28 Oct 2024 17:33:55 -0300 Subject: [PATCH 109/343] fix parsing Variable blockbody with multibyte character --- lib/liquid/block_body.rb | 2 +- test/unit/block_unit_test.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index 816c2bc62..e4ada7d16 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -252,7 +252,7 @@ def create_variable(token, parse_context) parse_end = token.length - 3 parse_end -= 1 if token[parse_end] == "-" markup_end = parse_end - i + 1 - markup = markup_end <= 0 ? "" : token.byteslice(i, markup_end) + markup = markup_end <= 0 ? "" : token.slice(i, markup_end) return Variable.new(markup, parse_context) end diff --git a/test/unit/block_unit_test.rb b/test/unit/block_unit_test.rb index 81b79d853..f28c30e16 100644 --- a/test/unit/block_unit_test.rb +++ b/test/unit/block_unit_test.rb @@ -32,6 +32,12 @@ def test_variable_middle assert_equal(String, template.root.nodelist[2].class) end + def test_variable_with_multibyte_character + template = Liquid::Template.parse("{{ '❤️' }}") + assert_equal(1, template.root.nodelist.size) + assert_equal(Variable, template.root.nodelist[0].class) + end + def test_variable_many_embedded_fragments template = Liquid::Template.parse(" {{funk}} {{so}} {{brother}} ") assert_equal(7, template.root.nodelist.size) From d94293a4649bdc7c931fe2b82867ac62db56ef75 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 28 Oct 2024 19:28:44 -0300 Subject: [PATCH 110/343] fix lexer parsing comparison without whitespaces --- lib/liquid/lexer.rb | 20 ++++++++++++++++++-- test/unit/lexer_unit_test.rb | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 9c392fad5..5e8e1023a 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -90,7 +90,12 @@ class Lexer2 SINGLE_STRING_LITERAL = /'[^\']*'/ WHITESPACE_OR_NOTHING = /\s*/ - COMPARISON_JUMP_TABLE = [].tap do |table| + SINGLE_COMPARISON_TOKENS = [].tap do |table| + table["<".ord] = COMPARISON_LESS_THAN + table[">".ord] = COMPARISON_GREATER_THAN + end + + TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table| table["=".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_EQUAL sub_table.freeze @@ -99,6 +104,9 @@ class Lexer2 sub_table["=".ord] = COMPARISION_NOT_EQUAL sub_table.freeze end + end + + COMPARISON_JUMP_TABLE = [].tap do |table| table["<".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT @@ -182,7 +190,7 @@ def tokenize else @output << special end - elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) + elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) @ss.scan_byte if (found = sub_table[@ss.peek_byte]) @output << found @@ -190,6 +198,14 @@ def tokenize else raise SyntaxError, "Unexpected character #{peeked.chr}" end + elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) + @ss.scan_byte + if (found = sub_table[@ss.peek_byte]) + @output << found + @ss.scan_byte + else + @output << SINGLE_COMPARISON_TOKENS[peeked] + end else type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 363740b42..0676033e6 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -25,6 +25,30 @@ def test_comparison assert_equal([[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokens) end + def test_comparison_without_whitespace + tokens = Lexer.new('1>0').tokenize + assert_equal([[:number, '1'], [:comparison, '>'], [:number, '0'], [:end_of_string]], tokens) + end + + def test_comparison_with_negative_number + tokens = Lexer.new('1>-1').tokenize + assert_equal([[:number, '1'], [:comparison, '>'], [:number, '-1'], [:end_of_string]], tokens) + end + + def test_raise_for_invalid_comparison + assert_raises(SyntaxError) do + Lexer.new('1>!1').tokenize + end + + assert_raises(SyntaxError) do + Lexer.new('1=<1').tokenize + end + + assert_raises(SyntaxError) do + Lexer.new('1!!1').tokenize + end + end + def test_specials tokens = Lexer.new('| .:').tokenize assert_equal([[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]], tokens) From 8a9f33a060eafca7080cea4ae084194877e50ed5 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 29 Oct 2024 22:04:37 -0300 Subject: [PATCH 111/343] raise syntax error from lexer parser with utf8 character --- lib/liquid/lexer.rb | 11 +++++++++-- test/unit/lexer_unit_test.rb | 11 +++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 5e8e1023a..6d956484b 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -171,6 +171,7 @@ def tokenize break if @ss.eos? + start_pos = @ss.pos peeked = @ss.peek_byte if (special = SPECIAL_TABLE[peeked]) @@ -196,7 +197,7 @@ def tokenize @output << found @ss.scan_byte else - raise SyntaxError, "Unexpected character #{peeked.chr}" + raise_syntax_error(start_pos) end elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) @ss.scan_byte @@ -217,7 +218,7 @@ def tokenize [type, t] end else - raise SyntaxError, "Unexpected character #{peeked.chr}" + raise_syntax_error(start_pos) end end end @@ -225,6 +226,12 @@ def tokenize @output << EOS end + + def raise_syntax_error(start_pos) + @ss.pos = start_pos + # the character could be a UTF-8 character, use getch to get all the bytes + raise SyntaxError, "Unexpected character #{@ss.getch}" + end end Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1 diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 0676033e6..b372c232e 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -84,4 +84,15 @@ def test_greater_than_two_digits tokens = Lexer.new("foo > 12").tokenize assert_equal([[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]], tokens) end + + def test_error_with_utf8_character + error = assert_raises(SyntaxError) do + Lexer.new("1 < 1Ø").tokenize + end + + assert_equal( + 'Liquid syntax error: Unexpected character Ø', + error.message, + ) + end end From 1375a9e4dcd6eacdf5a53212c546efe22a9783a5 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 29 Oct 2024 22:22:30 -0300 Subject: [PATCH 112/343] fix lexer parsing ID 'contains' as comparison --- lib/liquid/lexer.rb | 3 +-- test/unit/lexer_unit_test.rb | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 6d956484b..61ee24a9d 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -73,7 +73,6 @@ class Lexer2 COMPARISON_LESS_THAN = [:comparison, "<"].freeze COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze - CONTAINS = /contains(?=\s)/ DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze @@ -212,7 +211,7 @@ def tokenize if type && (t = @ss.scan(pattern)) # Special case for "contains" - @output << if type == :id && t == "contains" + @output << if type == :id && t == "contains" && @output.last&.first != :dot COMPARISON_CONTAINS else [type, t] diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index b372c232e..5aa6066fa 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -95,4 +95,11 @@ def test_error_with_utf8_character error.message, ) end + + def test_contains_as_attribute_name + assert_equal( + [[:id, "a"], [:dot, "."], [:id, "contains"], [:dot, "."], [:id, "b"], [:end_of_string]], + Lexer.new("a.contains.b").tokenize, + ) + end end From f6a3e25e2ea21f1014e46ec97e8446f77e870d47 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 29 Oct 2024 17:06:02 -0300 Subject: [PATCH 113/343] fix parsing quirky incomplete expressions --- lib/liquid/lexer.rb | 6 +++--- test/integration/parsing_quirks_test.rb | 12 ++++++++++++ test/unit/lexer_unit_test.rb | 7 +++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 61ee24a9d..d02c24e67 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -181,7 +181,7 @@ def tokenize @output << DOTDOT elsif special == DASH # Special case for negative numbers - if NUMBER_TABLE[@ss.peek_byte] + if !@ss.eos? && NUMBER_TABLE[@ss.peek_byte] @ss.pos -= 1 @output << [:number, @ss.scan(NUMBER_LITERAL)] else @@ -192,7 +192,7 @@ def tokenize end elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) @ss.scan_byte - if (found = sub_table[@ss.peek_byte]) + if !@ss.eos? && (found = sub_table[@ss.peek_byte]) @output << found @ss.scan_byte else @@ -200,7 +200,7 @@ def tokenize end elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) @ss.scan_byte - if (found = sub_table[@ss.peek_byte]) + if !@ss.eos? && (found = sub_table[@ss.peek_byte]) @output << found @ss.scan_byte else diff --git a/test/integration/parsing_quirks_test.rb b/test/integration/parsing_quirks_test.rb index 744936c97..48f914635 100644 --- a/test/integration/parsing_quirks_test.rb +++ b/test/integration/parsing_quirks_test.rb @@ -131,4 +131,16 @@ def test_lookup_on_var_with_literal_name def test_contains_in_id assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true }) end + + def test_incomplete_expression + with_error_mode(:lax) do + assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}") + assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}") + assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}") + assert_template_result("false", "{% liquid assign foo = false =\n%}{{ foo }}") + assert_template_result("false", "{% liquid assign foo = false !\n%}{{ foo }}") + assert_template_result("false", "{% liquid assign foo = false 1\n%}{{ foo }}") + assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}") + end + end end # ParsingQuirksTest diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 5aa6066fa..54ed0b50d 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -102,4 +102,11 @@ def test_contains_as_attribute_name Lexer.new("a.contains.b").tokenize, ) end + + def test_tokenize_incomplete_expression + assert_equal([[:id, "false"], [:dash, "-"], [:end_of_string]], Lexer.new("false -").tokenize) + assert_equal([[:id, "false"], [:comparison, "<"], [:end_of_string]], Lexer.new("false <").tokenize) + assert_equal([[:id, "false"], [:comparison, ">"], [:end_of_string]], Lexer.new("false >").tokenize) + assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], Lexer.new("false 1").tokenize) + end end From f00670cb01340dfd602a7fa6a22bf3b9c906d446 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 29 Oct 2024 17:11:35 -0300 Subject: [PATCH 114/343] refactor lexer unit test --- test/unit/lexer_unit_test.rb | 95 +++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 54ed0b50d..7f8457395 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -6,83 +6,104 @@ class LexerUnitTest < Minitest::Test include Liquid def test_strings - tokens = Lexer.new(%( 'this is a test""' "wat 'lol'")).tokenize - assert_equal([[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]], tokens) + assert_equal( + [[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]], + tokenize(%( 'this is a test""' "wat 'lol'")), + ) end def test_integer - tokens = Lexer.new('hi 50').tokenize - assert_equal([[:id, 'hi'], [:number, '50'], [:end_of_string]], tokens) + assert_equal( + [[:id, 'hi'], [:number, '50'], [:end_of_string]], + tokenize('hi 50'), + ) end def test_float - tokens = Lexer.new('hi 5.0').tokenize - assert_equal([[:id, 'hi'], [:number, '5.0'], [:end_of_string]], tokens) + assert_equal( + [[:id, 'hi'], [:number, '5.0'], [:end_of_string]], + tokenize('hi 5.0'), + ) end def test_comparison - tokens = Lexer.new('== <> contains ').tokenize - assert_equal([[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokens) + assert_equal( + [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], + tokenize('== <> contains '), + ) end def test_comparison_without_whitespace - tokens = Lexer.new('1>0').tokenize - assert_equal([[:number, '1'], [:comparison, '>'], [:number, '0'], [:end_of_string]], tokens) + assert_equal( + [[:number, '1'], [:comparison, '>'], [:number, '0'], [:end_of_string]], + tokenize('1>0'), + ) end def test_comparison_with_negative_number - tokens = Lexer.new('1>-1').tokenize - assert_equal([[:number, '1'], [:comparison, '>'], [:number, '-1'], [:end_of_string]], tokens) + assert_equal( + [[:number, '1'], [:comparison, '>'], [:number, '-1'], [:end_of_string]], + tokenize('1>-1'), + ) end def test_raise_for_invalid_comparison assert_raises(SyntaxError) do - Lexer.new('1>!1').tokenize + tokenize('1>!1') end assert_raises(SyntaxError) do - Lexer.new('1=<1').tokenize + tokenize('1=<1') end assert_raises(SyntaxError) do - Lexer.new('1!!1').tokenize + tokenize('1!!1') end end def test_specials - tokens = Lexer.new('| .:').tokenize - assert_equal([[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]], tokens) - tokens = Lexer.new('[,]').tokenize - assert_equal([[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]], tokens) + assert_equal( + [[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]], + tokenize('| .:'), + ) + + assert_equal( + [[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]], + tokenize('[,]'), + ) end def test_fancy_identifiers - tokens = Lexer.new('hi five?').tokenize - assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokens) + assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokenize('hi five?')) - tokens = Lexer.new('2foo').tokenize - assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokens) + assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokenize('2foo')) end def test_whitespace - tokens = Lexer.new("five|\n\t ==").tokenize - assert_equal([[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]], tokens) + assert_equal( + [[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]], + tokenize("five|\n\t =="), + ) end def test_unexpected_character assert_raises(SyntaxError) do - Lexer.new("%").tokenize + tokenize("%") end end def test_negative_numbers - tokens = Lexer.new("foo | default: -1").tokenize - assert_equal([[:id, 'foo'], [:pipe, '|'], [:id, 'default'], [:colon, ":"], [:number, '-1'], [:end_of_string]], tokens) + assert_equal( + [[:id, 'foo'], [:pipe, '|'], [:id, 'default'], [:colon, ":"], [:number, '-1'], [:end_of_string]], + tokenize("foo | default: -1"), + ) end def test_greater_than_two_digits - tokens = Lexer.new("foo > 12").tokenize - assert_equal([[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]], tokens) + assert_equal( + [[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]], + tokenize("foo > 12"), + ) end def test_error_with_utf8_character @@ -104,9 +125,15 @@ def test_contains_as_attribute_name end def test_tokenize_incomplete_expression - assert_equal([[:id, "false"], [:dash, "-"], [:end_of_string]], Lexer.new("false -").tokenize) - assert_equal([[:id, "false"], [:comparison, "<"], [:end_of_string]], Lexer.new("false <").tokenize) - assert_equal([[:id, "false"], [:comparison, ">"], [:end_of_string]], Lexer.new("false >").tokenize) - assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], Lexer.new("false 1").tokenize) + assert_equal([[:id, "false"], [:dash, "-"], [:end_of_string]], tokenize("false -")) + assert_equal([[:id, "false"], [:comparison, "<"], [:end_of_string]], tokenize("false <")) + assert_equal([[:id, "false"], [:comparison, ">"], [:end_of_string]], tokenize("false >")) + assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1")) + end + + private + + def tokenize(input) + Lexer.new(input).tokenize end end From ffce6de8bb484c660b88dbdb0c726867da541c84 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 30 Oct 2024 11:53:54 -0300 Subject: [PATCH 115/343] avoid using StringScanner eos --- lib/liquid/lexer.rb | 6 +++--- test/unit/lexer_unit_test.rb | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index d02c24e67..b9e5443c1 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -181,7 +181,7 @@ def tokenize @output << DOTDOT elsif special == DASH # Special case for negative numbers - if !@ss.eos? && NUMBER_TABLE[@ss.peek_byte] + if (peeked_byte = @ss.peek_byte) && NUMBER_TABLE[peeked_byte] @ss.pos -= 1 @output << [:number, @ss.scan(NUMBER_LITERAL)] else @@ -192,7 +192,7 @@ def tokenize end elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) @ss.scan_byte - if !@ss.eos? && (found = sub_table[@ss.peek_byte]) + if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte]) @output << found @ss.scan_byte else @@ -200,7 +200,7 @@ def tokenize end elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) @ss.scan_byte - if !@ss.eos? && (found = sub_table[@ss.peek_byte]) + if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte]) @output << found @ss.scan_byte else diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 7f8457395..26a25b629 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -108,7 +108,7 @@ def test_greater_than_two_digits def test_error_with_utf8_character error = assert_raises(SyntaxError) do - Lexer.new("1 < 1Ø").tokenize + tokenize("1 < 1Ø") end assert_equal( @@ -120,7 +120,7 @@ def test_error_with_utf8_character def test_contains_as_attribute_name assert_equal( [[:id, "a"], [:dot, "."], [:id, "contains"], [:dot, "."], [:id, "b"], [:end_of_string]], - Lexer.new("a.contains.b").tokenize, + tokenize("a.contains.b"), ) end From 29986d37048c6f4538cf6b6c4fcd95a5fdff2a9f Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 4 Nov 2024 15:22:56 -0400 Subject: [PATCH 116/343] remove TagRegistry --- lib/liquid.rb | 1 - lib/liquid/environment.rb | 4 +-- lib/liquid/template/tag_registry.rb | 44 ----------------------------- test/unit/template_unit_test.rb | 21 +------------- 4 files changed, 3 insertions(+), 67 deletions(-) delete mode 100644 lib/liquid/template/tag_registry.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 6f071a8a6..a64e8c188 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -52,7 +52,6 @@ module Liquid require "liquid/version" require "liquid/deprecations" require "liquid/const" -require "liquid/template/tag_registry" require 'liquid/standardfilters' require 'liquid/file_system' require 'liquid/parser_switching' diff --git a/lib/liquid/environment.rb b/lib/liquid/environment.rb index fdd404fcd..31b17b234 100644 --- a/lib/liquid/environment.rb +++ b/lib/liquid/environment.rb @@ -41,7 +41,7 @@ class << self # @return [Environment] The new environment instance. def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil) ret = new - ret.tags = Template::TagRegistry.new(tags) if tags + ret.tags = tags if tags ret.file_system = file_system if file_system ret.error_mode = error_mode if error_mode ret.exception_renderer = exception_renderer if exception_renderer @@ -74,7 +74,7 @@ def dangerously_override(environment) # Initializes a new environment instance. # @api private def initialize - @tags = Template::TagRegistry.new(Tags::STANDARD_TAGS) + @tags = Tags::STANDARD_TAGS.dup @error_mode = :lax @strainer_template = Class.new(StrainerTemplate).tap do |klass| klass.add_filter(StandardFilters) diff --git a/lib/liquid/template/tag_registry.rb b/lib/liquid/template/tag_registry.rb deleted file mode 100644 index d055ad2e0..000000000 --- a/lib/liquid/template/tag_registry.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -module Liquid - class Template - class TagRegistry - include Enumerable - - def initialize(tags = nil) - @tags = {} - @cache = {} - tags.each { |tag_name, klass| self[tag_name] = klass } - Deprecations.warn("Template::TagRegistry", "Use a Environment instance with zeitwerk") - end - - def [](tag_name) - return nil unless @tags.key?(tag_name) - return @cache[tag_name] if Liquid.cache_classes - - lookup_class(@tags[tag_name]).tap { |o| @cache[tag_name] = o } - end - - def delete(tag_name) - Deprecations.warn("Template::TagRegistry#delete", "Use a Environment instance with immutable tags") - @tags.delete(tag_name) - @cache.delete(tag_name) - end - - def []=(tag_name, klass) - @tags[tag_name] = klass.name - @cache[tag_name] = klass - end - - def each(&block) - @tags.each(&block) - end - - private - - def lookup_class(name) - Object.const_get(name) - end - end - end -end diff --git a/test/unit/template_unit_test.rb b/test/unit/template_unit_test.rb index f44cd1538..e5c248694 100644 --- a/test/unit/template_unit_test.rb +++ b/test/unit/template_unit_test.rb @@ -39,31 +39,12 @@ def test_with_cache_classes_tags_returns_the_same_class Liquid.cache_classes = original_cache_setting end - def test_without_cache_classes_tags_reloads_the_class - original_cache_setting = Liquid.cache_classes - Liquid.cache_classes = false - - original_klass = Class.new - Object.send(:const_set, :CustomTag, original_klass) - with_custom_tag('custom', CustomTag) do - Object.send(:remove_const, :CustomTag) - - new_klass = Class.new - Object.send(:const_set, :CustomTag, new_klass) - - assert(Template.tags['custom'].equal?(new_klass)) - end - ensure - Object.send(:remove_const, :CustomTag) - Liquid.cache_classes = original_cache_setting - end - class FakeTag; end def test_tags_can_be_looped_over with_custom_tag('fake', FakeTag) do result = Template.tags.map { |name, klass| [name, klass] } - assert(result.include?(["fake", "TemplateUnitTest::FakeTag"])) + assert(result.include?(["fake", TemplateUnitTest::FakeTag])) end end From 3ed54bfdf94a1ff485cf0ce430fd0ac784a41874 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 4 Nov 2024 15:32:04 -0400 Subject: [PATCH 117/343] propagate Environment on new Context creation --- lib/liquid/context.rb | 3 ++- test/integration/context_test.rb | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 011235644..19daaeb73 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -19,7 +19,7 @@ class Context # rubocop:disable Metrics/ParameterLists def self.build(environment: Environment.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) - new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, &block) + new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, environment, &block) end def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default) @@ -143,6 +143,7 @@ def new_isolated_subcontext check_overflow self.class.build( + environment: @environment, resource_limits: resource_limits, static_environments: static_environments, registers: Registers.new(registers), diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index 03b33cf58..f6afaca52 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -672,6 +672,21 @@ def test_variable_to_liquid_returns_contextual_drop assert_includes(result, "unscoped_products_count: 5") end + def test_new_isolated_context_inherits_parent_environment + global_environment = Liquid::Environment.build(tags: {}) + context = Context.build(environment: global_environment) + + subcontext = context.new_isolated_subcontext + assert_equal(global_environment, subcontext.environment) + end + + def test_newly_built_context_inherits_parent_environment + global_environment = Liquid::Environment.build(tags: {}) + context = Context.build(environment: global_environment) + assert_equal(global_environment, context.environment) + assert(context.environment.tags.each.to_a.empty?) + end + private def assert_no_object_allocations From 1f3ea7322b59b322afc207ead5fc5a224116ed46 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 4 Nov 2024 15:35:45 -0400 Subject: [PATCH 118/343] avoid allocating new empty array --- lib/liquid/parse_tree_visitor.rb | 2 +- lib/liquid/variable.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/parse_tree_visitor.rb b/lib/liquid/parse_tree_visitor.rb index c9c6646e1..6d23b2c50 100644 --- a/lib/liquid/parse_tree_visitor.rb +++ b/lib/liquid/parse_tree_visitor.rb @@ -36,7 +36,7 @@ def visit(context = nil) protected def children - @node.respond_to?(:nodelist) ? Array(@node.nodelist) : [] + @node.respond_to?(:nodelist) ? Array(@node.nodelist) : Const::EMPTY_ARRAY end end end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 1a4c07198..372ee4dbf 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -68,7 +68,7 @@ def strict_parse(markup) @name = parse_context.parse_expression(p.expression) while p.consume?(:pipe) filtername = p.consume(:id) - filterargs = p.consume?(:colon) ? parse_filterargs(p) : [] + filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY @filters << parse_filter_expressions(filtername, filterargs) end p.consume(:end_of_string) From b0cba0bfd27e5b82e6aef77d26aeeceed1a87b41 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Mon, 4 Nov 2024 14:41:56 -0500 Subject: [PATCH 119/343] Remove `Liquid.cache_classes` option (#1847) --- lib/liquid.rb | 3 --- test/unit/template_unit_test.rb | 19 ------------------- 2 files changed, 22 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index a64e8c188..18566f6b2 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -44,9 +44,6 @@ module Liquid VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } - - singleton_class.send(:attr_accessor, :cache_classes) - self.cache_classes = true end require "liquid/version" diff --git a/test/unit/template_unit_test.rb b/test/unit/template_unit_test.rb index e5c248694..e9de2d04e 100644 --- a/test/unit/template_unit_test.rb +++ b/test/unit/template_unit_test.rb @@ -20,25 +20,6 @@ def test_sets_default_localization_in_context_with_quick_initialization assert_equal(fixture("en_locale.yml"), locale.path) end - def test_with_cache_classes_tags_returns_the_same_class - original_cache_setting = Liquid.cache_classes - Liquid.cache_classes = true - - original_klass = Class.new - Object.send(:const_set, :CustomTag, original_klass) - Template.register_tag('custom', CustomTag) - - Object.send(:remove_const, :CustomTag) - - new_klass = Class.new - Object.send(:const_set, :CustomTag, new_klass) - - assert(Template.tags['custom'].equal?(original_klass)) - ensure - Object.send(:remove_const, :CustomTag) - Liquid.cache_classes = original_cache_setting - end - class FakeTag; end def test_tags_can_be_looped_over From c77ff68573f5964ee325716802625c7cb644c03e Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 4 Nov 2024 16:15:05 -0400 Subject: [PATCH 120/343] clean up all warnings by using new Environment --- lib/liquid.rb | 1 - lib/liquid/strainer_factory.rb | 23 ------------ performance/benchmark.rb | 3 +- performance/shopify/liquid.rb | 15 ++++---- test/integration/error_handling_test.rb | 9 +++-- test/integration/profiler_test.rb | 2 +- test/integration/tags/include_tag_test.rb | 26 ++++++++------ test/integration/tags/render_tag_test.rb | 13 ++++--- test/test_helper.rb | 8 ++--- ...nit_test.rb => environment_filter_test.rb} | 36 +++++++++++-------- test/unit/strainer_template_unit_test.rb | 16 +++++---- 11 files changed, 73 insertions(+), 79 deletions(-) delete mode 100644 lib/liquid/strainer_factory.rb rename test/unit/{strainer_factory_unit_test.rb => environment_filter_test.rb} (72%) diff --git a/lib/liquid.rb b/lib/liquid.rb index 18566f6b2..0a970005d 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -68,7 +68,6 @@ module Liquid require 'liquid/errors' require 'liquid/interrupts' require 'liquid/strainer_template' -require 'liquid/strainer_factory' require 'liquid/expression' require 'liquid/context' require 'liquid/tag' diff --git a/lib/liquid/strainer_factory.rb b/lib/liquid/strainer_factory.rb deleted file mode 100644 index 869b09ad3..000000000 --- a/lib/liquid/strainer_factory.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Liquid - # StrainerFactory is the factory for the filters system. - module StrainerFactory - extend self - - def add_global_filter(filter, environment = Environment.default) - Deprecations.warn("StrainerFactory.add_global_filter", "Environment#register_filter") - environment.register_filter(filter) - end - - def create(context, filters = Const::EMPTY_ARRAY, environment = Environment.default) - Deprecations.warn("StrainerFactory.create", "StrainerFactory.create_strainer") - environment.create_strainer(context, filters) - end - - def global_filter_names(environment = Environment.default) - Deprecations.warn("StrainerFactory.global_filter_names", "Environment#filter_method_names") - Environment.strainer_template.filter_method_names - end - end -end diff --git a/performance/benchmark.rb b/performance/benchmark.rb index da31aa0c6..f897c5113 100644 --- a/performance/benchmark.rb +++ b/performance/benchmark.rb @@ -4,7 +4,8 @@ require_relative 'theme_runner' RubyVM::YJIT.enable if defined?(RubyVM::YJIT) -Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first +Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first + profiler = ThemeRunner.new Benchmark.ips do |x| diff --git a/performance/shopify/liquid.rb b/performance/shopify/liquid.rb index 40444c3c6..d30e67b04 100644 --- a/performance/shopify/liquid.rb +++ b/performance/shopify/liquid.rb @@ -11,11 +11,12 @@ require_relative 'tag_filter' require_relative 'weight_filter' -Liquid::Template.register_tag('paginate', Paginate) -Liquid::Template.register_tag('form', CommentForm) +default_environment = Liquid::Environment.default +default_environment.register_tag('paginate', Paginate) +default_environment.register_tag('form', CommentForm) -Liquid::Template.register_filter(JsonFilter) -Liquid::Template.register_filter(MoneyFilter) -Liquid::Template.register_filter(WeightFilter) -Liquid::Template.register_filter(ShopFilter) -Liquid::Template.register_filter(TagFilter) +default_environment.register_filter(JsonFilter) +default_environment.register_filter(MoneyFilter) +default_environment.register_filter(WeightFilter) +default_environment.register_filter(ShopFilter) +default_environment.register_filter(TagFilter) diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index c19e75243..26b0e5f95 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -203,20 +203,19 @@ def test_default_exception_renderer_with_internal_error end def test_setting_default_exception_renderer - old_exception_renderer = Liquid::Template.default_exception_renderer exceptions = [] - Liquid::Template.default_exception_renderer = ->(e) { + default_exception_renderer = ->(e) { exceptions << e '' } - template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}') + + env = Liquid::Environment.build(exception_renderer: default_exception_renderer) + template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: env) output = template.render('errors' => ErrorDrop.new) assert_equal('This is a runtime error: ', output) assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) - ensure - Liquid::Template.default_exception_renderer = old_exception_renderer if old_exception_renderer end def test_setting_exception_renderer_on_environment diff --git a/test/integration/profiler_test.rb b/test/integration/profiler_test.rb index 1a610c56a..70a22fc06 100644 --- a/test/integration/profiler_test.rb +++ b/test/integration/profiler_test.rb @@ -33,7 +33,7 @@ def read_template_file(template_path) end def setup - Liquid::Template.file_system = ProfilingFileSystem.new + Liquid::Environment.default.file_system = ProfilingFileSystem.new end def test_template_allows_flagging_profiling diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index ad801f69a..6e1649663 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -174,10 +174,10 @@ def read_template_file(_template_path) end end - Liquid::Template.file_system = infinite_file_system.new + env = Liquid::Environment.build(file_system: infinite_file_system.new) assert_raises(Liquid::StackLevelError) do - Template.parse("{% include 'loop' %}").render! + Template.parse("{% include 'loop' %}", environment: env).render! end end @@ -264,26 +264,27 @@ def test_custom_include_tag_within_if_statement end def test_does_not_add_error_in_strict_mode_for_missing_variable - Liquid::Template.file_system = TestFileSystem.new + env = Liquid::Environment.build(file_system: TestFileSystem.new) - a = Liquid::Template.parse(' {% include "nested_template" %}') + a = Liquid::Template.parse(' {% include "nested_template" %}', environment: env) a.render! assert_empty(a.errors) end def test_passing_options_to_included_templates - Liquid::Template.file_system = TestFileSystem.new + env = Liquid::Environment.build(file_system: TestFileSystem.new) + assert_raises(Liquid::SyntaxError) do - Template.parse("{% include template %}", error_mode: :strict).render!("template" => '{{ "X" || downcase }}') + Template.parse("{% include template %}", error_mode: :strict, environment: env).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 }}')) + assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).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 }}') + Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).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 }}')) + assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}')) end end @@ -334,8 +335,11 @@ def test_including_via_variable_value end def test_including_with_strict_variables - Liquid::Template.file_system = StubFileSystem.new({ "simple" => "simple" }) - template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn) + env = Liquid::Environment.build( + file_system: StubFileSystem.new('simple' => 'simple'), + ) + + template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env) template.render(nil, strict_variables: true) assert_equal([], template.errors) diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 01485cfa2..eda80a040 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -82,19 +82,22 @@ def test_nested_render_tag end def test_recursively_rendered_template_does_not_produce_endless_loop - Liquid::Template.file_system = StubFileSystem.new('loop' => '{% render "loop" %}') + env = Liquid::Environment.build( + file_system: StubFileSystem.new('loop' => '{% render "loop" %}'), + ) assert_raises(Liquid::StackLevelError) do - Template.parse('{% render "loop" %}').render! + Template.parse('{% render "loop" %}', environment: env).render! end end def test_sub_contexts_count_towards_the_same_recursion_limit - Liquid::Template.file_system = StubFileSystem.new( - 'loop_render' => '{% render "loop_render" %}', + env = Liquid::Environment.build( + file_system: StubFileSystem.new('loop_render' => '{% render "loop_render" %}'), ) + assert_raises(Liquid::StackLevelError) do - Template.parse('{% render "loop_render" %}').render! + Template.parse('{% render "loop_render" %}', environment: env).render! end end diff --git a/test/test_helper.rb b/test/test_helper.rb index e5c88fb29..1ecfce3f5 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -13,7 +13,7 @@ puts "-- #{env_mode.upcase} ERROR MODE" mode = env_mode.to_sym end -Liquid::Template.error_mode = mode +Liquid::Environment.default.error_mode = mode if ENV['LIQUID_C'] == '1' puts "-- LIQUID C" @@ -88,11 +88,11 @@ def with_global_filter(*globals, &blk) end def with_error_mode(mode) - old_mode = Liquid::Template.error_mode - Liquid::Template.error_mode = mode + old_mode = Liquid::Environment.default.error_mode + Liquid::Environment.default.error_mode = mode yield ensure - Liquid::Template.error_mode = old_mode + Liquid::Environment.default.error_mode = old_mode end def with_custom_tag(tag_name, tag_class, &block) diff --git a/test/unit/strainer_factory_unit_test.rb b/test/unit/environment_filter_test.rb similarity index 72% rename from test/unit/strainer_factory_unit_test.rb rename to test/unit/environment_filter_test.rb index c78d422b6..942f6a3c7 100644 --- a/test/unit/strainer_factory_unit_test.rb +++ b/test/unit/environment_filter_test.rb @@ -2,7 +2,7 @@ require 'test_helper' -class StrainerFactoryUnitTest < Minitest::Test +class EnvironmentFilterTest < Minitest::Test include Liquid module AccessScopeFilters @@ -16,8 +16,6 @@ def private_filter private :private_filter end - StrainerFactory.add_global_filter(AccessScopeFilters) - module LateAddedFilter def late_added_filter(_input) "filtered" @@ -25,24 +23,28 @@ def late_added_filter(_input) end def setup - @context = Context.build + @environment = Liquid::Environment.build do |env| + env.register_filter(AccessScopeFilters) + end + + @context = Context.build(environment: @environment) end def test_strainer - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) assert_equal(5, strainer.invoke('size', 'input')) assert_equal("public", strainer.invoke("public_filter")) end def test_stainer_raises_argument_error - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) assert_raises(Liquid::ArgumentError) do strainer.invoke("public_filter", 1) end end def test_stainer_argument_error_contains_backtrace - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) exception = assert_raises(Liquid::ArgumentError) do strainer.invoke("public_filter", 1) @@ -57,7 +59,7 @@ def test_stainer_argument_error_contains_backtrace end def test_strainer_only_invokes_public_filter_methods - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) assert_equal(false, strainer.class.invokable?('__test__')) assert_equal(false, strainer.class.invokable?('test')) assert_equal(false, strainer.class.invokable?('instance_eval')) @@ -66,18 +68,18 @@ def test_strainer_only_invokes_public_filter_methods end def test_strainer_returns_nil_if_no_filter_method_found - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) assert_nil(strainer.invoke("private_filter")) assert_nil(strainer.invoke("undef_the_filter")) end def test_strainer_returns_first_argument_if_no_method_and_arguments_given - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) assert_equal("password", strainer.invoke("undef_the_method", "password")) end def test_strainer_only_allows_methods_defined_in_filters - strainer = StrainerFactory.create(@context) + strainer = @environment.create_strainer(@context) assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1")) assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom")) assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke")) @@ -86,7 +88,9 @@ def test_strainer_only_allows_methods_defined_in_filters def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation a = Module.new b = Module.new - strainer = StrainerFactory.create(@context, [a, b]) + + strainer = @environment.create_strainer(@context, [a, b]) + assert_kind_of(StrainerTemplate, strainer) assert_kind_of(a, strainer) assert_kind_of(b, strainer) @@ -94,8 +98,10 @@ def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation end def test_add_global_filter_clears_cache - assert_equal('input', StrainerFactory.create(@context).invoke('late_added_filter', 'input')) - StrainerFactory.add_global_filter(LateAddedFilter) - assert_equal('filtered', StrainerFactory.create(nil).invoke('late_added_filter', 'input')) + assert_equal('input', @environment.create_strainer(@context).invoke('late_added_filter', 'input')) + + @environment.register_filter(LateAddedFilter) + + assert_equal('filtered', @environment.create_strainer(nil).invoke('late_added_filter', 'input')) end end diff --git a/test/unit/strainer_template_unit_test.rb b/test/unit/strainer_template_unit_test.rb index 18b9f499b..a04107084 100644 --- a/test/unit/strainer_template_unit_test.rb +++ b/test/unit/strainer_template_unit_test.rb @@ -25,11 +25,13 @@ def public_filter end def test_add_filter_raises_when_module_privately_overrides_registered_public_methods - strainer = Context.new.strainer - error = assert_raises(Liquid::MethodOverrideError) do - strainer.class.add_filter(PrivateMethodOverrideFilter) + Liquid::Environment.build do |env| + env.register_filter(PublicMethodOverrideFilter) + env.register_filter(PrivateMethodOverrideFilter) + end end + assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) end @@ -42,11 +44,13 @@ def public_filter end def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected - strainer = Context.new.strainer - error = assert_raises(Liquid::MethodOverrideError) do - strainer.class.add_filter(ProtectedMethodOverrideFilter) + Liquid::Environment.build do |env| + env.register_filter(PublicMethodOverrideFilter) + env.register_filter(ProtectedMethodOverrideFilter) + end end + assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) end From 42b6763546f218dc8ad1184e10c5e2d9a262b006 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Mon, 4 Nov 2024 15:26:56 -0500 Subject: [PATCH 121/343] Bump to `v5.6.0.rc1` --- lib/liquid/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 98fa9ac62..d064151fb 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.0.alpha" + VERSION = "5.6.0.rc1" end From 63583ffe5b0e5c38dc719c2ab8cfd99fd9e7d6ba Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 11 Dec 2024 10:16:58 -0500 Subject: [PATCH 122/343] Write one value at a time for array variables (#1863) * Write one value at a time for array variables * Handle recursive array --- lib/liquid/variable.rb | 18 ++++++++++++------ test/integration/variable_test.rb | 4 ++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 372ee4dbf..2bcf871ea 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -95,15 +95,21 @@ def render(context) def render_to_output_buffer(context, output) obj = render(context) + render_obj_to_output(obj, output) + output + end - if obj.is_a?(Array) - output << obj.join - elsif obj.nil? - else + def render_obj_to_output(obj, output) + case obj + when NilClass + # Do nothing + when Array + obj.each do |o| + render_obj_to_output(o, output) + end + when output << obj.to_s end - - output end def disabled?(_context) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 22f2b64e8..19922c191 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -130,6 +130,10 @@ def test_render_symbol assert_template_result('bar', '{{ foo }}', { 'foo' => :bar }) end + def test_nested_array + assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] }) + end + def test_dynamic_find_var assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end From fdd8c714b203f1378356a8ae57edb92fca447991 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 11 Dec 2024 12:23:50 -0500 Subject: [PATCH 123/343] Stop testing against `liquid-c` (#1868) * Stop testing against `liquid-c` * Bump to `v5.6.0.rc2` --- Gemfile | 4 ---- Rakefile | 2 -- lib/liquid/version.rb | 2 +- test/test_helper.rb | 5 ----- 4 files changed, 1 insertion(+), 12 deletions(-) diff --git a/Gemfile b/Gemfile index 509316c1a..013b1700a 100644 --- a/Gemfile +++ b/Gemfile @@ -23,8 +23,4 @@ group :test do gem 'rubocop', '~> 1.61.0' gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false - - platform :mri, :truffleruby do - gem 'liquid-c', github: 'Shopify/liquid-c', ref: 'main' - end end diff --git a/Rakefile b/Rakefile index 82588eda1..21135d1b9 100755 --- a/Rakefile +++ b/Rakefile @@ -43,8 +43,6 @@ task :test do Rake::Task['base_test'].invoke if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby' - ENV['LIQUID_C'] = '1' - ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index d064151fb..4be6b9775 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.0.rc1" + VERSION = "5.6.0.rc2" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1ecfce3f5..184720b6d 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,11 +15,6 @@ end Liquid::Environment.default.error_mode = mode -if ENV['LIQUID_C'] == '1' - puts "-- LIQUID C" - require 'liquid/c' -end - if Minitest.const_defined?('Test') # We're on Minitest 5+. Nothing to do here. else From a0a4307e7d7e65071979b5ad163259696c0c9ad7 Mon Sep 17 00:00:00 2001 From: Bahar Pourazar Date: Tue, 17 Dec 2024 14:42:58 +0000 Subject: [PATCH 124/343] Fix bug in tokenizer with nil value --- lib/liquid/tokenizer.rb | 2 +- test/unit/tokenizer_unit_test.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 1b3984cab..cccdbf215 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -5,7 +5,7 @@ class Tokenizer attr_reader :line_number, :for_liquid_tag def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) - @source = source + @source = source.to_s @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag @offset = 0 diff --git a/test/unit/tokenizer_unit_test.rb b/test/unit/tokenizer_unit_test.rb index 6e8557242..5e0496e68 100644 --- a/test/unit/tokenizer_unit_test.rb +++ b/test/unit/tokenizer_unit_test.rb @@ -30,6 +30,10 @@ def test_calculate_line_numbers_per_token_with_profiling assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} ")) end + def test_tokenize_with_nil_source_returns_empty_array + assert_equal([], tokenize(nil)) + end + private def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil) From 0c49dd592f8b2f56e02eaab8464e97fd9d14c106 Mon Sep 17 00:00:00 2001 From: Bahar Pourazar Date: Tue, 17 Dec 2024 17:35:44 +0000 Subject: [PATCH 125/343] bring back to_s.to_str --- lib/liquid/tokenizer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index cccdbf215..740f85999 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -5,7 +5,7 @@ class Tokenizer attr_reader :line_number, :for_liquid_tag def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) - @source = source.to_s + @source = source.to_s.to_str @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag @offset = 0 From 60701f865d9a87437c79f4055123fef178b81310 Mon Sep 17 00:00:00 2001 From: Bahar Pourazar Date: Tue, 17 Dec 2024 16:51:26 +0000 Subject: [PATCH 126/343] Bump version patch --- History.md | 7 +++++++ lib/liquid/version.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/History.md b/History.md index 7d94db602..9414dd7af 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,12 @@ # Liquid Change Log +## 5.6.0 (unreleased) + +### Fixes + +* Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar] + + ## 5.5.0 2024-03-21 Please reference the GitHub release for more information. diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 4be6b9775..bc8ab3e56 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.0.rc2" + VERSION = "5.6.0.rc3" end From b4667adadff0b648b33678d5db958e59b59c7f80 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 19 Dec 2024 15:01:16 -0500 Subject: [PATCH 127/343] Bump to `v5.6.0` (#1876) --- .github/workflows/liquid.yml | 3 ++- .ruby-version | 2 +- lib/liquid/version.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index cb1087695..dce2d2811 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -13,7 +13,8 @@ jobs: entry: - { ruby: 3.0, allowed-failure: false } # minimum supported - { ruby: 3.2, allowed-failure: false } - - { ruby: 3.3, allowed-failure: false } # latest + - { ruby: 3.3, allowed-failure: false } + - { ruby: "3.4.0-rc1", allowed-failure: false } # latest - { ruby: ruby-head, allowed-failure: false } name: Test Ruby ${{ matrix.entry.ruby }} steps: diff --git a/.ruby-version b/.ruby-version index a0891f563..9c25013db 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.4 +3.3.6 diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index bc8ab3e56..52831e7b2 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.0.rc3" + VERSION = "5.6.0" end From 8ecb703d4d5adb1a2b0c8b3d2510e21d018ee8d3 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 13 Nov 2024 16:09:42 -0400 Subject: [PATCH 128/343] use StringScanner to improve Expression Parsing and Tokenizer --- Gemfile | 3 + Rakefile | 29 +++- lib/liquid/expression.rb | 106 ++++++++++++- lib/liquid/lexer.rb | 14 +- lib/liquid/tokenizer.rb | 154 ++++++++++++++++++- performance/benchmark.rb | 13 +- performance/theme_runner.rb | 8 + performance/unit/expression_benchmark.rb | 183 +++++++++++++++++++++++ test/integration/expression_test.rb | 2 + test/integration/parsing_quirks_test.rb | 8 + test/integration/tags/raw_tag_test.rb | 1 + test/unit/tokenizer_unit_test.rb | 14 ++ 12 files changed, 520 insertions(+), 15 deletions(-) create mode 100644 performance/unit/expression_benchmark.rb diff --git a/Gemfile b/Gemfile index 013b1700a..2053d5e0a 100644 --- a/Gemfile +++ b/Gemfile @@ -24,3 +24,6 @@ group :test do gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end + +gem "strscan", ">= 3.1" +gem "lru_redux" diff --git a/Rakefile b/Rakefile index 21135d1b9..6ccd2c866 100755 --- a/Rakefile +++ b/Rakefile @@ -71,7 +71,7 @@ end namespace :benchmark do desc "Run the liquid benchmark with lax parsing" - task :run do + task :lax do ruby "./performance/benchmark.rb lax" end @@ -80,11 +80,30 @@ namespace :benchmark do ruby "./performance/benchmark.rb strict" end + desc "Run the liquid benchmark with both lax and strict parsing" + task run: [:lax, :strict] + desc "Run unit benchmarks" - task :unit do - Dir["./performance/unit/*_benchmark.rb"].each do |file| - puts "🧪 Running #{file}" - ruby file + namespace :unit do + task :all do + Dir["./performance/unit/*_benchmark.rb"].each do |file| + puts "🧪 Running #{file}" + ruby file + end + end + + task :lexer do + Dir["./performance/unit/lexer_benchmark.rb"].each do |file| + puts "🧪 Running #{file}" + ruby file + end + end + + task :expression do + Dir["./performance/unit/expression_benchmark.rb"].each do |file| + puts "🧪 Running #{file}" + ruby file + end end end end diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index a1426732c..11a695ad9 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +require "lru_redux" + module Liquid - class Expression + class Expression1 LITERALS = { nil => nil, 'nil' => nil, @@ -45,4 +47,106 @@ def self.parse(markup) end end end + + class Expression2 + LITERALS = { + nil => nil, + 'nil' => nil, + 'null' => nil, + '' => nil, + 'true' => true, + 'false' => false, + 'blank' => '', + 'empty' => '' + }.freeze + + DOT = ".".ord + ZERO = "0".ord + NINE = "9".ord + DASH = "-".ord + + # Use an atomic group (?>...) to avoid pathological backtracing from + # malicious input as described in https://github.com/Shopify/liquid/issues/1357 + RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ + CACHE = LruRedux::Cache.new(1_000_000) # most themes would have less than 2,000 unique expression + + class << self + def string_scanner + @ss ||= StringScanner.new("") + end + + def parse(markup) + return unless markup + + markup = markup.strip # markup can be a frozen string + + return CACHE[markup] if CACHE.key?(markup) + + CACHE[markup] = inner_parse(markup) + end + + def inner_parse(markup) + if (markup.start_with?('"') && markup.end_with?('"')) || + (markup.start_with?("'") && markup.end_with?("'")) + return markup[1..-2] + elsif (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX + return RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2)) + end + + return LITERALS[markup] if LITERALS.key?(markup) + + if (num = parse_number(markup)) + num + else + VariableLookup.parse(markup) + end + end + + def parse_number(markup) + ss = string_scanner + ss.string = markup + + is_integer = true + last_dot_pos = nil + num_end_pos = nil + + # the first byte must be a digit, a period, or a dash + byte = ss.scan_byte + + return false if byte != DASH && byte != DOT && (byte < ZERO || byte > NINE) + + while (byte = ss.scan_byte) + return false if byte != DOT && (byte < ZERO || byte > NINE) + + # we found our number and now we are just scanning the rest of the string + next if num_end_pos + + if byte == DOT + if is_integer == false + num_end_pos = ss.pos - 1 + else + is_integer = false + last_dot_pos = ss.pos + end + end + end + + num_end_pos = markup.length if ss.eos? + + return markup.to_i if is_integer + + if num_end_pos + # number ends with a number "123.123" + markup.byteslice(0, num_end_pos).to_f + elsif last_dot_pos + markup.byteslice(0, last_dot_pos).to_f + else + # we should never reach this point + false + end + end + end + end + + Expression = StringScanner.instance_methods.include?(:scan_byte) ? Expression2 : Expression1 end diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index b9e5443c1..c2adec78f 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -92,6 +92,7 @@ class Lexer2 SINGLE_COMPARISON_TOKENS = [].tap do |table| table["<".ord] = COMPARISON_LESS_THAN table[">".ord] = COMPARISON_GREATER_THAN + table.freeze end TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table| @@ -103,18 +104,17 @@ class Lexer2 sub_table["=".ord] = COMPARISION_NOT_EQUAL sub_table.freeze end + table.freeze end COMPARISON_JUMP_TABLE = [].tap do |table| table["<".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT - RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_LESS_THAN } sub_table.freeze end table[">".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL - RUBY_WHITESPACE.each { |c| sub_table[c.ord] = COMPARISON_GREATER_THAN } sub_table.freeze end table.freeze @@ -157,8 +157,15 @@ class Lexer2 table.freeze end + class << self + def string_scanner + @string_scanner ||= StringScanner.new("") + end + end + def initialize(input) - @ss = StringScanner.new(input) + @ss = Lexer2.string_scanner + @ss.string = input end # rubocop:disable Metrics/BlockNesting @@ -233,5 +240,6 @@ def raise_syntax_error(start_pos) end end + # Remove this once we can depend on strscan >= 3.1.1 Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1 end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 740f85999..0bb05c73d 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +require "strscan" + module Liquid - class Tokenizer + class Tokenizer1 attr_reader :line_number, :for_liquid_tag def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) @@ -42,4 +44,154 @@ def tokenize tokens end end + + class Tokenizer2 + attr_reader :line_number, :for_liquid_tag + + TAG_END = /%\}/ + TAG_OR_VARIABLE_START = /\{[\{\%]/ + NEWLINE = /\n/ + + OPEN_CURLEY = "{".ord + CLOSE_CURLEY = "}".ord + PERCENTAGE = "%".ord + + class << self + def string_scanner + @string_scanner ||= StringScanner.new("") + end + end + + def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) + @line_number = line_number || (line_numbers ? 1 : nil) + @for_liquid_tag = for_liquid_tag + @source = source + @offset = 0 + @tokens = [] + tokenize + end + + def shift + token = @tokens[@offset] + + return unless token + + @offset += 1 + + if @line_number + @line_number += @for_liquid_tag ? 1 : token.count("\n") + end + + token + end + + private + + def tokenize + if @for_liquid_tag + @tokens = @source.split("\n") + else + @ss = Tokenizer2.string_scanner + @ss.string = @source + @tokens << shift_normal until @ss.eos? + end + + @ss = nil + @source = nil + end + + def shift_normal + token = next_token + + return unless token + + token + end + + def next_token + # possible states: :text, :tag, :variable + byte_a = @ss.peek_byte + + if byte_a == OPEN_CURLEY + @ss.scan_byte + + byte_b = @ss.peek_byte + + if byte_b == PERCENTAGE + @ss.scan_byte + return next_tag_token + elsif byte_b == OPEN_CURLEY + @ss.scan_byte + return next_variable_token + end + + @ss.pos -= 1 + end + + next_text_token + end + + def next_text_token + start = @ss.pos + + unless @ss.skip_until(TAG_OR_VARIABLE_START) + token = @ss.rest + @ss.terminate + return token + end + + pos = @ss.pos -= 2 + @source.byteslice(start, pos - start) + end + + def next_variable_token + start = @ss.pos - 2 + + byte_a = byte_b = @ss.scan_byte + + while byte_b + byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY) + + break unless byte_a + + if @ss.eos? + return byte_a == CLOSE_CURLEY ? @source.byteslice(start, @ss.pos - start) : "{{" + end + + byte_b = @ss.scan_byte + + if byte_a == CLOSE_CURLEY + if byte_b == CLOSE_CURLEY + return @source.byteslice(start, @ss.pos - start) + elsif byte_b != CLOSE_CURLEY + @ss.pos -= 1 + return @source.byteslice(start, @ss.pos - start) + end + elsif byte_a == OPEN_CURLEY && byte_b == PERCENTAGE + return next_tag_token_with_start(start) + end + + byte_a = byte_b + end + + "{{" + end + + def next_tag_token + start = @ss.pos - 2 + if (len = @ss.skip_until(TAG_END)) + @source.byteslice(start, len + 2) + else + "{%" + end + end + + def next_tag_token_with_start(start) + @ss.skip_until(TAG_END) + @source.byteslice(start, @ss.pos - start) + end + end + + # Remove this once we can depend on strscan >= 3.1.1 + Tokenizer = StringScanner.instance_methods.include?(:scan_byte) ? Tokenizer2 : Tokenizer1 end diff --git a/performance/benchmark.rb b/performance/benchmark.rb index f897c5113..b61e9057c 100644 --- a/performance/benchmark.rb +++ b/performance/benchmark.rb @@ -9,14 +9,17 @@ profiler = ThemeRunner.new Benchmark.ips do |x| - x.time = 10 - x.warmup = 5 + x.time = 20 + x.warmup = 10 puts puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)." puts - x.report("parse:") { profiler.compile } - x.report("render:") { profiler.render } - x.report("parse & render:") { profiler.run } + phase = ENV["PHASE"] || "all" + + x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize" + x.report("parse:") { profiler.compile } if phase == "all" || phase == "parse" + x.report("render:") { profiler.render } if phase == "all" || phase == "render" + x.report("parse & render:") { profiler.run } if phase == "all" || phase == "run" end diff --git a/performance/theme_runner.rb b/performance/theme_runner.rb index 3dd5548e3..c158f8778 100644 --- a/performance/theme_runner.rb +++ b/performance/theme_runner.rb @@ -48,6 +48,14 @@ def compile end end + # `tokenize` will just test the tokenizen portion of liquid without any templates + def tokenize + @tests.each do |test_hash| + tokenizer = Liquid::Tokenizer.new(test_hash[:liquid], true) + while tokenizer.shift; end + end + end + # `run` is called to benchmark rendering and compiling at the same time def run each_test do |liquid, layout, assigns, page_template, template_name| diff --git a/performance/unit/expression_benchmark.rb b/performance/unit/expression_benchmark.rb new file mode 100644 index 000000000..ae4eeaa59 --- /dev/null +++ b/performance/unit/expression_benchmark.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "benchmark/ips" + +# benchmark liquid lexing + +require 'liquid' + +RubyVM::YJIT.enable + +STRING_MARKUPS = [ + "\"foo\"", + "\"fooooooooooo\"", + "\"foooooooooooooooooooooooooooooo\"", + "'foo'", + "'fooooooooooo'", + "'foooooooooooooooooooooooooooooo'", +] + +VARIABLE_MARKUPS = [ + "article", + "article.title", + "article.title.size", + "very_long_variable_name_2024_11_05", + "very_long_variable_name_2024_11_05.size", +] + +NUMBER_MARKUPS = [ + "0", + "35", + "1241891024912849", + "3.5", + "3.51214128409128", + "12381902839.123819283910283", + "123.456.789", + "-123", + "-12.33", + "-405.231", + "-0", + "0", + "0.0", + "0.0000000000000000000000", + "0.00000000001", +] + +RANGE_MARKUPS = [ + "(1..30)", + "(1...30)", + "(1..30..5)", + "(1.0...30.0)", + "(1.........30)", + "(1..foo)", + "(foo..30)", + "(foo..bar)", + "(foo...bar...100)", + "(foo...bar...100.0)", +] + +LITERAL_MARKUPS = [ + nil, + 'nil', + 'null', + '', + 'true', + 'false', + 'blank', + 'empty', +] + +MARKUPS = { + "string" => STRING_MARKUPS, + "literal" => LITERAL_MARKUPS, + "variable" => VARIABLE_MARKUPS, + "number" => NUMBER_MARKUPS, + "range" => RANGE_MARKUPS, +} + +def compare_objects(object_1, object_2) + if object_1.is_a?(Liquid::VariableLookup) && object_2.is_a?(Liquid::VariableLookup) + return false if object_1.name != object_2.name + elsif object_1 != object_2 + return false + end + + true +end + +def compare_range_lookup(expression_1_result, expression_2_result) + return false unless expression_1_result.is_a?(Liquid::RangeLookup) && expression_2_result.is_a?(Liquid::RangeLookup) + + start_obj_1 = expression_1_result.start_obj + start_obj_2 = expression_2_result.start_obj + + return false unless compare_objects(start_obj_1, start_obj_2) + + end_obj_1 = expression_1_result.end_obj + end_obj_2 = expression_2_result.end_obj + + return false unless compare_objects(end_obj_1, end_obj_2) + + true +end + +MARKUPS.values.flatten.each do |markup| + expression_1_result = Liquid::Expression1.parse(markup) + expression_2_result = Liquid::Expression2.parse(markup) + + next if expression_1_result == expression_2_result + + if expression_1_result.is_a?(Liquid::RangeLookup) && expression_2_result.is_a?(Liquid::RangeLookup) + next if compare_range_lookup(expression_1_result, expression_2_result) + end + + warn "Expression1 and Expression2 results are different for markup: #{markup}" + warn "expected: #{expression_1_result}" + warn "got: #{expression_2_result}" + abort +end + +warmed_up = false + +MARKUPS.each do |type, markups| + Benchmark.ips do |x| + if warmed_up + x.config(time: 10, warmup: 5) + warmed_up = true + else + x.config(time: 10) + end + + x.report("Liquid::Expression1#parse: #{type}") do + if Liquid::Expression != Liquid::Expression1 + Liquid.send(:remove_const, :Expression) + Liquid.const_set(:Expression, Liquid::Expression1) + end + + markups.each do |markup| + Liquid::Expression1.parse(markup) + end + end + + x.report("Liquid::Expression2#parse: #{type}") do + if Liquid::Expression != Liquid::Expression2 + Liquid.send(:remove_const, :Expression) + Liquid.const_set(:Expression, Liquid::Expression2) + end + + markups.each do |markup| + Liquid::Expression2.parse(markup) + end + end + + x.compare! + end +end + +Benchmark.ips do |x| + x.config(time: 10) + + x.report("Liquid::Expression1#parse: all") do + if Liquid::Expression != Liquid::Expression1 + Liquid.send(:remove_const, :Expression) + Liquid.const_set(:Expression, Liquid::Expression1) + end + + MARKUPS.values.flatten.each do |markup| + Liquid::Expression1.parse(markup) + end + end + + x.report("Liquid::Expression2#parse: all") do + if Liquid::Expression != Liquid::Expression2 + Liquid.send(:remove_const, :Expression) + Liquid.const_set(:Expression, Liquid::Expression2) + end + + MARKUPS.values.flatten.each do |markup| + Liquid::Expression2.parse(markup) + end + end + + x.compare! +end diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 80f6f4173..7f451afd1 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -13,6 +13,7 @@ def test_string assert_template_result("double quoted", '{{"double quoted"}}') assert_template_result("spaced", "{{ 'spaced' }}") assert_template_result("spaced2", "{{ 'spaced2' }}") + assert_template_result("emoji🔥", "{{ 'emoji🔥' }}") end def test_int @@ -22,6 +23,7 @@ def test_int end def test_float + assert_template_result("-17.42", "{{ -17.42 }}") assert_template_result("2.5", "{{ 2.5 }}") assert_expression_result(1.5, "1.5") end diff --git a/test/integration/parsing_quirks_test.rb b/test/integration/parsing_quirks_test.rb index 48f914635..f5b483fcc 100644 --- a/test/integration/parsing_quirks_test.rb +++ b/test/integration/parsing_quirks_test.rb @@ -134,6 +134,14 @@ def test_contains_in_id def test_incomplete_expression with_error_mode(:lax) do + assert_template_result("false", "{{ false - }}") + assert_template_result("false", "{{ false > }}") + assert_template_result("false", "{{ false < }}") + assert_template_result("false", "{{ false = }}") + assert_template_result("false", "{{ false ! }}") + assert_template_result("false", "{{ false 1 }}") + assert_template_result("false", "{{ false a }}") + assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}") diff --git a/test/integration/tags/raw_tag_test.rb b/test/integration/tags/raw_tag_test.rb index 3629f39c2..2ca43d3b2 100644 --- a/test/integration/tags/raw_tag_test.rb +++ b/test/integration/tags/raw_tag_test.rb @@ -16,6 +16,7 @@ def test_output_in_raw assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <') assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <") assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <") + assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}") end def test_open_tag_in_raw diff --git a/test/unit/tokenizer_unit_test.rb b/test/unit/tokenizer_unit_test.rb index 5e0496e68..76d379d36 100644 --- a/test/unit/tokenizer_unit_test.rb +++ b/test/unit/tokenizer_unit_test.rb @@ -6,6 +6,7 @@ class TokenizerTest < Minitest::Test def test_tokenize_strings assert_equal([' '], tokenize(' ')) assert_equal(['hello world'], tokenize('hello world')) + assert_equal(['{}'], tokenize('{}')) end def test_tokenize_variables @@ -34,6 +35,19 @@ def test_tokenize_with_nil_source_returns_empty_array assert_equal([], tokenize(nil)) end + def test_incomplete_curly_braces + assert_equal(["{{.}", " "], tokenize('{{.} ')) + assert_equal(["{{}", "%}"], tokenize('{{}%}')) + assert_equal(["{{}}", "}"], tokenize('{{}}}')) + end + + def test_unmatching_start_and_end + assert_equal(["{{%}"], tokenize('{{%}')) + assert_equal(["{{%%%}}"], tokenize('{{%%%}}')) + assert_equal(["{%", "}}"], tokenize('{%}}')) + assert_equal(["{%%}", "}"], tokenize('{%%}}')) + end + private def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil) From 1de602536238eb15aa12ec132262f458e59c5826 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Nov 2024 00:39:05 -0400 Subject: [PATCH 129/343] decrease the size of Expression2 LRU cache to store 5 themes --- lib/liquid/expression.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 11a695ad9..22cc7227e 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -68,7 +68,7 @@ class Expression2 # Use an atomic group (?>...) to avoid pathological backtracing from # malicious input as described in https://github.com/Shopify/liquid/issues/1357 RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ - CACHE = LruRedux::Cache.new(1_000_000) # most themes would have less than 2,000 unique expression + CACHE = LruRedux::Cache.new(10_000) # most themes would have less than 2,000 unique expression class << self def string_scanner From 0b1dc295ff9604d67369515176d463ba7e4c71ac Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Nov 2024 00:59:01 -0400 Subject: [PATCH 130/343] fix quirky negative sign expression markup parsing --- lib/liquid.rb | 2 +- lib/liquid/expression.rb | 3 ++- test/integration/expression_test.rb | 14 +++++++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 0a970005d..367fc3c43 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -68,7 +68,6 @@ module Liquid require 'liquid/errors' require 'liquid/interrupts' require 'liquid/strainer_template' -require 'liquid/expression' require 'liquid/context' require 'liquid/tag' require 'liquid/block_body' @@ -77,6 +76,7 @@ module Liquid require 'liquid/variable_lookup' require 'liquid/range_lookup' require 'liquid/resource_limits' +require 'liquid/expression' require 'liquid/template' require 'liquid/condition' require 'liquid/utils' diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 22cc7227e..e10f97939 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -57,7 +57,8 @@ class Expression2 'true' => true, 'false' => false, 'blank' => '', - 'empty' => '' + 'empty' => '', + '-' => VariableLookup.parse("-") }.freeze DOT = ".".ord diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 7f451afd1..dc57ad286 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -23,7 +23,7 @@ def test_int end def test_float - assert_template_result("-17.42", "{{ -17.42 }}") + assert_template_result("-17.42", "{{ -17.42 }}") assert_template_result("2.5", "{{ 2.5 }}") assert_expression_result(1.5, "1.5") end @@ -42,6 +42,18 @@ def test_range ) end + def test_quirky_negative_sign_expression_markup + result = Expression.parse("-") + assert(result.is_a?(VariableLookup)) + assert_equal("-", result.name) + + # for this template, the expression markup is "-" + assert_template_result( + "", + "{{ - 'theme.css' - }}", + ) + end + private def assert_expression_result(expect, markup, **assigns) From 7c8a269b4d5ef134834019bcc026b2043512669e Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Nov 2024 15:26:43 -0400 Subject: [PATCH 131/343] fix cycle tag not resetting --- lib/liquid/tags/cycle.rb | 8 ++++- test/integration/tags/cycle_tag_test.rb | 48 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/integration/tags/cycle_tag_test.rb diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 06d788de3..c2d94d5f4 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -68,7 +68,13 @@ def render_to_output_buffer(context, output) def variables_from_string(markup) markup.split(',').collect do |var| var =~ /\s*(#{QuotedFragment})\s*/o - Regexp.last_match(1) ? parse_expression(Regexp.last_match(1)) : nil + next unless Regexp.last_match(1) + + # Expression Parser returns cached objects, and we need to dup them to + # start the cycle over for each new cycle call. + # Liquid-C does not have a cache, so we don't need to dup the object. + var = parse_expression(Regexp.last_match(1)) + var.is_a?(VariableLookup) ? var.dup : var end.compact end diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb new file mode 100644 index 000000000..a034db17e --- /dev/null +++ b/test/integration/tags/cycle_tag_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CycleTagTest < Minitest::Test + def test_simple_cycle + template = <<~LIQUID + {%- cycle '1', '2', '3' -%} + {%- cycle '1', '2', '3' -%} + {%- cycle '1', '2', '3' -%} + LIQUID + + assert_template_result("123", template) + end + + def test_simple_cycle_inside_for_loop + template = <<~LIQUID + {%- for i in (1..3) -%} + {% cycle '1', '2', '3' %} + {%- endfor -%} + LIQUID + + assert_template_result("123", template) + end + + def test_cycle_with_variables_inside_for_loop + template = <<~LIQUID + {%- assign a = 1 -%} + {%- assign b = 2 -%} + {%- assign c = 3 -%} + {%- for i in (1..3) -%} + {% cycle a, b, c %} + {%- endfor -%} + LIQUID + + assert_template_result("123", template) + end + + def test_cycle_tag_always_resets_cycle + template = <<~LIQUID + {%- assign a = "1" -%} + {%- cycle a, "2" -%} + {%- cycle a, "2" -%} + LIQUID + + assert_template_result("11", template) + end +end From 92fa33419248d5b100fcc6c6e15a172c9bdb490c Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Nov 2024 16:51:45 -0400 Subject: [PATCH 132/343] don't cache string expressions to be more perfomant --- lib/liquid/expression.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index e10f97939..d860e20b9 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -81,16 +81,18 @@ def parse(markup) markup = markup.strip # markup can be a frozen string + if (markup.start_with?('"') && markup.end_with?('"')) || + (markup.start_with?("'") && markup.end_with?("'")) + return markup[1..-2] + end + return CACHE[markup] if CACHE.key?(markup) CACHE[markup] = inner_parse(markup) end def inner_parse(markup) - if (markup.start_with?('"') && markup.end_with?('"')) || - (markup.start_with?("'") && markup.end_with?("'")) - return markup[1..-2] - elsif (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX + if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX return RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2)) end From a672f3836ccf7008966e2db873e355a5749ecb11 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 14 Nov 2024 17:50:46 -0400 Subject: [PATCH 133/343] don't cache literals for Expression parser --- lib/liquid/expression.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index d860e20b9..c9a50fd15 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -84,6 +84,8 @@ def parse(markup) if (markup.start_with?('"') && markup.end_with?('"')) || (markup.start_with?("'") && markup.end_with?("'")) return markup[1..-2] + elsif LITERALS.key?(markup) + return LITERALS[markup] end return CACHE[markup] if CACHE.key?(markup) @@ -96,8 +98,6 @@ def inner_parse(markup) return RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2)) end - return LITERALS[markup] if LITERALS.key?(markup) - if (num = parse_number(markup)) num else From 19adfbd86393b4baecb21ecb0d636d5dcc321a69 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 15 Nov 2024 17:24:48 -0400 Subject: [PATCH 134/343] don't force strscan 3.1 --- Gemfile | 1 - liquid.gemspec | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 2053d5e0a..e74d19ec7 100644 --- a/Gemfile +++ b/Gemfile @@ -25,5 +25,4 @@ group :test do gem 'rubocop-performance', require: false end -gem "strscan", ">= 3.1" gem "lru_redux" diff --git a/liquid.gemspec b/liquid.gemspec index 9f7e1923a..c692fc1d9 100644 --- a/liquid.gemspec +++ b/liquid.gemspec @@ -30,6 +30,7 @@ Gem::Specification.new do |s| s.add_dependency("strscan") s.add_dependency("bigdecimal") + s.add_dependency("lru_redux") s.add_development_dependency('rake', '~> 13.0') s.add_development_dependency('minitest') From eff5c5de8ec0a8a24e99e11ddc460ea96e61ab23 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 15 Nov 2024 17:24:52 -0400 Subject: [PATCH 135/343] code clean up --- lib/liquid/lexer.rb | 2 +- lib/liquid/tokenizer.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index c2adec78f..4e8d3d6ef 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -164,7 +164,7 @@ def string_scanner end def initialize(input) - @ss = Lexer2.string_scanner + @ss = self.class.string_scanner @ss.string = input end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 0bb05c73d..8920a5145 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -91,7 +91,7 @@ def tokenize if @for_liquid_tag @tokens = @source.split("\n") else - @ss = Tokenizer2.string_scanner + @ss = self.class.string_scanner @ss.string = @source @tokens << shift_normal until @ss.eos? end From 3c16c27ee1b4ee4b9535fe25096af08865cc646d Mon Sep 17 00:00:00 2001 From: Michael Go Date: Sun, 17 Nov 2024 16:36:04 -0400 Subject: [PATCH 136/343] add StringScannerPool for thread safety --- lib/liquid.rb | 1 + lib/liquid/expression.rb | 9 ++--- lib/liquid/lexer.rb | 58 ++++++++++++++----------------- lib/liquid/string_scanner_pool.rb | 23 ++++++++++++ lib/liquid/tokenizer.rb | 12 ++----- 5 files changed, 57 insertions(+), 46 deletions(-) create mode 100644 lib/liquid/string_scanner_pool.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 367fc3c43..63c087bee 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -49,6 +49,7 @@ module Liquid require "liquid/version" require "liquid/deprecations" require "liquid/const" +require "liquid/string_scanner_pool" require 'liquid/standardfilters' require 'liquid/file_system' require 'liquid/parser_switching' diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index c9a50fd15..ea8c68211 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -72,10 +72,6 @@ class Expression2 CACHE = LruRedux::Cache.new(10_000) # most themes would have less than 2,000 unique expression class << self - def string_scanner - @ss ||= StringScanner.new("") - end - def parse(markup) return unless markup @@ -106,8 +102,7 @@ def inner_parse(markup) end def parse_number(markup) - ss = string_scanner - ss.string = markup + ss = StringScannerPool.pop(markup) is_integer = true last_dot_pos = nil @@ -147,6 +142,8 @@ def parse_number(markup) # we should never reach this point false end + ensure + StringScannerPool.release(ss) end end end diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 4e8d3d6ef..3dae267a8 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -157,40 +157,34 @@ class Lexer2 table.freeze end - class << self - def string_scanner - @string_scanner ||= StringScanner.new("") - end - end - def initialize(input) - @ss = self.class.string_scanner - @ss.string = input + @input = input end # rubocop:disable Metrics/BlockNesting def tokenize + ss = StringScannerPool.pop(@input) @output = [] - until @ss.eos? - @ss.skip(WHITESPACE_OR_NOTHING) + until ss.eos? + ss.skip(WHITESPACE_OR_NOTHING) - break if @ss.eos? + break if ss.eos? - start_pos = @ss.pos - peeked = @ss.peek_byte + start_pos = ss.pos + peeked = ss.peek_byte if (special = SPECIAL_TABLE[peeked]) - @ss.scan_byte + ss.scan_byte # Special case for ".." - if special == DOT && @ss.peek_byte == DOT_ORD - @ss.scan_byte + if special == DOT && ss.peek_byte == DOT_ORD + ss.scan_byte @output << DOTDOT elsif special == DASH # Special case for negative numbers - if (peeked_byte = @ss.peek_byte) && NUMBER_TABLE[peeked_byte] - @ss.pos -= 1 - @output << [:number, @ss.scan(NUMBER_LITERAL)] + if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] + ss.pos -= 1 + @output << [:number, ss.scan(NUMBER_LITERAL)] else @output << special end @@ -198,25 +192,25 @@ def tokenize @output << special end elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) - @ss.scan_byte - if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte]) + ss.scan_byte + if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) @output << found - @ss.scan_byte + ss.scan_byte else - raise_syntax_error(start_pos) + raise_syntax_error(start_pos, ss) end elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) - @ss.scan_byte - if (peeked_byte = @ss.peek_byte) && (found = sub_table[peeked_byte]) + ss.scan_byte + if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) @output << found - @ss.scan_byte + ss.scan_byte else @output << SINGLE_COMPARISON_TOKENS[peeked] end else type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] - if type && (t = @ss.scan(pattern)) + if type && (t = ss.scan(pattern)) # Special case for "contains" @output << if type == :id && t == "contains" && @output.last&.first != :dot COMPARISON_CONTAINS @@ -224,19 +218,21 @@ def tokenize [type, t] end else - raise_syntax_error(start_pos) + raise_syntax_error(start_pos, ss) end end end # rubocop:enable Metrics/BlockNesting @output << EOS + ensure + StringScannerPool.release(ss) end - def raise_syntax_error(start_pos) - @ss.pos = start_pos + def raise_syntax_error(start_pos, ss) + ss.pos = start_pos # the character could be a UTF-8 character, use getch to get all the bytes - raise SyntaxError, "Unexpected character #{@ss.getch}" + raise SyntaxError, "Unexpected character #{ss.getch}" end end diff --git a/lib/liquid/string_scanner_pool.rb b/lib/liquid/string_scanner_pool.rb new file mode 100644 index 000000000..4114d7a2d --- /dev/null +++ b/lib/liquid/string_scanner_pool.rb @@ -0,0 +1,23 @@ +module Liquid + class StringScannerPool + class << self + def pop(input) + @ss_pool ||= [StringScanner.new("")] * 5 + + if @ss_pool.empty? + StringScanner.new(input) + else + ss = @ss_pool.pop + ss.string = input + ss + end + end + + def release(ss) + binding.irb if ss.nil? + @ss_pool ||= [] + @ss_pool << ss + end + end + end +end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 8920a5145..1ed3edf37 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -56,12 +56,6 @@ class Tokenizer2 CLOSE_CURLEY = "}".ord PERCENTAGE = "%".ord - class << self - def string_scanner - @string_scanner ||= StringScanner.new("") - end - end - def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag @@ -91,13 +85,13 @@ def tokenize if @for_liquid_tag @tokens = @source.split("\n") else - @ss = self.class.string_scanner - @ss.string = @source + @ss = StringScannerPool.pop(@source) @tokens << shift_normal until @ss.eos? end - @ss = nil @source = nil + ensure + StringScannerPool.release(@ss) if @ss end def shift_normal From 002e4caea7f08182cdad3306b2cac77712e6d8d1 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Sun, 17 Nov 2024 16:50:59 -0400 Subject: [PATCH 137/343] refactor Lexer to be static class function --- lib/liquid/lexer.rb | 170 +++++++++++++++--------------- lib/liquid/parser.rb | 3 +- lib/liquid/string_scanner_pool.rb | 5 +- test/unit/lexer_unit_test.rb | 2 +- 4 files changed, 88 insertions(+), 92 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 3dae267a8..d9a073aa8 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -25,38 +25,37 @@ class Lexer1 COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/ WHITESPACE_OR_NOTHING = /\s*/ - def initialize(input) - @ss = StringScanner.new(input) - end - - def tokenize - @output = [] - - until @ss.eos? - @ss.skip(WHITESPACE_OR_NOTHING) - break if @ss.eos? - tok = if (t = @ss.scan(COMPARISON_OPERATOR)) - [:comparison, t] - elsif (t = @ss.scan(STRING_LITERAL)) - [:string, t] - elsif (t = @ss.scan(NUMBER_LITERAL)) - [:number, t] - elsif (t = @ss.scan(IDENTIFIER)) - [:id, t] - elsif (t = @ss.scan(DOTDOT)) - [:dotdot, t] - else - c = @ss.getch - if (s = SPECIALS[c]) - [s, c] + class << self + def tokenize(input) + ss = StringScanner.new(input) + output = [] + + until ss.eos? + ss.skip(WHITESPACE_OR_NOTHING) + break if ss.eos? + tok = if (t = ss.scan(COMPARISON_OPERATOR)) + [:comparison, t] + elsif (t = ss.scan(STRING_LITERAL)) + [:string, t] + elsif (t = ss.scan(NUMBER_LITERAL)) + [:number, t] + elsif (t = ss.scan(IDENTIFIER)) + [:id, t] + elsif (t = ss.scan(DOTDOT)) + [:dotdot, t] else - raise SyntaxError, "Unexpected character #{c}" + c = ss.getch + if (s = SPECIALS[c]) + [s, c] + else + raise SyntaxError, "Unexpected character #{c}" + end end + output << tok end - @output << tok - end - @output << [:end_of_string] + output << [:end_of_string] + end end end @@ -157,82 +156,79 @@ class Lexer2 table.freeze end - def initialize(input) - @input = input - end - # rubocop:disable Metrics/BlockNesting - def tokenize - ss = StringScannerPool.pop(@input) - @output = [] + class << self + def tokenize(input) + ss = StringScannerPool.pop(input) + output = [] - until ss.eos? - ss.skip(WHITESPACE_OR_NOTHING) + until ss.eos? + ss.skip(WHITESPACE_OR_NOTHING) - break if ss.eos? + break if ss.eos? - start_pos = ss.pos - peeked = ss.peek_byte + start_pos = ss.pos + peeked = ss.peek_byte - if (special = SPECIAL_TABLE[peeked]) - ss.scan_byte - # Special case for ".." - if special == DOT && ss.peek_byte == DOT_ORD + if (special = SPECIAL_TABLE[peeked]) ss.scan_byte - @output << DOTDOT - elsif special == DASH - # Special case for negative numbers - if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] - ss.pos -= 1 - @output << [:number, ss.scan(NUMBER_LITERAL)] + # Special case for ".." + if special == DOT && ss.peek_byte == DOT_ORD + ss.scan_byte + output << DOTDOT + elsif special == DASH + # Special case for negative numbers + if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] + ss.pos -= 1 + output << [:number, ss.scan(NUMBER_LITERAL)] + else + output << special + end else - @output << special + output << special end - else - @output << special - end - elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) - ss.scan_byte - if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) - @output << found + elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) ss.scan_byte - else - raise_syntax_error(start_pos, ss) - end - elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) - ss.scan_byte - if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) - @output << found + if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) + output << found + ss.scan_byte + else + raise_syntax_error(start_pos, ss) + end + elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) ss.scan_byte - else - @output << SINGLE_COMPARISON_TOKENS[peeked] - end - else - type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] - - if type && (t = ss.scan(pattern)) - # Special case for "contains" - @output << if type == :id && t == "contains" && @output.last&.first != :dot - COMPARISON_CONTAINS + if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) + output << found + ss.scan_byte else - [type, t] + output << SINGLE_COMPARISON_TOKENS[peeked] end else - raise_syntax_error(start_pos, ss) + type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] + + if type && (t = ss.scan(pattern)) + # Special case for "contains" + output << if type == :id && t == "contains" && output.last&.first != :dot + COMPARISON_CONTAINS + else + [type, t] + end + else + raise_syntax_error(start_pos, ss) + end end end + # rubocop:enable Metrics/BlockNesting + output << EOS + ensure + StringScannerPool.release(ss) end - # rubocop:enable Metrics/BlockNesting - @output << EOS - ensure - StringScannerPool.release(ss) - end - - def raise_syntax_error(start_pos, ss) - ss.pos = start_pos - # the character could be a UTF-8 character, use getch to get all the bytes - raise SyntaxError, "Unexpected character #{ss.getch}" + def raise_syntax_error(start_pos, ss) + ss.pos = start_pos + # the character could be a UTF-8 character, use getch to get all the bytes + raise SyntaxError, "Unexpected character #{ss.getch}" + end end end diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 8560fc58e..ed18909a2 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -3,8 +3,7 @@ module Liquid class Parser def initialize(input) - l = Lexer.new(input) - @tokens = l.tokenize + @tokens = Lexer.tokenize(input) @p = 0 # pointer to current location end diff --git a/lib/liquid/string_scanner_pool.rb b/lib/liquid/string_scanner_pool.rb index 4114d7a2d..6ebc49212 100644 --- a/lib/liquid/string_scanner_pool.rb +++ b/lib/liquid/string_scanner_pool.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module Liquid class StringScannerPool class << self def pop(input) - @ss_pool ||= [StringScanner.new("")] * 5 + @ss_pool ||= 5.times.each_with_object([]) { |_i, arr| arr << StringScanner.new("") } if @ss_pool.empty? StringScanner.new(input) @@ -14,7 +16,6 @@ def pop(input) end def release(ss) - binding.irb if ss.nil? @ss_pool ||= [] @ss_pool << ss end diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 26a25b629..d4f22fb60 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -134,6 +134,6 @@ def test_tokenize_incomplete_expression private def tokenize(input) - Lexer.new(input).tokenize + Lexer.tokenize(input) end end From 7c592c1c008929d26f1a26b201a2719c01e99026 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Sun, 17 Nov 2024 23:36:10 -0400 Subject: [PATCH 138/343] store StringScanner in ParseContext and reuse it through parsing --- lib/liquid.rb | 1 - lib/liquid/context.rb | 6 +++- lib/liquid/expression.rb | 44 ++++++++++++++--------------- lib/liquid/lexer.rb | 10 +++---- lib/liquid/parse_context.rb | 21 +++++++++++--- lib/liquid/parser.rb | 4 +-- lib/liquid/range_lookup.rb | 6 ++-- lib/liquid/string_scanner_pool.rb | 24 ---------------- lib/liquid/tags/for.rb | 2 +- lib/liquid/tags/if.rb | 2 +- lib/liquid/tokenizer.rb | 32 +++++++++++++++------ lib/liquid/variable.rb | 2 +- lib/liquid/variable_lookup.rb | 16 +++++++---- performance/theme_runner.rb | 7 ++++- test/integration/expression_test.rb | 2 +- test/unit/lexer_unit_test.rb | 2 +- test/unit/parser_unit_test.rb | 26 ++++++++++------- test/unit/tag_unit_test.rb | 17 ++++++++--- 18 files changed, 126 insertions(+), 98 deletions(-) delete mode 100644 lib/liquid/string_scanner_pool.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 63c087bee..367fc3c43 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -49,7 +49,6 @@ module Liquid require "liquid/version" require "liquid/deprecations" require "liquid/const" -require "liquid/string_scanner_pool" require 'liquid/standardfilters' require 'liquid/file_system' require 'liquid/parser_switching' diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 19daaeb73..1ef272d46 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -40,6 +40,10 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @global_filter = nil @disabled_tags = {} + # Instead of constructing new StringScanner objects for each Expression parse, + # we recycle the same one. + @string_scanner = StringScanner.new("") + @registers.static[:cached_partials] ||= {} @registers.static[:file_system] ||= environment.file_system @registers.static[:template_factory] ||= Liquid::TemplateFactory.new @@ -176,7 +180,7 @@ def []=(key, value) # Example: # products == empty #=> products.empty? def [](expression) - evaluate(Expression.parse(expression)) + evaluate(Expression.parse(expression, @string_scanner)) end def key?(key) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index ea8c68211..ee98d7577 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -22,7 +22,7 @@ class Expression1 # malicious input as described in https://github.com/Shopify/liquid/issues/1357 RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ - def self.parse(markup) + def self.parse(markup, _ss = nil) return nil unless markup markup = markup.strip @@ -35,14 +35,14 @@ def self.parse(markup) when INTEGERS_REGEX Regexp.last_match(1).to_i when RANGES_REGEX - RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2)) + RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2), nil) when FLOATS_REGEX Regexp.last_match(1).to_f else if LITERALS.key?(markup) LITERALS[markup] else - VariableLookup.parse(markup) + VariableLookup.parse(markup, nil) end end end @@ -58,7 +58,7 @@ class Expression2 'false' => false, 'blank' => '', 'empty' => '', - '-' => VariableLookup.parse("-") + '-' => VariableLookup.parse("-", nil), }.freeze DOT = ".".ord @@ -72,7 +72,7 @@ class Expression2 CACHE = LruRedux::Cache.new(10_000) # most themes would have less than 2,000 unique expression class << self - def parse(markup) + def parse(markup, ss = StringScanner.new("")) return unless markup markup = markup.strip # markup can be a frozen string @@ -86,33 +86,36 @@ def parse(markup) return CACHE[markup] if CACHE.key?(markup) - CACHE[markup] = inner_parse(markup) + CACHE[markup] = inner_parse(markup, ss) end - def inner_parse(markup) + def inner_parse(markup, ss) if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX - return RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2)) + return RangeLookup.parse( + Regexp.last_match(1), + Regexp.last_match(2), + ss, + ) end - if (num = parse_number(markup)) + if (num = parse_number(markup, ss)) num else - VariableLookup.parse(markup) + VariableLookup.parse(markup, ss) end end - def parse_number(markup) - ss = StringScannerPool.pop(markup) - - is_integer = true - last_dot_pos = nil - num_end_pos = nil - + def parse_number(markup, ss) + ss.string = markup # the first byte must be a digit, a period, or a dash byte = ss.scan_byte return false if byte != DASH && byte != DOT && (byte < ZERO || byte > NINE) + is_integer = true + last_dot_pos = nil + num_end_pos = nil + while (byte = ss.scan_byte) return false if byte != DOT && (byte < ZERO || byte > NINE) @@ -136,14 +139,9 @@ def parse_number(markup) if num_end_pos # number ends with a number "123.123" markup.byteslice(0, num_end_pos).to_f - elsif last_dot_pos - markup.byteslice(0, last_dot_pos).to_f else - # we should never reach this point - false + markup.byteslice(0, last_dot_pos).to_f end - ensure - StringScannerPool.release(ss) end end end diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index d9a073aa8..0ab9f3ca3 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -26,8 +26,8 @@ class Lexer1 WHITESPACE_OR_NOTHING = /\s*/ class << self - def tokenize(input) - ss = StringScanner.new(input) + def tokenize(input, ss = StringScanner.new("")) + ss.string = input output = [] until ss.eos? @@ -158,8 +158,8 @@ class Lexer2 # rubocop:disable Metrics/BlockNesting class << self - def tokenize(input) - ss = StringScannerPool.pop(input) + def tokenize(input, ss) + ss.string = input output = [] until ss.eos? @@ -220,8 +220,6 @@ def tokenize(input) end # rubocop:enable Metrics/BlockNesting output << EOS - ensure - StringScannerPool.release(ss) end def raise_syntax_error(start_pos, ss) diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 7bd5418d5..f66e86be3 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -3,7 +3,7 @@ module Liquid class ParseContext attr_accessor :locale, :line_number, :trim_whitespace, :depth - attr_reader :partial, :warnings, :error_mode, :environment + attr_reader :partial, :warnings, :error_mode, :environment, :string_scanner def initialize(options = Const::EMPTY_HASH) @environment = options.fetch(:environment, Environment.default) @@ -12,6 +12,10 @@ def initialize(options = Const::EMPTY_HASH) @locale = @template_options[:locale] ||= I18n.new @warnings = [] + # constructing new StringScanner in Lexer, Tokenizer, etc is expensive + # This StringScanner will be shared by all of them + @string_scanner = StringScanner.new("") + self.depth = 0 self.partial = false end @@ -24,12 +28,21 @@ def new_block_body Liquid::BlockBody.new end - def new_tokenizer(markup, start_line_number: nil, for_liquid_tag: false) - Tokenizer.new(markup, line_number: start_line_number, for_liquid_tag: for_liquid_tag) + def new_parser(input) + Parser.new(input, @string_scanner) + end + + def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) + Tokenizer.new( + source: source, + string_scanner: @string_scanner, + line_number: start_line_number, + for_liquid_tag: for_liquid_tag, + ) end def parse_expression(markup) - Expression.parse(markup) + Expression.parse(markup, string_scanner) end def partial=(value) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index ed18909a2..a4f781d70 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -2,8 +2,8 @@ module Liquid class Parser - def initialize(input) - @tokens = Lexer.tokenize(input) + def initialize(input, string_scanner) + @tokens = Lexer.tokenize(input, string_scanner) @p = 0 # pointer to current location end diff --git a/lib/liquid/range_lookup.rb b/lib/liquid/range_lookup.rb index fd208a676..763717240 100644 --- a/lib/liquid/range_lookup.rb +++ b/lib/liquid/range_lookup.rb @@ -2,9 +2,9 @@ module Liquid class RangeLookup - def self.parse(start_markup, end_markup) - start_obj = Expression.parse(start_markup) - end_obj = Expression.parse(end_markup) + def self.parse(start_markup, end_markup, string_scanner) + start_obj = Expression.parse(start_markup, string_scanner) + end_obj = Expression.parse(end_markup, string_scanner) if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) new(start_obj, end_obj) else diff --git a/lib/liquid/string_scanner_pool.rb b/lib/liquid/string_scanner_pool.rb deleted file mode 100644 index 6ebc49212..000000000 --- a/lib/liquid/string_scanner_pool.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Liquid - class StringScannerPool - class << self - def pop(input) - @ss_pool ||= 5.times.each_with_object([]) { |_i, arr| arr << StringScanner.new("") } - - if @ss_pool.empty? - StringScanner.new(input) - else - ss = @ss_pool.pop - ss.string = input - ss - end - end - - def release(ss) - @ss_pool ||= [] - @ss_pool << ss - end - end - end -end diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 0dda3081d..27af15bf1 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -88,7 +88,7 @@ def lax_parse(markup) end def strict_parse(markup) - p = Parser.new(markup) + p = @parse_context.new_parser(markup) @variable_name = p.consume(:id) raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in') diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index bcf250a11..040fecb84 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -102,7 +102,7 @@ def lax_parse(markup) end def strict_parse(markup) - p = Parser.new(markup) + p = @parse_context.new_parser(markup) condition = parse_binary_comparisons(p) p.consume(:end_of_string) condition diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 1ed3edf37..dcfc8fd4a 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -6,7 +6,13 @@ module Liquid class Tokenizer1 attr_reader :line_number, :for_liquid_tag - def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) + def initialize( + source:, + string_scanner:, # this is not used + line_numbers: false, + line_number: nil, + for_liquid_tag: false + ) @source = source.to_s.to_str @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag @@ -56,12 +62,22 @@ class Tokenizer2 CLOSE_CURLEY = "}".ord PERCENTAGE = "%".ord - def initialize(source, line_numbers = false, line_number: nil, for_liquid_tag: false) - @line_number = line_number || (line_numbers ? 1 : nil) + def initialize( + source:, + string_scanner:, + line_numbers: false, + line_number: nil, + for_liquid_tag: false + ) + @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag - @source = source - @offset = 0 - @tokens = [] + @source = source + @offset = 0 + @tokens = [] + + @ss = string_scanner + @ss.string = @source + tokenize end @@ -85,13 +101,11 @@ def tokenize if @for_liquid_tag @tokens = @source.split("\n") else - @ss = StringScannerPool.pop(@source) @tokens << shift_normal until @ss.eos? end @source = nil - ensure - StringScannerPool.release(@ss) if @ss + @ss = nil end def shift_normal diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 2bcf871ea..090690559 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -61,7 +61,7 @@ def lax_parse(markup) def strict_parse(markup) @filters = [] - p = Parser.new(markup) + p = @parse_context.new_parser(markup) return if p.look(:end_of_string) diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index 1ade30ef9..f544b3bc4 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -6,16 +6,19 @@ class VariableLookup attr_reader :name, :lookups - def self.parse(markup) - new(markup) + def self.parse(markup, string_scanner) + new(markup, string_scanner) end - def initialize(markup) + def initialize(markup, string_scanner = StringScanner.new("")) lookups = markup.scan(VariableParser) name = lookups.shift if name&.start_with?('[') && name&.end_with?(']') - name = Expression.parse(name[1..-2]) + name = Expression.parse( + name[1..-2], + string_scanner, + ) end @name = name @@ -25,7 +28,10 @@ def initialize(markup) @lookups.each_index do |i| lookup = lookups[i] if lookup&.start_with?('[') && lookup&.end_with?(']') - lookups[i] = Expression.parse(lookup[1..-2]) + lookups[i] = Expression.parse( + lookup[1..-2], + string_scanner, + ) elsif COMMAND_METHODS.include?(lookup) @command_flags |= 1 << i end diff --git a/performance/theme_runner.rb b/performance/theme_runner.rb index c158f8778..469503670 100644 --- a/performance/theme_runner.rb +++ b/performance/theme_runner.rb @@ -50,8 +50,13 @@ def compile # `tokenize` will just test the tokenizen portion of liquid without any templates def tokenize + ss = StringScanner.new("") @tests.each do |test_hash| - tokenizer = Liquid::Tokenizer.new(test_hash[:liquid], true) + tokenizer = Liquid::Tokenizer.new( + source: test_hash[:liquid], + string_scanner: ss, + line_numbers: true, + ) while tokenizer.shift; end end end diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index dc57ad286..6d29fc792 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -43,7 +43,7 @@ def test_range end def test_quirky_negative_sign_expression_markup - result = Expression.parse("-") + result = Expression.parse("-", nil) assert(result.is_a?(VariableLookup)) assert_equal("-", result.name) diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index d4f22fb60..e6f161288 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -134,6 +134,6 @@ def test_tokenize_incomplete_expression private def tokenize(input) - Lexer.tokenize(input) + Lexer.tokenize(input, StringScanner.new("")) end end diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index 130b8b793..789c5b663 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -6,20 +6,20 @@ class ParserUnitTest < Minitest::Test include Liquid def test_consume - p = Parser.new("wat: 7") + p = new_parser("wat: 7") assert_equal('wat', p.consume(:id)) assert_equal(':', p.consume(:colon)) assert_equal('7', p.consume(:number)) end def test_jump - p = Parser.new("wat: 7") + p = new_parser("wat: 7") p.jump(2) assert_equal('7', p.consume(:number)) end def test_consume? - p = Parser.new("wat: 7") + p = new_parser("wat: 7") assert_equal('wat', p.consume?(:id)) assert_equal(false, p.consume?(:dot)) assert_equal(':', p.consume(:colon)) @@ -27,7 +27,7 @@ def test_consume? end def test_id? - p = Parser.new("wat 6 Peter Hegemon") + p = new_parser("wat 6 Peter Hegemon") assert_equal('wat', p.id?('wat')) assert_equal(false, p.id?('endgame')) assert_equal('6', p.consume(:number)) @@ -36,7 +36,7 @@ def test_id? end def test_look - p = Parser.new("wat 6 Peter Hegemon") + p = new_parser("wat 6 Peter Hegemon") assert_equal(true, p.look(:id)) assert_equal('wat', p.consume(:id)) assert_equal(false, p.look(:comparison)) @@ -46,12 +46,12 @@ def test_look end def test_expressions - p = Parser.new("hi.there hi?[5].there? hi.there.bob") + p = new_parser("hi.there hi?[5].there? hi.there.bob") assert_equal('hi.there', p.expression) assert_equal('hi?[5].there?', p.expression) assert_equal('hi.there.bob', p.expression) - p = Parser.new("567 6.0 'lol' \"wut\"") + p = new_parser("567 6.0 'lol' \"wut\"") assert_equal('567', p.expression) assert_equal('6.0', p.expression) assert_equal("'lol'", p.expression) @@ -59,7 +59,7 @@ def test_expressions end def test_ranges - p = Parser.new("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)") + p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)") assert_equal('(5..7)', p.expression) assert_equal('(1.5..9.6)', p.expression) assert_equal('(young..old)', p.expression) @@ -67,7 +67,7 @@ def test_ranges end def test_arguments - p = Parser.new("filter: hi.there[5], keyarg: 7") + p = new_parser("filter: hi.there[5], keyarg: 7") assert_equal('filter', p.consume(:id)) assert_equal(':', p.consume(:colon)) assert_equal('hi.there[5]', p.argument) @@ -77,8 +77,14 @@ def test_arguments def test_invalid_expression assert_raises(SyntaxError) do - p = Parser.new("==") + p = new_parser("==") p.expression end end + + private + + def new_parser(str) + Parser.new(str, StringScanner.new("")) + end end diff --git a/test/unit/tag_unit_test.rb b/test/unit/tag_unit_test.rb index 7f9b490c9..9b4bf88b6 100644 --- a/test/unit/tag_unit_test.rb +++ b/test/unit/tag_unit_test.rb @@ -6,18 +6,18 @@ class TagUnitTest < Minitest::Test include Liquid def test_tag - tag = Tag.parse('tag', "", Tokenizer.new(""), ParseContext.new) + tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new) assert_equal('liquid::tag', tag.name) assert_equal('', tag.render(Context.new)) end def test_return_raw_text_of_tag - tag = Tag.parse("long_tag", "param1, param2, param3", Tokenizer.new(""), ParseContext.new) + tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new) assert_equal("long_tag param1, param2, param3", tag.raw) end def test_tag_name_should_return_name_of_the_tag - tag = Tag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new) + tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new) assert_equal('some_tag', tag.tag_name) end @@ -26,7 +26,16 @@ def render(_context); end end def test_tag_render_to_output_buffer_nil_value - custom_tag = CustomTag.parse("some_tag", "", Tokenizer.new(""), ParseContext.new) + custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new) assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string")) end + + private + + def new_tokenizer + Tokenizer.new( + source: "", + string_scanner: StringScanner.new(""), + ) + end end From cef64e277ecb985db8201746b4d1b41c2bcd40a1 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 18 Nov 2024 19:53:43 -0400 Subject: [PATCH 139/343] early match number expressions with Regex --- lib/liquid/expression.rb | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index ee98d7577..0a569e999 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -69,6 +69,9 @@ class Expression2 # Use an atomic group (?>...) to avoid pathological backtracing from # malicious input as described in https://github.com/Shopify/liquid/issues/1357 RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ + INTEGER_REGEX = /\A(-?\d+)\z/ + FLOAT_REGEX = /\A(-?\d+)\.\d+\z/ + CACHE = LruRedux::Cache.new(10_000) # most themes would have less than 2,000 unique expression class << self @@ -112,8 +115,16 @@ def parse_number(markup, ss) return false if byte != DASH && byte != DOT && (byte < ZERO || byte > NINE) - is_integer = true - last_dot_pos = nil + # check if the markup is simple integer or float + case markup + when INTEGER_REGEX + return markup.to_i + when FLOAT_REGEX + return markup.to_f + end + + # The markup could be a float with multiple dots + first_dot_pos = nil num_end_pos = nil while (byte = ss.scan_byte) @@ -123,24 +134,23 @@ def parse_number(markup, ss) next if num_end_pos if byte == DOT - if is_integer == false - num_end_pos = ss.pos - 1 + if first_dot_pos.nil? + first_dot_pos = ss.pos else - is_integer = false - last_dot_pos = ss.pos + # we found another dot, so we know that the number ends here + num_end_pos = ss.pos - 1 end end end num_end_pos = markup.length if ss.eos? - return markup.to_i if is_integer - if num_end_pos # number ends with a number "123.123" markup.byteslice(0, num_end_pos).to_f else - markup.byteslice(0, last_dot_pos).to_f + # number ends with a dot "123." + markup.byteslice(0, first_dot_pos).to_f end end end From 71506ad54e43fdf0ed476a47cc67c7635db61824 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 18 Nov 2024 20:12:10 -0400 Subject: [PATCH 140/343] avoid setting up StringScanner for parsing simple numbers --- lib/liquid/expression.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 0a569e999..0d8a44711 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -109,12 +109,6 @@ def inner_parse(markup, ss) end def parse_number(markup, ss) - ss.string = markup - # the first byte must be a digit, a period, or a dash - byte = ss.scan_byte - - return false if byte != DASH && byte != DOT && (byte < ZERO || byte > NINE) - # check if the markup is simple integer or float case markup when INTEGER_REGEX @@ -123,6 +117,12 @@ def parse_number(markup, ss) return markup.to_f end + ss.string = markup + # the first byte must be a digit, a period, or a dash + byte = ss.scan_byte + + return false if byte != DASH && byte != DOT && (byte < ZERO || byte > NINE) + # The markup could be a float with multiple dots first_dot_pos = nil num_end_pos = nil From 40191022e847be536a99aa6947c74117470ae814 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 21 Nov 2024 21:30:37 -0400 Subject: [PATCH 141/343] initialize StringScanner with input for default --- lib/liquid/lexer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 0ab9f3ca3..2f9ea20e5 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -26,7 +26,7 @@ class Lexer1 WHITESPACE_OR_NOTHING = /\s*/ class << self - def tokenize(input, ss = StringScanner.new("")) + def tokenize(input, ss = StringScanner.new(input)) ss.string = input output = [] From 91be3dd75ed37e113ad64d528e41302fefc73ac6 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Thu, 21 Nov 2024 22:01:07 -0400 Subject: [PATCH 142/343] keep the expression parse caching per document parsing --- lib/liquid/expression.rb | 20 ++++++++++++-------- lib/liquid/parse_context.rb | 5 +++-- lib/liquid/range_lookup.rb | 6 +++--- lib/liquid/variable_lookup.rb | 8 +++++--- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 0d8a44711..1b7d0ca23 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -22,7 +22,7 @@ class Expression1 # malicious input as described in https://github.com/Shopify/liquid/issues/1357 RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ - def self.parse(markup, _ss = nil) + def self.parse(markup, _ss = nil, _cache = nil) return nil unless markup markup = markup.strip @@ -72,10 +72,8 @@ class Expression2 INTEGER_REGEX = /\A(-?\d+)\z/ FLOAT_REGEX = /\A(-?\d+)\.\d+\z/ - CACHE = LruRedux::Cache.new(10_000) # most themes would have less than 2,000 unique expression - class << self - def parse(markup, ss = StringScanner.new("")) + def parse(markup, ss = StringScanner.new(""), cache = nil) return unless markup markup = markup.strip # markup can be a frozen string @@ -87,24 +85,30 @@ def parse(markup, ss = StringScanner.new("")) return LITERALS[markup] end - return CACHE[markup] if CACHE.key?(markup) + # Cache only exists during parsing + if cache + return cache[markup] if cache.key?(markup) - CACHE[markup] = inner_parse(markup, ss) + cache[markup] = inner_parse(markup, ss, cache) + else + inner_parse(markup, ss, nil) + end end - def inner_parse(markup, ss) + def inner_parse(markup, ss, cache) if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX return RangeLookup.parse( Regexp.last_match(1), Regexp.last_match(2), ss, + cache, ) end if (num = parse_number(markup, ss)) num else - VariableLookup.parse(markup, ss) + VariableLookup.parse(markup, ss, cache) end end diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index f66e86be3..ae2b3ee02 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -3,7 +3,7 @@ module Liquid class ParseContext attr_accessor :locale, :line_number, :trim_whitespace, :depth - attr_reader :partial, :warnings, :error_mode, :environment, :string_scanner + attr_reader :partial, :warnings, :error_mode, :environment, :string_scanner, :expression_cache def initialize(options = Const::EMPTY_HASH) @environment = options.fetch(:environment, Environment.default) @@ -15,6 +15,7 @@ def initialize(options = Const::EMPTY_HASH) # constructing new StringScanner in Lexer, Tokenizer, etc is expensive # This StringScanner will be shared by all of them @string_scanner = StringScanner.new("") + @expression_cache = LruRedux::Cache.new(10_000) self.depth = 0 self.partial = false @@ -42,7 +43,7 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) end def parse_expression(markup) - Expression.parse(markup, string_scanner) + Expression.parse(markup, string_scanner, expression_cache) end def partial=(value) diff --git a/lib/liquid/range_lookup.rb b/lib/liquid/range_lookup.rb index 763717240..bc316fe12 100644 --- a/lib/liquid/range_lookup.rb +++ b/lib/liquid/range_lookup.rb @@ -2,9 +2,9 @@ module Liquid class RangeLookup - def self.parse(start_markup, end_markup, string_scanner) - start_obj = Expression.parse(start_markup, string_scanner) - end_obj = Expression.parse(end_markup, string_scanner) + def self.parse(start_markup, end_markup, string_scanner, cache = nil) + start_obj = Expression.parse(start_markup, string_scanner, cache) + end_obj = Expression.parse(end_markup, string_scanner, cache) if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) new(start_obj, end_obj) else diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index f544b3bc4..06c24b764 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -6,11 +6,11 @@ class VariableLookup attr_reader :name, :lookups - def self.parse(markup, string_scanner) - new(markup, string_scanner) + def self.parse(markup, string_scanner, cache = nil) + new(markup, string_scanner, cache) end - def initialize(markup, string_scanner = StringScanner.new("")) + def initialize(markup, string_scanner = StringScanner.new(""), cache = nil) lookups = markup.scan(VariableParser) name = lookups.shift @@ -18,6 +18,7 @@ def initialize(markup, string_scanner = StringScanner.new("")) name = Expression.parse( name[1..-2], string_scanner, + cache, ) end @name = name @@ -31,6 +32,7 @@ def initialize(markup, string_scanner = StringScanner.new("")) lookups[i] = Expression.parse( lookup[1..-2], string_scanner, + cache, ) elsif COMMAND_METHODS.include?(lookup) @command_flags |= 1 << i From ac374a208fe13e5ec8079e4c39ca24019ee35308 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:19:42 -0400 Subject: [PATCH 143/343] freeze Expression2 parse results --- lib/liquid/expression.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 1b7d0ca23..5ed4712f8 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -89,9 +89,9 @@ def parse(markup, ss = StringScanner.new(""), cache = nil) if cache return cache[markup] if cache.key?(markup) - cache[markup] = inner_parse(markup, ss, cache) + cache[markup] = inner_parse(markup, ss, cache).freeze else - inner_parse(markup, ss, nil) + inner_parse(markup, ss, nil).freeze end end From e05719fb82bbd43d4d7886453e1a7c9a298f847e Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:22:53 -0400 Subject: [PATCH 144/343] use Kernel Integer parsing for simple Integer expression --- lib/liquid/expression.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 5ed4712f8..b3e0b36e1 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -116,7 +116,7 @@ def parse_number(markup, ss) # check if the markup is simple integer or float case markup when INTEGER_REGEX - return markup.to_i + return Integer(markup, 10) when FLOAT_REGEX return markup.to_f end From 253ec81b565ce3fa879b32e5692d5bfb795c8601 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:28:45 -0400 Subject: [PATCH 145/343] don't add a breaking change to Parser constructor --- lib/liquid/parse_context.rb | 3 ++- lib/liquid/parser.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index ae2b3ee02..d8471475c 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -30,7 +30,8 @@ def new_block_body end def new_parser(input) - Parser.new(input, @string_scanner) + @string_scanner.string = input + Parser.new(@string_scanner) end def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index a4f781d70..cba3dc7c6 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -2,7 +2,8 @@ module Liquid class Parser - def initialize(input, string_scanner) + def initialize(input) + input = input.is_a?(StringScanner) ? input : StringScanner.new(input.to_s) @tokens = Lexer.tokenize(input, string_scanner) @p = 0 # pointer to current location end From 04bd9dbe90f894b9cb08bde8ec2f263b90553e20 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:35:39 -0400 Subject: [PATCH 146/343] refactor Lexer to only take StringScanner --- lib/liquid/lexer.rb | 6 ++---- lib/liquid/parse_context.rb | 4 ++-- lib/liquid/parser.rb | 4 ++-- test/unit/lexer_unit_test.rb | 2 +- test/unit/parser_unit_test.rb | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 2f9ea20e5..3b23f5ff9 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -26,8 +26,7 @@ class Lexer1 WHITESPACE_OR_NOTHING = /\s*/ class << self - def tokenize(input, ss = StringScanner.new(input)) - ss.string = input + def tokenize(ss) output = [] until ss.eos? @@ -158,8 +157,7 @@ class Lexer2 # rubocop:disable Metrics/BlockNesting class << self - def tokenize(input, ss) - ss.string = input + def tokenize(ss) output = [] until ss.eos? diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index d8471475c..5eebd0459 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -3,7 +3,7 @@ module Liquid class ParseContext attr_accessor :locale, :line_number, :trim_whitespace, :depth - attr_reader :partial, :warnings, :error_mode, :environment, :string_scanner, :expression_cache + attr_reader :partial, :warnings, :error_mode, :environment def initialize(options = Const::EMPTY_HASH) @environment = options.fetch(:environment, Environment.default) @@ -44,7 +44,7 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) end def parse_expression(markup) - Expression.parse(markup, string_scanner, expression_cache) + Expression.parse(markup, @string_scanner, @expression_cache) end def partial=(value) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index cba3dc7c6..645dfa3a1 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -3,8 +3,8 @@ module Liquid class Parser def initialize(input) - input = input.is_a?(StringScanner) ? input : StringScanner.new(input.to_s) - @tokens = Lexer.tokenize(input, string_scanner) + ss = input.is_a?(StringScanner) ? input : StringScanner.new(input) + @tokens = Lexer.tokenize(ss) @p = 0 # pointer to current location end diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index e6f161288..18e1e439a 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -134,6 +134,6 @@ def test_tokenize_incomplete_expression private def tokenize(input) - Lexer.tokenize(input, StringScanner.new("")) + Lexer.tokenize(StringScanner.new(input)) end end diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index 789c5b663..2c6a3594e 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -85,6 +85,6 @@ def test_invalid_expression private def new_parser(str) - Parser.new(str, StringScanner.new("")) + Parser.new(StringScanner.new(str)) end end From 0a17c152895ea3b482614082d18e39f5c26c1975 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:39:48 -0400 Subject: [PATCH 147/343] freeze '-' VariableLookup in Expression --- lib/liquid/expression.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index b3e0b36e1..32f357e70 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -58,7 +58,9 @@ class Expression2 'false' => false, 'blank' => '', 'empty' => '', - '-' => VariableLookup.parse("-", nil), + # in lax mode, minus sign can be a VariableLookup + # For simplicity and performace, we treat it like a literal + '-' => VariableLookup.parse("-", nil).freeze, }.freeze DOT = ".".ord From e9f86724f60b981de23f1f3047b7ad0cbdc5208a Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:40:41 -0400 Subject: [PATCH 148/343] code styling --- lib/liquid/expression.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 32f357e70..2bef9cb52 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -89,9 +89,7 @@ def parse(markup, ss = StringScanner.new(""), cache = nil) # Cache only exists during parsing if cache - return cache[markup] if cache.key?(markup) - - cache[markup] = inner_parse(markup, ss, cache).freeze + cache.fetch(markup) { inner_parse(markup, ss, cache).freeze } else inner_parse(markup, ss, nil).freeze end From 313d01706acff3b2cbb3a1e7dd89eaac3b4135f6 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 15:56:22 -0400 Subject: [PATCH 149/343] code styling --- lib/liquid.rb | 3 +++ lib/liquid/expression.rb | 3 ++- lib/liquid/lexer.rb | 4 +--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 367fc3c43..4d0a71a64 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -21,6 +21,8 @@ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +require "strscan" + module Liquid FilterSeparator = /\|/ ArgumentSeparator = ',' @@ -44,6 +46,7 @@ module Liquid VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } + HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte) end require "liquid/version" diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 2bef9cb52..8eb1f2c32 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -160,5 +160,6 @@ def parse_number(markup, ss) end end - Expression = StringScanner.instance_methods.include?(:scan_byte) ? Expression2 : Expression1 + # Remove this once we can depend on strscan >= 3.1.1 + Expression = HAS_STRING_SCANNER_SCAN_BYTE ? Expression2 : Expression1 end diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 3b23f5ff9..ac4a0b8fc 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "strscan" - module Liquid class Lexer1 SPECIALS = { @@ -229,5 +227,5 @@ def raise_syntax_error(start_pos, ss) end # Remove this once we can depend on strscan >= 3.1.1 - Lexer = StringScanner.instance_methods.include?(:scan_byte) ? Lexer2 : Lexer1 + Lexer = HAS_STRING_SCANNER_SCAN_BYTE ? Lexer2 : Lexer1 end From 2c7d68669046f4d4e3120784e44833b83deafc84 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 16:09:33 -0400 Subject: [PATCH 150/343] adding caching to context variable lookup --- lib/liquid/context.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 1ef272d46..83aab805a 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "lru_redux" + module Liquid # Context keeps the variable stack and resolves variables, as well as keywords # @@ -39,6 +41,7 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @filters = [] @global_filter = nil @disabled_tags = {} + @expression_cache = LruRedux::ThreadSafeCache.new(1000) # Instead of constructing new StringScanner objects for each Expression parse, # we recycle the same one. @@ -180,7 +183,7 @@ def []=(key, value) # Example: # products == empty #=> products.empty? def [](expression) - evaluate(Expression.parse(expression, @string_scanner)) + evaluate(Expression.parse(expression, @string_scanner, @expression_cache)) end def key?(key) From 42d822bda9cb7b69273cc69aa18ad7a6bdd2f918 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 25 Nov 2024 17:01:49 -0400 Subject: [PATCH 151/343] users can provide optional expression cache --- Gemfile | 3 +-- lib/liquid/expression.rb | 4 ++- lib/liquid/parse_context.rb | 5 +++- test/integration/expression_test.rb | 39 +++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index e74d19ec7..6802722cb 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ end gemspec gem "base64" +gem "lru_redux" group :benchmark, :test do gem 'benchmark-ips' @@ -24,5 +25,3 @@ group :test do gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end - -gem "lru_redux" diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 8eb1f2c32..5b3f0cd18 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -89,7 +89,9 @@ def parse(markup, ss = StringScanner.new(""), cache = nil) # Cache only exists during parsing if cache - cache.fetch(markup) { inner_parse(markup, ss, cache).freeze } + return cache[markup] if cache.key?(markup) + + cache[markup] = inner_parse(markup, ss, cache).freeze else inner_parse(markup, ss, nil).freeze end diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 5eebd0459..030529724 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -15,7 +15,10 @@ def initialize(options = Const::EMPTY_HASH) # constructing new StringScanner in Lexer, Tokenizer, etc is expensive # This StringScanner will be shared by all of them @string_scanner = StringScanner.new("") - @expression_cache = LruRedux::Cache.new(10_000) + + @expression_cache = if options[:expression_cache] != false + options[:expression_cache] || LruRedux::Cache.new(10_000) + end self.depth = 0 self.partial = false diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 6d29fc792..190a6bec9 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'lru_redux' class ExpressionTest < Minitest::Test def test_keyword_literals @@ -54,6 +55,44 @@ def test_quirky_negative_sign_expression_markup ) end + def test_expression_cache + skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + + cache = LruRedux::Cache.new(10) + template = <<~LIQUID + {% assign x = 1 %} + {{ x }} + {% assign x = 2 %} + {{ x }} + {% assign y = 1 %} + {{ y }} + LIQUID + + Liquid::Template.parse(template, expression_cache: cache).render + + assert_equal( + ["1", "2", "x", "y"], + cache.to_a.map { _1[0] }.sort, + ) + end + + def test_disable_expression_cache + skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + + template = <<~LIQUID + {% assign x = 1 %} + {{ x }} + {% assign x = 2 %} + {{ x }} + {% assign y = 1 %} + {{ y }} + LIQUID + + parse_context = Liquid::ParseContext.new(expression_cache: false) + Liquid::Template.parse(template, parse_context).render + assert(parse_context.instance_variable_get(:@expression_cache).nil?) + end + private def assert_expression_result(expect, markup, **assigns) From d48708ae46422a479ec5ff62f6ab1b0792e319e3 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 26 Nov 2024 14:28:52 -0400 Subject: [PATCH 152/343] skip expression caching with Expression1 --- test/integration/expression_test.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 190a6bec9..bb2c02e0b 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -57,6 +57,7 @@ def test_quirky_negative_sign_expression_markup def test_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + skip("Expression Caching is only available with Expression2") if Liquid::Expression != Liquid::Expression2 cache = LruRedux::Cache.new(10) template = <<~LIQUID @@ -78,6 +79,7 @@ def test_expression_cache def test_disable_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + skip("Expression Caching is only available with Expression2") if Liquid::Expression != Liquid::Expression2 template = <<~LIQUID {% assign x = 1 %} From 7c9b77e8cf7058805d71e665146871af2585b852 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Tue, 7 Jan 2025 14:51:29 -0400 Subject: [PATCH 153/343] remove StringScanner version check in Lexer and Tokenizer --- lib/liquid/lexer.rb | 60 +------------------------------------- lib/liquid/tokenizer.rb | 64 +++++------------------------------------ liquid.gemspec | 2 +- 3 files changed, 9 insertions(+), 117 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index ac4a0b8fc..b28e87842 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -1,62 +1,7 @@ # frozen_string_literal: true module Liquid - class Lexer1 - SPECIALS = { - '|' => :pipe, - '.' => :dot, - ':' => :colon, - ',' => :comma, - '[' => :open_square, - ']' => :close_square, - '(' => :open_round, - ')' => :close_round, - '?' => :question, - '-' => :dash, - }.freeze - IDENTIFIER = /[a-zA-Z_][\w-]*\??/ - SINGLE_STRING_LITERAL = /'[^\']*'/ - DOUBLE_STRING_LITERAL = /"[^\"]*"/ - STRING_LITERAL = Regexp.union(SINGLE_STRING_LITERAL, DOUBLE_STRING_LITERAL) - NUMBER_LITERAL = /-?\d+(\.\d+)?/ - DOTDOT = /\.\./ - COMPARISON_OPERATOR = /==|!=|<>|<=?|>=?|contains(?=\s)/ - WHITESPACE_OR_NOTHING = /\s*/ - - class << self - def tokenize(ss) - output = [] - - until ss.eos? - ss.skip(WHITESPACE_OR_NOTHING) - break if ss.eos? - tok = if (t = ss.scan(COMPARISON_OPERATOR)) - [:comparison, t] - elsif (t = ss.scan(STRING_LITERAL)) - [:string, t] - elsif (t = ss.scan(NUMBER_LITERAL)) - [:number, t] - elsif (t = ss.scan(IDENTIFIER)) - [:id, t] - elsif (t = ss.scan(DOTDOT)) - [:dotdot, t] - else - c = ss.getch - if (s = SPECIALS[c]) - [s, c] - else - raise SyntaxError, "Unexpected character #{c}" - end - end - output << tok - end - - output << [:end_of_string] - end - end - end - - class Lexer2 + class Lexer CLOSE_ROUND = [:close_round, ")"].freeze CLOSE_SQUARE = [:close_square, "]"].freeze COLON = [:colon, ":"].freeze @@ -225,7 +170,4 @@ def raise_syntax_error(start_pos, ss) end end end - - # Remove this once we can depend on strscan >= 3.1.1 - Lexer = HAS_STRING_SCANNER_SCAN_BYTE ? Lexer2 : Lexer1 end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index dcfc8fd4a..008ec070d 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -3,55 +3,7 @@ require "strscan" module Liquid - class Tokenizer1 - attr_reader :line_number, :for_liquid_tag - - def initialize( - source:, - string_scanner:, # this is not used - line_numbers: false, - line_number: nil, - for_liquid_tag: false - ) - @source = source.to_s.to_str - @line_number = line_number || (line_numbers ? 1 : nil) - @for_liquid_tag = for_liquid_tag - @offset = 0 - @tokens = tokenize - end - - def shift - token = @tokens[@offset] - return nil unless token - - @offset += 1 - - if @line_number - @line_number += @for_liquid_tag ? 1 : token.count("\n") - end - - token - end - - private - - def tokenize - return [] if @source.empty? - - return @source.split("\n") if @for_liquid_tag - - tokens = @source.split(TemplateParser) - - # removes the rogue empty element at the beginning of the array - if tokens[0]&.empty? - @offset += 1 - end - - tokens - end - end - - class Tokenizer2 + class Tokenizer attr_reader :line_number, :for_liquid_tag TAG_END = /%\}/ @@ -71,14 +23,15 @@ def initialize( ) @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag - @source = source + @source = source.to_s.to_str @offset = 0 @tokens = [] - @ss = string_scanner - @ss.string = @source - - tokenize + if @source + @ss = string_scanner + @ss.string = @source + tokenize + end end def shift @@ -199,7 +152,4 @@ def next_tag_token_with_start(start) @source.byteslice(start, @ss.pos - start) end end - - # Remove this once we can depend on strscan >= 3.1.1 - Tokenizer = StringScanner.instance_methods.include?(:scan_byte) ? Tokenizer2 : Tokenizer1 end diff --git a/liquid.gemspec b/liquid.gemspec index c692fc1d9..a58a36019 100644 --- a/liquid.gemspec +++ b/liquid.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |s| s.require_path = "lib" - s.add_dependency("strscan") + s.add_dependency("strscan", ">= 3.1.1") s.add_dependency("bigdecimal") s.add_dependency("lru_redux") From 97cada61f9533902db29ddd19e6a2c615ba5eaf6 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 14:21:57 -0400 Subject: [PATCH 154/343] remove Expression2 strscan workaround --- lib/liquid/expression.rb | 50 +---------- performance/unit/expression_benchmark.rb | 103 ++--------------------- performance/unit/lexer_benchmark.rb | 23 +---- test/integration/expression_test.rb | 2 - 4 files changed, 10 insertions(+), 168 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 5b3f0cd18..f2abb2fc1 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -3,52 +3,7 @@ require "lru_redux" module Liquid - class Expression1 - LITERALS = { - nil => nil, - 'nil' => nil, - 'null' => nil, - '' => nil, - 'true' => true, - 'false' => false, - 'blank' => '', - 'empty' => '' - }.freeze - - INTEGERS_REGEX = /\A(-?\d+)\z/ - FLOATS_REGEX = /\A(-?\d[\d\.]+)\z/ - - # Use an atomic group (?>...) to avoid pathological backtracing from - # malicious input as described in https://github.com/Shopify/liquid/issues/1357 - RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ - - def self.parse(markup, _ss = nil, _cache = nil) - return nil unless markup - - markup = markup.strip - if (markup.start_with?('"') && markup.end_with?('"')) || - (markup.start_with?("'") && markup.end_with?("'")) - return markup[1..-2] - end - - case markup - when INTEGERS_REGEX - Regexp.last_match(1).to_i - when RANGES_REGEX - RangeLookup.parse(Regexp.last_match(1), Regexp.last_match(2), nil) - when FLOATS_REGEX - Regexp.last_match(1).to_f - else - if LITERALS.key?(markup) - LITERALS[markup] - else - VariableLookup.parse(markup, nil) - end - end - end - end - - class Expression2 + class Expression LITERALS = { nil => nil, 'nil' => nil, @@ -161,7 +116,4 @@ def parse_number(markup, ss) end end end - - # Remove this once we can depend on strscan >= 3.1.1 - Expression = HAS_STRING_SCANNER_SCAN_BYTE ? Expression2 : Expression1 end diff --git a/performance/unit/expression_benchmark.rb b/performance/unit/expression_benchmark.rb index ae4eeaa59..8cd552595 100644 --- a/performance/unit/expression_benchmark.rb +++ b/performance/unit/expression_benchmark.rb @@ -75,109 +75,20 @@ "range" => RANGE_MARKUPS, } -def compare_objects(object_1, object_2) - if object_1.is_a?(Liquid::VariableLookup) && object_2.is_a?(Liquid::VariableLookup) - return false if object_1.name != object_2.name - elsif object_1 != object_2 - return false - end - - true -end - -def compare_range_lookup(expression_1_result, expression_2_result) - return false unless expression_1_result.is_a?(Liquid::RangeLookup) && expression_2_result.is_a?(Liquid::RangeLookup) - - start_obj_1 = expression_1_result.start_obj - start_obj_2 = expression_2_result.start_obj - - return false unless compare_objects(start_obj_1, start_obj_2) - - end_obj_1 = expression_1_result.end_obj - end_obj_2 = expression_2_result.end_obj - - return false unless compare_objects(end_obj_1, end_obj_2) - - true -end - -MARKUPS.values.flatten.each do |markup| - expression_1_result = Liquid::Expression1.parse(markup) - expression_2_result = Liquid::Expression2.parse(markup) - - next if expression_1_result == expression_2_result - - if expression_1_result.is_a?(Liquid::RangeLookup) && expression_2_result.is_a?(Liquid::RangeLookup) - next if compare_range_lookup(expression_1_result, expression_2_result) - end - - warn "Expression1 and Expression2 results are different for markup: #{markup}" - warn "expected: #{expression_1_result}" - warn "got: #{expression_2_result}" - abort -end - -warmed_up = false - -MARKUPS.each do |type, markups| - Benchmark.ips do |x| - if warmed_up - x.config(time: 10, warmup: 5) - warmed_up = true - else - x.config(time: 10) - end - - x.report("Liquid::Expression1#parse: #{type}") do - if Liquid::Expression != Liquid::Expression1 - Liquid.send(:remove_const, :Expression) - Liquid.const_set(:Expression, Liquid::Expression1) - end - - markups.each do |markup| - Liquid::Expression1.parse(markup) - end - end - - x.report("Liquid::Expression2#parse: #{type}") do - if Liquid::Expression != Liquid::Expression2 - Liquid.send(:remove_const, :Expression) - Liquid.const_set(:Expression, Liquid::Expression2) - end +Benchmark.ips do |x| + x.config(time: 5, warmup: 5) + MARKUPS.each do |type, markups| + x.report("Liquid::Expression#parse: #{type}") do markups.each do |markup| - Liquid::Expression2.parse(markup) + Liquid::Expression.parse(markup) end end - - x.compare! - end -end - -Benchmark.ips do |x| - x.config(time: 10) - - x.report("Liquid::Expression1#parse: all") do - if Liquid::Expression != Liquid::Expression1 - Liquid.send(:remove_const, :Expression) - Liquid.const_set(:Expression, Liquid::Expression1) - end - - MARKUPS.values.flatten.each do |markup| - Liquid::Expression1.parse(markup) - end end - x.report("Liquid::Expression2#parse: all") do - if Liquid::Expression != Liquid::Expression2 - Liquid.send(:remove_const, :Expression) - Liquid.const_set(:Expression, Liquid::Expression2) - end - + x.report("Liquid::Expression#parse: all") do MARKUPS.values.flatten.each do |markup| - Liquid::Expression2.parse(markup) + Liquid::Expression.parse(markup) end end - - x.compare! end diff --git a/performance/unit/lexer_benchmark.rb b/performance/unit/lexer_benchmark.rb index 2faa1f686..fda6ce1f6 100644 --- a/performance/unit/lexer_benchmark.rb +++ b/performance/unit/lexer_benchmark.rb @@ -29,31 +29,12 @@ "foo | default: -1", ] -EXPRESSIONS.each do |expr| - lexer_1_result = Liquid::Lexer1.new(expr).tokenize - lexer_2_result = Liquid::Lexer2.new(expr).tokenize - - next if lexer_1_result == lexer_2_result - - warn "Lexer1 and Lexer2 results are different for expression: #{expr}" - warn "expected: #{lexer_1_result}" - warn "got: #{lexer_2_result}" - abort -end - Benchmark.ips do |x| x.config(time: 10, warmup: 5) - x.report("Liquid::Lexer1#tokenize") do - EXPRESSIONS.each do |expr| - l = Liquid::Lexer1.new(expr) - l.tokenize - end - end - - x.report("Liquid::Lexer2#tokenize") do + x.report("Liquid::Lexer#tokenize") do EXPRESSIONS.each do |expr| - l = Liquid::Lexer2.new(expr) + l = Liquid::Lexer.new(expr) l.tokenize end end diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index bb2c02e0b..190a6bec9 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -57,7 +57,6 @@ def test_quirky_negative_sign_expression_markup def test_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled - skip("Expression Caching is only available with Expression2") if Liquid::Expression != Liquid::Expression2 cache = LruRedux::Cache.new(10) template = <<~LIQUID @@ -79,7 +78,6 @@ def test_expression_cache def test_disable_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled - skip("Expression Caching is only available with Expression2") if Liquid::Expression != Liquid::Expression2 template = <<~LIQUID {% assign x = 1 %} From 0b8e30b81989601641d245094528c7543c1ef0a1 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 14:37:54 -0400 Subject: [PATCH 155/343] update unit test to be compatible with new source_location format --- test/unit/environment_filter_test.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/unit/environment_filter_test.rb b/test/unit/environment_filter_test.rb index 942f6a3c7..e710573b1 100644 --- a/test/unit/environment_filter_test.rb +++ b/test/unit/environment_filter_test.rb @@ -36,14 +36,14 @@ def test_strainer assert_equal("public", strainer.invoke("public_filter")) end - def test_stainer_raises_argument_error + def test_strainer_raises_argument_error strainer = @environment.create_strainer(@context) assert_raises(Liquid::ArgumentError) do strainer.invoke("public_filter", 1) end end - def test_stainer_argument_error_contains_backtrace + def test_strainer_argument_error_contains_backtrace strainer = @environment.create_strainer(@context) exception = assert_raises(Liquid::ArgumentError) do @@ -54,8 +54,9 @@ def test_stainer_argument_error_contains_backtrace /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, exception.message, ) + source = AccessScopeFilters.instance_method(:public_filter).source_location - assert_equal(source.map(&:to_s), exception.backtrace[0].split(':')[0..1]) + assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1]) end def test_strainer_only_invokes_public_filter_methods From 2a5ecf068feca41590804dd5ab41a4747fe0327d Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 14:38:50 -0400 Subject: [PATCH 156/343] remove lru_redux from Gemfile --- Gemfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Gemfile b/Gemfile index 6802722cb..013b1700a 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,6 @@ end gemspec gem "base64" -gem "lru_redux" group :benchmark, :test do gem 'benchmark-ips' From bd05dfbd1c9a53a0ec3b43cdc97cd41ef21767b5 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 14:41:35 -0400 Subject: [PATCH 157/343] use lru_redux getset for simplicity --- lib/liquid/expression.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index f2abb2fc1..f10c25db0 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -44,9 +44,7 @@ def parse(markup, ss = StringScanner.new(""), cache = nil) # Cache only exists during parsing if cache - return cache[markup] if cache.key?(markup) - - cache[markup] = inner_parse(markup, ss, cache).freeze + cache.getset(markup) { inner_parse(markup, ss, cache).freeze } else inner_parse(markup, ss, nil).freeze end From 2e236b0a0ea9408a8d29643151a2c9f87ded18d4 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 14:58:23 -0400 Subject: [PATCH 158/343] fix expression cache to be compatible with Ruby Hash and LruCache --- lib/liquid/expression.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index f10c25db0..f2abb2fc1 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -44,7 +44,9 @@ def parse(markup, ss = StringScanner.new(""), cache = nil) # Cache only exists during parsing if cache - cache.getset(markup) { inner_parse(markup, ss, cache).freeze } + return cache[markup] if cache.key?(markup) + + cache[markup] = inner_parse(markup, ss, cache).freeze else inner_parse(markup, ss, nil).freeze end From a07ae905230172a40156cca7c7b2a6becdccb89d Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 15:07:57 -0400 Subject: [PATCH 159/343] use Ruby Hash as default expression cache --- Gemfile | 1 + lib/liquid/parse_context.rb | 8 +++-- liquid.gemspec | 1 - test/integration/expression_test.rb | 45 +++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 013b1700a..fd9d00b02 100644 --- a/Gemfile +++ b/Gemfile @@ -13,6 +13,7 @@ group :benchmark, :test do gem 'benchmark-ips' gem 'memory_profiler' gem 'terminal-table' + gem "lru_redux" install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do gem 'stackprof' diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 030529724..60cdf9e41 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -16,8 +16,12 @@ def initialize(options = Const::EMPTY_HASH) # This StringScanner will be shared by all of them @string_scanner = StringScanner.new("") - @expression_cache = if options[:expression_cache] != false - options[:expression_cache] || LruRedux::Cache.new(10_000) + @expression_cache = if options[:expression_cache].nil? + {} + elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=) + options[:expression_cache] + elsif options[:expression_cache] + {} end self.depth = 0 diff --git a/liquid.gemspec b/liquid.gemspec index a58a36019..1b9a91004 100644 --- a/liquid.gemspec +++ b/liquid.gemspec @@ -30,7 +30,6 @@ Gem::Specification.new do |s| s.add_dependency("strscan", ">= 3.1.1") s.add_dependency("bigdecimal") - s.add_dependency("lru_redux") s.add_development_dependency('rake', '~> 13.0') s.add_development_dependency('minitest') diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 190a6bec9..0eef5dc9d 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -58,6 +58,51 @@ def test_quirky_negative_sign_expression_markup def test_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + cache = {} + template = <<~LIQUID + {% assign x = 1 %} + {{ x }} + {% assign x = 2 %} + {{ x }} + {% assign y = 1 %} + {{ y }} + LIQUID + + Liquid::Template.parse(template, expression_cache: cache).render + + assert_equal( + ["1", "2", "x", "y"], + cache.to_a.map { _1[0] }.sort, + ) + end + + def test_expression_cache_with_true_boolean + skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + + template = <<~LIQUID + {% assign x = 1 %} + {{ x }} + {% assign x = 2 %} + {{ x }} + {% assign y = 1 %} + {{ y }} + LIQUID + + parse_context = ParseContext.new(expression_cache: true) + + Liquid::Template.parse(template, parse_context).render + + cache = parse_context.instance_variable_get(:@expression_cache) + + assert_equal( + ["1", "2", "x", "y"], + cache.to_a.map { _1[0] }.sort, + ) + end + + def test_expression_cache_with_lru_redux + skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + cache = LruRedux::Cache.new(10) template = <<~LIQUID {% assign x = 1 %} From 10114b333eac9e46454e335dea9a6ba906552630 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Fri, 10 Jan 2025 15:28:02 -0400 Subject: [PATCH 160/343] minor version bump --- lib/liquid/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 52831e7b2..7c3004e50 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.0" + VERSION = "5.6.1" end From a5b91e83ae116e7dc8210b21f795d0a5e9f89ec0 Mon Sep 17 00:00:00 2001 From: Benjamin Stein Date: Fri, 10 Jan 2025 11:28:46 -0800 Subject: [PATCH 161/343] Update README.md with default environment (#1879) Smaller projects, especially when upgrading from earlier versions, may not need different environment scopes, so just adding to the docs. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e1df18ba7..3734a290f 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Liquid::Template.parse(<<~LIQUID, environment: email_environment) LIQUID ``` -By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. +By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`. ### Error Modes From 58777bcd930b1f4285a3d3ce879310dfd4b2e36b Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 13 Jan 2025 17:13:27 -0400 Subject: [PATCH 162/343] float has to start with a digit --- lib/liquid/expression.rb | 11 +++++++++-- test/integration/expression_test.rb | 9 +++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index f2abb2fc1..979c34571 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -79,10 +79,17 @@ def parse_number(markup, ss) end ss.string = markup - # the first byte must be a digit, a period, or a dash + # the first byte must be a digit or a dash byte = ss.scan_byte - return false if byte != DASH && byte != DOT && (byte < ZERO || byte > NINE) + return false if byte != DASH && (byte < ZERO || byte > NINE) + + if byte == DASH + peek_byte = ss.peek_byte + + # if it starts with a dash, the next byte must be a digit + return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE) + end # The markup could be a float with multiple dots first_dot_pos = nil diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 0eef5dc9d..29e456321 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -26,7 +26,16 @@ def test_int def test_float assert_template_result("-17.42", "{{ -17.42 }}") assert_template_result("2.5", "{{ 2.5 }}") + assert_expression_result(0.0, "0.....5") + assert_expression_result(0.0, "-0..1") assert_expression_result(1.5, "1.5") + + # this is a unfortunate quirky behavior of Liquid + result = Expression.parse(".5") + assert_kind_of(Liquid::VariableLookup, result) + + result = Expression.parse("-.5") + assert_kind_of(Liquid::VariableLookup, result) end def test_range From 0639a094a8fb007d20a2a65f86163708df9eea89 Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 13 Jan 2025 17:16:33 -0400 Subject: [PATCH 163/343] bump version to 5.6.2 --- lib/liquid/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 7c3004e50..ab7d74083 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.1" + VERSION = "5.6.2" end From 7124540563d7879bf2f8fe1502f8fa9a69032d3e Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 13 Jan 2025 18:27:56 -0400 Subject: [PATCH 164/343] remove lru-redux --- lib/liquid/context.rb | 2 -- lib/liquid/expression.rb | 2 -- lib/liquid/version.rb | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 83aab805a..17e7bab2e 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "lru_redux" - module Liquid # Context keeps the variable stack and resolves variables, as well as keywords # diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 979c34571..adf340f1f 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require "lru_redux" - module Liquid class Expression LITERALS = { diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index ab7d74083..4cfca9b16 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.2" + VERSION = "5.6.3" end From e200c4544b0456bb42a12f2d58fddc93290f690b Mon Sep 17 00:00:00 2001 From: Michael Go Date: Mon, 13 Jan 2025 18:36:30 -0400 Subject: [PATCH 165/343] remove expression caching while rendering --- lib/liquid/context.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 17e7bab2e..1ef272d46 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -39,7 +39,6 @@ def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_erro @filters = [] @global_filter = nil @disabled_tags = {} - @expression_cache = LruRedux::ThreadSafeCache.new(1000) # Instead of constructing new StringScanner objects for each Expression parse, # we recycle the same one. @@ -181,7 +180,7 @@ def []=(key, value) # Example: # products == empty #=> products.empty? def [](expression) - evaluate(Expression.parse(expression, @string_scanner, @expression_cache)) + evaluate(Expression.parse(expression, @string_scanner)) end def key?(key) From b3f9639e7d7f1d12ef6170677b5dd63b617e4efc Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Mon, 13 Jan 2025 21:56:39 -0500 Subject: [PATCH 166/343] Add default string_scanner to `Liquid::VariableLookup.parse` --- lib/liquid/variable_lookup.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index 06c24b764..340c0b66d 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -6,7 +6,7 @@ class VariableLookup attr_reader :name, :lookups - def self.parse(markup, string_scanner, cache = nil) + def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil) new(markup, string_scanner, cache) end From 8e37c5e18b853bee1c11ab2181082efa2924b8c5 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Mon, 13 Jan 2025 21:57:52 -0500 Subject: [PATCH 167/343] Bump to v5.6.4 --- lib/liquid/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 4cfca9b16..0cc2adc85 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.3" + VERSION = "5.6.4" end From 6909570f8efd67fb0e5becbe05ca10393e54de53 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 14 Jan 2025 10:33:31 +0100 Subject: [PATCH 168/343] Add `find`, `find_index`, `has`, and `reject` filters to arrays (#1869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add reject filter #1573 * Add deep search for filter taking in properties #1749 * Update branch with main * Add `find`, `find_index`, `has`, and `reject` filters to arrays * Refactor: avoid usage of public_send --------- Co-authored-by: Anders Søgaard Co-authored-by: Anders Søgaard <9662430+andershagbard@users.noreply.github.com> --- lib/liquid/standardfilters.rb | 130 ++++++++--- test/integration/standard_filter_test.rb | 284 ++++++++++++++++++++++- 2 files changed, 382 insertions(+), 32 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index ffaceb32f..fa506f91c 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -378,7 +378,7 @@ def sort(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - ary.sort { |a, b| nil_safe_compare(a[property], b[property]) } + ary.sort { |a, b| nil_safe_compare(fetch_property(a, property), fetch_property(b, property)) } rescue TypeError raise_property_error(property) end @@ -407,7 +407,7 @@ def sort_natural(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) } + ary.sort { |a, b| nil_safe_casecmp(fetch_property(a, property), fetch_property(b, property)) } rescue TypeError raise_property_error(property) end @@ -424,29 +424,59 @@ def sort_natural(input, property = nil) # @liquid_syntax array | where: string, string # @liquid_return [array[untyped]] def where(input, property, target_value = nil) - ary = InputIterator.new(input, context) + filter_array(input, property, target_value) { |ary, &block| ary.select(&block) } + end - if ary.empty? - [] - elsif target_value.nil? - ary.select do |item| - item[property] - rescue TypeError - raise_property_error(property) - rescue NoMethodError - return nil unless item.respond_to?(:[]) - raise - end - else - ary.select do |item| - item[property] == target_value - rescue TypeError - raise_property_error(property) - rescue NoMethodError - return nil unless item.respond_to?(:[]) - raise - end - end + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Filters an array to exclude items with a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | reject: string, string + # @liquid_return [array[untyped]] + def reject(input, property, target_value = nil) + filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) } + end + + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Tests if any item in an array has a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | some: string, string + # @liquid_return [boolean] + def has(input, property, target_value = nil) + filter_array(input, property, target_value) { |ary, &block| ary.any?(&block) } + end + + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Returns the first item in an array with a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | find: string, string + # @liquid_return [untyped] + def find(input, property, target_value = nil) + filter_array(input, property, target_value) { |ary, &block| ary.find(&block) } + end + + # @liquid_public_docs + # @liquid_type filter + # @liquid_category array + # @liquid_summary + # Returns the index of the first item in an array with a specific property value. + # @liquid_description + # This requires you to provide both the property name and the associated value. + # @liquid_syntax array | find_index: string, string + # @liquid_return [number] + def find_index(input, property, target_value = nil) + filter_array(input, property, target_value) { |ary, &block| ary.find_index(&block) } end # @liquid_public_docs @@ -465,7 +495,7 @@ def uniq(input, property = nil) [] else ary.uniq do |item| - item[property] + fetch_property(item, property) rescue TypeError raise_property_error(property) rescue NoMethodError @@ -501,7 +531,7 @@ def map(input, property) if property == "to_liquid" e elsif e.respond_to?(:[]) - r = e[property] + r = fetch_property(e, property) r.is_a?(Proc) ? r.call : r end end @@ -525,7 +555,7 @@ def compact(input, property = nil) [] else ary.reject do |item| - item[property].nil? + fetch_property(item, property).nil? rescue TypeError raise_property_error(property) rescue NoMethodError @@ -899,7 +929,7 @@ def sum(input, property = nil) if property.nil? item elsif item.respond_to?(:[]) - item[property] + fetch_property(item, property) else 0 end @@ -918,6 +948,50 @@ def sum(input, property = nil) attr_reader :context + def filter_array(input, property, target_value, &block) + ary = InputIterator.new(input, context) + + return [] if ary.empty? + + block.call(ary) do |item| + if target_value.nil? + fetch_property(item, property) + else + fetch_property(item, property) == target_value + end + rescue TypeError + raise_property_error(property) + rescue NoMethodError + return nil unless item.respond_to?(:[]) + raise + end + end + + def fetch_property(drop, property_or_keys) + ## + # This keeps backward compatibility by supporting properties containing + # dots. This is valid in Liquid syntax and used in some runtimes, such as + # Shopify with metafields. + # + # Using this approach, properties like 'price.value' can be accessed in + # both of the following examples: + # + # ``` + # [ + # { 'name' => 'Item 1', 'price.price' => 40000 }, + # { 'name' => 'Item 2', 'price' => { 'value' => 39900 } } + # ] + # ``` + value = drop[property_or_keys] + + return value if !value.nil? || !property_or_keys.is_a?(String) + + keys = property_or_keys.split('.') + keys.reduce(drop) do |drop, key| + drop.respond_to?(:[]) ? drop[key] : drop + end + end + def raise_property_error(property) raise Liquid::ArgumentError, "cannot select the property '#{property}'" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index eae4a1c9c..e4eb3bec6 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -54,6 +54,30 @@ def each(&block) end end +class TestDeepEnumerable < Liquid::Drop + include Enumerable + + class Product < Liquid::Drop + attr_reader :title, :price, :premium + + def initialize(title:, price:, premium: nil) + @title = { "content" => title, "language" => "en" } + @price = { "value" => price, "unit" => "USD" } + @premium = { "category" => premium } if premium + end + end + + def each(&block) + [ + Product.new(title: "Pro goggles", price: 1299), + Product.new(title: "Thermal gloves", price: 1299), + Product.new(title: "Alpine jacket", price: 3999, premium: 'Basic'), + Product.new(title: "Mountain boots", price: 3899, premium: 'Pro'), + Product.new(title: "Safety helmet", price: 1999) + ].each(&block) + end +end + class NumberLikeThing < Liquid::Drop def initialize(amount) @amount = amount @@ -392,6 +416,15 @@ def test_sort_natural_invalid_property end end + def test_sort_natural_with_deep_enumerables + template = <<~LIQUID + {{- products | sort_natural: 'title.content' | map: 'title.content' | join: ', ' -}} + LIQUID + expected_output = "Alpine jacket, Mountain boots, Pro goggles, Safety helmet, Thermal gloves" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_legacy_sort_hash assert_equal([{ a: 1, b: 2 }], @filters.sort(a: 1, b: 2)) end @@ -428,6 +461,15 @@ def test_uniq_invalid_property end end + def test_uniq_with_deep_enumerables + template = <<~LIQUID + {{- products | uniq: 'price.value' | map: "title.content" | join: ', ' -}} + LIQUID + expected_output = "Pro goggles, Alpine jacket, Mountain boots, Safety helmet" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_compact_empty_array assert_equal([], @filters.compact([], "a")) end @@ -444,6 +486,15 @@ def test_compact_invalid_property end end + def test_compact_with_deep_enumerables + template = <<~LIQUID + {{- products | compact: 'premium.category' | map: 'title.content' | join: ', ' -}} + LIQUID + expected_output = "Alpine jacket, Mountain boots" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_reverse assert_equal([4, 3, 2, 1], @filters.reverse([1, 2, 3, 4])) end @@ -553,6 +604,15 @@ def test_sort_works_on_enumerables assert_template_result("213", '{{ foo | sort: "bar" | map: "foo" }}', { "foo" => TestEnumerable.new }) end + def test_sort_with_deep_enumerables + template = <<~LIQUID + {{- products | sort: 'price.value' | map: 'title.content' | join: ', ' -}} + LIQUID + expected_output = "Pro goggles, Thermal gloves, Safety helmet, Mountain boots, Alpine jacket" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_first_and_last_call_to_liquid assert_template_result('foobar', '{{ foo | first }}', { 'foo' => [ThingWithToLiquid.new] }) assert_template_result('foobar', '{{ foo | last }}', { 'foo' => [ThingWithToLiquid.new] }) @@ -827,21 +887,219 @@ def test_date_raises_nothing assert_template_result('abc', "{{ 'abc' | date: '%D' }}") end + def test_reject + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}" + expected_output = "beta gamma" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_reject_with_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}" + expected_output = "beta gamma" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_reject_with_false_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_reject_with_deep_enumerables + template = <<~LIQUID + {{- products | reject: 'title.content', 'Pro goggles' | map: 'price.value' | join: ', ' -}} + LIQUID + expected_output = "1299, 3999, 3899, 1999" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + + def test_has + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => false }, + ] + + expected_output = "true" + + assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array }) + assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) + end + + def test_has_when_does_not_have_it + array = [ + { "handle" => "alpha", "ok" => false }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => false }, + ] + + expected_output = "false" + + assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array }) + assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) + end + + def test_has_with_false_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | has: 'ok', false }}" + expected_output = "true" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_has_with_false_value_when_does_not_have_it + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => true }, + { "handle" => "gamma", "ok" => true }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | has: 'ok', false }}" + expected_output = "false" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_has_with_deep_enumerables + template = <<~LIQUID + {{- products | has: 'title.content', 'Pro goggles' -}}, + {{- products | has: 'title.content', 'foo' -}} + LIQUID + expected_output = "true,false" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + + def test_find_with_value + products = [ + { "title" => "Pro goggles", "price" => 1299 }, + { "title" => "Thermal gloves", "price" => 1499 }, + { "title" => "Alpine jacket", "price" => 3999 }, + { "title" => "Mountain boots", "price" => 3899 }, + { "title" => "Safety helmet", "price" => 1999 } + ] + + template = <<~LIQUID + {%- assign product = products | find: 'price', 3999 -%} + {{- product.title -}} + LIQUID + expected_output = "Alpine jacket" + + assert_template_result(expected_output, template, { "products" => products }) + end + + def test_find_with_deep_enumerables + template = <<~LIQUID + {%- assign product = products | find: 'title.content', 'Pro goggles' -%} + {{- product.title.content -}} + LIQUID + expected_output = "Pro goggles" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + + def test_find_index_with_value + products = [ + { "title" => "Pro goggles", "price" => 1299 }, + { "title" => "Thermal gloves", "price" => 1499 }, + { "title" => "Alpine jacket", "price" => 3999 }, + { "title" => "Mountain boots", "price" => 3899 }, + { "title" => "Safety helmet", "price" => 1999 } + ] + + template = <<~LIQUID + {%- assign index = products | find_index: 'price', 3999 -%} + {{- index -}} + LIQUID + expected_output = "2" + + assert_template_result(expected_output, template, { "products" => products }) + end + + def test_find_index_with_deep_enumerables + template = <<~LIQUID + {%- assign index = products | find_index: 'title.content', 'Alpine jacket' -%} + {{- index -}} + LIQUID + expected_output = "2" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_where - input = [ + array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] - expectation = [ + template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_where_with_value + array = [ { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] - assert_equal(expectation, @filters.where(input, "ok", true)) - assert_equal(expectation, @filters.where(input, "ok")) + template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array }) + end + + def test_where_with_false_value + array = [ + { "handle" => "alpha", "ok" => true }, + { "handle" => "beta", "ok" => false }, + { "handle" => "gamma", "ok" => false }, + { "handle" => "delta", "ok" => true }, + ] + + template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}" + expected_output = "beta gamma" + + assert_template_result(expected_output, template, { "array" => array }) end def test_where_string_keys @@ -900,6 +1158,15 @@ def test_where_array_of_only_unindexable_values assert_nil(@filters.where([nil], "ok")) end + def test_where_with_deep_enumerables + template = <<~LIQUID + {{- products | where: 'title.content', 'Pro goggles' | map: 'price.value' -}} + LIQUID + expected_output = "1299" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + def test_all_filters_never_raise_non_liquid_exception test_drop = TestDrop.new(value: "test") test_drop.context = Context.new @@ -1051,6 +1318,15 @@ def test_sum_with_floats_and_indexable_map_values assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input }) end + def test_sum_with_deep_enumerables + template = <<~LIQUID + {{- products | sum: 'price.value' -}} + LIQUID + expected_output = "12495" + + assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) + end + private def with_timezone(tz) From ecf25ea83d53b51c66f12ad8cf3e855dab6f4e4d Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 14 Jan 2025 19:07:11 +0100 Subject: [PATCH 169/343] Add the "Releasing" section to `CONTRIBUTING.md` (#1891) * Add 'Releasing' workflow to 'CONTRIBUTING.md' * Update CONTRIBUTING.md Co-authored-by: Michael Go --------- Co-authored-by: Michael Go --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1d6b354a1..34ebcd297 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,3 +26,9 @@ * If it makes sense, add tests for your code and/or run a performance benchmark * Make sure all tests pass (`bundle exec rake`) * Create a pull request + +## Releasing + +* Bump the version in `lib/liquid/version.rb` and merge it on `main` +* Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new) + From 4b65a287229599f09f5df5fe1a322e1ef44a25f7 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 15 Jan 2025 16:36:53 -0500 Subject: [PATCH 170/343] Implement logic for stringify Hashes to keep compat with 3.4 (#1892) * Exploring * Bump to v5.6.5 --------- Co-authored-by: Dominic Petrick --- .github/workflows/liquid.yml | 2 +- lib/liquid/standardfilters.rb | 85 +++++++++++++++--------- lib/liquid/utils.rb | 86 +++++++++++++++++++++++++ lib/liquid/variable.rb | 4 +- lib/liquid/version.rb | 2 +- test/integration/hash_rendering_test.rb | 83 ++++++++++++++++++++++++ 6 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 test/integration/hash_rendering_test.rb diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index dce2d2811..3b66161da 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -14,7 +14,7 @@ jobs: - { ruby: 3.0, allowed-failure: false } # minimum supported - { ruby: 3.2, allowed-failure: false } - { ruby: 3.3, allowed-failure: false } - - { ruby: "3.4.0-rc1", allowed-failure: false } # latest + - { ruby: 3.4, allowed-failure: false } # latest - { ruby: ruby-head, allowed-failure: false } name: Test Ruby ${{ matrix.entry.ruby }} steps: diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index fa506f91c..73dfe346d 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -64,7 +64,7 @@ def size(input) # @liquid_syntax string | downcase # @liquid_return [string] def downcase(input) - input.to_s.downcase + Utils.to_s(input).downcase end # @liquid_public_docs @@ -75,7 +75,7 @@ def downcase(input) # @liquid_syntax string | upcase # @liquid_return [string] def upcase(input) - input.to_s.upcase + Utils.to_s(input).upcase end # @liquid_public_docs @@ -86,7 +86,7 @@ def upcase(input) # @liquid_syntax string | capitalize # @liquid_return [string] def capitalize(input) - input.to_s.capitalize + Utils.to_s(input).capitalize end # @liquid_public_docs @@ -97,7 +97,7 @@ def capitalize(input) # @liquid_syntax string | escape # @liquid_return [string] def escape(input) - CGI.escapeHTML(input.to_s) unless input.nil? + CGI.escapeHTML(Utils.to_s(input)) unless input.nil? end alias_method :h, :escape @@ -109,7 +109,7 @@ def escape(input) # @liquid_syntax string | escape_once # @liquid_return [string] def escape_once(input) - input.to_s.gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) + Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) end # @liquid_public_docs @@ -124,7 +124,7 @@ def escape_once(input) # @liquid_syntax string | url_encode # @liquid_return [string] def url_encode(input) - CGI.escape(input.to_s) unless input.nil? + CGI.escape(Utils.to_s(input)) unless input.nil? end # @liquid_public_docs @@ -138,7 +138,7 @@ def url_encode(input) def url_decode(input) return if input.nil? - result = CGI.unescape(input.to_s) + result = CGI.unescape(Utils.to_s(input)) raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding? result @@ -152,7 +152,7 @@ def url_decode(input) # @liquid_syntax string | base64_encode # @liquid_return [string] def base64_encode(input) - Base64.strict_encode64(input.to_s) + Base64.strict_encode64(Utils.to_s(input)) end # @liquid_public_docs @@ -163,7 +163,7 @@ def base64_encode(input) # @liquid_syntax string | base64_decode # @liquid_return [string] def base64_decode(input) - input = input.to_s + input = Utils.to_s(input) StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid base64 provided to base64_decode" @@ -177,7 +177,7 @@ def base64_decode(input) # @liquid_syntax string | base64_url_safe_encode # @liquid_return [string] def base64_url_safe_encode(input) - Base64.urlsafe_encode64(input.to_s) + Base64.urlsafe_encode64(Utils.to_s(input)) end # @liquid_public_docs @@ -188,7 +188,7 @@ def base64_url_safe_encode(input) # @liquid_syntax string | base64_url_safe_decode # @liquid_return [string] def base64_url_safe_decode(input) - input = input.to_s + input = Utils.to_s(input) StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode" @@ -212,7 +212,7 @@ def slice(input, offset, length = nil) if input.is_a?(Array) input.slice(offset, length) || [] else - input.to_s.slice(offset, length) || '' + Utils.to_s(input).slice(offset, length) || '' end rescue RangeError if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset) @@ -236,10 +236,10 @@ def slice(input, offset, length = nil) # @liquid_return [string] def truncate(input, length = 50, truncate_string = "...") return if input.nil? - input_str = input.to_s + input_str = Utils.to_s(input) length = Utils.to_integer(length) - truncate_string_str = truncate_string.to_s + truncate_string_str = Utils.to_s(truncate_string) l = length - truncate_string_str.length l = 0 if l < 0 @@ -263,7 +263,7 @@ def truncate(input, length = 50, truncate_string = "...") # @liquid_return [string] def truncatewords(input, words = 15, truncate_string = "...") return if input.nil? - input = input.to_s + input = Utils.to_s(input) words = Utils.to_integer(words) words = 1 if words <= 0 @@ -277,7 +277,8 @@ def truncatewords(input, words = 15, truncate_string = "...") return input if wordlist.length <= words wordlist.pop - wordlist.join(" ").concat(truncate_string.to_s) + truncate_string = Utils.to_s(truncate_string) + wordlist.join(" ").concat(truncate_string) end # @liquid_public_docs @@ -288,7 +289,9 @@ def truncatewords(input, words = 15, truncate_string = "...") # @liquid_syntax string | split: string # @liquid_return [array[string]] def split(input, pattern) - input.to_s.split(pattern.to_s) + pattern = Utils.to_s(pattern) + input = Utils.to_s(input) + input.split(pattern) end # @liquid_public_docs @@ -299,7 +302,8 @@ def split(input, pattern) # @liquid_syntax string | strip # @liquid_return [string] def strip(input) - input.to_s.strip + input = Utils.to_s(input) + input.strip end # @liquid_public_docs @@ -310,7 +314,8 @@ def strip(input) # @liquid_syntax string | lstrip # @liquid_return [string] def lstrip(input) - input.to_s.lstrip + input = Utils.to_s(input) + input.lstrip end # @liquid_public_docs @@ -321,7 +326,8 @@ def lstrip(input) # @liquid_syntax string | rstrip # @liquid_return [string] def rstrip(input) - input.to_s.rstrip + input = Utils.to_s(input) + input.rstrip end # @liquid_public_docs @@ -332,8 +338,9 @@ def rstrip(input) # @liquid_syntax string | strip_html # @liquid_return [string] def strip_html(input) + input = Utils.to_s(input) empty = '' - result = input.to_s.gsub(STRIP_HTML_BLOCKS, empty) + result = input.gsub(STRIP_HTML_BLOCKS, empty) result.gsub!(STRIP_HTML_TAGS, empty) result end @@ -346,7 +353,8 @@ def strip_html(input) # @liquid_syntax string | strip_newlines # @liquid_return [string] def strip_newlines(input) - input.to_s.gsub(/\r?\n/, '') + input = Utils.to_s(input) + input.gsub(/\r?\n/, '') end # @liquid_public_docs @@ -357,6 +365,7 @@ def strip_newlines(input) # @liquid_syntax array | join # @liquid_return [string] def join(input, glue = ' ') + glue = Utils.to_s(glue) InputIterator.new(input, context).join(glue) end @@ -573,7 +582,10 @@ def compact(input, property = nil) # @liquid_syntax string | replace: string, string # @liquid_return [string] def replace(input, string, replacement = '') - input.to_s.gsub(string.to_s, replacement.to_s) + string = Utils.to_s(string) + replacement = Utils.to_s(replacement) + input = Utils.to_s(input) + input.gsub(string, replacement) end # @liquid_public_docs @@ -584,7 +596,10 @@ def replace(input, string, replacement = '') # @liquid_syntax string | replace_first: string, string # @liquid_return [string] def replace_first(input, string, replacement = '') - input.to_s.sub(string.to_s, replacement.to_s) + string = Utils.to_s(string) + replacement = Utils.to_s(replacement) + input = Utils.to_s(input) + input.sub(string, replacement) end # @liquid_public_docs @@ -595,9 +610,9 @@ def replace_first(input, string, replacement = '') # @liquid_syntax string | replace_last: string, string # @liquid_return [string] def replace_last(input, string, replacement) - input = input.to_s - string = string.to_s - replacement = replacement.to_s + input = Utils.to_s(input) + string = Utils.to_s(string) + replacement = Utils.to_s(replacement) start_index = input.rindex(string) @@ -649,7 +664,9 @@ def remove_last(input, string) # @liquid_syntax string | append: string # @liquid_return [string] def append(input, string) - input.to_s + string.to_s + input = Utils.to_s(input) + string = Utils.to_s(string) + input + string end # @liquid_public_docs @@ -678,7 +695,9 @@ def concat(input, array) # @liquid_syntax string | prepend: string # @liquid_return [string] def prepend(input, string) - string.to_s + input.to_s + input = Utils.to_s(input) + string = Utils.to_s(string) + string + input end # @liquid_public_docs @@ -689,7 +708,8 @@ def prepend(input, string) # @liquid_syntax string | newline_to_br # @liquid_return [string] def newline_to_br(input) - input.to_s.gsub(/\r?\n/, "
\n") + input = Utils.to_s(input) + input.gsub(/\r?\n/, "
\n") end # Reformat a date using Ruby's core Time#strftime( string ) -> string @@ -724,11 +744,12 @@ def newline_to_br(input) # # See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime def date(input, format) - return input if format.to_s.empty? + str_format = Utils.to_s(format) + return input if str_format.empty? return input unless (date = Utils.to_date(input)) - date.strftime(format.to_s) + date.strftime(str_format) end # @liquid_public_docs diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 38a406ef2..e7d2a4e32 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -89,5 +89,91 @@ def self.to_liquid_value(obj) # Otherwise return the object itself obj end + + if RUBY_VERSION >= '3.4' + def self.to_s(obj, seen = {}) + case obj + when Hash + hash_inspect(obj, seen) + when Array + array_inspect(obj, seen) + else + obj.to_s + end + end + + def self.inspect(obj, seen = {}) + case obj + when Hash + hash_inspect(obj, seen) + when Array + array_inspect(obj, seen) + else + obj.inspect + end + end + else + def self.to_s(obj, seen = nil) + obj.to_s + end + + def self.inspect(obj, seen = nil) + obj.inspect + end + end + + def self.array_inspect(arr, seen = {}) + if seen[arr.object_id] + return "[...]" + end + + seen[arr.object_id] = true + str = +"[" + cursor = 0 + len = arr.length + + while cursor < len + if cursor > 0 + str << ", " + end + + item_str = inspect(arr[cursor], seen) + str << item_str + cursor += 1 + end + + str << "]" + str + ensure + seen.delete(arr.object_id) + end + + def self.hash_inspect(hash, seen = {}) + if seen[hash.object_id] + return "{...}" + end + seen[hash.object_id] = true + + str = +"{" + first = true + hash.each do |key, value| + if first + first = false + else + str << ", " + end + + key_str = inspect(key, seen) + str << key_str + str << "=>" + + value_str = inspect(value, seen) + str << value_str + end + str << "}" + str + ensure + seen.delete(hash.object_id) + end end end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 090690559..209570654 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -107,8 +107,8 @@ def render_obj_to_output(obj, output) obj.each do |o| render_obj_to_output(o, output) end - when - output << obj.to_s + else + output << Liquid::Utils.to_s(obj) end end diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 0cc2adc85..00fa6e312 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.4" + VERSION = "5.6.5" end diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb new file mode 100644 index 000000000..c465208fd --- /dev/null +++ b/test/integration/hash_rendering_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'test_helper' + +class HashRenderingTest < Minitest::Test + def test_render_empty_hash + assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} }) + end + + def test_render_hash_with_string_keys_and_values + assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } }) + end + + def test_render_hash_with_symbol_keys_and_integer_values + assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } }) + end + + def test_render_nested_hash + assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } }) + end + + def test_render_hash_with_array_values + assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } }) + end + + def test_render_recursive_hash + recursive_hash = { "self" => {} } + recursive_hash["self"]["self"] = recursive_hash + assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash }) + end + + def test_hash_with_downcase_filter + assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_upcase_filter + assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_strip_filter + assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_escape_filter + assert_template_result("{"Key"=>"Value", "AnotherKey"=>"AnotherValue"}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_url_encode_filter + assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_strip_html_filter + assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_truncate__20_filter + assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_replace___key____replaced_key__filter + assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_append____appended_text__filter + assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_hash_with_prepend___prepended_text___filter + assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) + end + + def test_render_hash_with_array_values_empty + assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } }) + end + + def test_render_hash_with_array_values_hash + assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } }) + end + + def test_render_hash_with_hash_key + assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } }) + end +end From 74af735f0e40467f95d9fab92fdc9363212e125a Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 16 Jan 2025 11:16:13 -0500 Subject: [PATCH 171/343] Allow for custom `< Hash` classes to override `#to_s` (#1896) --- .ruby-version | 2 +- lib/liquid/utils.rb | 46 +++++++++++++++---------- test/integration/hash_rendering_test.rb | 17 +++++++++ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/.ruby-version b/.ruby-version index 9c25013db..47b322c97 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.6 +3.4.1 diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index e7d2a4e32..a75b9fa37 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -90,34 +90,38 @@ def self.to_liquid_value(obj) obj end - if RUBY_VERSION >= '3.4' - def self.to_s(obj, seen = {}) - case obj - when Hash + def self.to_s(obj, seen = {}) + case obj + when Hash + # If the custom hash implementation overrides `#to_s`, use their + # custom implementation. Otherwise we use Liquid's default + # implementation. + if obj.class.instance_method(:to_s) == HASH_TO_S_METHOD hash_inspect(obj, seen) - when Array - array_inspect(obj, seen) else obj.to_s end + when Array + array_inspect(obj, seen) + else + obj.to_s end + end - def self.inspect(obj, seen = {}) - case obj - when Hash + def self.inspect(obj, seen = {}) + case obj + when Hash + # If the custom hash implementation overrides `#inspect`, use their + # custom implementation. Otherwise we use Liquid's default + # implementation. + if obj.class.instance_method(:inspect) == HASH_INSPECT_METHOD hash_inspect(obj, seen) - when Array - array_inspect(obj, seen) else obj.inspect end - end - else - def self.to_s(obj, seen = nil) - obj.to_s - end - - def self.inspect(obj, seen = nil) + when Array + array_inspect(obj, seen) + else obj.inspect end end @@ -175,5 +179,11 @@ def self.hash_inspect(hash, seen = {}) ensure seen.delete(hash.object_id) end + + HASH_TO_S_METHOD = Hash.instance_method(:to_s) + private_constant :HASH_TO_S_METHOD + + HASH_INSPECT_METHOD = Hash.instance_method(:inspect) + private_constant :HASH_INSPECT_METHOD end end diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb index c465208fd..39933c553 100644 --- a/test/integration/hash_rendering_test.rb +++ b/test/integration/hash_rendering_test.rb @@ -80,4 +80,21 @@ def test_render_hash_with_array_values_hash def test_render_hash_with_hash_key assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } }) end + + def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s + my_hash = Class.new(Hash) do + def to_s + "kewl" + end + end.new + + assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash }) + end + + def test_rendering_hash_without_custom_to_s_uses_default_inspect + my_hash = Class.new(Hash).new + my_hash[:foo] = :bar + + assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash }) + end end From 0ec52a40b57289d33409d81c8188219f22a59b92 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 16 Jan 2025 11:21:58 -0500 Subject: [PATCH 172/343] Use `Liquid::Utils.to_s` for `join` filter (#1897) --- lib/liquid/standardfilters.rb | 13 ++++++++++++- test/integration/hash_rendering_test.rb | 6 ++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 73dfe346d..a18b986f5 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -1063,7 +1063,18 @@ def initialize(input, context) end def join(glue) - to_a.join(glue.to_s) + first = true + output = +"" + @input.each do |item| + if first + first = false + else + output << glue + end + + output << Liquid::Utils.to_s(item) + end + output end def concat(args) diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb index 39933c553..47e6288b3 100644 --- a/test/integration/hash_rendering_test.rb +++ b/test/integration/hash_rendering_test.rb @@ -77,6 +77,12 @@ def test_render_hash_with_array_values_hash assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } }) end + def test_join_filter_with_hash + array = [{ "key1" => "value1" }, { "key2" => "value2" }] + glue = { "lol" => "wut" } + assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue }) + end + def test_render_hash_with_hash_key assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } }) end From 6372289ba3e90a1ec54d1b5a7d2b84aabe9aca14 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 16 Jan 2025 11:36:36 -0500 Subject: [PATCH 173/343] Ensure we use `InputIterator#each` when in `join` filter (#1898) --- lib/liquid/standardfilters.rb | 2 +- test/integration/standard_filter_test.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index a18b986f5..9da58e062 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -1065,7 +1065,7 @@ def initialize(input, context) def join(glue) first = true output = +"" - @input.each do |item| + each do |item| if first first = false else diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index e4eb3bec6..ce2a2841b 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -305,6 +305,16 @@ def test_join assert_equal('1121314', @filters.join([1, 2, 3, 4], 1)) end + def test_join_calls_to_liquid_on_each_element + drop = Class.new(Liquid::Drop) do + def to_liquid + 'i did it' + end + end + + assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", ")) + end + def test_sort assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1])) assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a")) From 03aafa974c2ace05605be5ce8cb732e92e4ca94d Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 17 Jan 2025 12:10:18 +0100 Subject: [PATCH 174/343] Bump to 5.7.0 (#1894) * Bump to 5.7.0 * Update 'History.md' * Update 'Releasing' steps * Update CONTRIBUTING.md Co-authored-by: Gray Gilmore * Update History.md Co-authored-by: Ian Ker-Seymer --------- Co-authored-by: Gray Gilmore Co-authored-by: Ian Ker-Seymer --- CONTRIBUTING.md | 4 +++- History.md | 46 +++++++++++++++++++++++++++++++++++++++++-- lib/liquid/version.rb | 2 +- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34ebcd297..61fe1f5a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,8 @@ ## Releasing -* Bump the version in `lib/liquid/version.rb` and merge it on `main` +* Bump the version in `lib/liquid/version.rb` +* Update the `History.md` file +* Open a PR like [this one](https://github.com/Shopify/liquid/pull/1894) and merge it to `main` * Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new) diff --git a/History.md b/History.md index 9414dd7af..db821a1f2 100644 --- a/History.md +++ b/History.md @@ -1,11 +1,53 @@ # Liquid Change Log -## 5.6.0 (unreleased) +## 5.8.0 (unreleased) + +## 5.7.0 2025-01-16 + +### Features +* Add `find`, `find_index`, `has`, and `reject` filters to arrays +* Compatibility with Ruby 3.4 + +## 5.6.4 2025-01-14 ### Fixes +* Add a default `string_scanner` to avoid errors with `Liquid::VariableLookup.parse("foo.bar")` [Ian Ker-Seymer] -* Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar] +## 5.6.3 2025-01-13 +* Remove `lru_redux` dependency [Michael Go] + +## 5.6.2 2025-01-13 + +### Fixes +* Preserve the old behavior of requiring floats to start with a digit [Michael Go] +## 5.6.1 2025-01-13 + +### Performance improvements +* Faster Expression parser / Tokenizer with StringScanner [Michael Go] + +## 5.6.0 2024-12-19 + +### Architectural changes +* Added new `Environment` class to manage configuration and state that was previously stored in `Template` [Ian Ker-Seymer] +* Moved tag registration from `Template` to `Environment` [Ian Ker-Seymer] +* Removed `StrainerFactory` in favor of `Environment`-based strainer creation [Ian Ker-Seymer] +* Consolidated standard tags into a new `Tags` module with `STANDARD_TAGS` constant [Ian Ker-Seymer] + +### Performance improvements +* Optimized `Lexer` with a new `Lexer2` implementation using jump tables for faster tokenization, requires Ruby 3.4 [Ian Ker-Seymer] +* Improved variable rendering with specialized handling for different types [Michael Go] +* Reduced array allocations by using frozen empty constants [Michael Go] + +### API changes +* Deprecated several `Template` class methods in favor of `Environment` methods [Ian Ker-Seymer] +* Added deprecation warnings system [Ian Ker-Seymer] +* Changed how filters and tags are registered to use Environment [Ian Ker-Seymer] + +### Fixes +* Fixed table row handling of break interrupts [Alex Coco] +* Improved variable output handling for arrays [Ian Ker-Seymer] +* Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar] ## 5.5.0 2024-03-21 diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 00fa6e312..65c0a74f1 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.6.5" + VERSION = "5.7.0" end From b0dbc626960fc0166457eb63bdaae0eb86ab43d9 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Jan 2025 08:21:38 +0100 Subject: [PATCH 175/343] Fix `bundle exec rake example` (#1900) * Fix `bundle exec rake example` * Move 'webrick' from 'benchmark/test' to 'development' --- Gemfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Gemfile b/Gemfile index fd9d00b02..a404c0d4b 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,10 @@ group :benchmark, :test do end end +group :development do + gem "webrick" +end + group :test do gem 'rubocop', '~> 1.61.0' gem 'rubocop-shopify', '~> 2.12.0', require: false From 5718c4cee23dfa6673519c5a8e8336729e16ff1a Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Jan 2025 10:54:46 +0100 Subject: [PATCH 176/343] Fix the `find` filter to return `nil` when filtering empty arrays --- History.md | 5 +++++ lib/liquid/standardfilters.rb | 6 +++--- lib/liquid/version.rb | 2 +- test/integration/standard_filter_test.rb | 16 ++++++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/History.md b/History.md index db821a1f2..eda214085 100644 --- a/History.md +++ b/History.md @@ -2,9 +2,14 @@ ## 5.8.0 (unreleased) +## 5.7.1 2025-01-23 + +* Fix the `find` filter to return `nil` when filtering empty arrays + ## 5.7.0 2025-01-16 ### Features + * Add `find`, `find_index`, `has`, and `reject` filters to arrays * Compatibility with Ruby 3.4 diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 9da58e062..c4bd8758d 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -472,7 +472,7 @@ def has(input, property, target_value = nil) # @liquid_syntax array | find: string, string # @liquid_return [untyped] def find(input, property, target_value = nil) - filter_array(input, property, target_value) { |ary, &block| ary.find(&block) } + filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) } end # @liquid_public_docs @@ -969,10 +969,10 @@ def sum(input, property = nil) attr_reader :context - def filter_array(input, property, target_value, &block) + def filter_array(input, property, target_value, default_value = [], &block) ary = InputIterator.new(input, context) - return [] if ary.empty? + return default_value if ary.empty? block.call(ary) do |item| if target_value.nil? diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 65c0a74f1..8cfd6e80f 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.7.0" + VERSION = "5.7.1" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index ce2a2841b..3ea9abaef 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -157,6 +157,10 @@ def test_slice_on_arrays assert_equal([], @filters.slice(input, -(1 << 63), 6)) end + def test_find_on_empty_array + assert_nil(@filters.find([], 'foo', 'bar')) + end + def test_truncate assert_equal('1234...', @filters.truncate('1234567890', 7)) assert_equal('1234567890', @filters.truncate('1234567890', 20)) @@ -1042,6 +1046,18 @@ def test_find_with_deep_enumerables assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) end + def test_find_with_empty_arrays + template = <<~LIQUID + {%- assign product = products | find: 'title.content', 'Not found' -%} + {%- unless product -%} + Product not found. + {%- endunless -%} + LIQUID + expected_output = "Product not found." + + assert_template_result(expected_output, template, { "products" => [] }) + end + def test_find_index_with_value products = [ { "title" => "Pro goggles", "price" => 1299 }, From bf1419b8acd88b3b58d4a69fd9db83fc7208153f Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Jan 2025 16:44:58 +0100 Subject: [PATCH 177/343] Apply the same fix for `find_index` and `has` --- History.md | 3 ++- lib/liquid/standardfilters.rb | 4 +-- test/integration/standard_filter_test.rb | 32 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/History.md b/History.md index eda214085..94d25da33 100644 --- a/History.md +++ b/History.md @@ -4,7 +4,8 @@ ## 5.7.1 2025-01-23 -* Fix the `find` filter to return `nil` when filtering empty arrays +* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays +* Fix the `has` filter to return `false` when filtering empty arrays ## 5.7.0 2025-01-16 diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index c4bd8758d..aee011847 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -459,7 +459,7 @@ def reject(input, property, target_value = nil) # @liquid_syntax array | some: string, string # @liquid_return [boolean] def has(input, property, target_value = nil) - filter_array(input, property, target_value) { |ary, &block| ary.any?(&block) } + filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) } end # @liquid_public_docs @@ -485,7 +485,7 @@ def find(input, property, target_value = nil) # @liquid_syntax array | find_index: string, string # @liquid_return [number] def find_index(input, property, target_value = nil) - filter_array(input, property, target_value) { |ary, &block| ary.find_index(&block) } + filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) } end # @liquid_public_docs diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 3ea9abaef..0ef8e2bc6 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -161,6 +161,14 @@ def test_find_on_empty_array assert_nil(@filters.find([], 'foo', 'bar')) end + def test_find_index_on_empty_array + assert_nil(@filters.find_index([], 'foo', 'bar')) + end + + def test_has_on_empty_array + refute(@filters.has([], 'foo', 'bar')) + end + def test_truncate assert_equal('1234...', @filters.truncate('1234567890', 7)) assert_equal('1234567890', @filters.truncate('1234567890', 20)) @@ -980,6 +988,18 @@ def test_has_when_does_not_have_it assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) end + def test_has_with_empty_arrays + template = <<~LIQUID + {%- assign has_product = products | has: 'title.content', 'Not found' -%} + {%- unless has_product -%} + Product not found. + {%- endunless -%} + LIQUID + expected_output = "Product not found." + + assert_template_result(expected_output, template, { "products" => [] }) + end + def test_has_with_false_value array = [ { "handle" => "alpha", "ok" => true }, @@ -1086,6 +1106,18 @@ def test_find_index_with_deep_enumerables assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) end + def test_find_index_with_empty_arrays + template = <<~LIQUID + {%- assign index = products | find_index: 'title.content', 'Not found' -%} + {%- unless index -%} + Index not found. + {%- endunless -%} + LIQUID + expected_output = "Index not found." + + assert_template_result(expected_output, template, { "products" => [] }) + end + def test_where array = [ { "handle" => "alpha", "ok" => true }, From 8dd9279265bdc92a680ae4c726f9d81e9ce88dda Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 24 Jan 2025 14:15:41 +0100 Subject: [PATCH 178/343] Fix release date on History.md --- History.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/History.md b/History.md index 94d25da33..87635f6a3 100644 --- a/History.md +++ b/History.md @@ -2,7 +2,7 @@ ## 5.8.0 (unreleased) -## 5.7.1 2025-01-23 +## 5.7.1 2025-01-24 * Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays * Fix the `has` filter to return `false` when filtering empty arrays From f9454d8cf386fcbe63a8a0e08d6067420010f269 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 31 Jan 2025 13:53:17 +0100 Subject: [PATCH 179/343] Fix array filters to not support nested properties --- History.md | 4 + lib/liquid/standardfilters.rb | 41 ++------ lib/liquid/version.rb | 2 +- test/integration/standard_filter_test.rb | 117 ----------------------- 4 files changed, 13 insertions(+), 151 deletions(-) diff --git a/History.md b/History.md index 87635f6a3..aeee0e0d7 100644 --- a/History.md +++ b/History.md @@ -2,6 +2,10 @@ ## 5.8.0 (unreleased) +## 5.7.2 2025-01-31 + +* Fix array filters to not support nested properties + ## 5.7.1 2025-01-24 * Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index aee011847..4291d86e8 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -387,7 +387,7 @@ def sort(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - ary.sort { |a, b| nil_safe_compare(fetch_property(a, property), fetch_property(b, property)) } + ary.sort { |a, b| nil_safe_compare(a[property], b[property]) } rescue TypeError raise_property_error(property) end @@ -416,7 +416,7 @@ def sort_natural(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - ary.sort { |a, b| nil_safe_casecmp(fetch_property(a, property), fetch_property(b, property)) } + ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) } rescue TypeError raise_property_error(property) end @@ -504,7 +504,7 @@ def uniq(input, property = nil) [] else ary.uniq do |item| - fetch_property(item, property) + item[property] rescue TypeError raise_property_error(property) rescue NoMethodError @@ -540,7 +540,7 @@ def map(input, property) if property == "to_liquid" e elsif e.respond_to?(:[]) - r = fetch_property(e, property) + r = e[property] r.is_a?(Proc) ? r.call : r end end @@ -564,7 +564,7 @@ def compact(input, property = nil) [] else ary.reject do |item| - fetch_property(item, property).nil? + item[property].nil? rescue TypeError raise_property_error(property) rescue NoMethodError @@ -950,7 +950,7 @@ def sum(input, property = nil) if property.nil? item elsif item.respond_to?(:[]) - fetch_property(item, property) + item[property] else 0 end @@ -976,9 +976,9 @@ def filter_array(input, property, target_value, default_value = [], &block) block.call(ary) do |item| if target_value.nil? - fetch_property(item, property) + item[property] else - fetch_property(item, property) == target_value + item[property] == target_value end rescue TypeError raise_property_error(property) @@ -988,31 +988,6 @@ def filter_array(input, property, target_value, default_value = [], &block) end end - def fetch_property(drop, property_or_keys) - ## - # This keeps backward compatibility by supporting properties containing - # dots. This is valid in Liquid syntax and used in some runtimes, such as - # Shopify with metafields. - # - # Using this approach, properties like 'price.value' can be accessed in - # both of the following examples: - # - # ``` - # [ - # { 'name' => 'Item 1', 'price.price' => 40000 }, - # { 'name' => 'Item 2', 'price' => { 'value' => 39900 } } - # ] - # ``` - value = drop[property_or_keys] - - return value if !value.nil? || !property_or_keys.is_a?(String) - - keys = property_or_keys.split('.') - keys.reduce(drop) do |drop, key| - drop.respond_to?(:[]) ? drop[key] : drop - end - end - def raise_property_error(property) raise Liquid::ArgumentError, "cannot select the property '#{property}'" end diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 8cfd6e80f..205d20800 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.7.1" + VERSION = "5.7.2" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 0ef8e2bc6..ef1278322 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -54,30 +54,6 @@ def each(&block) end end -class TestDeepEnumerable < Liquid::Drop - include Enumerable - - class Product < Liquid::Drop - attr_reader :title, :price, :premium - - def initialize(title:, price:, premium: nil) - @title = { "content" => title, "language" => "en" } - @price = { "value" => price, "unit" => "USD" } - @premium = { "category" => premium } if premium - end - end - - def each(&block) - [ - Product.new(title: "Pro goggles", price: 1299), - Product.new(title: "Thermal gloves", price: 1299), - Product.new(title: "Alpine jacket", price: 3999, premium: 'Basic'), - Product.new(title: "Mountain boots", price: 3899, premium: 'Pro'), - Product.new(title: "Safety helmet", price: 1999) - ].each(&block) - end -end - class NumberLikeThing < Liquid::Drop def initialize(amount) @amount = amount @@ -438,15 +414,6 @@ def test_sort_natural_invalid_property end end - def test_sort_natural_with_deep_enumerables - template = <<~LIQUID - {{- products | sort_natural: 'title.content' | map: 'title.content' | join: ', ' -}} - LIQUID - expected_output = "Alpine jacket, Mountain boots, Pro goggles, Safety helmet, Thermal gloves" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_legacy_sort_hash assert_equal([{ a: 1, b: 2 }], @filters.sort(a: 1, b: 2)) end @@ -483,15 +450,6 @@ def test_uniq_invalid_property end end - def test_uniq_with_deep_enumerables - template = <<~LIQUID - {{- products | uniq: 'price.value' | map: "title.content" | join: ', ' -}} - LIQUID - expected_output = "Pro goggles, Alpine jacket, Mountain boots, Safety helmet" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_compact_empty_array assert_equal([], @filters.compact([], "a")) end @@ -508,15 +466,6 @@ def test_compact_invalid_property end end - def test_compact_with_deep_enumerables - template = <<~LIQUID - {{- products | compact: 'premium.category' | map: 'title.content' | join: ', ' -}} - LIQUID - expected_output = "Alpine jacket, Mountain boots" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_reverse assert_equal([4, 3, 2, 1], @filters.reverse([1, 2, 3, 4])) end @@ -626,15 +575,6 @@ def test_sort_works_on_enumerables assert_template_result("213", '{{ foo | sort: "bar" | map: "foo" }}', { "foo" => TestEnumerable.new }) end - def test_sort_with_deep_enumerables - template = <<~LIQUID - {{- products | sort: 'price.value' | map: 'title.content' | join: ', ' -}} - LIQUID - expected_output = "Pro goggles, Thermal gloves, Safety helmet, Mountain boots, Alpine jacket" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_first_and_last_call_to_liquid assert_template_result('foobar', '{{ foo | first }}', { 'foo' => [ThingWithToLiquid.new] }) assert_template_result('foobar', '{{ foo | last }}', { 'foo' => [ThingWithToLiquid.new] }) @@ -951,15 +891,6 @@ def test_reject_with_false_value assert_template_result(expected_output, template, { "array" => array }) end - def test_reject_with_deep_enumerables - template = <<~LIQUID - {{- products | reject: 'title.content', 'Pro goggles' | map: 'price.value' | join: ', ' -}} - LIQUID - expected_output = "1299, 3999, 3899, 1999" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_has array = [ { "handle" => "alpha", "ok" => true }, @@ -1028,16 +959,6 @@ def test_has_with_false_value_when_does_not_have_it assert_template_result(expected_output, template, { "array" => array }) end - def test_has_with_deep_enumerables - template = <<~LIQUID - {{- products | has: 'title.content', 'Pro goggles' -}}, - {{- products | has: 'title.content', 'foo' -}} - LIQUID - expected_output = "true,false" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_find_with_value products = [ { "title" => "Pro goggles", "price" => 1299 }, @@ -1056,16 +977,6 @@ def test_find_with_value assert_template_result(expected_output, template, { "products" => products }) end - def test_find_with_deep_enumerables - template = <<~LIQUID - {%- assign product = products | find: 'title.content', 'Pro goggles' -%} - {{- product.title.content -}} - LIQUID - expected_output = "Pro goggles" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_find_with_empty_arrays template = <<~LIQUID {%- assign product = products | find: 'title.content', 'Not found' -%} @@ -1096,16 +1007,6 @@ def test_find_index_with_value assert_template_result(expected_output, template, { "products" => products }) end - def test_find_index_with_deep_enumerables - template = <<~LIQUID - {%- assign index = products | find_index: 'title.content', 'Alpine jacket' -%} - {{- index -}} - LIQUID - expected_output = "2" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_find_index_with_empty_arrays template = <<~LIQUID {%- assign index = products | find_index: 'title.content', 'Not found' -%} @@ -1216,15 +1117,6 @@ def test_where_array_of_only_unindexable_values assert_nil(@filters.where([nil], "ok")) end - def test_where_with_deep_enumerables - template = <<~LIQUID - {{- products | where: 'title.content', 'Pro goggles' | map: 'price.value' -}} - LIQUID - expected_output = "1299" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - def test_all_filters_never_raise_non_liquid_exception test_drop = TestDrop.new(value: "test") test_drop.context = Context.new @@ -1376,15 +1268,6 @@ def test_sum_with_floats_and_indexable_map_values assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input }) end - def test_sum_with_deep_enumerables - template = <<~LIQUID - {{- products | sum: 'price.value' -}} - LIQUID - expected_output = "12495" - - assert_template_result(expected_output, template, { "products" => TestDeepEnumerable.new }) - end - private def with_timezone(tz) From aec966eed7c27da37a5ccc8124bb0256c0db2a9b Mon Sep 17 00:00:00 2001 From: Chris AtLee Date: Tue, 11 Feb 2025 14:21:14 -0500 Subject: [PATCH 180/343] Bump version to 5.7.3 --- History.md | 2 +- lib/liquid/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/History.md b/History.md index aeee0e0d7..fe1e38196 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,6 @@ # Liquid Change Log -## 5.8.0 (unreleased) +## 5.7.3 (unreleased) ## 5.7.2 2025-01-31 diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 205d20800..9b339694a 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.7.2" + VERSION = "5.7.3" end From 550135c0b96d8df3697d9277567dba039fb75fc4 Mon Sep 17 00:00:00 2001 From: Chris AtLee Date: Thu, 30 Jan 2025 11:49:23 -0500 Subject: [PATCH 181/343] Raise SyntaxError on invalid UTF8 strings in lexer/tokenizer --- History.md | 2 ++ lib/liquid/lexer.rb | 6 ++++++ lib/liquid/tokenizer.rb | 6 ++++++ test/unit/lexer_unit_test.rb | 10 ++++++++++ test/unit/template_unit_test.rb | 11 +++++++++++ 5 files changed, 35 insertions(+) diff --git a/History.md b/History.md index fe1e38196..c03e2d958 100644 --- a/History.md +++ b/History.md @@ -2,6 +2,8 @@ ## 5.7.3 (unreleased) +* Raise Liquid::SyntaxError when parsing invalidly encoded strings + ## 5.7.2 2025-01-31 * Fix array filters to not support nested properties diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index b28e87842..f1740dbad 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -161,6 +161,12 @@ def tokenize(ss) end # rubocop:enable Metrics/BlockNesting output << EOS + rescue ::ArgumentError => e + if e.message == "invalid byte sequence in #{ss.string.encoding}" + raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}" + else + raise + end end def raise_syntax_error(start_pos, ss) diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 008ec070d..56dd218ec 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -103,6 +103,12 @@ def next_text_token pos = @ss.pos -= 2 @source.byteslice(start, pos - start) + rescue ::ArgumentError => e + if e.message == "invalid byte sequence in #{@ss.string.encoding}" + raise SyntaxError, "Invalid byte sequence in #{@ss.string.encoding}" + else + raise + end end def next_variable_token diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 18e1e439a..73eeb7398 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -131,6 +131,16 @@ def test_tokenize_incomplete_expression assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1")) end + def test_error_with_invalid_utf8 + error = assert_raises(SyntaxError) do + tokenize("\x00\xff") + end + assert_equal( + 'Liquid syntax error: Invalid byte sequence in UTF-8', + error.message, + ) + end + private def tokenize(input) diff --git a/test/unit/template_unit_test.rb b/test/unit/template_unit_test.rb index e9de2d04e..1f349657a 100644 --- a/test/unit/template_unit_test.rb +++ b/test/unit/template_unit_test.rb @@ -35,4 +35,15 @@ class TemplateSubclass < Liquid::Template def test_template_inheritance assert_equal("foo", TemplateSubclass.parse("foo").render) end + + def test_invalid_utf8 + input = "\xff\x00" + error = assert_raises(SyntaxError) do + Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input)) + end + assert_equal( + 'Liquid syntax error: Invalid byte sequence in UTF-8', + error.message, + ) + end end From 040801b32cbc403370553b2dd1d0dbebdb78848d Mon Sep 17 00:00:00 2001 From: Max Stoiber Date: Wed, 12 Feb 2025 08:37:49 -0800 Subject: [PATCH 182/343] Fix array `has` filters referring to `some` (#1910) --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 4291d86e8..08361a46a 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -456,7 +456,7 @@ def reject(input, property, target_value = nil) # Tests if any item in an array has a specific property value. # @liquid_description # This requires you to provide both the property name and the associated value. - # @liquid_syntax array | some: string, string + # @liquid_syntax array | has: string, string # @liquid_return [boolean] def has(input, property, target_value = nil) filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) } From 16592cfb8fb4b8ea33381792d4d18642fd52a5f3 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 15 Jan 2025 23:10:51 +0100 Subject: [PATCH 183/343] Add support to LiquidDoc with the new `{% doc %}` tag --- lib/liquid/tags.rb | 2 + lib/liquid/tags/comment.rb | 17 ++- lib/liquid/tags/doc.rb | 30 +++++ test/unit/block_unit_test.rb | 8 +- test/unit/tags/doc_tag_unit_test.rb | 199 ++++++++++++++++++++++++++++ 5 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 lib/liquid/tags/doc.rb create mode 100644 test/unit/tags/doc_tag_unit_test.rb diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb index 916a63bd5..dff7553f7 100644 --- a/lib/liquid/tags.rb +++ b/lib/liquid/tags.rb @@ -19,6 +19,7 @@ require_relative "tags/raw" require_relative "tags/render" require_relative "tags/cycle" +require_relative "tags/doc" module Liquid module Tags @@ -42,6 +43,7 @@ module Tags 'if' => If, 'echo' => Echo, 'tablerow' => TableRow, + 'doc' => Doc, }.freeze end end diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 659108e37..0a6c02c0c 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -15,6 +15,8 @@ module Liquid # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block + TAG_NAME = "comment" + def render_to_output_buffer(_context, output) output end @@ -34,7 +36,10 @@ def parse_body(body, tokenizer) end parse_context.depth += 1 - comment_tag_depth = 1 + tag_depth = 1 + + begin_tag = self.class::TAG_NAME + end_tag = "end#{self.class::TAG_NAME}" begin # Consume tokens without creating child nodes. @@ -57,13 +62,13 @@ def parse_body(body, tokenizer) case tag_name when "raw" parse_raw_tag_body(tokenizer) - when "comment" - comment_tag_depth += 1 - when "endcomment" - comment_tag_depth -= 1 + when begin_tag + tag_depth += 1 + when end_tag + tag_depth -= 1 end - if comment_tag_depth.zero? + if tag_depth.zero? parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag return false end diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb new file mode 100644 index 000000000..ae16b4aab --- /dev/null +++ b/lib/liquid/tags/doc.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Liquid + # @liquid_public_docs + # @liquid_type tag + # @liquid_category syntax + # @liquid_name doc + # @liquid_summary + # Documents template elements with annotations. + # @liquid_description + # The `doc` tag allows developers to include documentation within Liquid + # templates. Any content inside `doc` tags is not rendered or outputted. + # Liquid code inside will be parsed but not executed. This facilitates + # tooling support for features like code completion, linting, and inline + # documentation. + # @liquid_syntax + # {% doc %} + # Renders a message. + # + # @param {string} foo - A foo value. + # @param {string} [bar] - An optional bar value. + # + # @example + # {% render 'message', foo: 'Hello', bar: 'World' %} + # {% enddoc %} + # {{ foo }}, {{ bar }}! + class Doc < Comment + TAG_NAME = "doc" + end +end diff --git a/test/unit/block_unit_test.rb b/test/unit/block_unit_test.rb index f28c30e16..cfaf3f663 100644 --- a/test/unit/block_unit_test.rb +++ b/test/unit/block_unit_test.rb @@ -47,12 +47,18 @@ def test_variable_many_embedded_fragments ) end - def test_with_block + def test_comment_tag_with_block template = Liquid::Template.parse(" {% comment %} {% endcomment %} ") assert_equal([String, Comment, String], block_types(template.root.nodelist)) assert_equal(3, template.root.nodelist.size) end + def test_doc_tag_with_block + template = Liquid::Template.parse(" {% doc %} {% enddoc %} ") + assert_equal([String, Doc, String], block_types(template.root.nodelist)) + assert_equal(3, template.root.nodelist.size) + end + private def block_types(nodelist) diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb new file mode 100644 index 000000000..50e92a4c1 --- /dev/null +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DocTagUnitTest < Minitest::Test + def test_doc_inside_liquid_tag + assert_template_result('', <<~LIQUID.chomp) + {% liquid + if 1 != 1 + doc + else + echo 123 + enddoc + endif + %} + LIQUID + end + + def test_does_not_parse_nodes_inside_a_doc + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% if true %} + {% if ... %} + {%- for ? -%} + {% while true %} + {% + unless if + %} + {% endcase %} + {% enddoc %} + LIQUID + end + + def test_allows_unclosed_tags + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% if true %} + {% enddoc %} + LIQUID + end + + def test_open_tags_in_doc + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% assign a = 123 {% doc %} + {% enddoc %} + LIQUID + + assert_raises(Liquid::SyntaxError) do + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% assign foo = "1" + {% enddoc %} + LIQUID + end + + assert_raises(Liquid::SyntaxError) do + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% doc %} + {% invalid + {% enddoc %} + {% enddoc %} + LIQUID + end + + assert_raises(Liquid::SyntaxError) do + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% {{ {%- enddoc %} + LIQUID + end + end + + def test_child_doc_tags_need_to_be_closed + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% doc %} + {% doc %}{% enddoc %} + {% enddoc %} + {% enddoc %} + LIQUID + + assert_raises(Liquid::SyntaxError) do + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% doc %} + {% doc %} + {% enddoc %} + {% enddoc %} + LIQUID + end + end + + def test_child_raw_tags_need_to_be_closed + assert_template_result('', <<~LIQUID.chomp) + {% doc %} + {% raw %} + {% enddoc %} + {% endraw %} + {% enddoc %} + LIQUID + + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse(<<~LIQUID.chomp) + {% doc %} + {% raw %} + {% enddoc %} + {% enddoc %} + LIQUID + end + end + + def test_error_line_number_is_correct + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% doc %} + {% if true %} + {% enddoc %} + {{ errors.standard_error }} + LIQUID + + output = template.render('errors' => ErrorDrop.new) + expected = <<~TEXT.chomp + + Liquid error (line 4): standard error + TEXT + + assert_equal(expected, output) + end + + def test_doc_tag_delimiter_with_extra_strings + assert_template_result( + '', + <<~LIQUID.chomp, + {% doc %} + {% doc %} + {% enddoc + {% if true %} + {% endif %} + {% enddoc %} + LIQUID + ) + end + + def test_nested_doc_tag_with_extra_strings + assert_template_result( + '', + <<~LIQUID.chomp, + {% doc %} + {% doc + {% assign foo = 1 %} + {% enddoc + {% assign foo = 1 %} + {% enddoc %} + LIQUID + ) + end + + def test_ignores_delimiter_with_extra_strings + assert_template_result('', <<~LIQUID.chomp) + {% if true %} + {% doc %} + {% docEXTRA %}wut{% enddocEXTRA %}xyz + {% enddoc %} + {% endif %} + LIQUID + end + + def test_delimiter_can_have_extra_strings + assert_template_result('', "{% doc %}123{% enddoc xyz %}") + assert_template_result('', "{% doc %}123{% enddoc\txyz %}") + assert_template_result('', "{% doc %}123{% enddoc\nxyz %}") + assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}") + assert_template_result('', "{%doc}{% assign a = 1 %}{%enddoc}{% endif %}") + end + + def test_with_whitespace_control + assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!") + assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!") + assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!") + + assert_template_result("Hello!", <<~LIQUID.chomp) + {%- doc %}Whitespace control!{% enddoc -%} + Hello! + LIQUID + end + + def test_dont_override_liquid_tag_whitespace_control + assert_template_result("Hello!World!", <<~LIQUID.chomp) + Hello! + {%- liquid + doc + this is inside a liquid tag + enddoc + -%} + World! + LIQUID + end +end From b439d0da534759a3f4c7e640e744b92f3080d355 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 21 Jan 2025 12:18:55 +0100 Subject: [PATCH 184/343] Update `{% doc %}` to no longer support nested tags (as {`% comment %}` does) --- lib/liquid/locales/en.yml | 1 + lib/liquid/tags/comment.rb | 17 +-- lib/liquid/tags/doc.rb | 45 +++++- test/unit/tags/doc_tag_unit_test.rb | 211 ++++++++++++++-------------- 4 files changed, 158 insertions(+), 116 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index f33c61cec..60307ac79 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -8,6 +8,7 @@ case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]" + doc_invalid_nested: "Syntax Error in 'doc' - Nested doc tags are not allowed" for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]" for_invalid_in: "For loops require an 'in' clause" for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset" diff --git a/lib/liquid/tags/comment.rb b/lib/liquid/tags/comment.rb index 0a6c02c0c..659108e37 100644 --- a/lib/liquid/tags/comment.rb +++ b/lib/liquid/tags/comment.rb @@ -15,8 +15,6 @@ module Liquid # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block - TAG_NAME = "comment" - def render_to_output_buffer(_context, output) output end @@ -36,10 +34,7 @@ def parse_body(body, tokenizer) end parse_context.depth += 1 - tag_depth = 1 - - begin_tag = self.class::TAG_NAME - end_tag = "end#{self.class::TAG_NAME}" + comment_tag_depth = 1 begin # Consume tokens without creating child nodes. @@ -62,13 +57,13 @@ def parse_body(body, tokenizer) case tag_name when "raw" parse_raw_tag_body(tokenizer) - when begin_tag - tag_depth += 1 - when end_tag - tag_depth -= 1 + when "comment" + comment_tag_depth += 1 + when "endcomment" + comment_tag_depth -= 1 end - if tag_depth.zero? + if comment_tag_depth.zero? parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag return false end diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index ae16b4aab..e798ec653 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -24,7 +24,48 @@ module Liquid # {% render 'message', foo: 'Hello', bar: 'World' %} # {% enddoc %} # {{ foo }}, {{ bar }}! - class Doc < Comment - TAG_NAME = "doc" + class Doc < Block + def render_to_output_buffer(_context, output) + output + end + + def unknown_tag(_tag, _markup, _tokens) + end + + def blank? + true + end + + def parse_body(body, tokenizer) + while (token = tokenizer.send(:shift)) + tag_name = if tokenizer.for_liquid_tag + next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing) + + tag_name_match = BlockBody::LiquidTagToken.match(token) + + next if tag_name_match.nil? + + tag_name_match[1] + else + token =~ BlockBody::FullToken + Regexp.last_match(2) + end + + raise_nested_doc_error if tag_name == "doc" + + if tag_name == "enddoc" + parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag + return false + end + end + + raise_tag_never_closed(block_name) + end + + private + + def raise_nested_doc_error + raise SyntaxError, parse_context.locale.t("errors.syntax.doc_invalid_nested") + end end end diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index 50e92a4c1..5ad817443 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -3,8 +3,38 @@ require 'test_helper' class DocTagUnitTest < Minitest::Test - def test_doc_inside_liquid_tag - assert_template_result('', <<~LIQUID.chomp) + def test_doc_tag + template = <<~LIQUID.chomp + {% doc %} + Renders loading-spinner. + + @param {string} foo - some foo + @param {string} [bar] - optional bar + + @example + {% render 'loading-spinner', foo: 'foo' %} + {% render 'loading-spinner', foo: 'foo', bar: 'bar' %} + {% enddoc %} + LIQUID + + assert_template_result('', template) + end + + def test_doc_tag_inside_liquid_tag + template = <<~LIQUID.chomp + {% liquid + doc + Assigns foo to 1. + enddoc + assign foo = 1 + %} + LIQUID + + assert_template_result('', template) + end + + def test_doc_tag_inside_liquid_tag_with_control_flow_nodes + template = <<~LIQUID.chomp {% liquid if 1 != 1 doc @@ -14,10 +44,12 @@ def test_doc_inside_liquid_tag endif %} LIQUID + + assert_template_result('', template) end - def test_does_not_parse_nodes_inside_a_doc - assert_template_result('', <<~LIQUID.chomp) + def test_doc_tag_ignores_liquid_nodes + template = <<~LIQUID.chomp {% doc %} {% if true %} {% if ... %} @@ -29,89 +61,92 @@ def test_does_not_parse_nodes_inside_a_doc {% endcase %} {% enddoc %} LIQUID + + assert_template_result('', template) end - def test_allows_unclosed_tags - assert_template_result('', <<~LIQUID.chomp) + def test_doc_tag_ignores_unclosed_liquid_tags + template = <<~LIQUID.chomp {% doc %} {% if true %} {% enddoc %} LIQUID + + assert_template_result('', template) end - def test_open_tags_in_doc - assert_template_result('', <<~LIQUID.chomp) + def test_doc_tag_ignores_nested_doc_tags + template = <<~LIQUID.chomp {% doc %} {% assign a = 123 {% doc %} {% enddoc %} LIQUID - assert_raises(Liquid::SyntaxError) do - assert_template_result('', <<~LIQUID.chomp) - {% doc %} - {% assign foo = "1" - {% enddoc %} - LIQUID - end + assert_template_result('', template) + end - assert_raises(Liquid::SyntaxError) do - assert_template_result('', <<~LIQUID.chomp) + def test_doc_tag_does_not_allow_nested_docs + error = assert_raises(Liquid::SyntaxError) do + template = <<~LIQUID.chomp {% doc %} {% doc %} - {% invalid - {% enddoc %} + {% doc %} {% enddoc %} LIQUID - end - assert_raises(Liquid::SyntaxError) do - assert_template_result('', <<~LIQUID.chomp) - {% doc %} - {% {{ {%- enddoc %} - LIQUID + Liquid::Template.parse(template) end + + exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed" + act_error = error.message + + assert_equal(exp_error, act_error) end - def test_child_doc_tags_need_to_be_closed - assert_template_result('', <<~LIQUID.chomp) + def test_doc_tag_ignores_nested_raw_tags + template = <<~LIQUID.chomp {% doc %} - {% doc %} - {% doc %}{% enddoc %} - {% enddoc %} + {% raw %} {% enddoc %} LIQUID - assert_raises(Liquid::SyntaxError) do - assert_template_result('', <<~LIQUID.chomp) + assert_template_result('', template) + end + + def test_doc_tag_raises_an_error_for_unclosed_assign + error = assert_raises(Liquid::SyntaxError) do + template = <<~LIQUID.chomp {% doc %} - {% doc %} - {% doc %} - {% enddoc %} + {% assign foo = "1" {% enddoc %} LIQUID + + Liquid::Template.parse(template) end - end - def test_child_raw_tags_need_to_be_closed - assert_template_result('', <<~LIQUID.chomp) - {% doc %} - {% raw %} - {% enddoc %} - {% endraw %} - {% enddoc %} - LIQUID + exp_error = "Liquid syntax error: 'doc' tag was never closed" + act_error = error.message + + assert_equal(exp_error, act_error) + end - assert_raises(Liquid::SyntaxError) do - Liquid::Template.parse(<<~LIQUID.chomp) + def test_doc_tag_raises_an_error_for_malformed_syntax + error = assert_raises(Liquid::SyntaxError) do + template = <<~LIQUID.chomp {% doc %} - {% raw %} - {% enddoc %} - {% enddoc %} + {% {{ {%- enddoc %} LIQUID + + Liquid::Template.parse(template) end + + exp_error = "Liquid syntax error: 'doc' tag was never closed" + act_error = error.message + + assert_equal(exp_error, act_error) end - def test_error_line_number_is_correct + def test_doc_tag_preserves_error_line_numbers template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) {% doc %} {% if true %} @@ -119,44 +154,39 @@ def test_error_line_number_is_correct {{ errors.standard_error }} LIQUID - output = template.render('errors' => ErrorDrop.new) expected = <<~TEXT.chomp Liquid error (line 4): standard error TEXT - assert_equal(expected, output) + assert_equal(expected, template.render('errors' => ErrorDrop.new)) end - def test_doc_tag_delimiter_with_extra_strings - assert_template_result( - '', - <<~LIQUID.chomp, - {% doc %} - {% doc %} - {% enddoc - {% if true %} - {% endif %} - {% enddoc %} - LIQUID - ) - end + def test_doc_tag_whitespace_control + # Basic whitespace control + assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!") + assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!") + assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!") - def test_nested_doc_tag_with_extra_strings - assert_template_result( - '', - <<~LIQUID.chomp, - {% doc %} - {% doc - {% assign foo = 1 %} - {% enddoc - {% assign foo = 1 %} - {% enddoc %} - LIQUID - ) + # Whitespace control within liquid tags + assert_template_result("Hello!World!", <<~LIQUID.chomp) + Hello! + {%- liquid + doc + this is inside a liquid tag + enddoc + -%} + World! + LIQUID + + # Multiline whitespace control + assert_template_result("Hello!", <<~LIQUID.chomp) + {%- doc %}Whitespace control!{% enddoc -%} + Hello! + LIQUID end - def test_ignores_delimiter_with_extra_strings + def test_doc_tag_delimiter_handling assert_template_result('', <<~LIQUID.chomp) {% if true %} {% doc %} @@ -164,36 +194,11 @@ def test_ignores_delimiter_with_extra_strings {% enddoc %} {% endif %} LIQUID - end - def test_delimiter_can_have_extra_strings assert_template_result('', "{% doc %}123{% enddoc xyz %}") assert_template_result('', "{% doc %}123{% enddoc\txyz %}") assert_template_result('', "{% doc %}123{% enddoc\nxyz %}") assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}") assert_template_result('', "{%doc}{% assign a = 1 %}{%enddoc}{% endif %}") end - - def test_with_whitespace_control - assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!") - assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!") - assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!") - - assert_template_result("Hello!", <<~LIQUID.chomp) - {%- doc %}Whitespace control!{% enddoc -%} - Hello! - LIQUID - end - - def test_dont_override_liquid_tag_whitespace_control - assert_template_result("Hello!World!", <<~LIQUID.chomp) - Hello! - {%- liquid - doc - this is inside a liquid tag - enddoc - -%} - World! - LIQUID - end end From ae8a0a86acea9c5ad1312347d33c984c7a62cd48 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Jan 2025 16:15:06 +0100 Subject: [PATCH 185/343] Remove misleading unit test (thank you, @EvilGenius13) --- test/unit/tags/doc_tag_unit_test.rb | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index 5ad817443..d277ef058 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -75,16 +75,6 @@ def test_doc_tag_ignores_unclosed_liquid_tags assert_template_result('', template) end - def test_doc_tag_ignores_nested_doc_tags - template = <<~LIQUID.chomp - {% doc %} - {% assign a = 123 {% doc %} - {% enddoc %} - LIQUID - - assert_template_result('', template) - end - def test_doc_tag_does_not_allow_nested_docs error = assert_raises(Liquid::SyntaxError) do template = <<~LIQUID.chomp From f643af4bac25c5169352ac4156f3462b1f2236d0 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 17 Feb 2025 14:57:11 +0100 Subject: [PATCH 186/343] Update the implementation to make {% doc %} as strict as {% raw %} --- lib/liquid/locales/en.yml | 1 + lib/liquid/tags/doc.rb | 51 ++++++++-------- test/unit/tags/doc_tag_unit_test.rb | 94 +++++++++-------------------- 3 files changed, 56 insertions(+), 90 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 60307ac79..cd1607b3c 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -2,6 +2,7 @@ errors: syntax: tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}" + block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index e798ec653..c345e4c21 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -25,45 +25,44 @@ module Liquid # {% enddoc %} # {{ foo }}, {{ bar }}! class Doc < Block - def render_to_output_buffer(_context, output) - output - end + TAG_UNEXPECTED_ARGS = /\A\s*\z/ - def unknown_tag(_tag, _markup, _tokens) + def initialize(tag_name, markup, parse_context) + super + ensure_valid_markup(tag_name, markup, parse_context) end - def blank? - true - end - - def parse_body(body, tokenizer) - while (token = tokenizer.send(:shift)) - tag_name = if tokenizer.for_liquid_tag - next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing) - - tag_name_match = BlockBody::LiquidTagToken.match(token) - - next if tag_name_match.nil? - - tag_name_match[1] - else - token =~ BlockBody::FullToken - Regexp.last_match(2) - end + def parse(tokens) + while (token = tokens.shift) + tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2) - raise_nested_doc_error if tag_name == "doc" + raise_nested_doc_error if tag_name == @tag_name - if tag_name == "enddoc" - parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag - return false + if tag_name == block_delimiter + parse_context.trim_whitespace = (token[-3] == WhitespaceControl) + return end end raise_tag_never_closed(block_name) end + def render_to_output_buffer(_context, output) + output + end + + def blank? + true + end + private + def ensure_valid_markup(tag_name, markup, parse_context) + unless TAG_UNEXPECTED_ARGS.match?(markup) + raise SyntaxError, parse_context.locale.t("errors.syntax.block_tag_unexpected_args", tag: tag_name) + end + end + def raise_nested_doc_error raise SyntaxError, parse_context.locale.t("errors.syntax.doc_invalid_nested") end diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index d277ef058..861c1b6ca 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -20,32 +20,26 @@ def test_doc_tag assert_template_result('', template) end - def test_doc_tag_inside_liquid_tag - template = <<~LIQUID.chomp - {% liquid - doc - Assigns foo to 1. - enddoc - assign foo = 1 - %} - LIQUID + def test_doc_tag_does_not_support_extra_arguments + error = assert_raises(Liquid::SyntaxError) do + template = <<~LIQUID.chomp + {% doc extra %} + {% enddoc %} + LIQUID - assert_template_result('', template) - end + Liquid::Template.parse(template) + end - def test_doc_tag_inside_liquid_tag_with_control_flow_nodes - template = <<~LIQUID.chomp - {% liquid - if 1 != 1 - doc - else - echo 123 - enddoc - endif - %} - LIQUID + exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}" + act_error = error.message - assert_template_result('', template) + assert_equal(exp_error, act_error) + end + + def test_doc_tag_must_support_valid_tags + assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo') + assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}') + assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}') end def test_doc_tag_ignores_liquid_nodes @@ -103,37 +97,23 @@ def test_doc_tag_ignores_nested_raw_tags assert_template_result('', template) end - def test_doc_tag_raises_an_error_for_unclosed_assign - error = assert_raises(Liquid::SyntaxError) do - template = <<~LIQUID.chomp - {% doc %} - {% assign foo = "1" - {% enddoc %} - LIQUID - - Liquid::Template.parse(template) - end - - exp_error = "Liquid syntax error: 'doc' tag was never closed" - act_error = error.message + def test_doc_tag_ignores_unclosed_assign + template = <<~LIQUID.chomp + {% doc %} + {% assign foo = "1" + {% enddoc %} + LIQUID - assert_equal(exp_error, act_error) + assert_template_result('', template) end - def test_doc_tag_raises_an_error_for_malformed_syntax - error = assert_raises(Liquid::SyntaxError) do - template = <<~LIQUID.chomp - {% doc %} - {% {{ {%- enddoc %} - LIQUID - - Liquid::Template.parse(template) - end - - exp_error = "Liquid syntax error: 'doc' tag was never closed" - act_error = error.message + def test_doc_tag_ignores_malformed_syntax + template = <<~LIQUID.chomp + {% doc %} + {% {{ {%- enddoc %} + LIQUID - assert_equal(exp_error, act_error) + assert_template_result('', template) end def test_doc_tag_preserves_error_line_numbers @@ -157,19 +137,6 @@ def test_doc_tag_whitespace_control assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!") assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!") assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!") - - # Whitespace control within liquid tags - assert_template_result("Hello!World!", <<~LIQUID.chomp) - Hello! - {%- liquid - doc - this is inside a liquid tag - enddoc - -%} - World! - LIQUID - - # Multiline whitespace control assert_template_result("Hello!", <<~LIQUID.chomp) {%- doc %}Whitespace control!{% enddoc -%} Hello! @@ -189,6 +156,5 @@ def test_doc_tag_delimiter_handling assert_template_result('', "{% doc %}123{% enddoc\txyz %}") assert_template_result('', "{% doc %}123{% enddoc\nxyz %}") assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}") - assert_template_result('', "{%doc}{% assign a = 1 %}{%enddoc}{% endif %}") end end From 17d327988dd55375c6ed23a21eb9b2847694e1e1 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 17 Feb 2025 14:59:49 +0100 Subject: [PATCH 187/343] Rename {% doc %} constant strictly validates the abscense of args --- lib/liquid/tags/doc.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index c345e4c21..f2c299dd7 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -25,7 +25,7 @@ module Liquid # {% enddoc %} # {{ foo }}, {{ bar }}! class Doc < Block - TAG_UNEXPECTED_ARGS = /\A\s*\z/ + NO_UNEXPECTED_ARGS = /\A\s*\z/ def initialize(tag_name, markup, parse_context) super @@ -58,7 +58,7 @@ def blank? private def ensure_valid_markup(tag_name, markup, parse_context) - unless TAG_UNEXPECTED_ARGS.match?(markup) + unless NO_UNEXPECTED_ARGS.match?(markup) raise SyntaxError, parse_context.locale.t("errors.syntax.block_tag_unexpected_args", tag: tag_name) end end From cca9fe99cf71599f6e532976100cafc6f32a618e Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 24 Feb 2025 12:10:46 +0100 Subject: [PATCH 188/343] Bump version to 5.8.0 --- lib/liquid/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 9b339694a..12a44208c 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.7.3" + VERSION = "5.8.0" end From a398b4cc747fafa774bc52e602262ed0136febbd Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 24 Feb 2025 12:11:05 +0100 Subject: [PATCH 189/343] Fix History.md --- History.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/History.md b/History.md index c03e2d958..92da8ea98 100644 --- a/History.md +++ b/History.md @@ -1,24 +1,30 @@ # Liquid Change Log -## 5.7.3 (unreleased) +## 5.8.1 (unreleased) -* Raise Liquid::SyntaxError when parsing invalidly encoded strings +## 5.8.0 + +* Introduce the new `{% doc %}` tag [Guilherme Carreiro] + +## 5.7.3 + +* Raise Liquid::SyntaxError when parsing invalidly encoded strings [Chris AtLee] ## 5.7.2 2025-01-31 -* Fix array filters to not support nested properties +* Fix array filters to not support nested properties [Guilherme Carreiro] ## 5.7.1 2025-01-24 -* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays -* Fix the `has` filter to return `false` when filtering empty arrays +* Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays [Guilherme Carreiro] +* Fix the `has` filter to return `false` when filtering empty arrays [Guilherme Carreiro] ## 5.7.0 2025-01-16 ### Features -* Add `find`, `find_index`, `has`, and `reject` filters to arrays -* Compatibility with Ruby 3.4 +* Add `find`, `find_index`, `has`, and `reject` filters to arrays [Guilherme Carreiro] +* Compatibility with Ruby 3.4 [Ian Ker-Seymer] ## 5.6.4 2025-01-14 From 6453a0ea483d697c03056dabb21ba1aeb89ba8eb Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 26 Feb 2025 12:10:02 +0100 Subject: [PATCH 190/343] Implement nodelist in the Doc tag so it may be visited --- History.md | 4 ++++ lib/liquid/tags/doc.rb | 4 ++++ lib/liquid/version.rb | 2 +- test/unit/tags/doc_tag_unit_test.rb | 24 ++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/History.md b/History.md index 92da8ea98..ccb885a69 100644 --- a/History.md +++ b/History.md @@ -2,6 +2,10 @@ ## 5.8.1 (unreleased) +## 5.8.1 + +* Fix `{% doc %}` tag to be visitable [Guilherme Carreiro] + ## 5.8.0 * Introduce the new `{% doc %}` tag [Guilherme Carreiro] diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index f2c299dd7..425e3d5bc 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -55,6 +55,10 @@ def blank? true end + def nodelist + [] + end + private def ensure_valid_markup(tag_name, markup, parse_context) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 12a44208c..24600c63f 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.0" + VERSION = "5.8.1" end diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index 861c1b6ca..a0a366295 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -157,4 +157,28 @@ def test_doc_tag_delimiter_handling assert_template_result('', "{% doc %}123{% enddoc\nxyz %}") assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}") end + + def test_doc_tag_visitor + template_source = '{% doc %}{% enddoc %}' + + assert_equal( + [Liquid::Doc], + visit(template_source), + ) + end + + private + + def traversal(template) + ParseTreeVisitor + .for(Template.parse(template).root) + .add_callback_for(Liquid::Doc) do |tag| + tag_class = tag.class + tag_class + end + end + + def visit(template) + traversal(template).visit.flatten.compact + end end From 2d0442798b5bf43e52a7e300727a646209eb32cc Mon Sep 17 00:00:00 2001 From: Michael Nikitochkin Date: Wed, 26 Feb 2025 20:28:17 +0100 Subject: [PATCH 191/343] chore: Add RUBYOPT configuration to the tests (#1859) Co-authored-by: Ian Ker-Seymer --- .github/workflows/liquid.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 3b66161da..0a8474f16 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -14,8 +14,21 @@ jobs: - { ruby: 3.0, allowed-failure: false } # minimum supported - { ruby: 3.2, allowed-failure: false } - { ruby: 3.3, allowed-failure: false } + - { ruby: 3.3, allowed-failure: false } - { ruby: 3.4, allowed-failure: false } # latest + - { + ruby: 3.4, + allowed-failure: false, + rubyopt: "--enable-frozen-string-literal", + } + - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } - { ruby: ruby-head, allowed-failure: false } + - { + ruby: ruby-head, + allowed-failure: false, + rubyopt: "--enable-frozen-string-literal", + } + - { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" } name: Test Ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@v3 @@ -26,6 +39,8 @@ jobs: bundler: latest - run: bundle exec rake continue-on-error: ${{ matrix.entry.allowed-failure }} + env: + RUBYOPT: ${{ matrix.entry.rubyopt }} memory_profile: runs-on: ubuntu-latest From e38f730c0035a6a7a0c51803590a21bf6df0e262 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 26 Feb 2025 13:54:50 +0100 Subject: [PATCH 192/343] Update LiquidDoc documentation --- lib/liquid/tags/doc.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index 425e3d5bc..6f061650d 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -17,8 +17,8 @@ module Liquid # {% doc %} # Renders a message. # - # @param {string} foo - A foo value. - # @param {string} [bar] - An optional bar value. + # @param {string} foo - A string value. + # @param {string} [bar] - An optional string value. # # @example # {% render 'message', foo: 'Hello', bar: 'World' %} From 1783c0c084104c42ce5adc0ae095e923ea824679 Mon Sep 17 00:00:00 2001 From: James Meng Date: Wed, 12 Mar 2025 15:09:48 -0700 Subject: [PATCH 193/343] Add a link to the LiquidDoc reference in doc tag documentation comment --- lib/liquid/tags/doc.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index 6f061650d..c3bd41096 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -13,6 +13,10 @@ module Liquid # Liquid code inside will be parsed but not executed. This facilitates # tooling support for features like code completion, linting, and inline # documentation. + # + # For detailed documentation syntax and examples, see the + # [`LiquidDoc` reference](/themes/tools/liquid-doc). + # # @liquid_syntax # {% doc %} # Renders a message. From 21432928d0b815693cff9b81613a967debec67d6 Mon Sep 17 00:00:00 2001 From: James Meng <35415298+jamesmengo@users.noreply.github.com> Date: Thu, 13 Mar 2025 09:47:57 -0700 Subject: [PATCH 194/343] Use full relative path for hyperlink --- lib/liquid/tags/doc.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index c3bd41096..ef5d5d801 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -15,7 +15,7 @@ module Liquid # documentation. # # For detailed documentation syntax and examples, see the - # [`LiquidDoc` reference](/themes/tools/liquid-doc). + # [`LiquidDoc` reference](/docs/storefronts/themes/tools/liquid-doc). # # @liquid_syntax # {% doc %} From c5711c095f4269038b411dc960280e4f5981609f Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 13 Mar 2025 17:36:29 -0400 Subject: [PATCH 195/343] Improve docs of `date` filter (#1920) --- lib/liquid/standardfilters.rb | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 08361a46a..618cd71b2 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -712,7 +712,16 @@ def newline_to_br(input) input.gsub(/\r?\n/, "
\n") end - # Reformat a date using Ruby's core Time#strftime( string ) -> string + # @liquid_public_docs + # @liquid_type filter + # @liquid_category date + # @liquid_summary + # Formats a date according to a specified format string. + # @liquid_description + # This filter formats a date using various format specifiers. If the format string is empty, + # the original input is returned. If the input cannot be converted to a date, the original input is returned. + # + # The following format specifiers can be used: # # %a - The abbreviated weekday name (``Sun'') # %A - The full weekday name (``Sunday'') @@ -741,8 +750,8 @@ def newline_to_br(input) # %Y - Year with century # %Z - Time zone name # %% - Literal ``%'' character - # - # See also: http://www.ruby-doc.org/core/Time.html#method-i-strftime + # @liquid_syntax date | date: string + # @liquid_return [string] def date(input, format) str_format = Utils.to_s(format) return input if str_format.empty? From f5d6a36574b7ddf5e1a00395f553a0f872fb9f16 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Mon, 17 Mar 2025 17:43:57 -0400 Subject: [PATCH 196/343] Stringify properties before filtering (#1929) --- lib/liquid/standardfilters.rb | 4 +++- test/integration/standard_filter_test.rb | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 618cd71b2..63d080dd1 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -979,8 +979,10 @@ def sum(input, property = nil) attr_reader :context def filter_array(input, property, target_value, default_value = [], &block) - ary = InputIterator.new(input, context) + property = Liquid::Utils.to_s(property) + return default_value if property.empty? + ary = InputIterator.new(input, context) return default_value if ary.empty? block.call(ary) do |item| diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index ef1278322..913dfa25b 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1061,6 +1061,19 @@ def test_where_with_false_value assert_template_result(expected_output, template, { "array" => array }) end + def test_where_with_non_string_property + array = [ + { "handle" => "alpha", "{}" => true }, + { "handle" => "beta", "{}" => false }, + { "handle" => "gamma", "{}" => false }, + { "handle" => "delta", "{}" => true }, + ] + template = "{{ array | where: some_property, true | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array, "some_property" => {} }) + end + def test_where_string_keys input = [ "alpha", "beta", "gamma", "delta" From aa1640035f172bcbd064959d8e7d00aac07243d7 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 19 Mar 2025 18:50:01 +0100 Subject: [PATCH 197/343] Always stringify properties in all array filters (#1936) * Always stringify sum property. * Add test * always stringify properties in all array filters * fix syntax error * up version --------- Co-authored-by: Dominic Petrick --- lib/liquid/standardfilters.rb | 7 +++++++ lib/liquid/version.rb | 2 +- test/integration/standard_filter_test.rb | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 63d080dd1..4771b2427 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -387,6 +387,7 @@ def sort(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin + property = Utils.to_s(property) ary.sort { |a, b| nil_safe_compare(a[property], b[property]) } rescue TypeError raise_property_error(property) @@ -416,6 +417,7 @@ def sort_natural(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin + property = Utils.to_s(property) ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) } rescue TypeError raise_property_error(property) @@ -503,6 +505,7 @@ def uniq(input, property = nil) elsif ary.empty? # The next two cases assume a non-empty array. [] else + property = Utils.to_s(property) ary.uniq do |item| item[property] rescue TypeError @@ -534,6 +537,7 @@ def reverse(input) # @liquid_syntax array | map: string # @liquid_return [array[untyped]] def map(input, property) + property = Utils.to_s(property) InputIterator.new(input, context).map do |e| e = e.call if e.is_a?(Proc) @@ -563,6 +567,7 @@ def compact(input, property = nil) elsif ary.empty? # The next two cases assume a non-empty array. [] else + property = Liquid::Utils.to_s(property) ary.reject do |item| item[property].nil? rescue TypeError @@ -952,6 +957,8 @@ def default(input, default_value = '', options = {}) # @liquid_syntax array | sum # @liquid_return [number] def sum(input, property = nil) + property = property.nil? ? nil : Utils.to_s(property) + ary = InputIterator.new(input, context) return 0 if ary.empty? diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 24600c63f..7d261e99c 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.1" + VERSION = "5.8.2" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 913dfa25b..5c80fe3ce 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1281,6 +1281,17 @@ def test_sum_with_floats_and_indexable_map_values assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input }) end + def test_sum_with_non_string_property + input = [{ "true" => 1 }, { "1.0" => 0.2, "1" => -0.3 }, { "1..5" => 0.4 }] + + assert_equal(1, @filters.sum(input, true)) + assert_equal(0.2, @filters.sum(input, 1.0)) + assert_equal(-0.3, @filters.sum(input, 1)) + assert_equal(0.4, @filters.sum(input, (1..5))) + assert_equal(0, @filters.sum(input, nil)) + assert_equal(0, @filters.sum(input, "")) + end + private def with_timezone(tz) From 7f122aeed2890e860e95c2ba3e809b4c88f9887b Mon Sep 17 00:00:00 2001 From: James Meng Date: Mon, 31 Mar 2025 17:08:31 -0700 Subject: [PATCH 198/343] Fix snippet URLs in documentation links --- lib/liquid/tags/decrement.rb | 2 +- lib/liquid/tags/include.rb | 2 +- lib/liquid/tags/increment.rb | 2 +- lib/liquid/tags/render.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 677166621..49d43bc62 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -10,7 +10,7 @@ module Liquid # @liquid_description # Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across - # [snippets](/themes/architecture#snippets) included in the file. + # [snippets](/themes/architecture/snippets) included in the file. # # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign) # and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index b4f1be137..1fefa16f4 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -6,7 +6,7 @@ module Liquid # @liquid_category theme # @liquid_name include # @liquid_summary - # Renders a [snippet](/themes/architecture#snippets). + # Renders a [snippet](/themes/architecture/snippets). # @liquid_description # Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the # snippet. diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index c650e5ac5..3b6c16f56 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -10,7 +10,7 @@ module Liquid # @liquid_description # Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across - # [snippets](/themes/architecture#snippets) included in the file. + # [snippets](/themes/architecture/snippets) included in the file. # # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign) # and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 3615b1b35..26004d647 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -6,7 +6,7 @@ module Liquid # @liquid_category theme # @liquid_name render # @liquid_summary - # Renders a [snippet](/themes/architecture#snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks). + # Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks). # @liquid_description # Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside # of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet) From 2b75bfaff412c23c9b9434a11eafcef869d89c17 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Fri, 4 Apr 2025 11:27:29 -0400 Subject: [PATCH 199/343] Fix regression when using empty/nil properties with array filters (#1944) * Make all array filters that use `filter_array` util process empty string and nil correctly * up version * fix ordering of checks * also do it for map * Do not raise property error * Gracefully empty property in map filter --------- Co-authored-by: Marco Concetto Rudilosso --- lib/liquid/standardfilters.rb | 10 ++-- lib/liquid/version.rb | 2 +- test/integration/standard_filter_test.rb | 58 ++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 4771b2427..4d69f5bc3 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -3,7 +3,6 @@ require 'cgi' require 'base64' require 'bigdecimal' - module Liquid module StandardFilters MAX_I32 = (1 << 31) - 1 @@ -538,6 +537,10 @@ def reverse(input) # @liquid_return [array[untyped]] def map(input, property) property = Utils.to_s(property) + + # Return the input array if property is empty (no-op) + return InputIterator.new(input, context).to_a if property.empty? + InputIterator.new(input, context).map do |e| e = e.call if e.is_a?(Proc) @@ -986,12 +989,11 @@ def sum(input, property = nil) attr_reader :context def filter_array(input, property, target_value, default_value = [], &block) - property = Liquid::Utils.to_s(property) - return default_value if property.empty? - ary = InputIterator.new(input, context) return default_value if ary.empty? + property = Utils.to_s(property) + block.call(ary) do |item| if target_value.nil? item[property] diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 7d261e99c..3f0be7a17 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.2" + VERSION = "5.8.3" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 5c80fe3ce..ddf66333f 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -560,15 +560,47 @@ def test_map_returns_empty_on_2d_input_array end end - def test_map_returns_empty_with_no_property - foo = [ + def test_map_with_nil_property + array = [ + { "handle" => "alpha", "value" => "A" }, + { "handle" => "beta", "value" => "B" }, + { "handle" => "gamma", "value" => "C" } + ] + + assert_template_result("alpha beta gamma", "{{ array | map: nil | map: 'handle' | join: ' ' }}", { "array" => array }) + end + + def test_map_with_empty_string_property + array = [ + { "handle" => "alpha", "value" => "A" }, + { "handle" => "beta", "value" => "B" }, + { "handle" => "gamma", "value" => "C" } + ] + + assert_template_result("alpha beta gamma", "{{ array | map: '' | map: 'handle' | join: ' ' }}", { "array" => array }) + end + + def test_map_with_value_property + array = [ + { "handle" => "alpha", "value" => "A" }, + { "handle" => "beta", "value" => "B" }, + { "handle" => "gamma", "value" => "C" } + ] + + assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array }) + end + + def test_map_returns_input_with_no_property + input = [ [1], [2], [3], ] - assert_raises(Liquid::ArgumentError) do - @filters.map(foo, nil) - end + result = @filters.map(input, nil) + assert_equal(input.flatten, result) + + result = @filters.map(input, '') + assert_equal(input.flatten, result) end def test_sort_works_on_enumerables @@ -1033,6 +1065,22 @@ def test_where assert_template_result(expected_output, template, { "array" => array }) end + def test_where_with_empty_string_is_a_no_op + environment = { "array" => ["alpha", "beta", "gamma"] } + expected_output = "alpha beta gamma" + template = "{{ array | where: '' | join: ' ' }}" + + assert_template_result(expected_output, template, environment) + end + + def test_where_with_nil_is_a_no_op + environment = { "array" => ["alpha", "beta", "gamma"] } + expected_output = "alpha beta gamma" + template = "{{ array | where: nil | join: ' ' }}" + + assert_template_result(expected_output, template, environment) + end + def test_where_with_value array = [ { "handle" => "alpha", "ok" => true }, From dbe709c3bf2f8521764ae51b36dd551905dfad7e Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 9 Apr 2025 15:00:01 -0400 Subject: [PATCH 200/343] Use to_liquid_value in uniq filter (#1948) * Use to_liquid_value in uniq filter * Bump version to 5.8.4 --- lib/liquid/standardfilters.rb | 5 +++- lib/liquid/version.rb | 2 +- test/integration/standard_filter_test.rb | 16 +++++++++++++ test/test_helper.rb | 29 ++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 4d69f5bc3..daa2c5806 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -1081,7 +1081,10 @@ def reverse end def uniq(&block) - to_a.uniq(&block) + to_a.uniq do |item| + item = Utils.to_liquid_value(item) + block ? yield(item) : item + end end def compact diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 3f0be7a17..854073d00 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.3" + VERSION = "5.8.4" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index ddf66333f..6a2737bba 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1340,6 +1340,22 @@ def test_sum_with_non_string_property assert_equal(0, @filters.sum(input, "")) end + def test_uniq_with_to_liquid_value + input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"] + expected = [StringDrop.new("foo"), StringDrop.new("bar")] + result = @filters.uniq(input) + + assert_equal(expected, result) + end + + def test_uniq_with_to_liquid_value_pick_correct_classes + input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")] + expected = [String, StringDrop] + result = @filters.uniq(input).map(&:class) + + assert_equal(expected, result) + end + private def with_timezone(tz) diff --git a/test/test_helper.rb b/test/test_helper.rb index 184720b6d..4f4447384 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -146,6 +146,35 @@ def to_s end end +class StringDrop < Liquid::Drop + include Comparable + + def initialize(value) + super() + @value = value + end + + def to_liquid_value + @value + end + + def to_s + @value + end + + def to_str + @value + end + + def inspect + "#" + end + + def <=>(other) + to_liquid_value <=> Liquid::Utils.to_liquid_value(other) + end +end + class ErrorDrop < Liquid::Drop def standard_error raise Liquid::StandardError, 'standard error' From 4c07ff920b15f16d4e31008c57429217d0eb37d1 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 2 Apr 2025 15:08:34 +0200 Subject: [PATCH 201/343] Revert "Always stringify properties in all array filters (#1936)" This reverts commit aa1640035f172bcbd064959d8e7d00aac07243d7. --- lib/liquid/standardfilters.rb | 11 ----------- lib/liquid/version.rb | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index daa2c5806..1864742eb 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -386,7 +386,6 @@ def sort(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - property = Utils.to_s(property) ary.sort { |a, b| nil_safe_compare(a[property], b[property]) } rescue TypeError raise_property_error(property) @@ -416,7 +415,6 @@ def sort_natural(input, property = nil) end elsif ary.all? { |el| el.respond_to?(:[]) } begin - property = Utils.to_s(property) ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) } rescue TypeError raise_property_error(property) @@ -504,7 +502,6 @@ def uniq(input, property = nil) elsif ary.empty? # The next two cases assume a non-empty array. [] else - property = Utils.to_s(property) ary.uniq do |item| item[property] rescue TypeError @@ -536,11 +533,6 @@ def reverse(input) # @liquid_syntax array | map: string # @liquid_return [array[untyped]] def map(input, property) - property = Utils.to_s(property) - - # Return the input array if property is empty (no-op) - return InputIterator.new(input, context).to_a if property.empty? - InputIterator.new(input, context).map do |e| e = e.call if e.is_a?(Proc) @@ -570,7 +562,6 @@ def compact(input, property = nil) elsif ary.empty? # The next two cases assume a non-empty array. [] else - property = Liquid::Utils.to_s(property) ary.reject do |item| item[property].nil? rescue TypeError @@ -960,8 +951,6 @@ def default(input, default_value = '', options = {}) # @liquid_syntax array | sum # @liquid_return [number] def sum(input, property = nil) - property = property.nil? ? nil : Utils.to_s(property) - ary = InputIterator.new(input, context) return 0 if ary.empty? diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 854073d00..2eee338a6 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.4" + VERSION = "5.8.5" end From 4dae678c631dd73468d59bfd216ca506d62888ed Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 2 Apr 2025 15:08:42 +0200 Subject: [PATCH 202/343] Revert "Stringify properties before filtering (#1929)" This reverts commit f5d6a36574b7ddf5e1a00395f553a0f872fb9f16. --- lib/liquid/standardfilters.rb | 1 + test/integration/standard_filter_test.rb | 13 ------------- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 1864742eb..3bbee4a59 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -979,6 +979,7 @@ def sum(input, property = nil) def filter_array(input, property, target_value, default_value = [], &block) ary = InputIterator.new(input, context) + return default_value if ary.empty? property = Utils.to_s(property) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 6a2737bba..62ec5d2df 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1109,19 +1109,6 @@ def test_where_with_false_value assert_template_result(expected_output, template, { "array" => array }) end - def test_where_with_non_string_property - array = [ - { "handle" => "alpha", "{}" => true }, - { "handle" => "beta", "{}" => false }, - { "handle" => "gamma", "{}" => false }, - { "handle" => "delta", "{}" => true }, - ] - template = "{{ array | where: some_property, true | map: 'handle' | join: ' ' }}" - expected_output = "alpha delta" - - assert_template_result(expected_output, template, { "array" => array, "some_property" => {} }) - end - def test_where_string_keys input = [ "alpha", "beta", "gamma", "delta" From c6dcf3e714a2fe189a0b54c45efcd9cc29e49f50 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 2 Apr 2025 15:09:28 +0200 Subject: [PATCH 203/343] add test --- test/integration/standard_filter_test.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 62ec5d2df..6a2737bba 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1109,6 +1109,19 @@ def test_where_with_false_value assert_template_result(expected_output, template, { "array" => array }) end + def test_where_with_non_string_property + array = [ + { "handle" => "alpha", "{}" => true }, + { "handle" => "beta", "{}" => false }, + { "handle" => "gamma", "{}" => false }, + { "handle" => "delta", "{}" => true }, + ] + template = "{{ array | where: some_property, true | map: 'handle' | join: ' ' }}" + expected_output = "alpha delta" + + assert_template_result(expected_output, template, { "array" => array, "some_property" => {} }) + end + def test_where_string_keys input = [ "alpha", "beta", "gamma", "delta" From b459eb656f95008ade68f76ac7c8816bb95e6cfd Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 2 Apr 2025 15:10:21 +0200 Subject: [PATCH 204/343] remove test --- test/integration/standard_filter_test.rb | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 6a2737bba..62ec5d2df 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1109,19 +1109,6 @@ def test_where_with_false_value assert_template_result(expected_output, template, { "array" => array }) end - def test_where_with_non_string_property - array = [ - { "handle" => "alpha", "{}" => true }, - { "handle" => "beta", "{}" => false }, - { "handle" => "gamma", "{}" => false }, - { "handle" => "delta", "{}" => true }, - ] - template = "{{ array | where: some_property, true | map: 'handle' | join: ' ' }}" - expected_output = "alpha delta" - - assert_template_result(expected_output, template, { "array" => array, "some_property" => {} }) - end - def test_where_string_keys input = [ "alpha", "beta", "gamma", "delta" From fc0f7f44c1026c9c5a0df095be30df55af358e26 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Wed, 2 Apr 2025 17:52:13 +0200 Subject: [PATCH 205/343] use `Utils.to_s` on property error --- lib/liquid/standardfilters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 3bbee4a59..7359062ea 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -999,7 +999,7 @@ def filter_array(input, property, target_value, default_value = [], &block) end def raise_property_error(property) - raise Liquid::ArgumentError, "cannot select the property '#{property}'" + raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'" end def apply_operation(input, operand, operation) From f676e699a4d707e19f780ea29f9add9971d91280 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Mon, 14 Apr 2025 11:24:01 +0200 Subject: [PATCH 206/343] rollback changes to tests --- test/integration/standard_filter_test.rb | 31 ++++-------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 62ec5d2df..6b931de59 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -560,26 +560,6 @@ def test_map_returns_empty_on_2d_input_array end end - def test_map_with_nil_property - array = [ - { "handle" => "alpha", "value" => "A" }, - { "handle" => "beta", "value" => "B" }, - { "handle" => "gamma", "value" => "C" } - ] - - assert_template_result("alpha beta gamma", "{{ array | map: nil | map: 'handle' | join: ' ' }}", { "array" => array }) - end - - def test_map_with_empty_string_property - array = [ - { "handle" => "alpha", "value" => "A" }, - { "handle" => "beta", "value" => "B" }, - { "handle" => "gamma", "value" => "C" } - ] - - assert_template_result("alpha beta gamma", "{{ array | map: '' | map: 'handle' | join: ' ' }}", { "array" => array }) - end - def test_map_with_value_property array = [ { "handle" => "alpha", "value" => "A" }, @@ -591,16 +571,15 @@ def test_map_with_value_property end def test_map_returns_input_with_no_property - input = [ + foo = [ [1], [2], [3], ] - result = @filters.map(input, nil) - assert_equal(input.flatten, result) - result = @filters.map(input, '') - assert_equal(input.flatten, result) + assert_raises(Liquid::ArgumentError) do + @filters.map(foo, nil) + end end def test_sort_works_on_enumerables @@ -1317,7 +1296,7 @@ def test_sum_with_floats_and_indexable_map_values end def test_sum_with_non_string_property - input = [{ "true" => 1 }, { "1.0" => 0.2, "1" => -0.3 }, { "1..5" => 0.4 }] + input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }] assert_equal(1, @filters.sum(input, true)) assert_equal(0.2, @filters.sum(input, 1.0)) From c34dd812c5a8031dfa13a44b4130f4564ca70757 Mon Sep 17 00:00:00 2001 From: Marco Concetto Rudilosso Date: Mon, 14 Apr 2025 17:36:45 +0200 Subject: [PATCH 207/343] Fully revert calling to_s on filter array --- lib/liquid/standardfilters.rb | 2 -- lib/liquid/version.rb | 2 +- test/integration/standard_filter_test.rb | 5 +++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 7359062ea..6e072fcf5 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -982,8 +982,6 @@ def filter_array(input, property, target_value, default_value = [], &block) return default_value if ary.empty? - property = Utils.to_s(property) - block.call(ary) do |item| if target_value.nil? item[property] diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 2eee338a6..f1d61adad 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.5" + VERSION = "5.8.6" end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 6b931de59..e1370fae1 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1054,10 +1054,11 @@ def test_where_with_empty_string_is_a_no_op def test_where_with_nil_is_a_no_op environment = { "array" => ["alpha", "beta", "gamma"] } - expected_output = "alpha beta gamma" template = "{{ array | where: nil | join: ' ' }}" - assert_template_result(expected_output, template, environment) + assert_raises(Liquid::ArgumentError) do + assert_template_result("alpha beta gamma", template, environment) + end end def test_where_with_value From aefd48e34141ee534edec16d148d7feeb117094d Mon Sep 17 00:00:00 2001 From: "Justin D. Harris" <1594505+juharris@users.noreply.github.com> Date: Tue, 22 Apr 2025 09:50:47 -0400 Subject: [PATCH 208/343] template.rb: Correct typo in docs --- lib/liquid/template.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 9fb4089a4..a6d80e0ae 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -2,7 +2,7 @@ module Liquid # Templates are central to liquid. - # Interpretating templates is a two step process. First you compile the + # Interpreting templates is a two step process. First you compile the # source code you got. During compile time some extensive error checking is performed. # your code should expect to get some SyntaxErrors. # From 6b3f6c6fb43a6ae5dd2481f4b86dc437d0136c4c Mon Sep 17 00:00:00 2001 From: Brian Chen Date: Tue, 29 Apr 2025 14:04:22 -0400 Subject: [PATCH 209/343] update github actions to commits --- .github/dependabot.yaml | 6 ++++++ .github/workflows/liquid.yml | 8 ++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 .github/dependabot.yaml diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..c75e875a0 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 0a8474f16..e496f55f8 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -31,8 +31,8 @@ jobs: - { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" } name: Test Ruby ${{ matrix.entry.ruby }} steps: - - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0 with: ruby-version: ${{ matrix.entry.ruby }} bundler-cache: true @@ -45,8 +45,8 @@ jobs: memory_profile: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0 with: bundler-cache: true - run: bundle exec rake memory_profile:run From eabbd5cb6fe3786d084d90cdec1e2823f4669d26 Mon Sep 17 00:00:00 2001 From: James Meng Date: Tue, 6 May 2025 15:20:35 -0700 Subject: [PATCH 210/343] Remove {{ foo }} and {{ bar }} from doc tag description --- lib/liquid/tags/doc.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index ef5d5d801..2138b641d 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -27,7 +27,6 @@ module Liquid # @example # {% render 'message', foo: 'Hello', bar: 'World' %} # {% enddoc %} - # {{ foo }}, {{ bar }}! class Doc < Block NO_UNEXPECTED_ARGS = /\A\s*\z/ From f375d7b3aafd9ea37200f947fcba633a88426996 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 22 May 2025 11:38:10 -0400 Subject: [PATCH 211/343] Add unit test for custom Liquid tag registration (#1960) Adds EnvironmentTest to verify custom tag registration and rendering. --- test/unit/environment_test.rb | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 test/unit/environment_test.rb diff --git a/test/unit/environment_test.rb b/test/unit/environment_test.rb new file mode 100644 index 000000000..e989dc9bc --- /dev/null +++ b/test/unit/environment_test.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'test_helper' + +class EnvironmentTest < Minitest::Test + include Liquid + + class UnsubscribeFooter < Liquid::Tag + def render(_context) + 'Unsubscribe Footer' + end + end + + def test_custom_tag + email_environment = Liquid::Environment.build do |environment| + environment.register_tag("unsubscribe_footer", UnsubscribeFooter) + end + + assert(email_environment.tags["unsubscribe_footer"]) + assert(email_environment.tag_for_name("unsubscribe_footer")) + template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment) + + assert_equal('Unsubscribe Footer', template.render) + end +end From 65b1dedac547ca5a89e5613554e8ddacf8ae1018 Mon Sep 17 00:00:00 2001 From: James Meng Date: Thu, 1 May 2025 09:11:15 -0700 Subject: [PATCH 212/343] Expose tag body in the Doc tag --- lib/liquid/tags/doc.rb | 5 +++++ test/unit/tags/doc_tag_unit_test.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index 2138b641d..4c650b8c1 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -30,8 +30,11 @@ module Liquid class Doc < Block NO_UNEXPECTED_ARGS = /\A\s*\z/ + attr_reader :body + def initialize(tag_name, markup, parse_context) super + @body = +"" ensure_valid_markup(tag_name, markup, parse_context) end @@ -43,8 +46,10 @@ def parse(tokens) if tag_name == block_delimiter parse_context.trim_whitespace = (token[-3] == WhitespaceControl) + @body << Regexp.last_match(1) if Regexp.last_match(1) != "" return end + @body << token unless token.empty? end raise_tag_never_closed(block_name) diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index a0a366295..1d5db06d6 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -20,6 +20,21 @@ def test_doc_tag assert_template_result('', template) end + def test_doc_tag_body_content + doc_content = " Documentation content\n @param {string} foo - test\n" + template_source = "{% doc %}#{doc_content}{% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal(doc_content, doc_tag.body) + end + def test_doc_tag_does_not_support_extra_arguments error = assert_raises(Liquid::SyntaxError) do template = <<~LIQUID.chomp From 79a771d72427a514bcbff674dfd42af87d0e3f0f Mon Sep 17 00:00:00 2001 From: James Meng Date: Wed, 4 Jun 2025 19:30:47 -0700 Subject: [PATCH 213/343] Add test for doc tag capturing token before enddoc --- test/unit/tags/doc_tag_unit_test.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index 1d5db06d6..cf17b1d00 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -131,6 +131,20 @@ def test_doc_tag_ignores_malformed_syntax assert_template_result('', template) end + def test_doc_tag_captures_token_before_enddoc + template_source = "{% doc %}{{ incomplete{% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal("{{ incomplete", doc_tag.body) + end + def test_doc_tag_preserves_error_line_numbers template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) {% doc %} From 8548b96a97a65720552d7bef74e4404b11facedd Mon Sep 17 00:00:00 2001 From: James Meng Date: Fri, 6 Jun 2025 12:06:35 -0700 Subject: [PATCH 214/343] Remove `body` attr_reader and initiliaze `@body` instance variable in `parse` method --- lib/liquid/tags/doc.rb | 7 +++---- test/unit/tags/doc_tag_unit_test.rb | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index 4c650b8c1..b61d7e95a 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -30,15 +30,14 @@ module Liquid class Doc < Block NO_UNEXPECTED_ARGS = /\A\s*\z/ - attr_reader :body - def initialize(tag_name, markup, parse_context) super - @body = +"" ensure_valid_markup(tag_name, markup, parse_context) end def parse(tokens) + @body = +"" + while (token = tokens.shift) tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2) @@ -64,7 +63,7 @@ def blank? end def nodelist - [] + [@body] end private diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index cf17b1d00..7c6ef6f3f 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -32,7 +32,7 @@ def test_doc_tag_body_content end .visit - assert_equal(doc_content, doc_tag.body) + assert_equal(doc_content, doc_tag.nodelist.first.to_s) end def test_doc_tag_does_not_support_extra_arguments @@ -132,7 +132,7 @@ def test_doc_tag_ignores_malformed_syntax end def test_doc_tag_captures_token_before_enddoc - template_source = "{% doc %}{{ incomplete{% enddoc %}" + template_source = "{% doc %}{{ incomplete{% enddoc %}" doc_tag = nil ParseTreeVisitor @@ -142,7 +142,7 @@ def test_doc_tag_captures_token_before_enddoc end .visit - assert_equal("{{ incomplete", doc_tag.body) + assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s) end def test_doc_tag_preserves_error_line_numbers From 7b2b25fda1ff025b7ec790cdd28fb9535de96f3d Mon Sep 17 00:00:00 2001 From: James Meng Date: Mon, 9 Jun 2025 11:36:18 -0700 Subject: [PATCH 215/343] Fix Doc tag blank? method to check body content Previously the blank? method always returned true. Now it properly checks if the body is empty, making the tag behavior consistent with other tags. Also updated test to use whitespace control for cleaner assertions. --- lib/liquid/tags/doc.rb | 2 +- test/unit/tags/doc_tag_unit_test.rb | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/liquid/tags/doc.rb b/lib/liquid/tags/doc.rb index b61d7e95a..499102d72 100644 --- a/lib/liquid/tags/doc.rb +++ b/lib/liquid/tags/doc.rb @@ -59,7 +59,7 @@ def render_to_output_buffer(_context, output) end def blank? - true + @body.empty? end def nodelist diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index 7c6ef6f3f..64b950b73 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -174,11 +174,11 @@ def test_doc_tag_whitespace_control def test_doc_tag_delimiter_handling assert_template_result('', <<~LIQUID.chomp) - {% if true %} - {% doc %} - {% docEXTRA %}wut{% enddocEXTRA %}xyz - {% enddoc %} - {% endif %} + {%- if true -%} + {%- doc -%} + {%- docEXTRA -%}wut{% enddocEXTRA -%}xyz + {%- enddoc -%} + {%- endif -%} LIQUID assert_template_result('', "{% doc %}123{% enddoc xyz %}") From 7f2f8a226b013d7fe8abb140f2dea83405ca6623 Mon Sep 17 00:00:00 2001 From: James Meng Date: Mon, 9 Jun 2025 12:24:59 -0700 Subject: [PATCH 216/343] Add tests for new public methods --- test/unit/tags/doc_tag_unit_test.rb | 74 +++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/unit/tags/doc_tag_unit_test.rb b/test/unit/tags/doc_tag_unit_test.rb index 64b950b73..223f3ca0d 100644 --- a/test/unit/tags/doc_tag_unit_test.rb +++ b/test/unit/tags/doc_tag_unit_test.rb @@ -196,6 +196,80 @@ def test_doc_tag_visitor ) end + def test_doc_tag_blank_with_empty_content + template_source = "{% doc %}{% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal(true, doc_tag.blank?) + end + + def test_doc_tag_blank_with_content + template_source = "{% doc %}Some documentation{% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal(false, doc_tag.blank?) + end + + def test_doc_tag_blank_with_whitespace_only + template_source = "{% doc %} {% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal(false, doc_tag.blank?) + end + + def test_doc_tag_nodelist_returns_array_with_body + doc_content = "Documentation content\n@param {string} foo" + template_source = "{% doc %}#{doc_content}{% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal([doc_content], doc_tag.nodelist) + assert_equal(1, doc_tag.nodelist.length) + assert_equal(doc_content, doc_tag.nodelist.first) + end + + def test_doc_tag_nodelist_with_empty_content + template_source = "{% doc %}{% enddoc %}" + + doc_tag = nil + ParseTreeVisitor + .for(Template.parse(template_source).root) + .add_callback_for(Liquid::Doc) do |tag| + doc_tag = tag + end + .visit + + assert_equal([""], doc_tag.nodelist) + assert_equal(1, doc_tag.nodelist.length) + end + private def traversal(template) From 79b831d96cff319afb693e2775dcb202d8dec9bb Mon Sep 17 00:00:00 2001 From: James Meng Date: Mon, 9 Jun 2025 12:36:16 -0700 Subject: [PATCH 217/343] Bump Liquid to 5.8.7 --- History.md | 3 ++- lib/liquid/version.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/History.md b/History.md index ccb885a69..3d979dc14 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,7 @@ # Liquid Change Log -## 5.8.1 (unreleased) +## 5.8.7 +* Expose body content in the `Doc` tag [James Meng] ## 5.8.1 diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index f1d61adad..9c5807b63 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.6" + VERSION = "5.8.7" end From 8555fd8a2012aafe88978ce68e03b7c84d418309 Mon Sep 17 00:00:00 2001 From: Iain Campbell Date: Fri, 27 Jun 2025 16:57:00 -0400 Subject: [PATCH 218/343] inline warning previuosly at category level --- lib/liquid/tags/assign.rb | 4 ++++ lib/liquid/tags/capture.rb | 4 ++++ lib/liquid/tags/decrement.rb | 4 ++++ lib/liquid/tags/increment.rb | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 702831781..4743ab0b8 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -9,6 +9,10 @@ module Liquid # Creates a new variable. # @liquid_description # You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property. + # + # > Caution: + # > Predefined Liquid objects can be overridden by variables with the same name. + # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_syntax # {% assign variable_name = value %} # @liquid_syntax_keyword variable_name The name of the variable being created. diff --git a/lib/liquid/tags/capture.rb b/lib/liquid/tags/capture.rb index 59aa62e7f..1ec959fb0 100644 --- a/lib/liquid/tags/capture.rb +++ b/lib/liquid/tags/capture.rb @@ -9,6 +9,10 @@ module Liquid # Creates a new variable with a string value. # @liquid_description # You can create complex strings with Liquid logic and variables. + # + # > Caution: + # > Predefined Liquid objects can be overridden by variables with the same name. + # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_syntax # {% capture variable %} # value diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 49d43bc62..8dc620740 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -7,6 +7,10 @@ module Liquid # @liquid_name decrement # @liquid_summary # Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call. + # + # > Caution: + # > Predefined Liquid objects can be overridden by variables with the same name. + # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_description # Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 3b6c16f56..0e00e9704 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -7,6 +7,10 @@ module Liquid # @liquid_name increment # @liquid_summary # Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call. + # + # > Caution: + # > Predefined Liquid objects can be overridden by variables with the same name. + # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_description # Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across From 786381c762031db5cec030df172311911492740b Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Fri, 5 Sep 2025 16:43:48 -0400 Subject: [PATCH 219/343] Update liquid docs for loop's named params --- lib/liquid/tags/for.rb | 4 ++-- lib/liquid/tags/table_row.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 27af15bf1..6aa308f1c 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -20,8 +20,8 @@ module Liquid # @liquid_syntax_keyword variable The current item in the array. # @liquid_syntax_keyword array The array to iterate over. # @liquid_syntax_keyword expression The expression to render for each iteration. - # @liquid_optional_param limit [number] The number of iterations to perform. - # @liquid_optional_param offset [number] The 1-based index to start iterating at. + # @liquid_optional_param limit: [number] The number of iterations to perform. + # @liquid_optional_param offset: [number] The 1-based index to start iterating at. # @liquid_optional_param range [untyped] A custom numeric range to iterate over. # @liquid_optional_param reversed [untyped] Iterate in reverse order. class For < Block diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index d59791ce1..6767e4fb5 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -19,9 +19,9 @@ module Liquid # @liquid_syntax_keyword variable The current item in the array. # @liquid_syntax_keyword array The array to iterate over. # @liquid_syntax_keyword expression The expression to render. - # @liquid_optional_param cols [number] The number of columns that the table should have. - # @liquid_optional_param limit [number] The number of iterations to perform. - # @liquid_optional_param offset [number] The 1-based index to start iterating at. + # @liquid_optional_param cols: [number] The number of columns that the table should have. + # @liquid_optional_param limit: [number] The number of iterations to perform. + # @liquid_optional_param offset: [number] The 1-based index to start iterating at. # @liquid_optional_param range [untyped] A custom numeric range to iterate over. class TableRow < Block Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o From b5b36665e618935ea796d7c108dbe1fc1734cdbe Mon Sep 17 00:00:00 2001 From: Michael Go Date: Wed, 15 Oct 2025 10:08:13 -0300 Subject: [PATCH 220/343] avoid new regex allocation in util functions --- lib/liquid/utils.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index a75b9fa37..2bdb0c521 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -2,6 +2,9 @@ module Liquid module Utils + DECIMAL_REGEX = /\A-?\d+\.\d+\z/ + UNIX_TIMESTAMP_REGEX = /\A\d+\z/ + def self.slice_collection(collection, from, to) if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice) collection.load_slice(from, to) @@ -52,7 +55,7 @@ def self.to_number(obj) when Numeric obj when String - /\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i + DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i else if obj.respond_to?(:to_number) obj.to_number @@ -73,7 +76,7 @@ def self.to_date(obj) case obj when 'now', 'today' Time.now - when /\A\d+\z/, Integer + when UNIX_TIMESTAMP_REGEX, Integer Time.at(obj.to_i) when String Time.parse(obj) From edf06c288256af54a8381d938efc4270ff04322f Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Tue, 30 Sep 2025 14:31:44 +0200 Subject: [PATCH 221/343] Introduce :rigid parsing mode --- README.md | 1 + Rakefile | 10 +- bin/example.liquid | 5 + bin/render | 30 +++ lib/liquid/environment.rb | 2 +- lib/liquid/parse_context.rb | 20 ++ lib/liquid/parser_switching.rb | 3 + test/unit/partial_cache_unit_test.rb | 4 +- test/unit/rigid_mode_unit_test.rb | 264 +++++++++++++++++++++++++++ 9 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 bin/example.liquid create mode 100755 bin/render create mode 100644 test/unit/rigid_mode_unit_test.rb diff --git a/README.md b/README.md index 3734a290f..93a726a91 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ Liquid::Environment.default.error_mode = :strict Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. +Liquid::Environment.default.error_mode = :rigid # Uses Parser.new instead of Expression.parse for stricter parsing ``` If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: diff --git a/Rakefile b/Rakefile index 6ccd2c866..ae41567a3 100755 --- a/Rakefile +++ b/Rakefile @@ -33,7 +33,7 @@ task :rubocop do end end -desc('runs test suite with both strict and lax parsers') +desc('runs test suite with all parsers (lax, strict, and rigid)') task :test do ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['base_test'].invoke @@ -42,6 +42,10 @@ task :test do Rake::Task['base_test'].reenable Rake::Task['base_test'].invoke + ENV['LIQUID_PARSER_MODE'] = 'rigid' + Rake::Task['base_test'].reenable + Rake::Task['base_test'].invoke + if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby' ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['integration_test'].reenable @@ -50,6 +54,10 @@ task :test do ENV['LIQUID_PARSER_MODE'] = 'strict' Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke + + ENV['LIQUID_PARSER_MODE'] = 'rigid' + Rake::Task['integration_test'].reenable + Rake::Task['integration_test'].invoke end end diff --git a/bin/example.liquid b/bin/example.liquid new file mode 100644 index 000000000..c4a93aa3d --- /dev/null +++ b/bin/example.liquid @@ -0,0 +1,5 @@ + + {% tablerow i in (1..10) limit: foo=>bar %} + {{ i }} + {% endtablerow %} +
diff --git a/bin/render b/bin/render new file mode 100755 index 000000000..004c103f0 --- /dev/null +++ b/bin/render @@ -0,0 +1,30 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'bundler/setup' +require 'liquid' + +class VirtualFileSystem + def initialize + snippet_1 = '

{{ greating | default: "Hello" }}, {{ name | default: "world" }}!

' + snippet_2 = '{% for i in (1..5) %} > {{ i }}{% endfor %}' + + @templates = { + 'snippet_1' => snippet_1, + 'snippet_2' => snippet_2, + } + end + + def read_template_file(key) + @templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'") + end +end + +error_mode = :strict +# error_mode = :rigid +file = File.read(ARGV[0]) +template = Liquid::Template.parse(file, error_mode: error_mode) + +template.registers[:file_system] = VirtualFileSystem.new + +puts template.render diff --git a/lib/liquid/environment.rb b/lib/liquid/environment.rb index 31b17b234..987ec13f9 100644 --- a/lib/liquid/environment.rb +++ b/lib/liquid/environment.rb @@ -34,7 +34,7 @@ class << self # @param file_system The default file system that is used # to load templates from. # @param error_mode [Symbol] The default error mode for all templates - # (either :strict, :warn, or :lax). + # (either :strict, :warn, :lax, or :rigid). # @param exception_renderer [Proc] The exception renderer that is used to # render exceptions. # @yieldparam environment [Environment] The environment instance that is being built. diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 60cdf9e41..277d349e9 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -51,6 +51,26 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) end def parse_expression(markup) + if @error_mode == :rigid + parser = new_parser(markup) + + # Return nil immediately if the markup is empty or contains only + # whitespaces + return if parser.look(:end_of_string) + + expression_string = parser.expression + + # In rigid mode, verify that all tokens have been consumed + # + # Extra tokens remaining after the expression indicate invalid syntaxes, + # such as: "product title" (instead of "product.title") + parser.consume(:end_of_string) unless parser.look(:end_of_string) + + # Use Parser for strict token validation, but still return + # Expression objects for compatibility with the rendering pipeline. + markup = expression_string + end + Expression.parse(markup, @string_scanner, @expression_cache) end diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index 78afd58a9..e10476329 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -8,6 +8,8 @@ def strict_parse_with_error_mode_fallback(markup) case parse_context.error_mode when :strict raise + when :rigid + raise when :warn parse_context.warnings << e end @@ -17,6 +19,7 @@ def strict_parse_with_error_mode_fallback(markup) def parse_with_selected_parser(markup) case parse_context.error_mode when :strict then strict_parse_with_error_context(markup) + when :rigid then strict_parse_with_error_context(markup) when :lax then lax_parse(markup) when :warn begin diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb index b081afe52..31790b0d8 100644 --- a/test/unit/partial_cache_unit_test.rb +++ b/test/unit/partial_cache_unit_test.rb @@ -184,7 +184,7 @@ def test_includes_error_mode_into_template_cache }, ) - [:lax, :warn, :strict].each do |error_mode| + [:lax, :warn, :strict, :rigid].each do |error_mode| Liquid::PartialCache.load( 'my_partial', context: context, @@ -193,7 +193,7 @@ def test_includes_error_mode_into_template_cache end assert_equal( - ["my_partial:lax", "my_partial:warn", "my_partial:strict"], + ["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:rigid"], context.registers[:cached_partials].keys, ) end diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb new file mode 100644 index 000000000..d1cd615ed --- /dev/null +++ b/test/unit/rigid_mode_unit_test.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RigidModeUnitTest < Minitest::Test + include Liquid + + def test_direct_parse_expression_comparison + test_cases = [ + 'foo bar', + 'user.name first', + 'items[0] next', + 'products[0].name extra', + ] + + test_cases.each do |expr| + ctx_strict = ParseContext.new(environment: strict_env) + result = ctx_strict.parse_expression(expr) + refute_nil(result, "Strict mode should parse '#{expr}'") + + ctx_rigid = ParseContext.new(environment: rigid_env) + error = assert_raises(SyntaxError) do + ctx_rigid.parse_expression(expr) + end + + assert_match(/Expected end_of_string but found id/, error.message) + end + end + + def test_comparison_strict_vs_rigid_with_space_separated_lookups + expr = 'product title' + + ctx_lax = ParseContext.new(environment: lax_env) + result_lax = ctx_lax.parse_expression(expr) + assert_equal('product', result_lax.name) + assert_equal(['title'], result_lax.lookups) + + ctx_strict = ParseContext.new(environment: strict_env) + result_strict = ctx_strict.parse_expression(expr) + assert_equal('product', result_strict.name) + assert_equal(['title'], result_strict.lookups) + + ctx_rigid = ParseContext.new(environment: rigid_env) + assert_raises(SyntaxError) do + ctx_rigid.parse_expression(expr) + end + end + + def test_tablerow_limit_with_invalid_expression + template = <<~LIQUID + {% tablerow i in (1..10) limit: foo=>bar %}{{ i }}{% endtablerow %} + LIQUID + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_tablerow_offset_with_invalid_expression + template = <<~LIQUID + {% tablerow i in (1..10) offset: foo=>bar %}{{ i }}{% endtablerow %} + LIQUID + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_cycle_name_with_invalid_expression + template = <<~LIQUID + {% for i in (1..3) %} + {% cycle foo=>bar: "a", "b" %} + {% endfor %} + LIQUID + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_cycle_variable_with_invalid_expression + template = <<~LIQUID + {% for i in (1..3) %} + {% cycle foo=>bar, "a", "b" %} + {% endfor %} + LIQUID + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_case_with_invalid_expression + template = <<~LIQUID + {% case foo=>bar %} + {% when 1 %} + one + {% endcase %} + LIQUID + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_include_template_with_invalid_expression + template = "{% include foo=>bar %}" + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_include_with_invalid_expression + template = '{% include "snippet" with foo=>bar %}' + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_include_attribute_with_invalid_expression + template = '{% include "snippet", key: foo=>bar %}' + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_render_with_invalid_expression + template = '{% render "snippet" with foo=>bar %}' + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_render_attribute_with_invalid_expression + template = '{% render "snippet", key: foo=>bar %}' + + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + assert_match(/Unexpected character =/, error.message) + end + + def test_valid_expressions_work_in_rigid_mode + test_cases = { + '{{ foo }}' => { 'foo' => 'bar' }, + '{{ foo.bar }}' => { 'foo' => { 'bar' => 'baz' } }, + '{{ items[0] }}' => { 'items' => ['first', 'second'] }, + '{{ product.variants[0].title }}' => { 'product' => { 'variants' => [{ 'title' => 'Small' }] } }, + '{{ "hello" }}' => {}, + '{{ 42 }}' => {}, + '{{ 3.14 }}' => {}, + } + + test_cases.each do |template_str, data| + t = rigid_parse(template_str) + result = t.render(data) + assert(result.is_a?(String), "Should render successfully for '#{template_str}'") + end + end + + def test_rigid_mode_with_ranges + template = <<~LIQUID + {% for i in (1..3) %}{{ i }}{% endfor %} + LIQUID + + t = rigid_parse(template) + result = t.render + assert_equal("123\n", result) + end + + def test_rigid_mode_with_variable_ranges + template = <<~LIQUID + {% for i in (start..end) %}{{ i }}{% endfor %} + LIQUID + + t = rigid_parse(template) + result = t.render({ 'start' => 1, 'end' => 3 }) + assert_equal("123\n", result) + end + + def test_rigid_mode_valid_filters + template = <<~LIQUID + {{ "hello" | upcase | prepend: "Say: " }} + LIQUID + + t = rigid_parse(template) + result = t.render + assert_equal("Say: HELLO\n", result) + end + + def test_rigid_mode_valid_filter_with_correct_variable_args + template = <<~LIQUID + {{ "hello" | append: world.name }} + LIQUID + + t = rigid_parse(template) + result = t.render({ 'world' => { 'name' => ' world' } }) + assert_equal("hello world\n", result) + end + + def test_empty_expression_handling + ctx_rigid = ParseContext.new(environment: rigid_env) + result = ctx_rigid.parse_expression('') + assert_nil(result) + + result = ctx_rigid.parse_expression(' ') + assert_nil(result) + end + + private + + def rigid_parse(source) + Template.parse(source, environment: rigid_env) + end + + def strict_parse(source) + Template.parse(source, environment: strict_env) + end + + def lax_env + Environment.build(error_mode: :lax) + end + + def rigid_env + Environment.build(error_mode: :rigid) + end + + def strict_env + Environment.build(error_mode: :strict) + end +end From 6d585a24f1ffc1bb57a05d7e1b8e5d0ea84f7b2d Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 30 Sep 2025 15:45:30 -0400 Subject: [PATCH 222/343] Add rigid_parse_with_error_context and clarifications --- lib/liquid/parser_switching.rb | 17 ++++++++++++++--- lib/liquid/template.rb | 1 + 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index e10476329..a978dc43a 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -2,14 +2,17 @@ module Liquid module ParserSwitching + # Do not use this. Use parse_with_selected_parser instead. + # It's basically doing the same thing, except this will use strict_parse regardless + # of the error mode and fallback only if strict throws. def strict_parse_with_error_mode_fallback(markup) strict_parse_with_error_context(markup) rescue SyntaxError => e case parse_context.error_mode - when :strict - raise when :rigid raise + when :strict + raise when :warn parse_context.warnings << e end @@ -18,8 +21,8 @@ def strict_parse_with_error_mode_fallback(markup) def parse_with_selected_parser(markup) case parse_context.error_mode + when :rigid then rigid_parse_with_error_context(markup) when :strict then strict_parse_with_error_context(markup) - when :rigid then strict_parse_with_error_context(markup) when :lax then lax_parse(markup) when :warn begin @@ -33,6 +36,14 @@ def parse_with_selected_parser(markup) private + def rigid_parse_with_error_context(markup) + respond_to?(:rigid_parse) ? rigid_parse(markup) : strict_parse(markup) + rescue SyntaxError => e + e.line_number = line_number + e.markup_context = markup_context(markup) + raise e + end + def strict_parse_with_error_context(markup) strict_parse(markup) rescue SyntaxError => e diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index a6d80e0ae..acda1e4db 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -25,6 +25,7 @@ class << self # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. # :warn is the default and will give deprecation warnings when invalid syntax is used. # :strict will enforce correct syntax. + # :rigid is stricter even. def error_mode=(mode) Deprecations.warn("Template.error_mode=", "Environment#error_mode=") Environment.default.error_mode = mode From c78bf20010e61e2d73e04ee81f391482f5c1094c Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 30 Sep 2025 15:46:17 -0400 Subject: [PATCH 223/343] Add a rigid_parse method to `cycle` --- lib/liquid/tags/cycle.rb | 53 +++++++++++++++++++------ test/integration/tags/cycle_tag_test.rb | 15 +++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index c2d94d5f4..8f4d06890 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -22,18 +22,7 @@ class Cycle < Tag def initialize(tag_name, markup, options) super - case markup - when NamedSyntax - @variables = variables_from_string(Regexp.last_match(2)) - @name = parse_expression(Regexp.last_match(1)) - @is_named = true - when SimpleSyntax - @variables = variables_from_string(markup) - @name = @variables.to_s - @is_named = !@name.match?(/\w+:0x\h{8}/) - else - raise SyntaxError, options[:locale].t("errors.syntax.cycle") - end + parse_with_selected_parser(markup) end def named? @@ -65,6 +54,46 @@ def render_to_output_buffer(context, output) private + # cycle [name:] expression(, expression)* + def rigid_parse(markup) + $stderr.puts "using rigid" + p = @parse_context.new_parser(markup) + + if p.look(:id) && p.peek(1) == :colon + @name = p.consume(:id) + @is_named = true + p.consume(:colon) + end + + @variables = [] + while (var = p.expression) + @variables << var + break unless p.consume?(:comma) + end + + raise_syntax_error(options) if @variables.empty? + end + + # Temporarily until we migrate + def strict_parse(markup) + lax_parse(markup) + end + + def lax_parse(markup) + case markup + when NamedSyntax + @variables = variables_from_string(Regexp.last_match(2)) + @name = parse_expression(Regexp.last_match(1)) + @is_named = true + when SimpleSyntax + @variables = variables_from_string(markup) + @name = @variables.to_s + @is_named = !@name.match?(/\w+:0x\h{8}/) + else + raise SyntaxError, options[:locale].t("errors.syntax.cycle") + end + end + def variables_from_string(markup) markup.split(',').collect do |var| var =~ /\s*(#{QuotedFragment})\s*/o diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index a034db17e..8cd6daac4 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -45,4 +45,19 @@ def test_cycle_tag_always_resets_cycle assert_template_result("11", template) end + + def test_cycle_tag_with_error_mode + # QuotedFragment is more permissive than what Parser#expression allows. + [:lax, :strict].each do |mode| + with_error_mode(mode) do + assert_template_result("a", "{% cycle .5: 'a', 'b' %}") + assert_template_result("b", "{% assign 5 = 'b' %}{% cycle .5, .4 %}") + end + end + + with_error_mode(:rigid) do + assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5: 'a', 'b' %}") } + assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5, .4 %}") } + end + end end From e58ac0e75b8954c17e32172abe1c0c2b9de66bf3 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 1 Oct 2025 08:45:32 -0400 Subject: [PATCH 224/343] Add rigid_parse to `render` --- lib/liquid/tags/render.rb | 75 +++++++++++++++++++----- test/integration/tags/render_tag_test.rb | 26 ++++++++ 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 26004d647..89c11063c 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -35,22 +35,7 @@ class Render < Tag def initialize(tag_name, markup, options) super - - raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX - - template_name = Regexp.last_match(1) - with_or_for = Regexp.last_match(3) - variable_name = Regexp.last_match(4) - - @alias_name = Regexp.last_match(6) - @variable_name_expr = variable_name ? parse_expression(variable_name) : nil - @template_name_expr = parse_expression(template_name) - @is_for_loop = (with_or_for == FOR) - - @attributes = {} - markup.scan(TagAttributes) do |key, value| - @attributes[key] = parse_expression(value) - end + parse_with_selected_parser(markup) end def for_loop? @@ -99,6 +84,64 @@ def render_tag(context, output) output end + # render (string) (with|for expression)? (as id)? (key: value)* + def rigid_parse(markup) + p = @parse_context.new_parser(markup) + + template_name = rigid_template_name(p) + with_or_for = p.id?("for") || p.id?("with") || nil + if with_or_for + variable_name = p.expression + end + + alias_name = nil + if p.consume?(:as) + alias_name = p.consume(:id) + end + + @template_name_expr = parse_expression(template_name) + @variable_name_expr = variable_name ? parse_expression(variable_name) : nil + @alias_name = alias_name + @is_for_loop = (with_or_for == FOR) + + # optional comma + p.consume?(:comma) + + @attributes = {} + while p.look(:id) + key = p.consume + p.consume(:colon) + @attributes[key] = parse_expression(p.expression) + p.consume?(:comma) # optional comma + end + end + + def rigid_template_name(p) + p.consume(:string) + end + + def strict_parse(markup) + lax_parse(markup) + end + + def lax_parse(markup) + raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX + + template_name = Regexp.last_match(1) + with_or_for = Regexp.last_match(3) + variable_name = Regexp.last_match(4) + + @alias_name = Regexp.last_match(6) + @variable_name_expr = variable_name ? parse_expression(variable_name) : nil + @template_name_expr = parse_expression(template_name) + @is_for_loop = (with_or_for == FOR) + + @attributes = {} + markup.scan(TagAttributes) do |key, value| + @attributes[key] = parse_expression(value) + end + end + class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index eda80a040..b6cd5cfb6 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -105,6 +105,32 @@ def test_dynamically_choosen_templates_are_not_allowed assert_syntax_error("{% assign name = 'snippet' %}{% render name %}") end + def test_rigid_parsing_errors + [:lax, :strict].each do |mode| + with_error_mode(mode) do + assert_template_result( + 'hello value1 value2', + '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', + partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, + ) + end + end + + [:rigid].each do |mode| + assert_syntax_error( + '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', + error_mode: mode, + ) + end + end + + def test_optional_commas + partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' } + assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials) + assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials) + assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials) + end + def test_include_tag_caches_second_read_of_same_partial file_system = StubFileSystem.new('snippet' => 'echo') assert_equal( From 6a9e46dd1919069878e14ef9cc32868ecac25ec1 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 1 Oct 2025 08:58:14 -0400 Subject: [PATCH 225/343] Add rigid_parse method to `include` --- lib/liquid/tags/include.rb | 73 +++++++++++++++++------ test/integration/tags/include_tag_test.rb | 26 ++++++++ 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 1fefa16f4..6cdbfd6f2 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -27,24 +27,7 @@ class Include < Tag def initialize(tag_name, markup, options) super - - if markup =~ SYNTAX - - template_name = Regexp.last_match(1) - variable_name = Regexp.last_match(3) - - @alias_name = Regexp.last_match(5) - @variable_name_expr = variable_name ? parse_expression(variable_name) : nil - @template_name_expr = parse_expression(template_name) - @attributes = {} - - markup.scan(TagAttributes) do |key, value| - @attributes[key] = parse_expression(value) - end - - else - raise SyntaxError, options[:locale].t("errors.syntax.include") - end + parse_with_selected_parser(markup) end def parse(_tokens) @@ -101,6 +84,60 @@ def render_to_output_buffer(context, output) alias_method :parse_context, :options private :parse_context + def rigid_parse(markup) + p = @parse_context.new_parser(markup) + + template_name = p.expression + with_or_for = p.id?("for") || p.id?("with") || nil + if with_or_for + variable_name = p.expression + end + + alias_name = nil + if p.consume?(:as) + alias_name = p.consume(:id) + end + + @template_name_expr = parse_expression(template_name) + @variable_name_expr = variable_name ? parse_expression(variable_name) : nil + @alias_name = alias_name + + # optional comma + p.consume?(:comma) + + @attributes = {} + while p.look(:id) + key = p.consume + p.consume(:colon) + @attributes[key] = parse_expression(p.expression) + p.consume?(:comma) # optional comma + end + end + + def strict_parse(markup) + lax_parse(markup) + end + + def lax_parse(markup) + if markup =~ SYNTAX + + template_name = Regexp.last_match(1) + variable_name = Regexp.last_match(3) + + @alias_name = Regexp.last_match(5) + @variable_name_expr = variable_name ? parse_expression(variable_name) : nil + @template_name_expr = parse_expression(template_name) + @attributes = {} + + markup.scan(TagAttributes) do |key, value| + @attributes[key] = parse_expression(value) + end + + else + raise SyntaxError, options[:locale].t("errors.syntax.include") + end + end + class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 6e1649663..e8cd68576 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -204,6 +204,32 @@ def test_dynamically_choosen_template ) end + def test_rigid_parsing_errors + [:lax, :strict].each do |mode| + with_error_mode(mode) do + assert_template_result( + 'hello value1 value2', + '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', + partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, + ) + end + end + + [:rigid].each do |mode| + assert_syntax_error( + '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', + error_mode: mode, + ) + end + end + + def test_optional_commas + partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' } + assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials) + assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials) + assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials) + end + def test_include_tag_caches_second_read_of_same_partial file_system = CountingFileSystem.new environment = Liquid::Environment.build(file_system: file_system) From 1bff382ebc7dd2ec8e5717586b0c94221897bde3 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 1 Oct 2025 15:07:37 +0200 Subject: [PATCH 226/343] Add `ExpressionParser` and `ExpressionConsumer` --- lib/liquid.rb | 2 + lib/liquid/environment.rb | 2 +- lib/liquid/expression_consumer.rb | 182 +++++++++++ lib/liquid/expression_parser.rb | 51 +++ lib/liquid/parser.rb | 6 + test/unit/expression_consumer_test.rb | 429 ++++++++++++++++++++++++++ test/unit/expression_parser_test.rb | 102 ++++++ 7 files changed, 773 insertions(+), 1 deletion(-) create mode 100644 lib/liquid/expression_consumer.rb create mode 100644 lib/liquid/expression_parser.rb create mode 100644 test/unit/expression_consumer_test.rb create mode 100644 test/unit/expression_parser_test.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..edd2c4974 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -80,6 +80,8 @@ module Liquid require 'liquid/range_lookup' require 'liquid/resource_limits' require 'liquid/expression' +require 'liquid/expression_consumer' +require 'liquid/expression_parser' require 'liquid/template' require 'liquid/condition' require 'liquid/utils' diff --git a/lib/liquid/environment.rb b/lib/liquid/environment.rb index 987ec13f9..59719c4ad 100644 --- a/lib/liquid/environment.rb +++ b/lib/liquid/environment.rb @@ -34,7 +34,7 @@ class << self # @param file_system The default file system that is used # to load templates from. # @param error_mode [Symbol] The default error mode for all templates - # (either :strict, :warn, :lax, or :rigid). + # (either :rigid, :strict, :warn, or :lax). # @param exception_renderer [Proc] The exception renderer that is used to # render exceptions. # @yieldparam environment [Environment] The environment instance that is being built. diff --git a/lib/liquid/expression_consumer.rb b/lib/liquid/expression_consumer.rb new file mode 100644 index 000000000..a0c76d9b8 --- /dev/null +++ b/lib/liquid/expression_consumer.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Liquid + module ExpressionConsumer + INTEGER_REGEX = /\A(-?\d+)\z/ + FLOAT_REGEX = /\A(-?\d+)\.\d+\z/ + + LITERALS = { + 'nil' => nil, + 'null' => nil, + 'true' => true, + 'false' => false, + 'blank' => '', + 'empty' => '', + }.freeze + + MINUS_VARIABLE_LOOKUP = VariableLookup.parse("-", nil).freeze + + class << self + # Consumes tokens from a Parser instance to build an Expression + # object. + # + # This method reads tokens from the current parser position, + # consuming exactly one complete expression. The parser position is + # advanced past the consumed tokens. + # + # Unlike ExpressionParser.parse, this method does NOT validate that + # all tokens are consumed. It stops after consuming a complete + # expression, leaving any remaining tokens for the caller to handle. + # + # This is the efficient low-level method used by tags that manage + # their own Parser instances and need to consume multiple + # expressions from a single token stream. + # + # Returns an Expression object appropriate for the token type: + # - Literals (nil, true, false, numbers, strings) + # - VariableLookup (variables with optional property/index access) + # - RangeLookup or Range (for range expressions like (1..10)) + # + # Raises SyntaxError if invalid token encountered. + # + # Examples: + # parser = parse_context.new_parser("product.title | upcase") + # expr = ExpressionConsumer.consume(parser, parse_context) + # #=> # + # # Parser is now positioned at the pipe token + # + # parser = parse_context.new_parser("42") + # ExpressionConsumer.consume(parser, parse_context) + # #=> 42 + # + # parser = parse_context.new_parser("items[0]") + # ExpressionConsumer.consume(parser, parse_context) + # #=> # + def consume(parser, parse_context) + token = parser.tokens[parser.point] + + case token[0] + when :string + str = parser.consume(:string) + parse_string(str) + when :number + num_str = parser.consume(:number) + parse_number(num_str) + when :id + parse_id(parser, parse_context) + when :open_square + # Bracket notation: [expression] + parser.consume(:open_square) + inner = consume(parser, parse_context) + parser.consume(:close_square) + lookups = parse_variable_lookups(parser, parse_context) + build_variable_lookup(inner, lookups) + when :open_round + # Range notation: (start..end) + parser.consume(:open_round) + start_obj = consume(parser, parse_context) + parser.consume(:dotdot) + end_obj = consume(parser, parse_context) + parser.consume(:close_round) + build_range_lookup(start_obj, end_obj) + else + raise SyntaxError, "#{token} is not a valid expression" + end + end + + private + + def parse_string(str) + str[1..-2] + end + + def parse_number(num_str) + case num_str + when INTEGER_REGEX then Integer(num_str, 10) + when FLOAT_REGEX then num_str.to_f + else + raise Liquid::SyntaxError, "Invalid expression type in number expression" + end + end + + def parse_id(parser, parse_context) + id_value = parser.consume(:id) + lookups = parse_variable_lookups(parser, parse_context) + + if LITERALS.key?(id_value) + # Case: nil + return LITERALS[id_value] if lookups.empty? + + # Case: nil.size + return build_variable_lookup(id_value, lookups) + end + + if id_value == '-' + # Case (backwards compatibility): - + return MINUS_VARIABLE_LOOKUP if lookups.empty? + + # Case: -var + return build_variable_lookup('-', lookups) + end + + # Case: var + build_variable_lookup(id_value, lookups) + end + + def parse_variable_lookups(parser, parse_context) + lookups = [] + + loop do + if parser.look(:open_square) + parser.consume(:open_square) + lookup = consume(parser, parse_context) + parser.consume(:close_square) + lookups << lookup + next + end + + if parser.look(:dot) + parser.consume(:dot) + id = parser.consume(:id) + lookups << id + next + end + + break + end + + lookups + end + + # todo(guilherme): avoid allocate, simplify this + def build_variable_lookup(name, lookups) + lookup = VariableLookup.allocate + lookup.instance_variable_set(:@name, name) + lookup.instance_variable_set(:@lookups, lookups) + + command_flags = 0 + lookups.each_with_index do |lookup_item, i| + if lookup_item.is_a?(String) && VariableLookup::COMMAND_METHODS.include?(lookup_item) + command_flags |= 1 << i + end + end + lookup.instance_variable_set(:@command_flags, command_flags) + + lookup + end + + # todo(guilherme): use RangeLookup.parse logic, simplify this + def build_range_lookup(start_obj, end_obj) + if !start_obj.respond_to?(:evaluate) && !end_obj.respond_to?(:evaluate) + begin + start_obj.to_i..end_obj.to_i + rescue NoMethodError + raise Liquid::SyntaxError, "Invalid expression type in range expression" + end + else + RangeLookup.new(start_obj, end_obj) + end + end + end + end +end diff --git a/lib/liquid/expression_parser.rb b/lib/liquid/expression_parser.rb new file mode 100644 index 000000000..8712274bc --- /dev/null +++ b/lib/liquid/expression_parser.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Liquid + module ExpressionParser + class << self + # Parses a Liquid expression string into an Expression object using + # strict token-based validation. + # + # This method tokenizes the markup, validates that the expression + # consumes all available tokens (no trailing garbage), and builds + # an appropriate Expression object (literal, VariableLookup, or + # RangeLookup). + # + # Returns nil if the markup is empty or contains only whitespace. + # + # Raises SyntaxError if: + # - Invalid syntax is encountered + # - Extra tokens remain after the expression + # (e.g., "product title" instead of "product.title") + # + # Examples: + # ExpressionParser.parse("product.title", ctx) + # #=> # + # + # ExpressionParser.parse("42", ctx) + # #=> 42 + # + # ExpressionParser.parse("(1..10)", ctx) + # #=> 1..10 + # + # ExpressionParser.parse("", ctx) + # #=> nil + # + # ExpressionParser.parse("product title", ctx) + # #=> raises SyntaxError (extra token "title") + def parse(markup, parse_context) + parser = parse_context.new_parser(markup) + + # Whitespaces only. + return if parser.look(:end_of_string) + + result = ExpressionConsumer.consume(parser, parse_context) + + # Extra tokens after the expression. + parser.consume(:end_of_string) unless parser.look(:end_of_string) + + result + end + end + end +end diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 645dfa3a1..24b2c5267 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -2,6 +2,8 @@ module Liquid class Parser + attr_reader :tokens + def initialize(input) ss = input.is_a?(StringScanner) ? input : StringScanner.new(input) @tokens = Lexer.tokenize(ss) @@ -82,6 +84,10 @@ def argument str end + def point + @p + end + def variable_lookups str = +"" loop do diff --git a/test/unit/expression_consumer_test.rb b/test/unit/expression_consumer_test.rb new file mode 100644 index 000000000..99921ec7a --- /dev/null +++ b/test/unit/expression_consumer_test.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ExpressionConsumerTest < Minitest::Test + include Liquid + + def test_consume_string_literal_with_double_quotes + parser = parse_context.new_parser('"hello"') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('hello', result) + end + + def test_consume_string_literal_with_single_quotes + parser = parse_context.new_parser("'world'") + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('world', result) + end + + def test_consume_string_with_empty_content + parser = parse_context.new_parser('""') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('', result) + end + + def test_consume_single_quote_empty_string + parser = parse_context.new_parser("''") + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('', result) + end + + def test_consume_integer_literal + parser = parse_context.new_parser('42') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(42, result) + assert_kind_of(Integer, result) + end + + def test_consume_negative_integer_literal + parser = parse_context.new_parser('-42') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(-42, result) + end + + def test_consume_float_literal + parser = parse_context.new_parser('3.14') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(3.14, result) + assert_kind_of(Float, result) + end + + def test_consume_negative_float_literal + parser = parse_context.new_parser('-3.14') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(-3.14, result) + end + + def test_consume_zero_as_integer + parser = parse_context.new_parser('0') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(0, result) + assert_kind_of(Integer, result) + end + + def test_consume_zero_as_float + parser = parse_context.new_parser('0.0') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(0.0, result) + assert_kind_of(Float, result) + end + + def test_consume_nil_literal + parser = parse_context.new_parser('nil') + result = ExpressionConsumer.consume(parser, parse_context) + assert_nil(result) + end + + def test_consume_null_literal + parser = parse_context.new_parser('null') + result = ExpressionConsumer.consume(parser, parse_context) + assert_nil(result) + end + + def test_consume_true_literal + parser = parse_context.new_parser('true') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(true, result) + end + + def test_consume_false_literal + parser = parse_context.new_parser('false') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(false, result) + end + + def test_consume_blank_literal + parser = parse_context.new_parser('blank') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('', result) + end + + def test_consume_empty_literal + parser = parse_context.new_parser('empty') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('', result) + end + + def test_consume_nil_literal_with_lookups + parser = parse_context.new_parser('nil.size') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('nil', result.name) + assert_equal(['size'], result.lookups) + end + + def test_consume_true_literal_with_lookups + parser = parse_context.new_parser('true.size') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('true', result.name) + assert_equal(['size'], result.lookups) + end + + def test_consume_negative_number_parses_as_number + parser = parse_context.new_parser('-5') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(-5, result) + end + + def test_consume_simple_variable + parser = parse_context.new_parser('product') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal([], result.lookups) + end + + def test_consume_variable_with_dot_lookup + parser = parse_context.new_parser('product.title') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal(['title'], result.lookups) + end + + def test_consume_variable_with_multiple_dot_lookups + parser = parse_context.new_parser('product.variants.first') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal(['variants', 'first'], result.lookups) + end + + def test_consume_variable_with_bracket_lookup + parser = parse_context.new_parser('items[0]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('items', result.name) + assert_equal(0, result.lookups[0]) + end + + def test_consume_variable_with_bracket_string_lookup + parser = parse_context.new_parser('items["key"]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('items', result.name) + assert_equal('key', result.lookups[0]) + end + + def test_consume_variable_with_bracket_variable_lookup + parser = parse_context.new_parser('items[index]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('index', result.lookups[0].name) + end + + def test_consume_variable_with_mixed_lookups + parser = parse_context.new_parser('product.variants[0].title') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal(3, result.lookups.length) + assert_equal('variants', result.lookups[0]) + assert_equal(0, result.lookups[1]) + assert_equal('title', result.lookups[2]) + end + + def test_consume_bracket_notation_without_variable + parser = parse_context.new_parser('[0]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal(0, result.name) + end + + def test_consume_bracket_notation_with_lookups + parser = parse_context.new_parser('[0].title') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal(0, result.name) + assert_equal(['title'], result.lookups) + end + + def test_consume_bracket_notation_with_bracket_lookups + parser = parse_context.new_parser('[0][1]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_equal(0, result.name) + assert_equal(1, result.lookups[0]) + end + + def test_consume_range_with_integer_literals + parser = parse_context.new_parser('(1..5)') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(Range, result) + assert_equal(1..5, result) + end + + def test_consume_range_with_negative_integers + parser = parse_context.new_parser('(-5..-1)') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(Range, result) + assert_equal(-5..-1, result) + end + + def test_consume_range_with_variable_start + parser = parse_context.new_parser('(start..10)') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(RangeLookup, result) + assert_kind_of(VariableLookup, result.start_obj) + assert_equal(10, result.end_obj) + end + + def test_consume_range_with_variable_end + parser = parse_context.new_parser('(1..end)') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(RangeLookup, result) + assert_equal(1, result.start_obj) + assert_kind_of(VariableLookup, result.end_obj) + end + + def test_consume_range_with_both_variables + parser = parse_context.new_parser('(start..end)') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(RangeLookup, result) + assert_kind_of(VariableLookup, result.start_obj) + assert_kind_of(VariableLookup, result.end_obj) + end + + def test_consume_range_with_variable_lookups + parser = parse_context.new_parser('(start.value..end.value)') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(RangeLookup, result) + assert_equal(['value'], result.start_obj.lookups) + end + + def test_consume_command_method_size_sets_flag + parser = parse_context.new_parser('items.size') + result = ExpressionConsumer.consume(parser, parse_context) + assert(result.lookup_command?(0)) + end + + def test_consume_command_method_first_sets_flag + parser = parse_context.new_parser('items.first') + result = ExpressionConsumer.consume(parser, parse_context) + assert(result.lookup_command?(0)) + end + + def test_consume_command_method_last_sets_flag + parser = parse_context.new_parser('items.last') + result = ExpressionConsumer.consume(parser, parse_context) + assert(result.lookup_command?(0)) + end + + def test_consume_non_command_method_does_not_set_flag + parser = parse_context.new_parser('items.title') + result = ExpressionConsumer.consume(parser, parse_context) + refute(result.lookup_command?(0)) + end + + def test_consume_advances_parser_position + parser = parse_context.new_parser('foo.bar') + ExpressionConsumer.consume(parser, parse_context) + assert(parser.look(:end_of_string)) + end + + def test_consume_stops_before_extra_tokens + parser = parse_context.new_parser('foo bar') + ExpressionConsumer.consume(parser, parse_context) + refute(parser.look(:end_of_string)) + end + + def test_consume_with_nested_brackets + parser = parse_context.new_parser('items[items[0]]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result) + assert_kind_of(VariableLookup, result.lookups[0]) + end + + def test_consume_bracket_with_range + parser = parse_context.new_parser('items[(1..3)]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(Range, result.lookups[0]) + assert_equal(1..3, result.lookups[0]) + end + + def test_consume_raises_on_invalid_token_type + parser = parse_context.new_parser('|') + error = assert_raises(SyntaxError) do + ExpressionConsumer.consume(parser, parse_context) + end + assert_match(/is not a valid expression/, error.message) + end + + def test_consume_range_with_string_literals + parser = parse_context.new_parser('("a".."z")') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(Range, result) + assert_equal(0..0, result) + end + + def test_consume_multiple_command_methods + parser = parse_context.new_parser('items.first.size.last') + result = ExpressionConsumer.consume(parser, parse_context) + assert(result.lookup_command?(0)) + assert(result.lookup_command?(1)) + assert(result.lookup_command?(2)) + end + + def test_consume_dot_after_bracket + parser = parse_context.new_parser('items[0].title') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(0, result.lookups[0]) + assert_equal('title', result.lookups[1]) + end + + def test_consume_bracket_after_dot + parser = parse_context.new_parser('product.variants[0]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal('variants', result.lookups[0]) + assert_equal(0, result.lookups[1]) + end + + def test_consume_multiple_brackets_with_different_types + parser = parse_context.new_parser('a[0]["key"][var]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(0, result.lookups[0]) + assert_equal('key', result.lookups[1]) + assert_kind_of(VariableLookup, result.lookups[2]) + end + + def test_consume_deep_nested_brackets + parser = parse_context.new_parser('a[b[c[d]]]') + result = ExpressionConsumer.consume(parser, parse_context) + inner1 = result.lookups[0] + assert_kind_of(VariableLookup, inner1) + inner2 = inner1.lookups[0] + assert_kind_of(VariableLookup, inner2) + end + + def test_consume_with_spaces_in_range + parser = parse_context.new_parser('( 1 .. 10 )') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(1..10, result) + end + + def test_consume_starting_with_bracket_then_dots + parser = parse_context.new_parser('[0].first.last') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(0, result.name) + assert(result.lookup_command?(0)) + assert(result.lookup_command?(1)) + end + + def test_consume_only_dot_lookups + parser = parse_context.new_parser('a.b.c.d') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(['b', 'c', 'd'], result.lookups) + end + + def test_consume_only_bracket_lookups + parser = parse_context.new_parser('a[0][1][2]') + result = ExpressionConsumer.consume(parser, parse_context) + assert_equal(3, result.lookups.length) + assert_equal(0, result.lookups[0]) + assert_equal(1, result.lookups[1]) + assert_equal(2, result.lookups[2]) + end + + def test_consume_complex_nested_expression + parser = parse_context.new_parser('product.variants[index].title') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(VariableLookup, result.lookups[1]) + assert_equal('index', result.lookups[1].name) + end + + def test_consume_range_with_bracketed_variables + parser = parse_context.new_parser('(items[0]..items[1])') + result = ExpressionConsumer.consume(parser, parse_context) + assert_kind_of(RangeLookup, result) + assert_kind_of(VariableLookup, result.start_obj) + end + + def test_consume_evaluates_correctly + parser = parse_context.new_parser('product') + result = ExpressionConsumer.consume(parser, parse_context) + context = Context.new({ 'product' => 'Test Product' }) + assert_equal('Test Product', context.evaluate(result)) + end + + def test_consume_with_lookups_evaluates_correctly + parser = parse_context.new_parser('product.title') + result = ExpressionConsumer.consume(parser, parse_context) + context = Context.new({ 'product' => { 'title' => 'My Title' } }) + assert_equal('My Title', context.evaluate(result)) + end + + def test_consume_range_evaluates_correctly + parser = parse_context.new_parser('(start..end)') + result = ExpressionConsumer.consume(parser, parse_context) + context = Context.new({ 'start' => 1, 'end' => 5 }) + assert_equal(1..5, context.evaluate(result)) + end + + private + + def parse_context + @parse_context ||= ParseContext.new(environment: Environment.build(error_mode: :rigid)) + end +end diff --git a/test/unit/expression_parser_test.rb b/test/unit/expression_parser_test.rb new file mode 100644 index 000000000..8cc1dd31d --- /dev/null +++ b/test/unit/expression_parser_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ExpressionParserTest < Minitest::Test + include Liquid + + def test_parse_returns_nil_for_empty_string + result = ExpressionParser.parse('', parse_context) + assert_nil(result) + end + + def test_parse_returns_nil_for_whitespace_only + result = ExpressionParser.parse(' ', parse_context) + assert_nil(result) + end + + def test_parse_raises_on_extra_tokens_after_expression + error = assert_raises(SyntaxError) do + ExpressionParser.parse('foo bar', parse_context) + end + assert_match(/Expected end_of_string but found id/, error.message) + end + + def test_parse_string_literal_with_double_quotes + result = ExpressionParser.parse('"hello"', parse_context) + assert_equal('hello', result) + end + + def test_parse_string_literal_with_single_quotes + result = ExpressionParser.parse("'world'", parse_context) + assert_equal('world', result) + end + + def test_parse_integer_literal + result = ExpressionParser.parse('42', parse_context) + assert_equal(42, result) + end + + def test_parse_float_literal + result = ExpressionParser.parse('3.14', parse_context) + assert_equal(3.14, result) + end + + def test_parse_nil_literal + result = ExpressionParser.parse('nil', parse_context) + assert_nil(result) + end + + def test_parse_true_literal + result = ExpressionParser.parse('true', parse_context) + assert_equal(true, result) + end + + def test_parse_simple_variable + result = ExpressionParser.parse('product', parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('product', result.name) + end + + def test_parse_variable_with_dot_lookup + result = ExpressionParser.parse('product.title', parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal(['title'], result.lookups) + end + + def test_parse_variable_with_bracket_lookup + result = ExpressionParser.parse('items[0]', parse_context) + assert_kind_of(VariableLookup, result) + assert_equal('items', result.name) + assert_equal(0, result.lookups[0]) + end + + def test_parse_range_with_integer_literals + result = ExpressionParser.parse('(1..5)', parse_context) + assert_kind_of(Range, result) + assert_equal(1..5, result) + end + + def test_parse_range_with_variables + result = ExpressionParser.parse('(start..end)', parse_context) + assert_kind_of(RangeLookup, result) + end + + def test_parse_validates_end_of_string + result = ExpressionParser.parse('foo', parse_context) + assert_kind_of(VariableLookup, result) + end + + def test_parse_evaluates_correctly + result = ExpressionParser.parse('product.title', parse_context) + context = Context.new({ 'product' => { 'title' => 'My Title' } }) + assert_equal('My Title', context.evaluate(result)) + end + + private + + def parse_context + @parse_context ||= ParseContext.new(environment: Environment.build(error_mode: :rigid)) + end +end From d56e3c50f927c99f1a15d71d90edacafab2a9ac5 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 1 Oct 2025 15:08:44 +0200 Subject: [PATCH 227/343] Use `ExpressionParser` in the `ParseContext` when parsing in `:rigid` mode --- lib/liquid/parse_context.rb | 31 ++++++++++++----------------- test/integration/expression_test.rb | 31 +++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 277d349e9..d314448c0 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -52,26 +52,21 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) def parse_expression(markup) if @error_mode == :rigid - parser = new_parser(markup) - - # Return nil immediately if the markup is empty or contains only - # whitespaces - return if parser.look(:end_of_string) - - expression_string = parser.expression - - # In rigid mode, verify that all tokens have been consumed + # ExpressionParser doesn't use @expression_cache because rigid mode + # must run Lexer and Parser validation on every call to ensure all + # tokens are valid and properly consumed. # - # Extra tokens remaining after the expression indicate invalid syntaxes, - # such as: "product title" (instead of "product.title") - parser.consume(:end_of_string) unless parser.look(:end_of_string) - - # Use Parser for strict token validation, but still return - # Expression objects for compatibility with the rendering pipeline. - markup = expression_string + # The expensive operations (tokenization and validation) cannot be + # cached, while the cheap operation (building Expression objects from + # validated tokens) provides minimal benefit from caching. + # + # Most importantly, caching would skip the validation step entirely, + # which defeats the core purpose of rigid mode: strict validation of + # every expression to catch syntax errors like "product title". + ExpressionParser.parse(markup, self) + else + Expression.parse(markup, @string_scanner, @expression_cache) end - - Expression.parse(markup, @string_scanner, @expression_cache) end def partial=(value) diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 29e456321..4ee164b84 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -42,14 +42,25 @@ def test_range assert_template_result("3..4", "{{ ( 3 .. 4 ) }}") assert_expression_result(1..2, "(1..2)") - assert_match_syntax_error( - "Liquid syntax error (line 1): Invalid expression type 'false' in range expression", - "{{ (false..true) }}", - ) - assert_match_syntax_error( - "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", - "{{ ((1..2)..3) }}", - ) + if Liquid::Environment.default.error_mode == :rigid + assert_match_syntax_error( + 'Invalid expression type in range expression in "{{ (false..true) }}"', + "{{ (false..true) }}", + ) + assert_match_syntax_error( + 'Liquid syntax error (line 1): Invalid expression type in range expression in "{{ ((1..2)..3) }}"', + "{{ ((1..2)..3) }}", + ) + else + assert_match_syntax_error( + "Liquid syntax error (line 1): Invalid expression type 'false' in range expression", + "{{ (false..true) }}", + ) + assert_match_syntax_error( + "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", + "{{ ((1..2)..3) }}", + ) + end end def test_quirky_negative_sign_expression_markup @@ -66,6 +77,7 @@ def test_quirky_negative_sign_expression_markup def test_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid cache = {} template = <<~LIQUID @@ -87,6 +99,7 @@ def test_expression_cache def test_expression_cache_with_true_boolean skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid template = <<~LIQUID {% assign x = 1 %} @@ -111,6 +124,7 @@ def test_expression_cache_with_true_boolean def test_expression_cache_with_lru_redux skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid cache = LruRedux::Cache.new(10) template = <<~LIQUID @@ -132,6 +146,7 @@ def test_expression_cache_with_lru_redux def test_disable_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled + skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid template = <<~LIQUID {% assign x = 1 %} From e413104e78f738aa3e15d7dcb6049514cd0a87c3 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 1 Oct 2025 19:53:20 +0200 Subject: [PATCH 228/343] Remove `ExpressionParser` in favor of `ParseContext#safe_parse` --- lib/liquid.rb | 2 - lib/liquid/expression.rb | 4 + lib/liquid/expression_consumer.rb | 182 ---------- lib/liquid/expression_parser.rb | 51 --- lib/liquid/parse_context.rb | 25 +- lib/liquid/parser.rb | 6 - lib/liquid/tag.rb | 4 + lib/liquid/tags/cycle.rb | 1 - test/integration/expression_test.rb | 27 +- test/integration/tags/cycle_tag_test.rb | 9 +- test/unit/expression_consumer_test.rb | 429 ------------------------ test/unit/expression_parser_test.rb | 102 ------ test/unit/rigid_mode_unit_test.rb | 4 + 13 files changed, 34 insertions(+), 812 deletions(-) delete mode 100644 lib/liquid/expression_consumer.rb delete mode 100644 lib/liquid/expression_parser.rb delete mode 100644 test/unit/expression_consumer_test.rb delete mode 100644 test/unit/expression_parser_test.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index edd2c4974..4d0a71a64 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -80,8 +80,6 @@ module Liquid require 'liquid/range_lookup' require 'liquid/resource_limits' require 'liquid/expression' -require 'liquid/expression_consumer' -require 'liquid/expression_parser' require 'liquid/template' require 'liquid/condition' require 'liquid/utils' diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index adf340f1f..2605c5576 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -28,6 +28,10 @@ class Expression FLOAT_REGEX = /\A(-?\d+)\.\d+\z/ class << self + def safe_parse(parser, ss = StringScanner.new(""), cache = nil) + parse(parser.expression, ss, cache) + end + def parse(markup, ss = StringScanner.new(""), cache = nil) return unless markup diff --git a/lib/liquid/expression_consumer.rb b/lib/liquid/expression_consumer.rb deleted file mode 100644 index a0c76d9b8..000000000 --- a/lib/liquid/expression_consumer.rb +++ /dev/null @@ -1,182 +0,0 @@ -# frozen_string_literal: true - -module Liquid - module ExpressionConsumer - INTEGER_REGEX = /\A(-?\d+)\z/ - FLOAT_REGEX = /\A(-?\d+)\.\d+\z/ - - LITERALS = { - 'nil' => nil, - 'null' => nil, - 'true' => true, - 'false' => false, - 'blank' => '', - 'empty' => '', - }.freeze - - MINUS_VARIABLE_LOOKUP = VariableLookup.parse("-", nil).freeze - - class << self - # Consumes tokens from a Parser instance to build an Expression - # object. - # - # This method reads tokens from the current parser position, - # consuming exactly one complete expression. The parser position is - # advanced past the consumed tokens. - # - # Unlike ExpressionParser.parse, this method does NOT validate that - # all tokens are consumed. It stops after consuming a complete - # expression, leaving any remaining tokens for the caller to handle. - # - # This is the efficient low-level method used by tags that manage - # their own Parser instances and need to consume multiple - # expressions from a single token stream. - # - # Returns an Expression object appropriate for the token type: - # - Literals (nil, true, false, numbers, strings) - # - VariableLookup (variables with optional property/index access) - # - RangeLookup or Range (for range expressions like (1..10)) - # - # Raises SyntaxError if invalid token encountered. - # - # Examples: - # parser = parse_context.new_parser("product.title | upcase") - # expr = ExpressionConsumer.consume(parser, parse_context) - # #=> # - # # Parser is now positioned at the pipe token - # - # parser = parse_context.new_parser("42") - # ExpressionConsumer.consume(parser, parse_context) - # #=> 42 - # - # parser = parse_context.new_parser("items[0]") - # ExpressionConsumer.consume(parser, parse_context) - # #=> # - def consume(parser, parse_context) - token = parser.tokens[parser.point] - - case token[0] - when :string - str = parser.consume(:string) - parse_string(str) - when :number - num_str = parser.consume(:number) - parse_number(num_str) - when :id - parse_id(parser, parse_context) - when :open_square - # Bracket notation: [expression] - parser.consume(:open_square) - inner = consume(parser, parse_context) - parser.consume(:close_square) - lookups = parse_variable_lookups(parser, parse_context) - build_variable_lookup(inner, lookups) - when :open_round - # Range notation: (start..end) - parser.consume(:open_round) - start_obj = consume(parser, parse_context) - parser.consume(:dotdot) - end_obj = consume(parser, parse_context) - parser.consume(:close_round) - build_range_lookup(start_obj, end_obj) - else - raise SyntaxError, "#{token} is not a valid expression" - end - end - - private - - def parse_string(str) - str[1..-2] - end - - def parse_number(num_str) - case num_str - when INTEGER_REGEX then Integer(num_str, 10) - when FLOAT_REGEX then num_str.to_f - else - raise Liquid::SyntaxError, "Invalid expression type in number expression" - end - end - - def parse_id(parser, parse_context) - id_value = parser.consume(:id) - lookups = parse_variable_lookups(parser, parse_context) - - if LITERALS.key?(id_value) - # Case: nil - return LITERALS[id_value] if lookups.empty? - - # Case: nil.size - return build_variable_lookup(id_value, lookups) - end - - if id_value == '-' - # Case (backwards compatibility): - - return MINUS_VARIABLE_LOOKUP if lookups.empty? - - # Case: -var - return build_variable_lookup('-', lookups) - end - - # Case: var - build_variable_lookup(id_value, lookups) - end - - def parse_variable_lookups(parser, parse_context) - lookups = [] - - loop do - if parser.look(:open_square) - parser.consume(:open_square) - lookup = consume(parser, parse_context) - parser.consume(:close_square) - lookups << lookup - next - end - - if parser.look(:dot) - parser.consume(:dot) - id = parser.consume(:id) - lookups << id - next - end - - break - end - - lookups - end - - # todo(guilherme): avoid allocate, simplify this - def build_variable_lookup(name, lookups) - lookup = VariableLookup.allocate - lookup.instance_variable_set(:@name, name) - lookup.instance_variable_set(:@lookups, lookups) - - command_flags = 0 - lookups.each_with_index do |lookup_item, i| - if lookup_item.is_a?(String) && VariableLookup::COMMAND_METHODS.include?(lookup_item) - command_flags |= 1 << i - end - end - lookup.instance_variable_set(:@command_flags, command_flags) - - lookup - end - - # todo(guilherme): use RangeLookup.parse logic, simplify this - def build_range_lookup(start_obj, end_obj) - if !start_obj.respond_to?(:evaluate) && !end_obj.respond_to?(:evaluate) - begin - start_obj.to_i..end_obj.to_i - rescue NoMethodError - raise Liquid::SyntaxError, "Invalid expression type in range expression" - end - else - RangeLookup.new(start_obj, end_obj) - end - end - end - end -end diff --git a/lib/liquid/expression_parser.rb b/lib/liquid/expression_parser.rb deleted file mode 100644 index 8712274bc..000000000 --- a/lib/liquid/expression_parser.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -module Liquid - module ExpressionParser - class << self - # Parses a Liquid expression string into an Expression object using - # strict token-based validation. - # - # This method tokenizes the markup, validates that the expression - # consumes all available tokens (no trailing garbage), and builds - # an appropriate Expression object (literal, VariableLookup, or - # RangeLookup). - # - # Returns nil if the markup is empty or contains only whitespace. - # - # Raises SyntaxError if: - # - Invalid syntax is encountered - # - Extra tokens remain after the expression - # (e.g., "product title" instead of "product.title") - # - # Examples: - # ExpressionParser.parse("product.title", ctx) - # #=> # - # - # ExpressionParser.parse("42", ctx) - # #=> 42 - # - # ExpressionParser.parse("(1..10)", ctx) - # #=> 1..10 - # - # ExpressionParser.parse("", ctx) - # #=> nil - # - # ExpressionParser.parse("product title", ctx) - # #=> raises SyntaxError (extra token "title") - def parse(markup, parse_context) - parser = parse_context.new_parser(markup) - - # Whitespaces only. - return if parser.look(:end_of_string) - - result = ExpressionConsumer.consume(parser, parse_context) - - # Extra tokens after the expression. - parser.consume(:end_of_string) unless parser.look(:end_of_string) - - result - end - end - end -end diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index d314448c0..1c59fe4a6 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -50,23 +50,16 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) ) end + def safe_parse_expression(parser) + Expression.safe_parse(parser) + end + def parse_expression(markup) - if @error_mode == :rigid - # ExpressionParser doesn't use @expression_cache because rigid mode - # must run Lexer and Parser validation on every call to ensure all - # tokens are valid and properly consumed. - # - # The expensive operations (tokenization and validation) cannot be - # cached, while the cheap operation (building Expression objects from - # validated tokens) provides minimal benefit from caching. - # - # Most importantly, caching would skip the validation step entirely, - # which defeats the core purpose of rigid mode: strict validation of - # every expression to catch syntax errors like "product title". - ExpressionParser.parse(markup, self) - else - Expression.parse(markup, @string_scanner, @expression_cache) - end + # todo(guilherme): remove this once rigid mode is fully using safe_parse_expression + # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if @error_mode == :rigid + puts("🚨 parse_expression used in rigid mode") if @error_mode == :rigid + + Expression.parse(markup, @string_scanner, @expression_cache) end def partial=(value) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 24b2c5267..645dfa3a1 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -2,8 +2,6 @@ module Liquid class Parser - attr_reader :tokens - def initialize(input) ss = input.is_a?(StringScanner) ? input : StringScanner.new(input) @tokens = Lexer.tokenize(ss) @@ -84,10 +82,6 @@ def argument str end - def point - @p - end - def variable_lookups str = +"" loop do diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 9ca97d0f3..656d2e47c 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -68,6 +68,10 @@ def blank? private + def safe_parse_expression(parser) + parse_context.safe_parse_expression(parser) + end + def parse_expression(markup) parse_context.parse_expression(markup) end diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 8f4d06890..ad1058bd9 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -56,7 +56,6 @@ def render_to_output_buffer(context, output) # cycle [name:] expression(, expression)* def rigid_parse(markup) - $stderr.puts "using rigid" p = @parse_context.new_parser(markup) if p.look(:id) && p.peek(1) == :colon diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 4ee164b84..918f87c28 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -42,25 +42,14 @@ def test_range assert_template_result("3..4", "{{ ( 3 .. 4 ) }}") assert_expression_result(1..2, "(1..2)") - if Liquid::Environment.default.error_mode == :rigid - assert_match_syntax_error( - 'Invalid expression type in range expression in "{{ (false..true) }}"', - "{{ (false..true) }}", - ) - assert_match_syntax_error( - 'Liquid syntax error (line 1): Invalid expression type in range expression in "{{ ((1..2)..3) }}"', - "{{ ((1..2)..3) }}", - ) - else - assert_match_syntax_error( - "Liquid syntax error (line 1): Invalid expression type 'false' in range expression", - "{{ (false..true) }}", - ) - assert_match_syntax_error( - "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", - "{{ ((1..2)..3) }}", - ) - end + assert_match_syntax_error( + "Liquid syntax error (line 1): Invalid expression type 'false' in range expression", + "{{ (false..true) }}", + ) + assert_match_syntax_error( + "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", + "{{ ((1..2)..3) }}", + ) end def test_quirky_negative_sign_expression_markup diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index 8cd6daac4..8452c4678 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -55,9 +55,10 @@ def test_cycle_tag_with_error_mode end end - with_error_mode(:rigid) do - assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5: 'a', 'b' %}") } - assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5, .4 %}") } - end + skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + # with_error_mode(:rigid) do + # assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5: 'a', 'b' %}") } + # assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5, .4 %}") } + # end end end diff --git a/test/unit/expression_consumer_test.rb b/test/unit/expression_consumer_test.rb deleted file mode 100644 index 99921ec7a..000000000 --- a/test/unit/expression_consumer_test.rb +++ /dev/null @@ -1,429 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class ExpressionConsumerTest < Minitest::Test - include Liquid - - def test_consume_string_literal_with_double_quotes - parser = parse_context.new_parser('"hello"') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('hello', result) - end - - def test_consume_string_literal_with_single_quotes - parser = parse_context.new_parser("'world'") - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('world', result) - end - - def test_consume_string_with_empty_content - parser = parse_context.new_parser('""') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('', result) - end - - def test_consume_single_quote_empty_string - parser = parse_context.new_parser("''") - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('', result) - end - - def test_consume_integer_literal - parser = parse_context.new_parser('42') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(42, result) - assert_kind_of(Integer, result) - end - - def test_consume_negative_integer_literal - parser = parse_context.new_parser('-42') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(-42, result) - end - - def test_consume_float_literal - parser = parse_context.new_parser('3.14') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(3.14, result) - assert_kind_of(Float, result) - end - - def test_consume_negative_float_literal - parser = parse_context.new_parser('-3.14') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(-3.14, result) - end - - def test_consume_zero_as_integer - parser = parse_context.new_parser('0') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(0, result) - assert_kind_of(Integer, result) - end - - def test_consume_zero_as_float - parser = parse_context.new_parser('0.0') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(0.0, result) - assert_kind_of(Float, result) - end - - def test_consume_nil_literal - parser = parse_context.new_parser('nil') - result = ExpressionConsumer.consume(parser, parse_context) - assert_nil(result) - end - - def test_consume_null_literal - parser = parse_context.new_parser('null') - result = ExpressionConsumer.consume(parser, parse_context) - assert_nil(result) - end - - def test_consume_true_literal - parser = parse_context.new_parser('true') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(true, result) - end - - def test_consume_false_literal - parser = parse_context.new_parser('false') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(false, result) - end - - def test_consume_blank_literal - parser = parse_context.new_parser('blank') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('', result) - end - - def test_consume_empty_literal - parser = parse_context.new_parser('empty') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('', result) - end - - def test_consume_nil_literal_with_lookups - parser = parse_context.new_parser('nil.size') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('nil', result.name) - assert_equal(['size'], result.lookups) - end - - def test_consume_true_literal_with_lookups - parser = parse_context.new_parser('true.size') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('true', result.name) - assert_equal(['size'], result.lookups) - end - - def test_consume_negative_number_parses_as_number - parser = parse_context.new_parser('-5') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(-5, result) - end - - def test_consume_simple_variable - parser = parse_context.new_parser('product') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('product', result.name) - assert_equal([], result.lookups) - end - - def test_consume_variable_with_dot_lookup - parser = parse_context.new_parser('product.title') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('product', result.name) - assert_equal(['title'], result.lookups) - end - - def test_consume_variable_with_multiple_dot_lookups - parser = parse_context.new_parser('product.variants.first') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('product', result.name) - assert_equal(['variants', 'first'], result.lookups) - end - - def test_consume_variable_with_bracket_lookup - parser = parse_context.new_parser('items[0]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('items', result.name) - assert_equal(0, result.lookups[0]) - end - - def test_consume_variable_with_bracket_string_lookup - parser = parse_context.new_parser('items["key"]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('items', result.name) - assert_equal('key', result.lookups[0]) - end - - def test_consume_variable_with_bracket_variable_lookup - parser = parse_context.new_parser('items[index]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('index', result.lookups[0].name) - end - - def test_consume_variable_with_mixed_lookups - parser = parse_context.new_parser('product.variants[0].title') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('product', result.name) - assert_equal(3, result.lookups.length) - assert_equal('variants', result.lookups[0]) - assert_equal(0, result.lookups[1]) - assert_equal('title', result.lookups[2]) - end - - def test_consume_bracket_notation_without_variable - parser = parse_context.new_parser('[0]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal(0, result.name) - end - - def test_consume_bracket_notation_with_lookups - parser = parse_context.new_parser('[0].title') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal(0, result.name) - assert_equal(['title'], result.lookups) - end - - def test_consume_bracket_notation_with_bracket_lookups - parser = parse_context.new_parser('[0][1]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_equal(0, result.name) - assert_equal(1, result.lookups[0]) - end - - def test_consume_range_with_integer_literals - parser = parse_context.new_parser('(1..5)') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(Range, result) - assert_equal(1..5, result) - end - - def test_consume_range_with_negative_integers - parser = parse_context.new_parser('(-5..-1)') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(Range, result) - assert_equal(-5..-1, result) - end - - def test_consume_range_with_variable_start - parser = parse_context.new_parser('(start..10)') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(RangeLookup, result) - assert_kind_of(VariableLookup, result.start_obj) - assert_equal(10, result.end_obj) - end - - def test_consume_range_with_variable_end - parser = parse_context.new_parser('(1..end)') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(RangeLookup, result) - assert_equal(1, result.start_obj) - assert_kind_of(VariableLookup, result.end_obj) - end - - def test_consume_range_with_both_variables - parser = parse_context.new_parser('(start..end)') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(RangeLookup, result) - assert_kind_of(VariableLookup, result.start_obj) - assert_kind_of(VariableLookup, result.end_obj) - end - - def test_consume_range_with_variable_lookups - parser = parse_context.new_parser('(start.value..end.value)') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(RangeLookup, result) - assert_equal(['value'], result.start_obj.lookups) - end - - def test_consume_command_method_size_sets_flag - parser = parse_context.new_parser('items.size') - result = ExpressionConsumer.consume(parser, parse_context) - assert(result.lookup_command?(0)) - end - - def test_consume_command_method_first_sets_flag - parser = parse_context.new_parser('items.first') - result = ExpressionConsumer.consume(parser, parse_context) - assert(result.lookup_command?(0)) - end - - def test_consume_command_method_last_sets_flag - parser = parse_context.new_parser('items.last') - result = ExpressionConsumer.consume(parser, parse_context) - assert(result.lookup_command?(0)) - end - - def test_consume_non_command_method_does_not_set_flag - parser = parse_context.new_parser('items.title') - result = ExpressionConsumer.consume(parser, parse_context) - refute(result.lookup_command?(0)) - end - - def test_consume_advances_parser_position - parser = parse_context.new_parser('foo.bar') - ExpressionConsumer.consume(parser, parse_context) - assert(parser.look(:end_of_string)) - end - - def test_consume_stops_before_extra_tokens - parser = parse_context.new_parser('foo bar') - ExpressionConsumer.consume(parser, parse_context) - refute(parser.look(:end_of_string)) - end - - def test_consume_with_nested_brackets - parser = parse_context.new_parser('items[items[0]]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result) - assert_kind_of(VariableLookup, result.lookups[0]) - end - - def test_consume_bracket_with_range - parser = parse_context.new_parser('items[(1..3)]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(Range, result.lookups[0]) - assert_equal(1..3, result.lookups[0]) - end - - def test_consume_raises_on_invalid_token_type - parser = parse_context.new_parser('|') - error = assert_raises(SyntaxError) do - ExpressionConsumer.consume(parser, parse_context) - end - assert_match(/is not a valid expression/, error.message) - end - - def test_consume_range_with_string_literals - parser = parse_context.new_parser('("a".."z")') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(Range, result) - assert_equal(0..0, result) - end - - def test_consume_multiple_command_methods - parser = parse_context.new_parser('items.first.size.last') - result = ExpressionConsumer.consume(parser, parse_context) - assert(result.lookup_command?(0)) - assert(result.lookup_command?(1)) - assert(result.lookup_command?(2)) - end - - def test_consume_dot_after_bracket - parser = parse_context.new_parser('items[0].title') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(0, result.lookups[0]) - assert_equal('title', result.lookups[1]) - end - - def test_consume_bracket_after_dot - parser = parse_context.new_parser('product.variants[0]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal('variants', result.lookups[0]) - assert_equal(0, result.lookups[1]) - end - - def test_consume_multiple_brackets_with_different_types - parser = parse_context.new_parser('a[0]["key"][var]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(0, result.lookups[0]) - assert_equal('key', result.lookups[1]) - assert_kind_of(VariableLookup, result.lookups[2]) - end - - def test_consume_deep_nested_brackets - parser = parse_context.new_parser('a[b[c[d]]]') - result = ExpressionConsumer.consume(parser, parse_context) - inner1 = result.lookups[0] - assert_kind_of(VariableLookup, inner1) - inner2 = inner1.lookups[0] - assert_kind_of(VariableLookup, inner2) - end - - def test_consume_with_spaces_in_range - parser = parse_context.new_parser('( 1 .. 10 )') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(1..10, result) - end - - def test_consume_starting_with_bracket_then_dots - parser = parse_context.new_parser('[0].first.last') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(0, result.name) - assert(result.lookup_command?(0)) - assert(result.lookup_command?(1)) - end - - def test_consume_only_dot_lookups - parser = parse_context.new_parser('a.b.c.d') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(['b', 'c', 'd'], result.lookups) - end - - def test_consume_only_bracket_lookups - parser = parse_context.new_parser('a[0][1][2]') - result = ExpressionConsumer.consume(parser, parse_context) - assert_equal(3, result.lookups.length) - assert_equal(0, result.lookups[0]) - assert_equal(1, result.lookups[1]) - assert_equal(2, result.lookups[2]) - end - - def test_consume_complex_nested_expression - parser = parse_context.new_parser('product.variants[index].title') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(VariableLookup, result.lookups[1]) - assert_equal('index', result.lookups[1].name) - end - - def test_consume_range_with_bracketed_variables - parser = parse_context.new_parser('(items[0]..items[1])') - result = ExpressionConsumer.consume(parser, parse_context) - assert_kind_of(RangeLookup, result) - assert_kind_of(VariableLookup, result.start_obj) - end - - def test_consume_evaluates_correctly - parser = parse_context.new_parser('product') - result = ExpressionConsumer.consume(parser, parse_context) - context = Context.new({ 'product' => 'Test Product' }) - assert_equal('Test Product', context.evaluate(result)) - end - - def test_consume_with_lookups_evaluates_correctly - parser = parse_context.new_parser('product.title') - result = ExpressionConsumer.consume(parser, parse_context) - context = Context.new({ 'product' => { 'title' => 'My Title' } }) - assert_equal('My Title', context.evaluate(result)) - end - - def test_consume_range_evaluates_correctly - parser = parse_context.new_parser('(start..end)') - result = ExpressionConsumer.consume(parser, parse_context) - context = Context.new({ 'start' => 1, 'end' => 5 }) - assert_equal(1..5, context.evaluate(result)) - end - - private - - def parse_context - @parse_context ||= ParseContext.new(environment: Environment.build(error_mode: :rigid)) - end -end diff --git a/test/unit/expression_parser_test.rb b/test/unit/expression_parser_test.rb deleted file mode 100644 index 8cc1dd31d..000000000 --- a/test/unit/expression_parser_test.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class ExpressionParserTest < Minitest::Test - include Liquid - - def test_parse_returns_nil_for_empty_string - result = ExpressionParser.parse('', parse_context) - assert_nil(result) - end - - def test_parse_returns_nil_for_whitespace_only - result = ExpressionParser.parse(' ', parse_context) - assert_nil(result) - end - - def test_parse_raises_on_extra_tokens_after_expression - error = assert_raises(SyntaxError) do - ExpressionParser.parse('foo bar', parse_context) - end - assert_match(/Expected end_of_string but found id/, error.message) - end - - def test_parse_string_literal_with_double_quotes - result = ExpressionParser.parse('"hello"', parse_context) - assert_equal('hello', result) - end - - def test_parse_string_literal_with_single_quotes - result = ExpressionParser.parse("'world'", parse_context) - assert_equal('world', result) - end - - def test_parse_integer_literal - result = ExpressionParser.parse('42', parse_context) - assert_equal(42, result) - end - - def test_parse_float_literal - result = ExpressionParser.parse('3.14', parse_context) - assert_equal(3.14, result) - end - - def test_parse_nil_literal - result = ExpressionParser.parse('nil', parse_context) - assert_nil(result) - end - - def test_parse_true_literal - result = ExpressionParser.parse('true', parse_context) - assert_equal(true, result) - end - - def test_parse_simple_variable - result = ExpressionParser.parse('product', parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('product', result.name) - end - - def test_parse_variable_with_dot_lookup - result = ExpressionParser.parse('product.title', parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('product', result.name) - assert_equal(['title'], result.lookups) - end - - def test_parse_variable_with_bracket_lookup - result = ExpressionParser.parse('items[0]', parse_context) - assert_kind_of(VariableLookup, result) - assert_equal('items', result.name) - assert_equal(0, result.lookups[0]) - end - - def test_parse_range_with_integer_literals - result = ExpressionParser.parse('(1..5)', parse_context) - assert_kind_of(Range, result) - assert_equal(1..5, result) - end - - def test_parse_range_with_variables - result = ExpressionParser.parse('(start..end)', parse_context) - assert_kind_of(RangeLookup, result) - end - - def test_parse_validates_end_of_string - result = ExpressionParser.parse('foo', parse_context) - assert_kind_of(VariableLookup, result) - end - - def test_parse_evaluates_correctly - result = ExpressionParser.parse('product.title', parse_context) - context = Context.new({ 'product' => { 'title' => 'My Title' } }) - assert_equal('My Title', context.evaluate(result)) - end - - private - - def parse_context - @parse_context ||= ParseContext.new(environment: Environment.build(error_mode: :rigid)) - end -end diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb index d1cd615ed..595c6717c 100644 --- a/test/unit/rigid_mode_unit_test.rb +++ b/test/unit/rigid_mode_unit_test.rb @@ -5,6 +5,10 @@ class RigidModeUnitTest < Minitest::Test include Liquid + def setup + skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + end + def test_direct_parse_expression_comparison test_cases = [ 'foo bar', From 75c95d0791724a509027ae9ead327c5d855e8cd8 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 2 Oct 2025 10:24:32 +0200 Subject: [PATCH 229/343] Fix cycle tag - `respond_to?` was returning `false` in the parser switcher because `rigid_parse` was private It was working before because `parse_context` was doing the double-parsing thing, but when we removed that, this test fairly started breaking --- lib/liquid/tags/cycle.rb | 4 ++-- test/integration/tags/cycle_tag_test.rb | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index ad1058bd9..2df55bfba 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -52,8 +52,6 @@ def render_to_output_buffer(context, output) output end - private - # cycle [name:] expression(, expression)* def rigid_parse(markup) p = @parse_context.new_parser(markup) @@ -73,6 +71,8 @@ def rigid_parse(markup) raise_syntax_error(options) if @variables.empty? end + private + # Temporarily until we migrate def strict_parse(markup) lax_parse(markup) diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index 8452c4678..8cd6daac4 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -55,10 +55,9 @@ def test_cycle_tag_with_error_mode end end - skip("todo(guilherme): parse_context.safe_parse_expression in progress...") - # with_error_mode(:rigid) do - # assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5: 'a', 'b' %}") } - # assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5, .4 %}") } - # end + with_error_mode(:rigid) do + assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5: 'a', 'b' %}") } + assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5, .4 %}") } + end end end From 327790cdce43b30411635583f0bd841e33379b68 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 2 Oct 2025 11:29:49 +0200 Subject: [PATCH 230/343] Fix an int the `cycle` tag, add extra unit tests, and updated parser switcher: - Fixed NoMethod error with .peek (using look instead) - Add friendlier error message when {% cycle %} --- lib/liquid/parser_switching.rb | 13 ++++++++----- lib/liquid/tags/cycle.rb | 15 +++++++++++---- lib/liquid/tags/for.rb | 4 ++++ lib/liquid/tags/if.rb | 4 ++++ test/integration/tags/cycle_tag_test.rb | 24 ++++++++++++++++++++---- 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index a978dc43a..78b86b23d 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -2,9 +2,12 @@ module Liquid module ParserSwitching - # Do not use this. Use parse_with_selected_parser instead. - # It's basically doing the same thing, except this will use strict_parse regardless - # of the error mode and fallback only if strict throws. + # Do not use this. + # + # It's basically doing the same thing the {#parse_with_selected_parser}, + # except this will use the strict parser, instead of the rigid parser. + # + # @deprecated Use {#parse_with_selected_parser} instead. def strict_parse_with_error_mode_fallback(markup) strict_parse_with_error_context(markup) rescue SyntaxError => e @@ -26,7 +29,7 @@ def parse_with_selected_parser(markup) when :lax then lax_parse(markup) when :warn begin - strict_parse_with_error_context(markup) + rigid_parse_with_error_context(markup) rescue SyntaxError => e parse_context.warnings << e lax_parse(markup) @@ -37,7 +40,7 @@ def parse_with_selected_parser(markup) private def rigid_parse_with_error_context(markup) - respond_to?(:rigid_parse) ? rigid_parse(markup) : strict_parse(markup) + rigid_parse(markup) rescue SyntaxError => e e.line_number = line_number e.markup_context = markup_context(markup) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 2df55bfba..d4f7166aa 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -52,27 +52,34 @@ def render_to_output_buffer(context, output) output end + private + # cycle [name:] expression(, expression)* def rigid_parse(markup) p = @parse_context.new_parser(markup) - if p.look(:id) && p.peek(1) == :colon + if p.look(:id) && p.look(:colon, 1) @name = p.consume(:id) @is_named = true p.consume(:colon) end @variables = [] + + raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string) + while (var = p.expression) + var = parse_expression(var) @variables << var break unless p.consume?(:comma) end - raise_syntax_error(options) if @variables.empty? + unless @is_named + @name = @variables.to_s + @is_named = !@name.match?(/\w+:0x\h{8}/) + end end - private - # Temporarily until we migrate def strict_parse(markup) lax_parse(markup) diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 6aa308f1c..c2be5db1d 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -111,6 +111,10 @@ def strict_parse(markup) private + def rigid_parse(markup) + strict_parse(markup) + end + def collection_segment(context) offsets = context.registers[:for] ||= {} diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 040fecb84..342374f1c 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -66,6 +66,10 @@ def render_to_output_buffer(context, output) private + def rigid_parse(markup) + strict_parse(markup) + end + def push_block(tag, markup) block = if tag == 'else' ElseCondition.new diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index 8cd6daac4..a40424796 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -46,18 +46,34 @@ def test_cycle_tag_always_resets_cycle assert_template_result("11", template) end + def test_cycle_tag_without_arguments + error = assert_raises(Liquid::SyntaxError) do + Template.parse("{% cycle %}") + end + + assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message) + end + def test_cycle_tag_with_error_mode # QuotedFragment is more permissive than what Parser#expression allows. + temlate1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}" + temlate2 = "{% cycle .5: 'a', 'b' %}" + [:lax, :strict].each do |mode| with_error_mode(mode) do - assert_template_result("a", "{% cycle .5: 'a', 'b' %}") - assert_template_result("b", "{% assign 5 = 'b' %}{% cycle .5, .4 %}") + assert_template_result("b", temlate1) + assert_template_result("a", temlate2) end end with_error_mode(:rigid) do - assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5: 'a', 'b' %}") } - assert_raises(Liquid::SyntaxError) { Template.parse("{% cycle .5, .4 %}") } + error1 = assert_raises(Liquid::SyntaxError) { Template.parse(temlate1) } + error2 = assert_raises(Liquid::SyntaxError) { Template.parse(temlate2) } + + expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/ + + assert_match(expected_error, error1.message) + assert_match(expected_error, error2.message) end end end From e2a15334f077e0107f24b292099bb5996d594f67 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 2 Oct 2025 12:38:03 +0200 Subject: [PATCH 231/343] Fail with trailing elements in the `cycle` tag --- lib/liquid/tags/cycle.rb | 2 ++ test/integration/tags/cycle_tag_test.rb | 36 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index d4f7166aa..745fd3536 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -74,6 +74,8 @@ def rigid_parse(markup) break unless p.consume?(:comma) end + p.consume(:end_of_string) + unless @is_named @name = @variables.to_s @is_named = !@name.match?(/\w+:0x\h{8}/) diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index a40424796..dde8a1bc1 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -76,4 +76,40 @@ def test_cycle_tag_with_error_mode assert_match(expected_error, error2.message) end end + + def test_cycle_with_trailing_elements + assignments = "{% assign a = 'A' %}{% assign n = 'N' %}" + + template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}" + template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}" + template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}" + template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}" + template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}" + + [:lax, :strict].each do |mode| + with_error_mode(mode) do + assert_template_result("a", template1) + assert_template_result("a", template2) + assert_template_result("a", template3) + assert_template_result("N", template4) + assert_template_result("N", template5) + end + end + + with_error_mode(:rigid) do + error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } + error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } + error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) } + error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) } + error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) } + + expected_error = /Expected end_of_string but found/ + + assert_match(expected_error, error1.message) + assert_match(expected_error, error2.message) + assert_match(expected_error, error3.message) + assert_match(expected_error, error4.message) + assert_match(expected_error, error5.message) + end + end end From 65a1c167b33a8d8fe28ec657b6108c7f35258873 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 2 Oct 2025 12:51:42 +0200 Subject: [PATCH 232/343] Add rigid_parse to `case/when` --- lib/liquid/parser_switching.rb | 4 + lib/liquid/tags/case.rb | 49 +++++++++-- test/unit/tags/case_tag_unit_test.rb | 120 +++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 6 deletions(-) diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index 78b86b23d..799250a06 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -37,6 +37,10 @@ def parse_with_selected_parser(markup) end end + def rigid_mode? + parse_context.error_mode == :rigid + end + private def rigid_parse_with_error_context(markup) diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 6b67601fe..bdf329a2b 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -31,12 +31,7 @@ class Case < Block def initialize(tag_name, markup, options) super @blocks = [] - - if markup =~ Syntax - @left = parse_expression(Regexp.last_match(1)) - else - raise SyntaxError, options[:locale].t("errors.syntax.case") - end + parse_with_selected_parser(markup) end def parse(tokens) @@ -91,9 +86,51 @@ def render_to_output_buffer(context, output) private + def rigid_parse(markup) + parser = @parse_context.new_parser(markup) + @left = safe_parse_expression(parser) + parser.consume(:end_of_string) + end + + def strict_parse(markup) + lax_parse(markup) + end + + def lax_parse(markup) + if markup =~ Syntax + @left = parse_expression(Regexp.last_match(1)) + else + raise SyntaxError, options[:locale].t("errors.syntax.case") + end + end + def record_when_condition(markup) body = new_body + if rigid_mode? + parse_rigid_when(markup, body) + else + parse_lax_when(markup, body) + end + end + + def parse_rigid_when(markup, body) + parser = @parse_context.new_parser(markup) + + loop do + expr = safe_parse_expression(parser) + block = Condition.new(@left, '==', expr) + block.attach(body) + @blocks << block + + # Temporarily until support :or lexeme. + break unless parser.id?('or') || parser.consume?(:comma) + end + + parser.consume(:end_of_string) + end + + def parse_lax_when(markup, body) while markup unless markup =~ WhenSyntax raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when") diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb index a94d167f5..687cf9d91 100644 --- a/test/unit/tags/case_tag_unit_test.rb +++ b/test/unit/tags/case_tag_unit_test.rb @@ -9,4 +9,124 @@ def test_case_nodelist template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}') assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end + + def test_case_with_trailing_element + template = <<~LIQUID + {%- case 1 bar -%} + {%- when 1 -%} + one + {%- else -%} + two + {%- endcase -%} + LIQUID + + [:lax, :strict].each do |mode| + with_error_mode(mode) { assert_template_result("one", template) } + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + + assert_match(/Expected end_of_string but found/, error.message) + end + end + + def test_case_when_trailing_element + template = <<~LIQUID + {%- case 1 -%} + {%- when 1 bar -%} + one + {%- else -%} + two + {%- endcase -%} + LIQUID + + [:lax, :strict].each do |mode| + with_error_mode(mode) { assert_template_result("one", template) } + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + + assert_match(/Expected end_of_string but found/, error.message) + end + end + + def test_case_when_with_comma + template = <<~LIQUID + {%- case 1 -%} + {%- when 2, 1 -%} + one + {%- else -%} + two + {%- endcase -%} + LIQUID + + [:lax, :strict, :rigid].each do |mode| + with_error_mode(mode) { assert_template_result("one", template) } + end + end + + def test_case_when_with_or + template = <<~LIQUID + {%- case 1 -%} + {%- when 2 or 1 -%} + one + {%- else -%} + two + {%- endcase -%} + LIQUID + + [:lax, :strict, :rigid].each do |mode| + with_error_mode(mode) { assert_template_result("one", template) } + end + end + + def test_case_with_invalid_expression + template = <<~LIQUID + {%- case foo=>bar -%} + {%- when 'baz' -%} + one + {%- else -%} + two + {%- endcase -%} + LIQUID + assigns = { 'foo' => { 'bar' => 'baz' } } + + [:lax, :strict].each do |mode| + with_error_mode(mode) do + assert_template_result("one", template, assigns) + end + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + + assert_match(/Unexpected character =/, error.message) + end + end + + def test_case_when_with_invalid_expression + template = <<~LIQUID + {%- case 'baz' -%} + {%- when foo=>bar -%} + one + {%- else -%} + two + {%- endcase -%} + LIQUID + assigns = { 'foo' => { 'bar' => 'baz' } } + + [:lax, :strict].each do |mode| + with_error_mode(mode) do + assert_template_result("one", template, assigns) + end + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + + assert_match(/Unexpected character =/, error.message) + end + end end From d77662cd0e60e39caad54541327441bfc707a670 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 2 Oct 2025 12:52:01 +0200 Subject: [PATCH 233/343] Remove unnecessary skips --- test/unit/rigid_mode_unit_test.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb index 595c6717c..1071d483f 100644 --- a/test/unit/rigid_mode_unit_test.rb +++ b/test/unit/rigid_mode_unit_test.rb @@ -5,11 +5,9 @@ class RigidModeUnitTest < Minitest::Test include Liquid - def setup + def test_direct_parse_expression_comparison skip("todo(guilherme): parse_context.safe_parse_expression in progress...") - end - def test_direct_parse_expression_comparison test_cases = [ 'foo bar', 'user.name first', @@ -32,6 +30,8 @@ def test_direct_parse_expression_comparison end def test_comparison_strict_vs_rigid_with_space_separated_lookups + skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + expr = 'product title' ctx_lax = ParseContext.new(environment: lax_env) @@ -51,6 +51,8 @@ def test_comparison_strict_vs_rigid_with_space_separated_lookups end def test_tablerow_limit_with_invalid_expression + skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + template = <<~LIQUID {% tablerow i in (1..10) limit: foo=>bar %}{{ i }}{% endtablerow %} LIQUID @@ -64,6 +66,8 @@ def test_tablerow_limit_with_invalid_expression end def test_tablerow_offset_with_invalid_expression + skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + template = <<~LIQUID {% tablerow i in (1..10) offset: foo=>bar %}{{ i }}{% endtablerow %} LIQUID From 4f7dafbcac0cd177a2fc781609cf4b9f61e89842 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 2 Oct 2025 12:58:14 +0200 Subject: [PATCH 234/343] Use safe_parse_expression instead of parse_expression --- lib/liquid/tags/cycle.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 745fd3536..5a2048fca 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -68,8 +68,7 @@ def rigid_parse(markup) raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string) - while (var = p.expression) - var = parse_expression(var) + while (var = safe_parse_expression(p)) @variables << var break unless p.consume?(:comma) end From 94bbf6ca32295dac13cfed79a2c6a9b109421a4e Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 7 Oct 2025 13:50:48 -0400 Subject: [PATCH 235/343] Make it possible to safe_parse subsets of expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e.g. sometimes you want to only accept strings | lookups. {% render snippetName %} for example. snippetName is a string right now. We don't want safe_parse_expression because this would allow snippetName to be a number, a boolean, etc. But we still want to strict parse this. So what we'll do is use parse_expression(string, safe: true), this is an optional opt-in to say "I know what I'm doing". Usually that's because you're using the output of Parser#something as the input of parse_expression. It is true that Parser#expression is subset of Expression.parse, it is not true of the opposite (e.g. Expression.parse doesn't care about .5 and happily parses that as a global lookup of the variable named "5", Parser#expression throws for that.) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index e5c321dc..9ab350f0 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -48,8 +48,8 @@ module Liquid @@operators end - def self.parse_expression(parse_context, markup) - @@method_literals[markup] || parse_context.parse_expression(markup) + def self.parse_expression(parse_context, markup, safe: false) + @@method_literals[markup] || parse_context.parse_expression(markup, safe: safe) end attr_reader :attachment, :child_condition diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 1c59fe4a..82cf5768 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -51,13 +51,13 @@ module Liquid end def safe_parse_expression(parser) - Expression.safe_parse(parser) + Expression.safe_parse(parser, @string_scanner, @expression_cache) end - def parse_expression(markup) + def parse_expression(markup, safe: false) # todo(guilherme): remove this once rigid mode is fully using safe_parse_expression - # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if @error_mode == :rigid - puts("🚨 parse_expression used in rigid mode") if @error_mode == :rigid + # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if !safe && @error_mode == :rigid + puts("🚨 parse_expression used in rigid mode") if !safe && @error_mode == :rigid Expression.parse(markup, @string_scanner, @expression_cache) end diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 656d2e47..374ee511 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -72,8 +72,8 @@ module Liquid parse_context.safe_parse_expression(parser) end - def parse_expression(markup) - parse_context.parse_expression(markup) + def parse_expression(markup, safe: false) + parse_context.parse_expression(markup, safe: safe) end end end diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index c2be5db1..3182983b 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -93,7 +93,7 @@ module Liquid raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in') collection_name = p.expression - @collection_name = parse_expression(collection_name) + @collection_name = parse_expression(collection_name, safe: true) @name = "#{@variable_name}-#{collection_name}" @reversed = p.id?('reversed') diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 342374f1..e25d6250 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -81,8 +81,8 @@ module Liquid block.attach(new_body) end - def parse_expression(markup) - Condition.parse_expression(parse_context, markup) + def parse_expression(markup, safe: false) + Condition.parse_expression(parse_context, markup, safe: safe) end def lax_parse(markup) @@ -124,9 +124,9 @@ module Liquid end def parse_comparison(p) - a = parse_expression(p.expression) + a = parse_expression(p.expression, safe: true) if (op = p.consume?(:comparison)) - b = parse_expression(p.expression) + b = parse_expression(p.expression, safe: true) Condition.new(a, op, b) else Condition.new(a) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 6cdbfd6f..b72a235b 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -87,10 +87,11 @@ module Liquid def rigid_parse(markup) p = @parse_context.new_parser(markup) - template_name = p.expression + @template_name_expr = safe_parse_expression(p) with_or_for = p.id?("for") || p.id?("with") || nil + @variable_name_expr = nil if with_or_for - variable_name = p.expression + @variable_name_expr = parse_expression(p.consume(:id), safe: true) end alias_name = nil @@ -98,8 +99,6 @@ module Liquid alias_name = p.consume(:id) end - @template_name_expr = parse_expression(template_name) - @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @alias_name = alias_name # optional comma @@ -109,7 +108,7 @@ module Liquid while p.look(:id) key = p.consume p.consume(:colon) - @attributes[key] = parse_expression(p.expression) + @attributes[key] = safe_parse_expression(p) p.consume?(:comma) # optional comma end end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 89c11063..4f716b24 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -88,10 +88,11 @@ module Liquid def rigid_parse(markup) p = @parse_context.new_parser(markup) - template_name = rigid_template_name(p) + @template_name_expr = parse_expression(rigid_template_name(p), safe: true) + @variable_name_expr = nil with_or_for = p.id?("for") || p.id?("with") || nil if with_or_for - variable_name = p.expression + @variable_name_expr = safe_parse_expression(p) end alias_name = nil @@ -99,8 +100,6 @@ module Liquid alias_name = p.consume(:id) end - @template_name_expr = parse_expression(template_name) - @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @alias_name = alias_name @is_for_loop = (with_or_for == FOR) @@ -111,7 +110,7 @@ module Liquid while p.look(:id) key = p.consume p.consume(:colon) - @attributes[key] = parse_expression(p.expression) + @attributes[key] = safe_parse_expression(p) p.consume?(:comma) # optional comma end end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 20957065..a3623bc5 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -65,11 +65,11 @@ module Liquid return if p.look(:end_of_string) - @name = parse_context.parse_expression(p.expression) + @name = parse_context.safe_parse_expression(p) while p.consume?(:pipe) filtername = p.consume(:id) filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY - @filters << parse_filter_expressions(filtername, filterargs) + @filters << parse_filter_expressions(filtername, filterargs, safe: true) end p.consume(:end_of_string) end @@ -122,15 +122,15 @@ module Liquid private - def parse_filter_expressions(filter_name, unparsed_args) + def parse_filter_expressions(filter_name, unparsed_args, safe: false) filter_args = [] keyword_args = nil unparsed_args.each do |a| - if (matches = a.match(JustTagAttributes)) + if (matches = a.match(JustTagAttributes)) # we'll need to fix this keyword_args ||= {} - keyword_args[matches[1]] = parse_context.parse_expression(matches[2]) + keyword_args[matches[1]] = parse_context.parse_expression(matches[2], safe: false) else - filter_args << parse_context.parse_expression(a) + filter_args << parse_context.parse_expression(a, safe: safe) end end result = [filter_name, filter_args] --- lib/liquid/condition.rb | 4 ++-- lib/liquid/parse_context.rb | 8 ++++---- lib/liquid/tag.rb | 4 ++-- lib/liquid/tags/for.rb | 2 +- lib/liquid/tags/if.rb | 8 ++++---- lib/liquid/tags/include.rb | 9 ++++----- lib/liquid/tags/render.rb | 9 ++++----- lib/liquid/variable.rb | 12 ++++++------ 8 files changed, 27 insertions(+), 29 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index e5c321dca..9ab350f07 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -48,8 +48,8 @@ def self.operators @@operators end - def self.parse_expression(parse_context, markup) - @@method_literals[markup] || parse_context.parse_expression(markup) + def self.parse_expression(parse_context, markup, safe: false) + @@method_literals[markup] || parse_context.parse_expression(markup, safe: safe) end attr_reader :attachment, :child_condition diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 1c59fe4a6..82cf5768c 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -51,13 +51,13 @@ def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) end def safe_parse_expression(parser) - Expression.safe_parse(parser) + Expression.safe_parse(parser, @string_scanner, @expression_cache) end - def parse_expression(markup) + def parse_expression(markup, safe: false) # todo(guilherme): remove this once rigid mode is fully using safe_parse_expression - # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if @error_mode == :rigid - puts("🚨 parse_expression used in rigid mode") if @error_mode == :rigid + # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if !safe && @error_mode == :rigid + puts("🚨 parse_expression used in rigid mode") if !safe && @error_mode == :rigid Expression.parse(markup, @string_scanner, @expression_cache) end diff --git a/lib/liquid/tag.rb b/lib/liquid/tag.rb index 656d2e47c..374ee511e 100644 --- a/lib/liquid/tag.rb +++ b/lib/liquid/tag.rb @@ -72,8 +72,8 @@ def safe_parse_expression(parser) parse_context.safe_parse_expression(parser) end - def parse_expression(markup) - parse_context.parse_expression(markup) + def parse_expression(markup, safe: false) + parse_context.parse_expression(markup, safe: safe) end end end diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index c2be5db1d..3182983b4 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -93,7 +93,7 @@ def strict_parse(markup) raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in') collection_name = p.expression - @collection_name = parse_expression(collection_name) + @collection_name = parse_expression(collection_name, safe: true) @name = "#{@variable_name}-#{collection_name}" @reversed = p.id?('reversed') diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 342374f1c..e25d62505 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -81,8 +81,8 @@ def push_block(tag, markup) block.attach(new_body) end - def parse_expression(markup) - Condition.parse_expression(parse_context, markup) + def parse_expression(markup, safe: false) + Condition.parse_expression(parse_context, markup, safe: safe) end def lax_parse(markup) @@ -124,9 +124,9 @@ def parse_binary_comparisons(p) end def parse_comparison(p) - a = parse_expression(p.expression) + a = parse_expression(p.expression, safe: true) if (op = p.consume?(:comparison)) - b = parse_expression(p.expression) + b = parse_expression(p.expression, safe: true) Condition.new(a, op, b) else Condition.new(a) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 6cdbfd6f2..b72a235b3 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -87,10 +87,11 @@ def render_to_output_buffer(context, output) def rigid_parse(markup) p = @parse_context.new_parser(markup) - template_name = p.expression + @template_name_expr = safe_parse_expression(p) with_or_for = p.id?("for") || p.id?("with") || nil + @variable_name_expr = nil if with_or_for - variable_name = p.expression + @variable_name_expr = parse_expression(p.consume(:id), safe: true) end alias_name = nil @@ -98,8 +99,6 @@ def rigid_parse(markup) alias_name = p.consume(:id) end - @template_name_expr = parse_expression(template_name) - @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @alias_name = alias_name # optional comma @@ -109,7 +108,7 @@ def rigid_parse(markup) while p.look(:id) key = p.consume p.consume(:colon) - @attributes[key] = parse_expression(p.expression) + @attributes[key] = safe_parse_expression(p) p.consume?(:comma) # optional comma end end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 89c11063c..4f716b246 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -88,10 +88,11 @@ def render_tag(context, output) def rigid_parse(markup) p = @parse_context.new_parser(markup) - template_name = rigid_template_name(p) + @template_name_expr = parse_expression(rigid_template_name(p), safe: true) + @variable_name_expr = nil with_or_for = p.id?("for") || p.id?("with") || nil if with_or_for - variable_name = p.expression + @variable_name_expr = safe_parse_expression(p) end alias_name = nil @@ -99,8 +100,6 @@ def rigid_parse(markup) alias_name = p.consume(:id) end - @template_name_expr = parse_expression(template_name) - @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @alias_name = alias_name @is_for_loop = (with_or_for == FOR) @@ -111,7 +110,7 @@ def rigid_parse(markup) while p.look(:id) key = p.consume p.consume(:colon) - @attributes[key] = parse_expression(p.expression) + @attributes[key] = safe_parse_expression(p) p.consume?(:comma) # optional comma end end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 209570654..a3623bc53 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -65,11 +65,11 @@ def strict_parse(markup) return if p.look(:end_of_string) - @name = parse_context.parse_expression(p.expression) + @name = parse_context.safe_parse_expression(p) while p.consume?(:pipe) filtername = p.consume(:id) filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY - @filters << parse_filter_expressions(filtername, filterargs) + @filters << parse_filter_expressions(filtername, filterargs, safe: true) end p.consume(:end_of_string) end @@ -122,15 +122,15 @@ def disabled_tags private - def parse_filter_expressions(filter_name, unparsed_args) + def parse_filter_expressions(filter_name, unparsed_args, safe: false) filter_args = [] keyword_args = nil unparsed_args.each do |a| - if (matches = a.match(JustTagAttributes)) + if (matches = a.match(JustTagAttributes)) # we'll need to fix this keyword_args ||= {} - keyword_args[matches[1]] = parse_context.parse_expression(matches[2]) + keyword_args[matches[1]] = parse_context.parse_expression(matches[2], safe: false) else - filter_args << parse_context.parse_expression(a) + filter_args << parse_context.parse_expression(a, safe: safe) end end result = [filter_name, filter_args] From b8958f626dc48f38bfe2d4fede09e78a2af403a1 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 8 Oct 2025 15:42:17 -0400 Subject: [PATCH 236/343] Stricter 1:1 refactor of strict_parse for Variable --- lib/liquid/parser_switching.rb | 10 ++++-- lib/liquid/variable.rb | 60 +++++++++++++++++++++++++++------ test/unit/variable_unit_test.rb | 48 +++++++++++++++++++++++--- 3 files changed, 101 insertions(+), 17 deletions(-) diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index 799250a06..c6b09bc86 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -5,11 +5,17 @@ module ParserSwitching # Do not use this. # # It's basically doing the same thing the {#parse_with_selected_parser}, - # except this will use the strict parser, instead of the rigid parser. + # except this will try the strict parser regardless of the error mode, + # and fall back to the lax parser if the error mode is lax or warn. # # @deprecated Use {#parse_with_selected_parser} instead. def strict_parse_with_error_mode_fallback(markup) - strict_parse_with_error_context(markup) + case parse_context.error_mode + when :rigid + rigid_parse_with_error_context(markup) + else + strict_parse_with_error_context(markup) + end rescue SyntaxError => e case parse_context.error_mode when :rigid diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index a3623bc53..f355d694b 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -54,7 +54,7 @@ def lax_parse(markup) next unless f =~ /\w+/ filtername = Regexp.last_match(0) filterargs = f.scan(FilterArgsRegex).flatten - @filters << parse_filter_expressions(filtername, filterargs) + @filters << lax_parse_filter_expressions(filtername, filterargs) end end end @@ -66,14 +66,14 @@ def strict_parse(markup) return if p.look(:end_of_string) @name = parse_context.safe_parse_expression(p) - while p.consume?(:pipe) - filtername = p.consume(:id) - filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY - @filters << parse_filter_expressions(filtername, filterargs, safe: true) - end + @filters << strict_parse_filter_expressions(p) while p.consume?(:pipe) p.consume(:end_of_string) end + def rigid_parse(markup) + strict_parse(markup) + end + def parse_filterargs(p) # first argument filterargs = [p.argument] @@ -122,15 +122,15 @@ def disabled_tags private - def parse_filter_expressions(filter_name, unparsed_args, safe: false) + def lax_parse_filter_expressions(filter_name, unparsed_args) filter_args = [] keyword_args = nil unparsed_args.each do |a| - if (matches = a.match(JustTagAttributes)) # we'll need to fix this + if (matches = a.match(JustTagAttributes)) keyword_args ||= {} - keyword_args[matches[1]] = parse_context.parse_expression(matches[2], safe: false) + keyword_args[matches[1]] = parse_context.parse_expression(matches[2]) else - filter_args << parse_context.parse_expression(a, safe: safe) + filter_args << parse_context.parse_expression(a) end end result = [filter_name, filter_args] @@ -138,6 +138,46 @@ def parse_filter_expressions(filter_name, unparsed_args, safe: false) result end + # Surprisingly, positional and keyword arguments can be mixed. + # + # filter = filtername [":" filterargs?] + # filterargs = argument ("," argument)* + # argument = (positional_argument | keyword_argument) + # positional_argument = expression + # keyword_argument = id ":" expression + def strict_parse_filter_expressions(p) + filtername = p.consume(:id) + filter_args = [] + keyword_args = {} + + if p.consume?(:colon) + # Parse first argument (no leading comma) + argument(p, filter_args, keyword_args) unless end_of_arguments?(p) + + # Parse remaining arguments (with leading commas) and optional trailing comma + argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p) + end + + result = [filtername, filter_args] + result << keyword_args unless keyword_args.empty? + result + end + + def argument(p, positional_arguments, keyword_arguments) + if p.look(:id) && p.look(:colon, 1) + key = p.consume(:id) + p.consume(:colon) + value = parse_context.safe_parse_expression(p) + keyword_arguments[key] = value + else + positional_arguments << parse_context.safe_parse_expression(p) + end + end + + def end_of_arguments?(p) + p.look(:pipe) || p.look(:end_of_string) + end + def evaluate_filter_expressions(context, filter_args, filter_kwargs) parsed_args = filter_args.map { |expr| context.evaluate(expr) } if filter_kwargs diff --git a/test/unit/variable_unit_test.rb b/test/unit/variable_unit_test.rb index 2cc42e7d3..c8e572c97 100644 --- a/test/unit/variable_unit_test.rb +++ b/test/unit/variable_unit_test.rb @@ -135,14 +135,52 @@ def test_lax_filter_argument_parsing var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax) assert_equal(VariableLookup.new('number_of_comments'), var.name) assert_equal([['pluralize', ['comment', 'comments']]], var.filters) + + # missing does not throws error + create_variable(%(n | f1: ,), error_mode: :lax) + create_variable(%(n | f1: ,| f2), error_mode: :lax) + + # arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':' + var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax) + assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters) + + # positional and kwargs parsing + var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax) + assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) + + # positional and kwargs intermixed (pos1, key1: val1, pos2) + var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax) + assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) end def test_strict_filter_argument_parsing - with_error_mode(:strict) do - assert_raises(SyntaxError) do - create_variable(%( number_of_comments | pluralize: 'comment': 'comments' )) - end - end + # optional colon + var = create_variable(%(n | f1 | f2:), error_mode: :strict) + assert_equal([['f1', []], ['f2', []]], var.filters) + + # missing argument throws error + assert_raises(SyntaxError) { create_variable(%(n | f1: ,), error_mode: :strict) } + assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2), error_mode: :strict) } + + # arg requires colon + assert_raises(SyntaxError) { create_variable(%(n | f1 1), error_mode: :strict) } + + # trailing comma doesn't throw + create_variable(%(n | f1: 1, 2, 3, | f2:), error_mode: :strict) + + # missing comma throws error + assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3), error_mode: :strict) } + + # positional and kwargs parsing + var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :strict) + assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) + + # positional and kwargs intermixed (pos1, key1: val1, pos2) + var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :strict) + assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) + + # string key throws + assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments'), error_mode: :strict) } end def test_output_raw_source_of_variable From b5fbad08c6a38c7eca044d037e76dda8d2f96eef Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 8 Oct 2025 15:50:33 -0400 Subject: [PATCH 237/343] rigid set_attribute in for parsing --- lib/liquid/tags/for.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index 3182983b4..da06d64ad 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -104,7 +104,7 @@ def strict_parse(markup) raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute") end p.consume(:colon) - set_attribute(attribute, p.expression) + set_attribute(attribute, p.expression, safe: true) end p.consume(:end_of_string) end @@ -178,16 +178,16 @@ def render_segment(context, output, segment) output end - def set_attribute(key, expr) + def set_attribute(key, expr, safe: false) case key when 'offset' @from = if expr == 'continue' :continue else - parse_expression(expr) + parse_expression(expr, safe: safe) end when 'limit' - @limit = parse_expression(expr) + @limit = parse_expression(expr, safe: safe) end end From 0bc69b86b75926b90d68b3dda92cee25a4f3330b Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 8 Oct 2025 14:27:01 -0400 Subject: [PATCH 238/343] No longer test `ParseContext` directly on `RigidModeUnitTest` as now the `safe: true` calls are considered safe Test the entire template instead --- lib/liquid/parse_context.rb | 6 +- test/unit/rigid_mode_unit_test.rb | 141 +++++++++++++++++------------- 2 files changed, 85 insertions(+), 62 deletions(-) diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 82cf5768c..1b192d8cb 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -56,8 +56,10 @@ def safe_parse_expression(parser) def parse_expression(markup, safe: false) # todo(guilherme): remove this once rigid mode is fully using safe_parse_expression - # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" if !safe && @error_mode == :rigid - puts("🚨 parse_expression used in rigid mode") if !safe && @error_mode == :rigid + if !safe && @error_mode == :rigid + # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" + puts("🚨 parse_expression used in rigid mode") + end Expression.parse(markup, @string_scanner, @expression_cache) end diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb index 1071d483f..0658c719c 100644 --- a/test/unit/rigid_mode_unit_test.rb +++ b/test/unit/rigid_mode_unit_test.rb @@ -6,57 +6,38 @@ class RigidModeUnitTest < Minitest::Test include Liquid def test_direct_parse_expression_comparison - skip("todo(guilherme): parse_context.safe_parse_expression in progress...") - test_cases = [ - 'foo bar', - 'user.name first', - 'items[0] next', - 'products[0].name extra', + '{{ foo bar }}', + '{{ user.name first }}', + '{{ items[0] next }}', + '{{ products[0].name extra }}', ] - test_cases.each do |expr| - ctx_strict = ParseContext.new(environment: strict_env) - result = ctx_strict.parse_expression(expr) - refute_nil(result, "Strict mode should parse '#{expr}'") - - ctx_rigid = ParseContext.new(environment: rigid_env) - error = assert_raises(SyntaxError) do - ctx_rigid.parse_expression(expr) - end - - assert_match(/Expected end_of_string but found id/, error.message) + test_cases.each do |template| + refute_nil(lax_parse(template)) + assert_raises(SyntaxError) { strict_parse(template) } + assert_raises(SyntaxError) { rigid_parse(template) } end end def test_comparison_strict_vs_rigid_with_space_separated_lookups - skip("todo(guilherme): parse_context.safe_parse_expression in progress...") - - expr = 'product title' - - ctx_lax = ParseContext.new(environment: lax_env) - result_lax = ctx_lax.parse_expression(expr) - assert_equal('product', result_lax.name) - assert_equal(['title'], result_lax.lookups) + template = '{{ product title }}' - ctx_strict = ParseContext.new(environment: strict_env) - result_strict = ctx_strict.parse_expression(expr) - assert_equal('product', result_strict.name) - assert_equal(['title'], result_strict.lookups) + output = lax_parse(template).render({ 'product' => { 'title' => 'Snow' } }) + assert_equal('{"title"=>"Snow"}', output) - ctx_rigid = ParseContext.new(environment: rigid_env) - assert_raises(SyntaxError) do - ctx_rigid.parse_expression(expr) - end + assert_raises(SyntaxError) { strict_parse(template) } + assert_raises(SyntaxError) { rigid_parse(template) } end def test_tablerow_limit_with_invalid_expression - skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + skip template = <<~LIQUID {% tablerow i in (1..10) limit: foo=>bar %}{{ i }}{% endtablerow %} LIQUID + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -66,12 +47,13 @@ def test_tablerow_limit_with_invalid_expression end def test_tablerow_offset_with_invalid_expression - skip("todo(guilherme): parse_context.safe_parse_expression in progress...") + skip template = <<~LIQUID {% tablerow i in (1..10) offset: foo=>bar %}{{ i }}{% endtablerow %} LIQUID + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -87,6 +69,7 @@ def test_cycle_name_with_invalid_expression {% endfor %} LIQUID + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -102,6 +85,7 @@ def test_cycle_variable_with_invalid_expression {% endfor %} LIQUID + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -118,6 +102,7 @@ def test_case_with_invalid_expression {% endcase %} LIQUID + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -129,6 +114,7 @@ def test_case_with_invalid_expression def test_include_template_with_invalid_expression template = "{% include foo=>bar %}" + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -140,6 +126,7 @@ def test_include_template_with_invalid_expression def test_include_with_invalid_expression template = '{% include "snippet" with foo=>bar %}' + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -151,6 +138,7 @@ def test_include_with_invalid_expression def test_include_attribute_with_invalid_expression template = '{% include "snippet", key: foo=>bar %}' + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -162,6 +150,7 @@ def test_include_attribute_with_invalid_expression def test_render_with_invalid_expression template = '{% render "snippet" with foo=>bar %}' + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -173,6 +162,7 @@ def test_render_with_invalid_expression def test_render_attribute_with_invalid_expression template = '{% render "snippet", key: foo=>bar %}' + refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) error = assert_raises(SyntaxError) do @@ -193,9 +183,14 @@ def test_valid_expressions_work_in_rigid_mode } test_cases.each do |template_str, data| - t = rigid_parse(template_str) - result = t.render(data) - assert(result.is_a?(String), "Should render successfully for '#{template_str}'") + lax_result = lax_parse(template_str).render(data) + assert(lax_result.is_a?(String), "Lax mode should render '#{template_str}'") + + strict_result = strict_parse(template_str).render(data) + assert(strict_result.is_a?(String), "Strict mode should render '#{template_str}'") + + rigid_result = rigid_parse(template_str).render(data) + assert(rigid_result.is_a?(String), "Rigid mode should render '#{template_str}'") end end @@ -204,9 +199,14 @@ def test_rigid_mode_with_ranges {% for i in (1..3) %}{{ i }}{% endfor %} LIQUID - t = rigid_parse(template) - result = t.render - assert_equal("123\n", result) + lax_result = lax_parse(template).render + assert_equal("123\n", lax_result) + + strict_result = strict_parse(template).render + assert_equal("123\n", strict_result) + + rigid_result = rigid_parse(template).render + assert_equal("123\n", rigid_result) end def test_rigid_mode_with_variable_ranges @@ -214,9 +214,16 @@ def test_rigid_mode_with_variable_ranges {% for i in (start..end) %}{{ i }}{% endfor %} LIQUID - t = rigid_parse(template) - result = t.render({ 'start' => 1, 'end' => 3 }) - assert_equal("123\n", result) + data = { 'start' => 1, 'end' => 3 } + + lax_result = lax_parse(template).render(data) + assert_equal("123\n", lax_result) + + strict_result = strict_parse(template).render(data) + assert_equal("123\n", strict_result) + + rigid_result = rigid_parse(template).render(data) + assert_equal("123\n", rigid_result) end def test_rigid_mode_valid_filters @@ -224,9 +231,14 @@ def test_rigid_mode_valid_filters {{ "hello" | upcase | prepend: "Say: " }} LIQUID - t = rigid_parse(template) - result = t.render - assert_equal("Say: HELLO\n", result) + lax_result = lax_parse(template).render + assert_equal("Say: HELLO\n", lax_result) + + strict_result = strict_parse(template).render + assert_equal("Say: HELLO\n", strict_result) + + rigid_result = rigid_parse(template).render + assert_equal("Say: HELLO\n", rigid_result) end def test_rigid_mode_valid_filter_with_correct_variable_args @@ -234,39 +246,48 @@ def test_rigid_mode_valid_filter_with_correct_variable_args {{ "hello" | append: world.name }} LIQUID - t = rigid_parse(template) - result = t.render({ 'world' => { 'name' => ' world' } }) - assert_equal("hello world\n", result) + data = { 'world' => { 'name' => ' world' } } + + lax_result = lax_parse(template).render(data) + assert_equal("hello world\n", lax_result) + + strict_result = strict_parse(template).render(data) + assert_equal("hello world\n", strict_result) + + rigid_result = rigid_parse(template).render(data) + assert_equal("hello world\n", rigid_result) end def test_empty_expression_handling - ctx_rigid = ParseContext.new(environment: rigid_env) - result = ctx_rigid.parse_expression('') - assert_nil(result) + ctx_rigid = ParseContext.new(environment: rigid) - result = ctx_rigid.parse_expression(' ') - assert_nil(result) + assert_nil(ctx_rigid.parse_expression('', safe: true)) + assert_nil(ctx_rigid.parse_expression(' ', safe: true)) end private def rigid_parse(source) - Template.parse(source, environment: rigid_env) + Template.parse(source, environment: rigid) end def strict_parse(source) - Template.parse(source, environment: strict_env) + Template.parse(source, environment: strict) + end + + def lax_parse(source) + Template.parse(source, environment: lax) end - def lax_env + def lax Environment.build(error_mode: :lax) end - def rigid_env + def rigid Environment.build(error_mode: :rigid) end - def strict_env + def strict Environment.build(error_mode: :strict) end end From 524802543974106c7f6dfef7dc00ee61c56eaa7c Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 8 Oct 2025 14:46:01 -0400 Subject: [PATCH 239/343] Remove redundant tests where rigid and strict modes have the same behavior --- test/unit/rigid_mode_unit_test.rb | 152 ++++-------------------------- 1 file changed, 16 insertions(+), 136 deletions(-) diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb index 0658c719c..cb0de7c44 100644 --- a/test/unit/rigid_mode_unit_test.rb +++ b/test/unit/rigid_mode_unit_test.rb @@ -5,31 +5,6 @@ class RigidModeUnitTest < Minitest::Test include Liquid - def test_direct_parse_expression_comparison - test_cases = [ - '{{ foo bar }}', - '{{ user.name first }}', - '{{ items[0] next }}', - '{{ products[0].name extra }}', - ] - - test_cases.each do |template| - refute_nil(lax_parse(template)) - assert_raises(SyntaxError) { strict_parse(template) } - assert_raises(SyntaxError) { rigid_parse(template) } - end - end - - def test_comparison_strict_vs_rigid_with_space_separated_lookups - template = '{{ product title }}' - - output = lax_parse(template).render({ 'product' => { 'title' => 'Snow' } }) - assert_equal('{"title"=>"Snow"}', output) - - assert_raises(SyntaxError) { strict_parse(template) } - assert_raises(SyntaxError) { rigid_parse(template) } - end - def test_tablerow_limit_with_invalid_expression skip @@ -72,9 +47,8 @@ def test_cycle_name_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -88,9 +62,8 @@ def test_cycle_variable_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -105,9 +78,8 @@ def test_case_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -117,9 +89,8 @@ def test_include_template_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -129,9 +100,8 @@ def test_include_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -141,9 +111,8 @@ def test_include_attribute_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -153,9 +122,8 @@ def test_render_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end + error = assert_raises(SyntaxError) { rigid_parse(template) } + assert_match(/Unexpected character =/, error.message) end @@ -165,97 +133,9 @@ def test_render_attribute_with_invalid_expression refute_nil(lax_parse(template)) refute_nil(strict_parse(template)) - error = assert_raises(SyntaxError) do - rigid_parse(template) - end - assert_match(/Unexpected character =/, error.message) - end - - def test_valid_expressions_work_in_rigid_mode - test_cases = { - '{{ foo }}' => { 'foo' => 'bar' }, - '{{ foo.bar }}' => { 'foo' => { 'bar' => 'baz' } }, - '{{ items[0] }}' => { 'items' => ['first', 'second'] }, - '{{ product.variants[0].title }}' => { 'product' => { 'variants' => [{ 'title' => 'Small' }] } }, - '{{ "hello" }}' => {}, - '{{ 42 }}' => {}, - '{{ 3.14 }}' => {}, - } - - test_cases.each do |template_str, data| - lax_result = lax_parse(template_str).render(data) - assert(lax_result.is_a?(String), "Lax mode should render '#{template_str}'") - - strict_result = strict_parse(template_str).render(data) - assert(strict_result.is_a?(String), "Strict mode should render '#{template_str}'") - - rigid_result = rigid_parse(template_str).render(data) - assert(rigid_result.is_a?(String), "Rigid mode should render '#{template_str}'") - end - end - - def test_rigid_mode_with_ranges - template = <<~LIQUID - {% for i in (1..3) %}{{ i }}{% endfor %} - LIQUID - - lax_result = lax_parse(template).render - assert_equal("123\n", lax_result) - - strict_result = strict_parse(template).render - assert_equal("123\n", strict_result) - - rigid_result = rigid_parse(template).render - assert_equal("123\n", rigid_result) - end - - def test_rigid_mode_with_variable_ranges - template = <<~LIQUID - {% for i in (start..end) %}{{ i }}{% endfor %} - LIQUID - - data = { 'start' => 1, 'end' => 3 } + error = assert_raises(SyntaxError) { rigid_parse(template) } - lax_result = lax_parse(template).render(data) - assert_equal("123\n", lax_result) - - strict_result = strict_parse(template).render(data) - assert_equal("123\n", strict_result) - - rigid_result = rigid_parse(template).render(data) - assert_equal("123\n", rigid_result) - end - - def test_rigid_mode_valid_filters - template = <<~LIQUID - {{ "hello" | upcase | prepend: "Say: " }} - LIQUID - - lax_result = lax_parse(template).render - assert_equal("Say: HELLO\n", lax_result) - - strict_result = strict_parse(template).render - assert_equal("Say: HELLO\n", strict_result) - - rigid_result = rigid_parse(template).render - assert_equal("Say: HELLO\n", rigid_result) - end - - def test_rigid_mode_valid_filter_with_correct_variable_args - template = <<~LIQUID - {{ "hello" | append: world.name }} - LIQUID - - data = { 'world' => { 'name' => ' world' } } - - lax_result = lax_parse(template).render(data) - assert_equal("hello world\n", lax_result) - - strict_result = strict_parse(template).render(data) - assert_equal("hello world\n", strict_result) - - rigid_result = rigid_parse(template).render(data) - assert_equal("hello world\n", rigid_result) + assert_match(/Unexpected character =/, error.message) end def test_empty_expression_handling From 5ec3008b37d934d71b17e77be45eb3b8e446a436 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 8 Oct 2025 16:35:41 -0400 Subject: [PATCH 240/343] Add rigid parser to `tablerow` tag --- lib/liquid/locales/en.yml | 1 + lib/liquid/tags/table_row.rb | 38 ++++ test/integration/tags/table_row_test.rb | 257 ++++++++++++++++++++++++ test/unit/rigid_mode_unit_test.rb | 30 ++- 4 files changed, 320 insertions(+), 6 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index cd1607b3c..b99d490c8 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -20,6 +20,7 @@ invalid_template_encoding: "Invalid template encoding" render: "Syntax error in tag 'render' - Template name must be a quoted string" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" + table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range" tag_never_closed: "'%{block_name}' tag was never closed" tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}" unexpected_else: "%{block_name} tag does not expect 'else' tag" diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 6767e4fb5..11aa4ef50 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -25,11 +25,49 @@ module Liquid # @liquid_optional_param range [untyped] A custom numeric range to iterate over. class TableRow < Block Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o + ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze attr_reader :variable_name, :collection_name, :attributes def initialize(tag_name, markup, options) super + parse_with_selected_parser(markup) + end + + def rigid_parse(markup) + p = @parse_context.new_parser(markup) + + @variable_name = p.consume(:id) + + unless p.id?("in") + raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") + end + + @collection_name = safe_parse_expression(p) + + # optional comma + p.consume?(:comma) + + @attributes = {} + while p.look(:id) + key = p.consume + unless ALLOWED_ATTRIBUTES.include?(key) + raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key) + end + + p.consume(:colon) + @attributes[key] = safe_parse_expression(p) + p.consume?(:comma) # optional comma + end + + p.consume(:end_of_string) + end + + def strict_parse(markup) + lax_parse(markup) + end + + def lax_parse(markup) if markup =~ Syntax @variable_name = Regexp.last_match(1) @collection_name = parse_expression(Regexp.last_match(2)) diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index ddc0877df..ad12d76f1 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -255,4 +255,261 @@ def test_table_row_does_not_leak_interrupts template, ) end + + def test_tablerow_with_cols_attribute_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 123 + 456 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_limit_attribute_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 123 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_offset_attribute_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 345 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_range_attribute_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 123 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_multiple_attributes_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 23 + 45 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_variable_collection_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 12 + 34 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render('numbers' => [1, 2, 3, 4]) + assert_equal(expected, result) + end + + def test_tablerow_with_dotted_access_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 12 + 34 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render('obj' => { 'numbers' => [1, 2, 3, 4] }) + assert_equal(expected, result) + end + + def test_tablerow_with_bracketed_access_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 1020 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render('obj' => { 'numbers' => [10, 20] }) + assert_equal(expected, result) + end + + def test_tablerow_without_attributes_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..3) %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 123 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_trailing_comma_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in (1..3) cols: 2, %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 12 + 3 + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render + assert_equal(expected, result) + end + + def test_tablerow_with_invalid_attribute_name_in_rigid_mode + template = '{% tablerow i in (1..10) invalid_attr: 5 %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: Invalid attribute 'invalid_attr' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid_attr: 5\"", error.message) + end + + def test_tablerow_with_invalid_expression_in_limit_in_rigid_mode + template = '{% tablerow i in (1..10) limit: foo=>bar %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) limit: foo=>bar\"", error.message) + end + + def test_tablerow_with_invalid_expression_in_offset_in_rigid_mode + template = '{% tablerow i in (1..10) offset: foo=>bar %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) offset: foo=>bar\"", error.message) + end + + def test_tablerow_with_invalid_expression_in_cols_in_rigid_mode + template = '{% tablerow i in (1..10) cols: foo=>bar %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) cols: foo=>bar\"", error.message) + end + + def test_tablerow_with_invalid_expression_in_range_in_rigid_mode + template = '{% tablerow i in (1..10) range: foo=>bar %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) range: foo=>bar\"", error.message) + end + + def test_tablerow_without_in_keyword_in_rigid_mode + template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message) + end + + def test_tablerow_with_multiple_invalid_attributes_reports_first_in_rigid_mode + template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}' + error = assert_raises(SyntaxError) do + Template.parse(template, environment: rigid_environment) + end + assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message) + end + + def test_tablerow_with_empty_collection_in_rigid_mode + template = <<~LIQUID.chomp + {% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + + OUTPUT + + result = Template.parse(template, environment: rigid_environment).render('empty_array' => []) + assert_equal(expected, result) + end + + def test_tablerow_lax_mode_still_accepts_invalid_attributes + template = <<~LIQUID.chomp + {% tablerow i in (1..3) invalid_attr: 5 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 123 + OUTPUT + + result = Template.parse(template, environment: lax_environment).render + assert_equal(expected, result) + end + + def test_tablerow_strict_mode_still_accepts_invalid_attributes + template = <<~LIQUID.chomp + {% tablerow i in (1..3) invalid_attr: 5 %}{{ i }}{% endtablerow %} + LIQUID + + expected = <<~OUTPUT + + 123 + OUTPUT + + result = Template.parse(template, environment: strict_environment).render + assert_equal(expected, result) + end + + private + + def rigid_environment + Environment.build(error_mode: :rigid) + end + + def strict_environment + Environment.build(error_mode: :strict) + end + + def lax_environment + Environment.build(error_mode: :lax) + end end diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb index cb0de7c44..60ac0423f 100644 --- a/test/unit/rigid_mode_unit_test.rb +++ b/test/unit/rigid_mode_unit_test.rb @@ -6,10 +6,10 @@ class RigidModeUnitTest < Minitest::Test include Liquid def test_tablerow_limit_with_invalid_expression - skip - template = <<~LIQUID - {% tablerow i in (1..10) limit: foo=>bar %}{{ i }}{% endtablerow %} + {% tablerow i in (1..10) limit: foo=>bar %} + {{ i }} + {% endtablerow %} LIQUID refute_nil(lax_parse(template)) @@ -22,10 +22,10 @@ def test_tablerow_limit_with_invalid_expression end def test_tablerow_offset_with_invalid_expression - skip - template = <<~LIQUID - {% tablerow i in (1..10) offset: foo=>bar %}{{ i }}{% endtablerow %} + {% tablerow i in (1..10) offset: foo=>bar %} + {{ i }} + {% endtablerow %} LIQUID refute_nil(lax_parse(template)) @@ -37,6 +37,24 @@ def test_tablerow_offset_with_invalid_expression assert_match(/Unexpected character =/, error.message) end + def test_tablerow_with_invalid_attribute + template = <<~LIQUID + {% tablerow i in (1..10) invalid_attr: 5 %} + {{ i }} + {% endtablerow %} + LIQUID + + refute_nil(lax_parse(template)) + refute_nil(strict_parse(template)) + + error = assert_raises(SyntaxError) do + rigid_parse(template) + end + + assert_match(/Invalid attribute 'invalid_attr'/, error.message) + assert_match(/Valid attributes are cols, limit, offset, and range/, error.message) + end + def test_cycle_name_with_invalid_expression template = <<~LIQUID {% for i in (1..3) %} From 5660ce6945c5feb1591000d1ca8a0fee35d8ea3f Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 14 Oct 2025 14:57:07 -0400 Subject: [PATCH 241/343] render end of string is not optional --- lib/liquid/tags/include.rb | 2 ++ lib/liquid/tags/render.rb | 2 ++ test/integration/tags/include_tag_test.rb | 4 ++++ test/integration/tags/render_tag_test.rb | 4 ++++ 4 files changed, 12 insertions(+) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index b72a235b3..e9049e65f 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -111,6 +111,8 @@ def rigid_parse(markup) @attributes[key] = safe_parse_expression(p) p.consume?(:comma) # optional comma end + + p.consume(:end_of_string) end def strict_parse(markup) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 4f716b246..4df5b954c 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -113,6 +113,8 @@ def rigid_parse(markup) @attributes[key] = safe_parse_expression(p) p.consume?(:comma) # optional comma end + + p.consume(:end_of_string) end def rigid_template_name(p) diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index e8cd68576..9f92f1b2c 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -220,6 +220,10 @@ def test_rigid_parsing_errors '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', error_mode: mode, ) + assert_syntax_error( + '{% include "snippet" | filter %}', + error_mode: mode, + ) end end diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index b6cd5cfb6..15a0adc22 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -121,6 +121,10 @@ def test_rigid_parsing_errors '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', error_mode: mode, ) + assert_syntax_error( + '{% render "snippet" | filter %}', + error_mode: mode, + ) end end From 2ba81b3f1aba12d0ed9f5bb7567263c4d29e37a8 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 16 Oct 2025 11:13:20 -0400 Subject: [PATCH 242/343] Fix alias parsing --- lib/liquid/tags/render.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 4df5b954c..efe83b0d3 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -96,7 +96,7 @@ def rigid_parse(markup) end alias_name = nil - if p.consume?(:as) + if p.id?("as") alias_name = p.consume(:id) end From 902ff978a60c2124fe45fad1e511cb4e161b5ca4 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 16 Oct 2025 14:14:53 -0400 Subject: [PATCH 243/343] Fixup include parsing of with expression --- lib/liquid/tags/include.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index e9049e65f..8dc9567a5 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -91,11 +91,11 @@ def rigid_parse(markup) with_or_for = p.id?("for") || p.id?("with") || nil @variable_name_expr = nil if with_or_for - @variable_name_expr = parse_expression(p.consume(:id), safe: true) + @variable_name_expr = safe_parse_expression(p) end alias_name = nil - if p.consume?(:as) + if p.id?("as") alias_name = p.consume(:id) end From 1be1e36a8d7d8bbdaad6795131706d998d130847 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Mon, 20 Oct 2025 13:54:36 -0400 Subject: [PATCH 244/343] Fixup cycle rigid parsing to be backwards compatible --- lib/liquid/tags/cycle.rb | 42 ++++++++----- test/integration/tags/cycle_tag_test.rb | 78 ++++++++++++++++++------- 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 5a2048fca..60f44d04d 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -58,19 +58,27 @@ def render_to_output_buffer(context, output) def rigid_parse(markup) p = @parse_context.new_parser(markup) - if p.look(:id) && p.look(:colon, 1) - @name = p.consume(:id) - @is_named = true - p.consume(:colon) - end - @variables = [] raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string) - while (var = safe_parse_expression(p)) - @variables << var - break unless p.consume?(:comma) + first_expression = safe_parse_expression(p) + if p.look(:colon) + # cycle name: expr1, expr2, ... + @name = first_expression + @is_named = true + p.consume(:colon) + # After the colon, parse the first variable (required for named cycles) + @variables << maybe_dup_lookup(safe_parse_expression(p)) + else + # cycle expr1, expr2, ... + @variables << maybe_dup_lookup(first_expression) + end + + # Parse remaining comma-separated expressions + while p.consume?(:comma) + break if p.look(:end_of_string) + @variables << maybe_dup_lookup(safe_parse_expression(p)) end p.consume(:end_of_string) @@ -106,14 +114,22 @@ def variables_from_string(markup) var =~ /\s*(#{QuotedFragment})\s*/o next unless Regexp.last_match(1) - # Expression Parser returns cached objects, and we need to dup them to - # start the cycle over for each new cycle call. - # Liquid-C does not have a cache, so we don't need to dup the object. var = parse_expression(Regexp.last_match(1)) - var.is_a?(VariableLookup) ? var.dup : var + maybe_dup_lookup(var) end.compact end + # For backwards compatibility, whenever a lookup is used in an unnamed cycle, + # we make it so that the @variables.to_s produces different strings for cycles + # called with the same arguments (since @variables.to_s is used as the cycle counter key) + # This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value. + # This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter. + # I was really scratching my head about this one, but migrating away from this would be more headache + # than it's worth. So we're keeping this quirk for now. + def maybe_dup_lookup(var) + var.is_a?(VariableLookup) ? var.dup : var + end + class ParseTreeVisitor < Liquid::ParseTreeVisitor def children Array(@node.variables) diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index dde8a1bc1..b0ee69255 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -3,20 +3,11 @@ require 'test_helper' class CycleTagTest < Minitest::Test - def test_simple_cycle - template = <<~LIQUID - {%- cycle '1', '2', '3' -%} - {%- cycle '1', '2', '3' -%} - {%- cycle '1', '2', '3' -%} - LIQUID - - assert_template_result("123", template) - end def test_simple_cycle_inside_for_loop template = <<~LIQUID {%- for i in (1..3) -%} - {% cycle '1', '2', '3' %} + {%- cycle '1', '2', '3' -%} {%- endfor -%} LIQUID @@ -36,14 +27,61 @@ def test_cycle_with_variables_inside_for_loop assert_template_result("123", template) end - def test_cycle_tag_always_resets_cycle + def test_cycle_named_groups_string + template = <<~LIQUID + {%- for i in (1..3) -%} + {%- cycle 'placeholder1': 1, 2, 3 -%} + {%- cycle 'placeholder2': 1, 2, 3 -%} + {%- endfor -%} + LIQUID + + assert_template_result("112233", template) + end + + def test_cycle_named_groups_vlookup + template = <<~LIQUID + {%- assign placeholder1 = 'placeholder1' -%} + {%- assign placeholder2 = 'placeholder2' -%} + {%- for i in (1..3) -%} + {%- cycle placeholder1: 1, 2, 3 -%} + {%- cycle placeholder2: 1, 2, 3 -%} + {%- endfor -%} + LIQUID + + assert_template_result("112233", template) + end + + def test_unnamed_cycle_have_independent_counters_when_used_with_lookups template = <<~LIQUID {%- assign a = "1" -%} - {%- cycle a, "2" -%} - {%- cycle a, "2" -%} + {%- for i in (1..3) -%} + {%- cycle a, "2" -%} + {%- cycle a, "2" -%} + {%- endfor -%} + LIQUID + + assert_template_result("112211", template) + end + + def test_unnamed_cycle_dependent_counter_when_used_with_literal_values + template = <<~LIQUID + {%- cycle "1", "2" -%} + {%- cycle "1", "2" -%} + {%- cycle "1", "2" -%} LIQUID - assert_template_result("11", template) + assert_template_result("121", template) + end + + def test_optional_trailing_comma + template = <<~LIQUID + {%- cycle "1", "2", -%} + {%- cycle "1", "2", -%} + {%- cycle "1", "2", -%} + {%- cycle "1", -%} + LIQUID + + assert_template_result("1211", template) end def test_cycle_tag_without_arguments @@ -56,19 +94,19 @@ def test_cycle_tag_without_arguments def test_cycle_tag_with_error_mode # QuotedFragment is more permissive than what Parser#expression allows. - temlate1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}" - temlate2 = "{% cycle .5: 'a', 'b' %}" + template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}" + template2 = "{% cycle .5: 'a', 'b' %}" [:lax, :strict].each do |mode| with_error_mode(mode) do - assert_template_result("b", temlate1) - assert_template_result("a", temlate2) + assert_template_result("b", template1) + assert_template_result("a", template2) end end with_error_mode(:rigid) do - error1 = assert_raises(Liquid::SyntaxError) { Template.parse(temlate1) } - error2 = assert_raises(Liquid::SyntaxError) { Template.parse(temlate2) } + error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } + error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/ From 34ab3bdc8e0a8dcfda3a7940da024595308cd510 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Mon, 20 Oct 2025 15:00:11 -0400 Subject: [PATCH 245/343] Fix assert_template_result tests not picking up Liquid::Environment.default.error_mode The `rake test` command gave us the impression that we were running all the tests on all the error modes, that was false. --- lib/liquid/tags/cycle.rb | 1 + test/integration/expression_test.rb | 9 +++++++-- test/integration/tags/cycle_tag_test.rb | 1 - test/integration/tags/table_row_test.rb | 9 ++++++--- test/test_helper.rb | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 60f44d04d..51fa9714a 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -78,6 +78,7 @@ def rigid_parse(markup) # Parse remaining comma-separated expressions while p.consume?(:comma) break if p.look(:end_of_string) + @variables << maybe_dup_lookup(safe_parse_expression(p)) end diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 918f87c28..719699001 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -26,8 +26,12 @@ def test_int def test_float assert_template_result("-17.42", "{{ -17.42 }}") assert_template_result("2.5", "{{ 2.5 }}") - assert_expression_result(0.0, "0.....5") - assert_expression_result(0.0, "-0..1") + + with_error_mode(:lax) do + assert_expression_result(0.0, "0.....5") + assert_expression_result(0.0, "-0..1") + end + assert_expression_result(1.5, "1.5") # this is a unfortunate quirky behavior of Liquid @@ -61,6 +65,7 @@ def test_quirky_negative_sign_expression_markup assert_template_result( "", "{{ - 'theme.css' - }}", + error_mode: :lax, ) end diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index b0ee69255..c41fb68e1 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -3,7 +3,6 @@ require 'test_helper' class CycleTagTest < Minitest::Test - def test_simple_cycle_inside_for_loop template = <<~LIQUID {%- for i in (1..3) -%} diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index ad12d76f1..81444945b 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -138,7 +138,7 @@ def test_nil_offset_is_treated_as_zero def test_tablerow_loop_drop_attributes template = <<~LIQUID.chomp - {% tablerow i in (1...2) %} + {% tablerow i in (1..2) %} col: {{ tablerowloop.col }} col0: {{ tablerowloop.col0 }} col_first: {{ tablerowloop.col_first }} @@ -192,12 +192,14 @@ def test_table_row_renders_correct_error_message_for_invalid_parameters assert_template_result( "Liquid error (line 1): invalid integer", '{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}', + error_mode: :warn, render_errors: true, ) assert_template_result( "Liquid error (line 1): invalid integer", '{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}', + error_mode: :warn, render_errors: true, ) @@ -205,18 +207,19 @@ def test_table_row_renders_correct_error_message_for_invalid_parameters "Liquid error (line 1): invalid integer", '{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}', render_errors: true, + error_mode: :warn, ) end def test_table_row_handles_interrupts assert_template_result( "\n 1 \n", - '{% tablerow n in (1...3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}', + '{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}', ) assert_template_result( "\n 1 2 \n 3 \n", - '{% tablerow n in (1...3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}', + '{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}', ) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 4f4447384..69172d470 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -34,7 +34,7 @@ module Assertions def assert_template_result( expected, template, assigns = {}, - message: nil, partials: nil, error_mode: nil, render_errors: false, + message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false, template_factory: nil ) file_system = StubFileSystem.new(partials || {}) From 4cd367d97128fc1e7f67dd15ae92121a4da8e59d Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Oct 2025 11:45:49 +0200 Subject: [PATCH 246/343] * Update `bin/render` script to present an error when no template is passed * Remove `bin/example.liquid` as it's not executable --- README.md | 11 +++++------ bin/example.liquid | 5 ----- bin/render | 36 ++++++++++++++++++++++++++---------- 3 files changed, 31 insertions(+), 21 deletions(-) delete mode 100644 bin/example.liquid diff --git a/README.md b/README.md index 93a726a91..da24e54fa 100644 --- a/README.md +++ b/README.md @@ -99,15 +99,14 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make it very hard to debug and can lead to unexpected behaviour. -Liquid also comes with a stricter parser that can be used when editing templates to give better error messages +Liquid also comes with a parser that can be used when editing templates to give better error messages when templates are invalid. You can enable this new parser like this: ```ruby -Liquid::Environment.default.error_mode = :strict -Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used -Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal -Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. -Liquid::Environment.default.error_mode = :rigid # Uses Parser.new instead of Expression.parse for stricter parsing +Liquid::Environment.default.error_mode = :rigid # Raises a SyntaxError when invalid syntax is used in all tags +Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags +Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal +Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. ``` If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: diff --git a/bin/example.liquid b/bin/example.liquid deleted file mode 100644 index c4a93aa3d..000000000 --- a/bin/example.liquid +++ /dev/null @@ -1,5 +0,0 @@ - - {% tablerow i in (1..10) limit: foo=>bar %} - {{ i }} - {% endtablerow %} -
diff --git a/bin/render b/bin/render index 004c103f0..26d9b53d8 100755 --- a/bin/render +++ b/bin/render @@ -6,12 +6,20 @@ require 'liquid' class VirtualFileSystem def initialize - snippet_1 = '

{{ greating | default: "Hello" }}, {{ name | default: "world" }}!

' - snippet_2 = '{% for i in (1..5) %} > {{ i }}{% endfor %}' + snippet_1 = <<~LIQUID +

+ {{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}! +

+ LIQUID + snippet_2 = <<~LIQUID + {%- for i in (1..5) -%} + > {{ i }} + {%- endfor -%} + LIQUID @templates = { - 'snippet_1' => snippet_1, - 'snippet_2' => snippet_2, + 'snippet-1' => snippet_1, + 'snippet-2' => snippet_2, } end @@ -20,11 +28,19 @@ class VirtualFileSystem end end -error_mode = :strict -# error_mode = :rigid -file = File.read(ARGV[0]) -template = Liquid::Template.parse(file, error_mode: error_mode) +def source + File.read(ARGV[0]) +rescue StandardError + 'Usage: bin/render example/server/templates/index.liquid' +end -template.registers[:file_system] = VirtualFileSystem.new +def assigns + { + 'date' => Time.now, + } +end -puts template.render +puts Liquid::Template + .parse(source, error_mode: :rigid) + .tap { |t| t.registers[:file_system] = VirtualFileSystem.new } + .render(assigns) From 738540a601c5b012af2bb594076be224bab1811e Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Oct 2025 11:48:12 +0200 Subject: [PATCH 247/343] Update infrastructure that handles parsing switching: * Remove development helpers from parse context * Simplify strict_parse_with_error_mode_fallback and update documentation * Add unit tests for `Liquid::Expression` and `Liquid::ParseContext` * Update test helpers to work better with the `:rigid` mode --- lib/liquid/parse_context.rb | 11 ++- lib/liquid/parser_switching.rb | 12 ++- test/integration/expression_test.rb | 33 ++++++- test/test_helper.rb | 8 +- test/unit/parse_context_unit_test.rb | 123 +++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 test/unit/parse_context_unit_test.rb diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 1b192d8cb..476123393 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -55,10 +55,15 @@ def safe_parse_expression(parser) end def parse_expression(markup, safe: false) - # todo(guilherme): remove this once rigid mode is fully using safe_parse_expression if !safe && @error_mode == :rigid - # raise Liquid::InternalError, "parse_expression is not supported in rigid mode" - puts("🚨 parse_expression used in rigid mode") + # parse_expression is a widely used API. To maintain backward + # compatibility while raising awareness about rigid parser standards, + # the safe flag supports API users make a deliberate decision. + # + # In rigid mode, markup MUST come from a string returned by the parser + # (e.g., parser.expression). We're not calling the parser here to + # prevent redundant parser overhead. + raise Liquid::InternalError, "unsafe parse_expression cannot be used in rigid mode" end Expression.parse(markup, @string_scanner, @expression_cache) diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index c6b09bc86..7c662e928 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -6,16 +6,14 @@ module ParserSwitching # # It's basically doing the same thing the {#parse_with_selected_parser}, # except this will try the strict parser regardless of the error mode, - # and fall back to the lax parser if the error mode is lax or warn. + # and fall back to the lax parser if the error mode is lax or warn, + # except when in rigid mode where it uses the rigid parser. # # @deprecated Use {#parse_with_selected_parser} instead. def strict_parse_with_error_mode_fallback(markup) - case parse_context.error_mode - when :rigid - rigid_parse_with_error_context(markup) - else - strict_parse_with_error_context(markup) - end + return rigid_parse_with_error_context(markup) if rigid_mode? + + strict_parse_with_error_context(markup) rescue SyntaxError => e case parse_context.error_mode when :rigid diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index 719699001..fe54bd99b 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -71,7 +71,6 @@ def test_quirky_negative_sign_expression_markup def test_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled - skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid cache = {} template = <<~LIQUID @@ -93,7 +92,6 @@ def test_expression_cache def test_expression_cache_with_true_boolean skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled - skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid template = <<~LIQUID {% assign x = 1 %} @@ -118,7 +116,6 @@ def test_expression_cache_with_true_boolean def test_expression_cache_with_lru_redux skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled - skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid cache = LruRedux::Cache.new(10) template = <<~LIQUID @@ -140,7 +137,6 @@ def test_expression_cache_with_lru_redux def test_disable_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled - skip("Rigid mode does not use Expression caching") if Liquid::Environment.default.error_mode == :rigid template = <<~LIQUID {% assign x = 1 %} @@ -156,6 +152,35 @@ def test_disable_expression_cache assert(parse_context.instance_variable_get(:@expression_cache).nil?) end + def test_safe_parse_with_variable_lookup + parse_context = Liquid::ParseContext.new + parser = parse_context.new_parser('product.title') + result = Liquid::Expression.safe_parse(parser) + + assert_instance_of(Liquid::VariableLookup, result) + assert_equal('product', result.name) + assert_equal(['title'], result.lookups) + end + + def test_safe_parse_with_number + parse_context = Liquid::ParseContext.new + parser = parse_context.new_parser('42') + result = Liquid::Expression.safe_parse(parser) + + assert_equal(42, result) + end + + def test_safe_parse_raises_syntax_error_for_invalid_expression + parse_context = Liquid::ParseContext.new + parser = parse_context.new_parser('') + + error = assert_raises(Liquid::SyntaxError) do + Liquid::Expression.safe_parse(parser) + end + + assert_match(/is not a valid expression/, error.message) + end + private def assert_expression_result(expect, markup, **assigns) diff --git a/test/test_helper.rb b/test/test_helper.rb index 69172d470..a09f22771 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -82,10 +82,12 @@ def with_global_filter(*globals, &blk) Environment.dangerously_override(environment, &blk) end - def with_error_mode(mode) + def with_error_mode(*modes) old_mode = Liquid::Environment.default.error_mode - Liquid::Environment.default.error_mode = mode - yield + modes.each do |mode| + Liquid::Environment.default.error_mode = mode + yield + end ensure Liquid::Environment.default.error_mode = old_mode end diff --git a/test/unit/parse_context_unit_test.rb b/test/unit/parse_context_unit_test.rb new file mode 100644 index 000000000..b1c8fe470 --- /dev/null +++ b/test/unit/parse_context_unit_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ParseContextUnitTest < Minitest::Test + include Liquid + + def test_safe_parse_expression_with_variable_lookup + parser_strict = strict_parse_context.new_parser('product.title') + result_strict = strict_parse_context.safe_parse_expression(parser_strict) + + parser_rigid = rigid_parse_context.new_parser('product.title') + result_rigid = rigid_parse_context.safe_parse_expression(parser_rigid) + + assert_instance_of(VariableLookup, result_strict) + assert_equal('product', result_strict.name) + assert_equal(['title'], result_strict.lookups) + + assert_instance_of(VariableLookup, result_rigid) + assert_equal('product', result_rigid.name) + assert_equal(['title'], result_rigid.lookups) + end + + def test_safe_parse_expression_raises_syntax_error_for_invalid_expression + parser_strict = strict_parse_context.new_parser('') + parser_rigid = rigid_parse_context.new_parser('') + + error_strict = assert_raises(Liquid::SyntaxError) do + strict_parse_context.safe_parse_expression(parser_strict) + end + assert_match(/is not a valid expression/, error_strict.message) + + error_rigid = assert_raises(Liquid::SyntaxError) do + rigid_parse_context.safe_parse_expression(parser_rigid) + end + + assert_match(/is not a valid expression/, error_rigid.message) + end + + def test_parse_expression_with_variable_lookup + result_strict = strict_parse_context.parse_expression('product.title') + + assert_instance_of(VariableLookup, result_strict) + assert_equal('product', result_strict.name) + assert_equal(['title'], result_strict.lookups) + + error = assert_raises(Liquid::InternalError) do + rigid_parse_context.parse_expression('product.title') + end + + assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message) + end + + def test_parse_expression_with_safe_true + result_strict = strict_parse_context.parse_expression('product.title', safe: true) + + assert_instance_of(VariableLookup, result_strict) + assert_equal('product', result_strict.name) + assert_equal(['title'], result_strict.lookups) + + result_rigid = rigid_parse_context.parse_expression('product.title', safe: true) + + assert_instance_of(VariableLookup, result_rigid) + assert_equal('product', result_rigid.name) + assert_equal(['title'], result_rigid.lookups) + end + + def test_parse_expression_with_empty_string + result_strict = strict_parse_context.parse_expression('') + assert_nil(result_strict) + + error = assert_raises(Liquid::InternalError) do + rigid_parse_context.parse_expression('') + end + + assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message) + end + + def test_parse_expression_with_empty_string_and_safe_true + result_strict = strict_parse_context.parse_expression('', safe: true) + assert_nil(result_strict) + + result_rigid = rigid_parse_context.parse_expression('', safe: true) + assert_nil(result_rigid) + end + + def test_safe_parse_expression_advances_parser_pointer + parser = rigid_parse_context.new_parser('foo, bar') + + # safe_parse_expression consumes "foo" + first_result = rigid_parse_context.safe_parse_expression(parser) + assert_instance_of(VariableLookup, first_result) + assert_equal('foo', first_result.name) + + parser.consume(:comma) + + # safe_parse_expression consumes "bar" + second_result = rigid_parse_context.safe_parse_expression(parser) + assert_instance_of(VariableLookup, second_result) + assert_equal('bar', second_result.name) + + parser.consume(:end_of_string) + end + + def test_parse_expression_with_whitespace_in_rigid_mode + result = rigid_parse_context.parse_expression(' ', safe: true) + assert_nil(result) + end + + private + + def strict_parse_context + @strict_parse_context ||= ParseContext.new( + environment: Environment.build(error_mode: :strict), + ) + end + + def rigid_parse_context + @rigid_parse_context ||= ParseContext.new( + environment: Environment.build(error_mode: :rigid), + ) + end +end From a23c71e40b0cee0b74413b5a3ae13c37e3dc9ad9 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Oct 2025 12:05:19 +0200 Subject: [PATCH 248/343] Fix variable to keep it backward-compatible in strict mode * lax_parse - no changes * strict_parse - uses the `lax_parse_filter_expressions` (as it was doing before) * rigid_parse - uses the `rigid_parse_filter_expressions` --- lib/liquid/variable.rb | 17 ++++++-- test/integration/variable_test.rb | 65 +++++++++++++++++++++++++++++++ test/unit/variable_unit_test.rb | 50 ++++++++++++++---------- 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index f355d694b..3fd947a7d 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -66,12 +66,23 @@ def strict_parse(markup) return if p.look(:end_of_string) @name = parse_context.safe_parse_expression(p) - @filters << strict_parse_filter_expressions(p) while p.consume?(:pipe) + while p.consume?(:pipe) + filtername = p.consume(:id) + filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY + @filters << lax_parse_filter_expressions(filtername, filterargs) + end p.consume(:end_of_string) end def rigid_parse(markup) - strict_parse(markup) + @filters = [] + p = @parse_context.new_parser(markup) + + return if p.look(:end_of_string) + + @name = parse_context.safe_parse_expression(p) + @filters << rigid_parse_filter_expressions(p) while p.consume?(:pipe) + p.consume(:end_of_string) end def parse_filterargs(p) @@ -145,7 +156,7 @@ def lax_parse_filter_expressions(filter_name, unparsed_args) # argument = (positional_argument | keyword_argument) # positional_argument = expression # keyword_argument = id ":" expression - def strict_parse_filter_expressions(p) + def rigid_parse_filter_expressions(p) filtername = p.consume(:id) filter_args = [] keyword_args = {} diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 19922c191..740ea9f9a 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -209,4 +209,69 @@ def test_variable_lookup_should_not_hang_with_invalid_syntax end end end + + def test_filter_with_single_trailing_comma + template = '{{ "hello" | append: "world", }}' + + with_error_mode(:strict) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/is not a valid expression/, error.message) + end + + with_error_mode(:rigid) do + assert_template_result('helloworld', template) + end + end + + def test_multiple_filters_with_trailing_commas + template = '{{ "hello" | append: "1", | append: "2", }}' + + with_error_mode(:strict) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/is not a valid expression/, error.message) + end + + with_error_mode(:rigid) do + assert_template_result('hello12', template) + end + end + + def test_filter_with_colon_but_no_arguments + template = '{{ "test" | upcase: }}' + + with_error_mode(:strict) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/is not a valid expression/, error.message) + end + + with_error_mode(:rigid) do + assert_template_result('TEST', template) + end + end + + def test_filter_chain_with_colon_no_args + template = '{{ "test" | append: "x" | upcase: }}' + + with_error_mode(:strict) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/is not a valid expression/, error.message) + end + + with_error_mode(:rigid) do + assert_template_result('TESTX', template) + end + end + + def test_combining_trailing_comma_and_empty_args + template = '{{ "test" | append: "x", | upcase: }}' + + with_error_mode(:strict) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/is not a valid expression/, error.message) + end + + with_error_mode(:rigid) do + assert_template_result('TESTX', template) + end + end end diff --git a/test/unit/variable_unit_test.rb b/test/unit/variable_unit_test.rb index c8e572c97..82ac03302 100644 --- a/test/unit/variable_unit_test.rb +++ b/test/unit/variable_unit_test.rb @@ -154,33 +154,43 @@ def test_lax_filter_argument_parsing end def test_strict_filter_argument_parsing - # optional colon - var = create_variable(%(n | f1 | f2:), error_mode: :strict) - assert_equal([['f1', []], ['f2', []]], var.filters) + with_error_mode(:strict) do + assert_raises(SyntaxError) do + create_variable(%( number_of_comments | pluralize: 'comment': 'comments' )) + end + end + end - # missing argument throws error - assert_raises(SyntaxError) { create_variable(%(n | f1: ,), error_mode: :strict) } - assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2), error_mode: :strict) } + def test_rigid_filter_argument_parsing + with_error_mode(:rigid) do + # optional colon + var = create_variable(%(n | f1 | f2:)) + assert_equal([['f1', []], ['f2', []]], var.filters) - # arg requires colon - assert_raises(SyntaxError) { create_variable(%(n | f1 1), error_mode: :strict) } + # missing argument throws error + assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) } + assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) } - # trailing comma doesn't throw - create_variable(%(n | f1: 1, 2, 3, | f2:), error_mode: :strict) + # arg requires colon + assert_raises(SyntaxError) { create_variable(%(n | f1 1)) } - # missing comma throws error - assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3), error_mode: :strict) } + # trailing comma doesn't throw + create_variable(%(n | f1: 1, 2, 3, | f2:)) - # positional and kwargs parsing - var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :strict) - assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) + # missing comma throws error + assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) } - # positional and kwargs intermixed (pos1, key1: val1, pos2) - var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :strict) - assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) + # positional and kwargs parsing + var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2)) + assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) - # string key throws - assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments'), error_mode: :strict) } + # positional and kwargs intermixed (pos1, key1: val1, pos2) + var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title")) + assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) + + # string key throws + assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) } + end end def test_output_raw_source_of_variable From 018442b7d1ff1e5e0570c4eacf55d8afa27e4a1e Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Oct 2025 12:09:32 +0200 Subject: [PATCH 249/343] Covered changes with more tests, remove redundant cases, and the new `with_error_mode(*modes)` Most of changes update this: ``` [:lax, :strict].each do |mode| with_error_mode(mode) do assert_template_result(... ``` to be this: ``` with_error_mode(:lax, :strict) do assert_template_result(... ``` --- lib/liquid/tags/case.rb | 1 - lib/liquid/tags/cycle.rb | 1 - lib/liquid/template.rb | 4 +- test/integration/tags/cycle_tag_test.rb | 56 +++++-- test/integration/tags/include_tag_test.rb | 57 +++++-- test/integration/tags/render_tag_test.rb | 44 +++-- test/integration/tags/table_row_test.rb | 171 +++++++------------ test/unit/condition_unit_test.rb | 31 ++++ test/unit/rigid_mode_unit_test.rb | 191 ---------------------- test/unit/tags/case_tag_unit_test.rb | 28 ++-- 10 files changed, 228 insertions(+), 356 deletions(-) delete mode 100644 test/unit/rigid_mode_unit_test.rb diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index bdf329a2b..926e4107a 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -123,7 +123,6 @@ def parse_rigid_when(markup, body) block.attach(body) @blocks << block - # Temporarily until support :or lexeme. break unless parser.id?('or') || parser.consume?(:comma) end diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 51fa9714a..639ed9a5b 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -90,7 +90,6 @@ def rigid_parse(markup) end end - # Temporarily until we migrate def strict_parse(markup) lax_parse(markup) end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index acda1e4db..5d349abd6 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -24,8 +24,8 @@ class << self # Sets how strict the parser should be. # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. # :warn is the default and will give deprecation warnings when invalid syntax is used. - # :strict will enforce correct syntax. - # :rigid is stricter even. + # :strict enforces correct syntax for most tags + # :rigid enforces correct syntax for all tags def error_mode=(mode) Deprecations.warn("Template.error_mode=", "Environment#error_mode=") Environment.default.error_mode = mode diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index c41fb68e1..14d8997b4 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -96,11 +96,9 @@ def test_cycle_tag_with_error_mode template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}" template2 = "{% cycle .5: 'a', 'b' %}" - [:lax, :strict].each do |mode| - with_error_mode(mode) do - assert_template_result("b", template1) - assert_template_result("a", template2) - end + with_error_mode(:lax, :strict) do + assert_template_result("b", template1) + assert_template_result("a", template2) end with_error_mode(:rigid) do @@ -123,14 +121,12 @@ def test_cycle_with_trailing_elements template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}" template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}" - [:lax, :strict].each do |mode| - with_error_mode(mode) do - assert_template_result("a", template1) - assert_template_result("a", template2) - assert_template_result("a", template3) - assert_template_result("N", template4) - assert_template_result("N", template5) - end + with_error_mode(:lax, :strict) do + assert_template_result("a", template1) + assert_template_result("a", template2) + assert_template_result("a", template3) + assert_template_result("N", template4) + assert_template_result("N", template5) end with_error_mode(:rigid) do @@ -149,4 +145,38 @@ def test_cycle_with_trailing_elements assert_match(expected_error, error5.message) end end + + def test_cycle_name_with_invalid_expression + template = <<~LIQUID + {% for i in (1..3) %} + {% cycle foo=>bar: "a", "b" %} + {% endfor %} + LIQUID + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end + + def test_cycle_variable_with_invalid_expression + template = <<~LIQUID + {% for i in (1..3) %} + {% cycle foo=>bar, "a", "b" %} + {% endfor %} + LIQUID + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end end diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 9f92f1b2c..b41a98c6e 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -205,24 +205,20 @@ def test_dynamically_choosen_template end def test_rigid_parsing_errors - [:lax, :strict].each do |mode| - with_error_mode(mode) do - assert_template_result( - 'hello value1 value2', - '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', - partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, - ) - end + with_error_mode(:lax, :strict) do + assert_template_result( + 'hello value1 value2', + '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', + partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, + ) end - [:rigid].each do |mode| + with_error_mode(:rigid) do assert_syntax_error( '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', - error_mode: mode, ) assert_syntax_error( '{% include "snippet" | filter %}', - error_mode: mode, ) end end @@ -404,4 +400,43 @@ def test_render_tag_renders_error_with_template_name_from_template_factory render_errors: true, ) end + + def test_include_template_with_invalid_expression + template = "{% include foo=>bar %}" + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end + + def test_include_with_invalid_expression + template = '{% include "snippet" with foo=>bar %}' + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end + + def test_include_attribute_with_invalid_expression + template = '{% include "snippet", key: foo=>bar %}' + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end end # IncludeTagTest diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 15a0adc22..e2f4530a5 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -106,24 +106,20 @@ def test_dynamically_choosen_templates_are_not_allowed end def test_rigid_parsing_errors - [:lax, :strict].each do |mode| - with_error_mode(mode) do - assert_template_result( - 'hello value1 value2', - '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', - partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, - ) - end + with_error_mode(:lax, :strict) do + assert_template_result( + 'hello value1 value2', + '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', + partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, + ) end - [:rigid].each do |mode| + with_error_mode(:rigid) do assert_syntax_error( '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', - error_mode: mode, ) assert_syntax_error( '{% render "snippet" | filter %}', - error_mode: mode, ) end end @@ -318,4 +314,30 @@ def test_render_tag_renders_error_with_template_name_from_template_factory render_errors: true, ) end + + def test_render_with_invalid_expression + template = '{% render "snippet" with foo=>bar %}' + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end + + def test_render_attribute_with_invalid_expression + template = '{% render "snippet", key: foo=>bar %}' + + with_error_mode(:lax, :strict) do + refute_nil(Template.parse(template)) + end + + with_error_mode(:rigid) do + error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end + end end diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 81444945b..0f9b051fe 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -270,8 +270,9 @@ def test_tablerow_with_cols_attribute_in_rigid_mode 456 OUTPUT - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template) + end end def test_tablerow_with_limit_attribute_in_rigid_mode @@ -284,8 +285,9 @@ def test_tablerow_with_limit_attribute_in_rigid_mode 123 OUTPUT - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template) + end end def test_tablerow_with_offset_attribute_in_rigid_mode @@ -298,8 +300,9 @@ def test_tablerow_with_offset_attribute_in_rigid_mode 345 OUTPUT - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template) + end end def test_tablerow_with_range_attribute_in_rigid_mode @@ -312,8 +315,9 @@ def test_tablerow_with_range_attribute_in_rigid_mode 123 OUTPUT - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template) + end end def test_tablerow_with_multiple_attributes_in_rigid_mode @@ -327,8 +331,9 @@ def test_tablerow_with_multiple_attributes_in_rigid_mode 45 OUTPUT - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template) + end end def test_tablerow_with_variable_collection_in_rigid_mode @@ -342,8 +347,9 @@ def test_tablerow_with_variable_collection_in_rigid_mode 34 OUTPUT - result = Template.parse(template, environment: rigid_environment).render('numbers' => [1, 2, 3, 4]) - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] }) + end end def test_tablerow_with_dotted_access_in_rigid_mode @@ -357,8 +363,9 @@ def test_tablerow_with_dotted_access_in_rigid_mode 34 OUTPUT - result = Template.parse(template, environment: rigid_environment).render('obj' => { 'numbers' => [1, 2, 3, 4] }) - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } }) + end end def test_tablerow_with_bracketed_access_in_rigid_mode @@ -371,8 +378,9 @@ def test_tablerow_with_bracketed_access_in_rigid_mode 1020 OUTPUT - result = Template.parse(template, environment: rigid_environment).render('obj' => { 'numbers' => [10, 20] }) - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } }) + end end def test_tablerow_without_attributes_in_rigid_mode @@ -385,79 +393,27 @@ def test_tablerow_without_attributes_in_rigid_mode 123 OUTPUT - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) - end - - def test_tablerow_with_trailing_comma_in_rigid_mode - template = <<~LIQUID.chomp - {% tablerow i in (1..3) cols: 2, %}{{ i }}{% endtablerow %} - LIQUID - - expected = <<~OUTPUT - - 12 - 3 - OUTPUT - - result = Template.parse(template, environment: rigid_environment).render - assert_equal(expected, result) - end - - def test_tablerow_with_invalid_attribute_name_in_rigid_mode - template = '{% tablerow i in (1..10) invalid_attr: 5 %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) - end - assert_equal("Liquid syntax error: Invalid attribute 'invalid_attr' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid_attr: 5\"", error.message) - end - - def test_tablerow_with_invalid_expression_in_limit_in_rigid_mode - template = '{% tablerow i in (1..10) limit: foo=>bar %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) - end - assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) limit: foo=>bar\"", error.message) - end - - def test_tablerow_with_invalid_expression_in_offset_in_rigid_mode - template = '{% tablerow i in (1..10) offset: foo=>bar %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) - end - assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) offset: foo=>bar\"", error.message) - end - - def test_tablerow_with_invalid_expression_in_cols_in_rigid_mode - template = '{% tablerow i in (1..10) cols: foo=>bar %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) + with_error_mode(:rigid) do + assert_template_result(expected, template) end - assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) cols: foo=>bar\"", error.message) - end - - def test_tablerow_with_invalid_expression_in_range_in_rigid_mode - template = '{% tablerow i in (1..10) range: foo=>bar %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) - end - assert_equal("Liquid syntax error: Unexpected character = in \"i in (1..10) range: foo=>bar\"", error.message) end def test_tablerow_without_in_keyword_in_rigid_mode template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) + + with_error_mode(:rigid) do + error = assert_raises(SyntaxError) { Template.parse(template) } + assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message) end - assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message) end def test_tablerow_with_multiple_invalid_attributes_reports_first_in_rigid_mode template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}' - error = assert_raises(SyntaxError) do - Template.parse(template, environment: rigid_environment) + + with_error_mode(:rigid) do + error = assert_raises(SyntaxError) { Template.parse(template) } + assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message) end - assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message) end def test_tablerow_with_empty_collection_in_rigid_mode @@ -470,49 +426,44 @@ def test_tablerow_with_empty_collection_in_rigid_mode OUTPUT - result = Template.parse(template, environment: rigid_environment).render('empty_array' => []) - assert_equal(expected, result) + with_error_mode(:rigid) do + assert_template_result(expected, template, { 'empty_array' => [] }) + end end - def test_tablerow_lax_mode_still_accepts_invalid_attributes - template = <<~LIQUID.chomp - {% tablerow i in (1..3) invalid_attr: 5 %}{{ i }}{% endtablerow %} - LIQUID + def test_tablerow_with_invalid_attribute_strict_vs_rigid + template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}' expected = <<~OUTPUT - 123 + 12345 OUTPUT - result = Template.parse(template, environment: lax_environment).render - assert_equal(expected, result) - end - - def test_tablerow_strict_mode_still_accepts_invalid_attributes - template = <<~LIQUID.chomp - {% tablerow i in (1..3) invalid_attr: 5 %}{{ i }}{% endtablerow %} - LIQUID - - expected = <<~OUTPUT - - 123 - OUTPUT + with_error_mode(:lax, :strict) do + assert_template_result(expected, template) + end - result = Template.parse(template, environment: strict_environment).render - assert_equal(expected, result) + with_error_mode(:rigid) do + error = assert_raises(SyntaxError) { Template.parse(template) } + assert_match(/Invalid attribute 'invalid_attr'/, error.message) + end end - private - - def rigid_environment - Environment.build(error_mode: :rigid) - end + def test_tablerow_with_invalid_expression_strict_vs_rigid + template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}' - def strict_environment - Environment.build(error_mode: :strict) - end + with_error_mode(:lax, :strict) do + expected = <<~OUTPUT + + + OUTPUT + assert_template_result(expected, template) + end - def lax_environment - Environment.build(error_mode: :lax) + with_error_mode(:rigid) do + # Rigid mode validates expression syntax and rejects invalid expressions + error = assert_raises(SyntaxError) { Template.parse(template) } + assert_match(/Unexpected character =/, error.message) + end end end diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index acc71dc4e..f5206ff53 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -166,6 +166,37 @@ def test_default_context_is_deprecated assert_includes(err.lines.map(&:strip), expected) end + def test_parse_expression_in_strict_mode + environment = Environment.build(error_mode: :strict) + parse_context = ParseContext.new(environment: environment) + result = Condition.parse_expression(parse_context, 'product.title') + + assert_instance_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal(['title'], result.lookups) + end + + def test_parse_expression_in_rigid_mode_raises_internal_error + environment = Environment.build(error_mode: :rigid) + parse_context = ParseContext.new(environment: environment) + + error = assert_raises(Liquid::InternalError) do + Condition.parse_expression(parse_context, 'product.title') + end + + assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message) + end + + def test_parse_expression_with_safe_true_in_rigid_mode + environment = Environment.build(error_mode: :rigid) + parse_context = ParseContext.new(environment: environment) + result = Condition.parse_expression(parse_context, 'product.title', safe: true) + + assert_instance_of(VariableLookup, result) + assert_equal('product', result.name) + assert_equal(['title'], result.lookups) + end + private def assert_evaluates_true(left, op, right) diff --git a/test/unit/rigid_mode_unit_test.rb b/test/unit/rigid_mode_unit_test.rb deleted file mode 100644 index 60ac0423f..000000000 --- a/test/unit/rigid_mode_unit_test.rb +++ /dev/null @@ -1,191 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class RigidModeUnitTest < Minitest::Test - include Liquid - - def test_tablerow_limit_with_invalid_expression - template = <<~LIQUID - {% tablerow i in (1..10) limit: foo=>bar %} - {{ i }} - {% endtablerow %} - LIQUID - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) do - rigid_parse(template) - end - assert_match(/Unexpected character =/, error.message) - end - - def test_tablerow_offset_with_invalid_expression - template = <<~LIQUID - {% tablerow i in (1..10) offset: foo=>bar %} - {{ i }} - {% endtablerow %} - LIQUID - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) do - rigid_parse(template) - end - assert_match(/Unexpected character =/, error.message) - end - - def test_tablerow_with_invalid_attribute - template = <<~LIQUID - {% tablerow i in (1..10) invalid_attr: 5 %} - {{ i }} - {% endtablerow %} - LIQUID - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) do - rigid_parse(template) - end - - assert_match(/Invalid attribute 'invalid_attr'/, error.message) - assert_match(/Valid attributes are cols, limit, offset, and range/, error.message) - end - - def test_cycle_name_with_invalid_expression - template = <<~LIQUID - {% for i in (1..3) %} - {% cycle foo=>bar: "a", "b" %} - {% endfor %} - LIQUID - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_cycle_variable_with_invalid_expression - template = <<~LIQUID - {% for i in (1..3) %} - {% cycle foo=>bar, "a", "b" %} - {% endfor %} - LIQUID - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_case_with_invalid_expression - template = <<~LIQUID - {% case foo=>bar %} - {% when 1 %} - one - {% endcase %} - LIQUID - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_include_template_with_invalid_expression - template = "{% include foo=>bar %}" - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_include_with_invalid_expression - template = '{% include "snippet" with foo=>bar %}' - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_include_attribute_with_invalid_expression - template = '{% include "snippet", key: foo=>bar %}' - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_render_with_invalid_expression - template = '{% render "snippet" with foo=>bar %}' - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_render_attribute_with_invalid_expression - template = '{% render "snippet", key: foo=>bar %}' - - refute_nil(lax_parse(template)) - refute_nil(strict_parse(template)) - - error = assert_raises(SyntaxError) { rigid_parse(template) } - - assert_match(/Unexpected character =/, error.message) - end - - def test_empty_expression_handling - ctx_rigid = ParseContext.new(environment: rigid) - - assert_nil(ctx_rigid.parse_expression('', safe: true)) - assert_nil(ctx_rigid.parse_expression(' ', safe: true)) - end - - private - - def rigid_parse(source) - Template.parse(source, environment: rigid) - end - - def strict_parse(source) - Template.parse(source, environment: strict) - end - - def lax_parse(source) - Template.parse(source, environment: lax) - end - - def lax - Environment.build(error_mode: :lax) - end - - def rigid - Environment.build(error_mode: :rigid) - end - - def strict - Environment.build(error_mode: :strict) - end -end diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb index 687cf9d91..c0bc2c344 100644 --- a/test/unit/tags/case_tag_unit_test.rb +++ b/test/unit/tags/case_tag_unit_test.rb @@ -20,8 +20,8 @@ def test_case_with_trailing_element {%- endcase -%} LIQUID - [:lax, :strict].each do |mode| - with_error_mode(mode) { assert_template_result("one", template) } + with_error_mode(:lax, :strict) do + assert_template_result("one", template) end with_error_mode(:rigid) do @@ -41,8 +41,8 @@ def test_case_when_trailing_element {%- endcase -%} LIQUID - [:lax, :strict].each do |mode| - with_error_mode(mode) { assert_template_result("one", template) } + with_error_mode(:lax, :strict) do + assert_template_result("one", template) end with_error_mode(:rigid) do @@ -62,8 +62,8 @@ def test_case_when_with_comma {%- endcase -%} LIQUID - [:lax, :strict, :rigid].each do |mode| - with_error_mode(mode) { assert_template_result("one", template) } + with_error_mode(:lax, :strict, :rigid) do + assert_template_result("one", template) end end @@ -77,8 +77,8 @@ def test_case_when_with_or {%- endcase -%} LIQUID - [:lax, :strict, :rigid].each do |mode| - with_error_mode(mode) { assert_template_result("one", template) } + with_error_mode(:lax, :strict, :rigid) do + assert_template_result("one", template) end end @@ -93,10 +93,8 @@ def test_case_with_invalid_expression LIQUID assigns = { 'foo' => { 'bar' => 'baz' } } - [:lax, :strict].each do |mode| - with_error_mode(mode) do - assert_template_result("one", template, assigns) - end + with_error_mode(:lax, :strict) do + assert_template_result("one", template, assigns) end with_error_mode(:rigid) do @@ -117,10 +115,8 @@ def test_case_when_with_invalid_expression LIQUID assigns = { 'foo' => { 'bar' => 'baz' } } - [:lax, :strict].each do |mode| - with_error_mode(mode) do - assert_template_result("one", template, assigns) - end + with_error_mode(:lax, :strict) do + assert_template_result("one", template, assigns) end with_error_mode(:rigid) do From 4f35764d440188a619285a17787077f8e0c6ed63 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Oct 2025 12:32:40 +0200 Subject: [PATCH 250/343] Update `History.md` --- History.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/History.md b/History.md index 3d979dc14..be20e055e 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,8 @@ # Liquid Change Log +## 5.8.8 +* Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro] + ## 5.8.7 * Expose body content in the `Doc` tag [James Meng] From 8d7bb9b6f8fa785d1305f64a691d6875703057b5 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 22 Oct 2025 16:46:47 +0200 Subject: [PATCH 251/343] Update `History.md` (5.8.8 -> 5.9.0) --- History.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/History.md b/History.md index be20e055e..763b80533 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,6 @@ # Liquid Change Log -## 5.8.8 +## 5.9.0 * Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro] ## 5.8.7 From 5a210fc8f66231221706edfcd8ada2ac0b40c513 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Oct 2025 09:52:53 +0200 Subject: [PATCH 252/343] Update Rakefile Co-authored-by: Alok Swamy --- Rakefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index ae41567a3..586b61734 100755 --- a/Rakefile +++ b/Rakefile @@ -33,7 +33,7 @@ task :rubocop do end end -desc('runs test suite with all parsers (lax, strict, and rigid)') +desc('runs test suite with lax, strict, and rigid parsers') task :test do ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['base_test'].invoke From 17335862423f92425e1e54e3eb8fb82a26655edc Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Oct 2025 09:57:21 +0200 Subject: [PATCH 253/343] Update test/unit/tags/case_tag_unit_test.rb Co-authored-by: Alok Swamy --- test/unit/tags/case_tag_unit_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb index c0bc2c344..e6a5ababf 100644 --- a/test/unit/tags/case_tag_unit_test.rb +++ b/test/unit/tags/case_tag_unit_test.rb @@ -31,7 +31,7 @@ def test_case_with_trailing_element end end - def test_case_when_trailing_element + def test_case_when_with_trailing_element template = <<~LIQUID {%- case 1 -%} {%- when 1 bar -%} From d63dd104de53ced56afa7e74a161847b3daf5a60 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Oct 2025 09:57:30 +0200 Subject: [PATCH 254/343] Update test/integration/tags/render_tag_test.rb Co-authored-by: Alok Swamy --- test/integration/tags/render_tag_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index e2f4530a5..12759daf5 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -131,7 +131,7 @@ def test_optional_commas assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials) end - def test_include_tag_caches_second_read_of_same_partial + def test_render_tag_caches_second_read_of_same_partial file_system = StubFileSystem.new('snippet' => 'echo') assert_equal( 'echoecho', From 6aa4041c0f33da8420891b10d9d1f668ab7e0456 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 23 Oct 2025 10:01:01 +0200 Subject: [PATCH 255/343] Rename `with_error_mode(...)` to `with_error_modes(...)` --- test/integration/context_test.rb | 2 +- test/integration/error_handling_test.rb | 2 +- test/integration/expression_test.rb | 2 +- test/integration/parsing_quirks_test.rb | 20 +++++++------- test/integration/tags/cycle_tag_test.rb | 16 ++++++------ test/integration/tags/include_tag_test.rb | 20 +++++++------- test/integration/tags/render_tag_test.rb | 12 ++++----- test/integration/tags/table_row_test.rb | 32 +++++++++++------------ test/integration/variable_test.rb | 20 +++++++------- test/test_helper.rb | 2 +- test/unit/tags/case_tag_unit_test.rb | 20 +++++++------- test/unit/variable_unit_test.rb | 6 ++--- 12 files changed, 77 insertions(+), 77 deletions(-) diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index f6afaca52..f59147e29 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -632,7 +632,7 @@ def notice(output) end def test_has_key_will_not_add_an_error_for_missing_keys - with_error_mode(:strict) do + with_error_modes(:strict) do context = Context.new context.key?('unknown') assert_empty(context.errors) diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index 26b0e5f95..0fda83ca5 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -67,7 +67,7 @@ def test_missing_endtag_parse_time_error end def test_unrecognized_operator - with_error_mode(:strict) do + with_error_modes(:strict) do assert_raises(SyntaxError) do Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ') end diff --git a/test/integration/expression_test.rb b/test/integration/expression_test.rb index fe54bd99b..ae84fa36d 100644 --- a/test/integration/expression_test.rb +++ b/test/integration/expression_test.rb @@ -27,7 +27,7 @@ def test_float assert_template_result("-17.42", "{{ -17.42 }}") assert_template_result("2.5", "{{ 2.5 }}") - with_error_mode(:lax) do + with_error_modes(:lax) do assert_expression_result(0.0, "0.....5") assert_expression_result(0.0, "-0..1") end diff --git a/test/integration/parsing_quirks_test.rb b/test/integration/parsing_quirks_test.rb index f5b483fcc..b82ce86c5 100644 --- a/test/integration/parsing_quirks_test.rb +++ b/test/integration/parsing_quirks_test.rb @@ -31,18 +31,18 @@ def test_raise_on_label_and_no_close_bracets_percent def test_error_on_empty_filter assert(Template.parse("{{test}}")) - with_error_mode(:lax) do + with_error_modes(:lax) do assert(Template.parse("{{|test}}")) end - with_error_mode(:strict) do + with_error_modes(:strict) do assert_raises(SyntaxError) { Template.parse("{{|test}}") } assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") } end end def test_meaningless_parens_error - with_error_mode(:strict) do + with_error_modes(:strict) do assert_raises(SyntaxError) do markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" Template.parse("{% if #{markup} %} YES {% endif %}") @@ -51,7 +51,7 @@ def test_meaningless_parens_error end def test_unexpected_characters_syntax_error - with_error_mode(:strict) do + with_error_modes(:strict) do assert_raises(SyntaxError) do markup = "true && false" Template.parse("{% if #{markup} %} YES {% endif %}") @@ -70,7 +70,7 @@ def test_no_error_on_lax_empty_filter end def test_meaningless_parens_lax - with_error_mode(:lax) do + with_error_modes(:lax) do assigns = { 'b' => 'bar', 'c' => 'baz' } markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns) @@ -78,7 +78,7 @@ def test_meaningless_parens_lax end def test_unexpected_characters_silently_eat_logic_lax - with_error_mode(:lax) do + with_error_modes(:lax) do markup = "true && false" assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}") markup = "false || true" @@ -93,7 +93,7 @@ def test_raise_on_invalid_tag_delimiter end def test_unanchored_filter_arguments - with_error_mode(:lax) do + with_error_modes(:lax) do assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}") assert_template_result('x', "{{ 'X' | downcase) }}") @@ -106,14 +106,14 @@ def test_unanchored_filter_arguments end def test_invalid_variables_work - with_error_mode(:lax) do + with_error_modes(:lax) do assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}") assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}") end end def test_extra_dots_in_ranges - with_error_mode(:lax) do + with_error_modes(:lax) do assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}") end end @@ -133,7 +133,7 @@ def test_contains_in_id end def test_incomplete_expression - with_error_mode(:lax) do + with_error_modes(:lax) do assert_template_result("false", "{{ false - }}") assert_template_result("false", "{{ false > }}") assert_template_result("false", "{{ false < }}") diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index 14d8997b4..f3f865f08 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -96,12 +96,12 @@ def test_cycle_tag_with_error_mode template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}" template2 = "{% cycle .5: 'a', 'b' %}" - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result("b", template1) assert_template_result("a", template2) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } @@ -121,7 +121,7 @@ def test_cycle_with_trailing_elements template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}" template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}" - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result("a", template1) assert_template_result("a", template2) assert_template_result("a", template3) @@ -129,7 +129,7 @@ def test_cycle_with_trailing_elements assert_template_result("N", template5) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) } @@ -153,11 +153,11 @@ def test_cycle_name_with_invalid_expression {% endfor %} LIQUID - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -170,11 +170,11 @@ def test_cycle_variable_with_invalid_expression {% endfor %} LIQUID - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index b41a98c6e..3ccd17794 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -205,7 +205,7 @@ def test_dynamically_choosen_template end def test_rigid_parsing_errors - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result( 'hello value1 value2', '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', @@ -213,7 +213,7 @@ def test_rigid_parsing_errors ) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_syntax_error( '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', ) @@ -303,13 +303,13 @@ def test_passing_options_to_included_templates assert_raises(Liquid::SyntaxError) do Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}') end - with_error_mode(:lax) do + with_error_modes(:lax) do assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}')) end assert_raises(Liquid::SyntaxError) do Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}') end - with_error_mode(:lax) do + with_error_modes(:lax) do assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}')) end end @@ -404,11 +404,11 @@ def test_render_tag_renders_error_with_template_name_from_template_factory def test_include_template_with_invalid_expression template = "{% include foo=>bar %}" - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -417,11 +417,11 @@ def test_include_template_with_invalid_expression def test_include_with_invalid_expression template = '{% include "snippet" with foo=>bar %}' - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -430,11 +430,11 @@ def test_include_with_invalid_expression def test_include_attribute_with_invalid_expression template = '{% include "snippet", key: foo=>bar %}' - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 12759daf5..d6453fb51 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -106,7 +106,7 @@ def test_dynamically_choosen_templates_are_not_allowed end def test_rigid_parsing_errors - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result( 'hello value1 value2', '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', @@ -114,7 +114,7 @@ def test_rigid_parsing_errors ) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_syntax_error( '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', ) @@ -318,11 +318,11 @@ def test_render_tag_renders_error_with_template_name_from_template_factory def test_render_with_invalid_expression template = '{% render "snippet" with foo=>bar %}' - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -331,11 +331,11 @@ def test_render_with_invalid_expression def test_render_attribute_with_invalid_expression template = '{% render "snippet", key: foo=>bar %}' - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 0f9b051fe..828f77380 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -270,7 +270,7 @@ def test_tablerow_with_cols_attribute_in_rigid_mode 456 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template) end end @@ -285,7 +285,7 @@ def test_tablerow_with_limit_attribute_in_rigid_mode 123 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template) end end @@ -300,7 +300,7 @@ def test_tablerow_with_offset_attribute_in_rigid_mode 345 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template) end end @@ -315,7 +315,7 @@ def test_tablerow_with_range_attribute_in_rigid_mode 123 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template) end end @@ -331,7 +331,7 @@ def test_tablerow_with_multiple_attributes_in_rigid_mode 45 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template) end end @@ -347,7 +347,7 @@ def test_tablerow_with_variable_collection_in_rigid_mode 34 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] }) end end @@ -363,7 +363,7 @@ def test_tablerow_with_dotted_access_in_rigid_mode 34 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } }) end end @@ -378,7 +378,7 @@ def test_tablerow_with_bracketed_access_in_rigid_mode 1020 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } }) end end @@ -393,7 +393,7 @@ def test_tablerow_without_attributes_in_rigid_mode 123 OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template) end end @@ -401,7 +401,7 @@ def test_tablerow_without_attributes_in_rigid_mode def test_tablerow_without_in_keyword_in_rigid_mode template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}' - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message) end @@ -410,7 +410,7 @@ def test_tablerow_without_in_keyword_in_rigid_mode def test_tablerow_with_multiple_invalid_attributes_reports_first_in_rigid_mode template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}' - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message) end @@ -426,7 +426,7 @@ def test_tablerow_with_empty_collection_in_rigid_mode OUTPUT - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result(expected, template, { 'empty_array' => [] }) end end @@ -439,11 +439,11 @@ def test_tablerow_with_invalid_attribute_strict_vs_rigid 12345 OUTPUT - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result(expected, template) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Invalid attribute 'invalid_attr'/, error.message) end @@ -452,7 +452,7 @@ def test_tablerow_with_invalid_attribute_strict_vs_rigid def test_tablerow_with_invalid_expression_strict_vs_rigid template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}' - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do expected = <<~OUTPUT @@ -460,7 +460,7 @@ def test_tablerow_with_invalid_expression_strict_vs_rigid assert_template_result(expected, template) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do # Rigid mode validates expression syntax and rejects invalid expressions error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 740ea9f9a..f0f2bc409 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -213,12 +213,12 @@ def test_variable_lookup_should_not_hang_with_invalid_syntax def test_filter_with_single_trailing_comma template = '{{ "hello" | append: "world", }}' - with_error_mode(:strict) do + with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result('helloworld', template) end end @@ -226,12 +226,12 @@ def test_filter_with_single_trailing_comma def test_multiple_filters_with_trailing_commas template = '{{ "hello" | append: "1", | append: "2", }}' - with_error_mode(:strict) do + with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result('hello12', template) end end @@ -239,12 +239,12 @@ def test_multiple_filters_with_trailing_commas def test_filter_with_colon_but_no_arguments template = '{{ "test" | upcase: }}' - with_error_mode(:strict) do + with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result('TEST', template) end end @@ -252,12 +252,12 @@ def test_filter_with_colon_but_no_arguments def test_filter_chain_with_colon_no_args template = '{{ "test" | append: "x" | upcase: }}' - with_error_mode(:strict) do + with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result('TESTX', template) end end @@ -265,12 +265,12 @@ def test_filter_chain_with_colon_no_args def test_combining_trailing_comma_and_empty_args template = '{{ "test" | append: "x", | upcase: }}' - with_error_mode(:strict) do + with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do assert_template_result('TESTX', template) end end diff --git a/test/test_helper.rb b/test/test_helper.rb index a09f22771..293ea4766 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -82,7 +82,7 @@ def with_global_filter(*globals, &blk) Environment.dangerously_override(environment, &blk) end - def with_error_mode(*modes) + def with_error_modes(*modes) old_mode = Liquid::Environment.default.error_mode modes.each do |mode| Liquid::Environment.default.error_mode = mode diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb index e6a5ababf..5e181f3eb 100644 --- a/test/unit/tags/case_tag_unit_test.rb +++ b/test/unit/tags/case_tag_unit_test.rb @@ -20,11 +20,11 @@ def test_case_with_trailing_element {%- endcase -%} LIQUID - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result("one", template) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Expected end_of_string but found/, error.message) @@ -41,11 +41,11 @@ def test_case_when_with_trailing_element {%- endcase -%} LIQUID - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result("one", template) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Expected end_of_string but found/, error.message) @@ -62,7 +62,7 @@ def test_case_when_with_comma {%- endcase -%} LIQUID - with_error_mode(:lax, :strict, :rigid) do + with_error_modes(:lax, :strict, :rigid) do assert_template_result("one", template) end end @@ -77,7 +77,7 @@ def test_case_when_with_or {%- endcase -%} LIQUID - with_error_mode(:lax, :strict, :rigid) do + with_error_modes(:lax, :strict, :rigid) do assert_template_result("one", template) end end @@ -93,11 +93,11 @@ def test_case_with_invalid_expression LIQUID assigns = { 'foo' => { 'bar' => 'baz' } } - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result("one", template, assigns) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) @@ -115,11 +115,11 @@ def test_case_when_with_invalid_expression LIQUID assigns = { 'foo' => { 'bar' => 'baz' } } - with_error_mode(:lax, :strict) do + with_error_modes(:lax, :strict) do assert_template_result("one", template, assigns) end - with_error_mode(:rigid) do + with_error_modes(:rigid) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) diff --git a/test/unit/variable_unit_test.rb b/test/unit/variable_unit_test.rb index 82ac03302..b46fc23f4 100644 --- a/test/unit/variable_unit_test.rb +++ b/test/unit/variable_unit_test.rb @@ -108,7 +108,7 @@ def test_dashes assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name) assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name) - with_error_mode(:strict) do + with_error_modes(:strict) do assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') } assert_raises(Liquid::SyntaxError) { create_variable('-foo') } assert_raises(Liquid::SyntaxError) { create_variable('2foo') } @@ -154,7 +154,7 @@ def test_lax_filter_argument_parsing end def test_strict_filter_argument_parsing - with_error_mode(:strict) do + with_error_modes(:strict) do assert_raises(SyntaxError) do create_variable(%( number_of_comments | pluralize: 'comment': 'comments' )) end @@ -162,7 +162,7 @@ def test_strict_filter_argument_parsing end def test_rigid_filter_argument_parsing - with_error_mode(:rigid) do + with_error_modes(:rigid) do # optional colon var = create_variable(%(n | f1 | f2:)) assert_equal([['f1', []], ['f2', []]], var.filters) From 15430c0770fbd759a52d18790f27b19690f3b335 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 24 Oct 2025 09:06:12 +0200 Subject: [PATCH 256/343] Add rigid mode to `rake benchmark` task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The benchmark results show that rigid mode performs a bit better than both strict and lax modes across most metrics, including tokenization, parsing, rendering, and their combined operations. Rigid mode consistently delivers the highest number of iterations per second, with performance differences staying within 1–2% compared to the other modes ``` ================================================================================ /opt/rubies/3.4.1/bin/ruby ./performance/benchmark.rb lax Running benchmark for 20 seconds (with 10 seconds warmup). ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- tokenize: 332.000 i/100ms parse: 14.000 i/100ms render: 61.000 i/100ms parse & render: 11.000 i/100ms Calculating ------------------------------------- tokenize: 3.325k (± 1.0%) i/s (300.73 μs/i) - 66.732k in 20.070562s parse: 148.166 (± 0.7%) i/s (6.75 ms/i) - 2.968k in 20.032971s render: 654.428 (± 4.0%) i/s (1.53 ms/i) - 13.115k in 20.090452s parse & render: 116.108 (± 1.7%) i/s (8.61 ms/i) - 2.332k in 20.089221s ================================================================================ /opt/rubies/3.4.1/bin/ruby ./performance/benchmark.rb strict Running benchmark for 20 seconds (with 10 seconds warmup). ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- tokenize: 332.000 i/100ms parse: 14.000 i/100ms render: 61.000 i/100ms parse & render: 11.000 i/100ms Calculating ------------------------------------- tokenize: 3.332k (± 0.2%) i/s (300.14 μs/i) - 66.732k in 20.029095s parse: 145.674 (± 0.0%) i/s (6.86 ms/i) - 2.926k in 20.086104s render: 656.711 (± 4.6%) i/s (1.52 ms/i) - 13.115k in 20.050810s parse & render: 114.705 (± 0.0%) i/s (8.72 ms/i) - 2.299k in 20.043028s ================================================================================ /opt/rubies/3.4.1/bin/ruby ./performance/benchmark.rb rigid Running benchmark for 20 seconds (with 10 seconds warmup). ruby 3.4.1 (2024-12-25 revision 48d4efcb85) +YJIT +PRISM [arm64-darwin23] Warming up -------------------------------------- tokenize: 333.000 i/100ms parse: 14.000 i/100ms render: 62.000 i/100ms parse & render: 11.000 i/100ms Calculating ------------------------------------- tokenize: 3.334k (± 0.3%) i/s (299.93 μs/i) - 66.933k in 20.075484s parse: 148.349 (± 2.0%) i/s (6.74 ms/i) - 2.968k in 20.019775s render: 663.752 (± 2.6%) i/s (1.51 ms/i) - 13.268k in 20.010303s parse & render: 116.464 (± 2.6%) i/s (8.59 ms/i) - 2.332k in 20.037869s liquid$ ``` --- Rakefile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Rakefile b/Rakefile index 586b61734..878536bf5 100755 --- a/Rakefile +++ b/Rakefile @@ -88,8 +88,13 @@ namespace :benchmark do ruby "./performance/benchmark.rb strict" end - desc "Run the liquid benchmark with both lax and strict parsing" - task run: [:lax, :strict] + desc "Run the liquid benchmark with rigid parsing" + task :rigid do + ruby "./performance/benchmark.rb rigid" + end + + desc "Run the liquid benchmark with lax, strict, and rigid parsing" + task run: [:lax, :strict, :rigid] desc "Run unit benchmarks" namespace :unit do From e57b7efe4e1cb3bc1ecf65887d07e881c0132448 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 24 Oct 2025 09:10:00 +0200 Subject: [PATCH 257/343] Simplify render/include tags following PR review feedback --- lib/liquid/tags/include.rb | 18 +++--------------- lib/liquid/tags/render.rb | 20 +++++--------------- lib/liquid/tags/table_row.rb | 3 +-- 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 8dc9567a5..5b2aaa432 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -88,20 +88,9 @@ def rigid_parse(markup) p = @parse_context.new_parser(markup) @template_name_expr = safe_parse_expression(p) - with_or_for = p.id?("for") || p.id?("with") || nil - @variable_name_expr = nil - if with_or_for - @variable_name_expr = safe_parse_expression(p) - end - - alias_name = nil - if p.id?("as") - alias_name = p.consume(:id) - end + @variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with") + @alias_name = p.consume(:id) if p.id?("as") - @alias_name = alias_name - - # optional comma p.consume?(:comma) @attributes = {} @@ -109,7 +98,7 @@ def rigid_parse(markup) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) # optional comma + p.consume?(:comma) end p.consume(:end_of_string) @@ -121,7 +110,6 @@ def strict_parse(markup) def lax_parse(markup) if markup =~ SYNTAX - template_name = Regexp.last_match(1) variable_name = Regexp.last_match(3) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index efe83b0d3..4d29e420e 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -89,21 +89,11 @@ def rigid_parse(markup) p = @parse_context.new_parser(markup) @template_name_expr = parse_expression(rigid_template_name(p), safe: true) - @variable_name_expr = nil - with_or_for = p.id?("for") || p.id?("with") || nil - if with_or_for - @variable_name_expr = safe_parse_expression(p) - end - - alias_name = nil - if p.id?("as") - alias_name = p.consume(:id) - end - - @alias_name = alias_name - @is_for_loop = (with_or_for == FOR) + with_or_for = p.id?("for") || p.id?("with") + @variable_name_expr = safe_parse_expression(p) if with_or_for + @alias_name = p.consume(:id) if p.id?("as") + @is_for_loop = (with_or_for == FOR) - # optional comma p.consume?(:comma) @attributes = {} @@ -111,7 +101,7 @@ def rigid_parse(markup) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) # optional comma + p.consume?(:comma) end p.consume(:end_of_string) diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 11aa4ef50..7348b5d91 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -45,7 +45,6 @@ def rigid_parse(markup) @collection_name = safe_parse_expression(p) - # optional comma p.consume?(:comma) @attributes = {} @@ -57,7 +56,7 @@ def rigid_parse(markup) p.consume(:colon) @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) # optional comma + p.consume?(:comma) end p.consume(:end_of_string) From 44f0429c089dda91b4d4fdd6548554522622d15a Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 24 Oct 2025 09:21:51 +0200 Subject: [PATCH 258/343] Extract `/\w+:0x\h{8}/` regex to `UNNAMED_CYCLE_PATTERN` constant --- lib/liquid/tags/cycle.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 639ed9a5b..7e17a9662 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -17,6 +17,7 @@ module Liquid class Cycle < Tag SimpleSyntax = /\A#{QuotedFragment}+/o NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om + UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/ attr_reader :variables @@ -86,7 +87,7 @@ def rigid_parse(markup) unless @is_named @name = @variables.to_s - @is_named = !@name.match?(/\w+:0x\h{8}/) + @is_named = !@name.match?(UNNAMED_CYCLE_PATTERN) end end @@ -103,7 +104,7 @@ def lax_parse(markup) when SimpleSyntax @variables = variables_from_string(markup) @name = @variables.to_s - @is_named = !@name.match?(/\w+:0x\h{8}/) + @is_named = !@name.match?(UNNAMED_CYCLE_PATTERN) else raise SyntaxError, options[:locale].t("errors.syntax.cycle") end From 51f312b2206e521e5c64769b2a16e3df44ddcaaa Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 27 Oct 2025 09:53:14 +0100 Subject: [PATCH 259/343] Update README.md Co-authored-by: Gray Gilmore --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da24e54fa..34884464d 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Setting the error mode of Liquid lets you specify how strictly you want your tem Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make it very hard to debug and can lead to unexpected behaviour. -Liquid also comes with a parser that can be used when editing templates to give better error messages +Liquid also comes with different parsers that can be used when editing templates to give better error messages when templates are invalid. You can enable this new parser like this: ```ruby From d38795168b8c4aad243d0b466c15adac30f4920c Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 27 Oct 2025 10:02:16 +0100 Subject: [PATCH 260/343] Update test/integration/tags/table_row_test.rb Co-authored-by: Gray Gilmore --- test/integration/tags/table_row_test.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 828f77380..44cdcdd99 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -461,7 +461,6 @@ def test_tablerow_with_invalid_expression_strict_vs_rigid end with_error_modes(:rigid) do - # Rigid mode validates expression syntax and rejects invalid expressions error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end From 1f58216f48b5671fe25090d1cb251bb083cc3858 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 27 Oct 2025 12:14:09 +0100 Subject: [PATCH 261/343] Add unit test mixing positional and kwargs arguments --- test/unit/variable_unit_test.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/unit/variable_unit_test.rb b/test/unit/variable_unit_test.rb index b46fc23f4..cb3a6d8ce 100644 --- a/test/unit/variable_unit_test.rb +++ b/test/unit/variable_unit_test.rb @@ -184,6 +184,10 @@ def test_rigid_filter_argument_parsing var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2)) assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) + # positional and kwargs mixed + var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c')) + assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters) + # positional and kwargs intermixed (pos1, key1: val1, pos2) var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title")) assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) From c357f91e0c677327180c8e745b6e1bf867d65ab2 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 27 Oct 2025 17:20:21 +0100 Subject: [PATCH 262/343] Bump to 5.9.0 --- lib/liquid/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 9c5807b63..6f3e83e7d 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.8.7" + VERSION = "5.9.0" end From ed9c4e31c40f94077d52afcb48391210c9b912ff Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Thu, 19 Sep 2024 12:03:43 -0400 Subject: [PATCH 263/343] Introduce new inline snippets tag Inline snippets will reduce code duplication and improve the developer experience, eliminating the need for one-off snippet files --- Gemfile | 4 + Gemfile.lock | 77 +++++++++++ example/server/parser_attempt.rb | 59 ++++++++ example/server/templates/index.liquid | 71 +++++++++- lib/liquid/locales/en.yml | 1 + lib/liquid/tags.rb | 2 + lib/liquid/tags/render.rb | 18 +++ lib/liquid/tags/snippet.rb | 51 +++++++ test/integration/tags/snippet_test.rb | 190 ++++++++++++++++++++++++++ 9 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 Gemfile.lock create mode 100644 example/server/parser_attempt.rb create mode 100644 lib/liquid/tags/snippet.rb create mode 100644 test/integration/tags/snippet_test.rb diff --git a/Gemfile b/Gemfile index a404c0d4b..fc327c921 100644 --- a/Gemfile +++ b/Gemfile @@ -29,3 +29,7 @@ group :test do gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end + +group :development do + gem "webrick" +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 000000000..eff33ff57 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,77 @@ +GIT + remote: https://github.com/Shopify/liquid-c.git + revision: 5a786af7284df55e013ea20551c4b688d02e8326 + ref: main + specs: + liquid-c (4.2.0) + liquid (>= 5.0.1) + +PATH + remote: . + specs: + liquid (5.6.0.alpha) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.2) + base64 (0.2.0) + benchmark-ips (2.13.0) + json (2.7.2) + language_server-protocol (3.17.0.3) + memory_profiler (1.0.1) + minitest (5.22.3) + parallel (1.24.0) + parser (3.3.0.5) + ast (~> 2.4.1) + racc + racc (1.7.3) + rainbow (3.1.1) + rake (13.2.1) + regexp_parser (2.9.0) + rexml (3.2.6) + rubocop (1.61.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.31.2) + parser (>= 3.3.0.4) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-shopify (2.12.0) + rubocop (~> 1.44) + ruby-progressbar (1.13.0) + stackprof (0.2.26) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.5.0) + webrick (1.8.1) + +PLATFORMS + ruby + +DEPENDENCIES + base64 + benchmark-ips + liquid! + liquid-c! + memory_profiler + minitest + rake (~> 13.0) + rubocop (~> 1.61.0) + rubocop-performance + rubocop-shopify (~> 2.12.0) + stackprof + terminal-table + webrick + +BUNDLED WITH + 2.5.7 diff --git a/example/server/parser_attempt.rb b/example/server/parser_attempt.rb new file mode 100644 index 000000000..68e214dab --- /dev/null +++ b/example/server/parser_attempt.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'bundler/inline' + +gemfile(true) do + source "https://rubygems.org" + gem 'liquid' +end + +require 'liquid' + +class Parser + def initialize(template) + @template = template + end + + def parse + @parsed_template = Liquid::Template.parse(@template) + end + + def test_parse + document = @parsed_template.root + + variables = [] + + if document.is_a?(Liquid::Document) + body = document.body + + if body.is_a?(Liquid::BlockBody) + body.nodelist.each do |node| + next unless node.is_a?(Liquid::Variable) + + puts node.inspect + variable_name = node.name.name + variables << variable_name + end + end + end + puts "Variables: #{variables}" + end + + def render + @parsed_template.render + end +end + +starter_template = "{{ foo }}" +starter_template_2 = "{{foo}}, {{bar}}" +starter_template_2_1 = "{{ foo }} and {{ bar }}" +starter_template_3 = "{% assign foo = 'bar' %}{{ foo }}" +# Let's start small here +template = <<~LIQUID + {% assign foo = 'bar' %} + {{ foo }} +LIQUID + +parser = Parser.new(starter_template) +parser.parse +parser.test_parse diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 4872aa845..529055089 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,6 +1,71 @@ -

Hello world!

+ + -

It is {{date}}

+ + + + Simple Code Editor + + + + + + + + +
+ {% snippet "main" %} -

Check out the Products screen

+ {% # Snippet input %} + {% snippet "input" |type, name| %} +
+ + +
+ {% endsnippet %} + + {% snippet "league" %} +

Welcome to the league of super evil

+ {% endsnippet %} + + {% render "league" %} + {% render "input", type: "text" %} + {% render "input", type: "password" %} + + {% endsnippet %} + {% render 'main' %} +
+ + + + + + diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index b99d490c8..b2196686f 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -5,6 +5,7 @@ block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" + snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb index dff7553f7..3ec5e7cfb 100644 --- a/lib/liquid/tags.rb +++ b/lib/liquid/tags.rb @@ -20,6 +20,7 @@ require_relative "tags/render" require_relative "tags/cycle" require_relative "tags/doc" +require_relative "tags/snippet" module Liquid module Tags @@ -44,6 +45,7 @@ module Tags 'echo' => Echo, 'tablerow' => TableRow, 'doc' => Doc, + 'snippet' => Snippet, }.freeze end end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 4d29e420e..5bff60737 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -51,6 +51,24 @@ def render_tag(context, output) template_name = @template_name_expr raise ::ArgumentError unless template_name.is_a?(String) + # Inline snippets take precedence over external snippets + if (inline_snippet = context.registers[:inline_snippet][template_name]) + inner_context = context.new_isolated_subcontext + + snippet_body = inline_snippet[:body] + snippet_args = inline_snippet[:args] + # Validate and set the arguments in the inner context + @attributes.each do |key, value| + unless snippet_args.include?(key) + raise Liquid::ArgumentError, "Invalid argument `#{key}` for snippet `#{template_name}`" + end + + inner_context[key] = context.evaluate(value) + end + + return output << snippet_body.render(inner_context) + end + partial = PartialCache.load( template_name, context: context, diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb new file mode 100644 index 000000000..72c6c2822 --- /dev/null +++ b/lib/liquid/tags/snippet.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Liquid + # @liquid_public_docs + # @liquid_type tag + # @liquid_category theme + # @liquid_name snippet + # @liquid_summary + # Creates a new inline snippet using a string value as the identifier. + # @liquid_description + # You can create inline snippets to make your Liquid code more modular. + # @liquid_syntax + # {% snippet "input" %} + # value + # {% endsnippet %} + class Snippet < Block + SYNTAX = /(#{QuotedString})(?:\s*\|\s*([\w\s,]+)\s*\|)?/o + def initialize(tag_name, markup, options) + super + + if markup =~ SYNTAX + @to = Regexp.last_match(1) + args = Regexp.last_match(2) + + @args = args ? args.split(/\s*,\s*/) : [] + else + raise SyntaxError, options[:locale].t("errors.syntax.snippet") + end + end + + def render(context) + context.registers[:inline_snippet] ||= {} + context.registers[:inline_snippet][snippet_id] = { + body: snippet_body, + args: @args, + } + '' + end + + private + + def snippet_id + @to[1, @to.size - 2] + end + + def snippet_body + body = @body + body + end + end +end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb new file mode 100644 index 000000000..fd1cbcdbe --- /dev/null +++ b/test/integration/tags/snippet_test.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SnippetTest < Minitest::Test + include Liquid + + def test_valid_inline_snippet + template = <<~LIQUID.strip + {% snippet "input" %} + Hey + {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template) + end + + def test_invalid_inline_snippet + template = <<~LIQUID.strip + {% snippet input %} + Hey + {% endsnippet %} + LIQUID + expected = "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" + + assert_match_syntax_error(expected, template) + end + + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet "hey" %} + Hey + {% endsnippet %} + + {%- render "hey" -%} + LIQUID + expected = <<~OUTPUT + + Hey + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_multiple_inline_snippets + template = <<~LIQUID.strip + {% snippet "input" %} + + {% endsnippet %} + + {% snippet "banner" %} + + Welcome to my store! + + {% endsnippet %} + + {%- render "input" -%} + {%- render "banner" -%} + LIQUID + expected = <<~OUTPUT + + + + + + + Welcome to my store! + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_multiple_arguments + template = <<~LIQUID.strip + {% snippet "input" |type, value| %} + + {% endsnippet %} + + {%- render "input", type: "text", value: "Hello" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippets_using_same_argument_name + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + + {% snippet "inputs" |type, value| %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + {%- render "inputs", type: "password", value: "pass" -%} + LIQUID + expected = <<~OUTPUT + + + + + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_empty_string_when_missing_argument + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_shouldnt_leak_context + template = <<~LIQUID.strip + {% snippet "input" |type, value| %} + + {% endsnippet %} + + {%- render "input", type: "text", value: "Hello" -%} + + {{ type }} + {{ value }} + LIQUID + expected = <<~OUTPUT + + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_multiple_inline_snippets_without_leaking_context + template = <<~LIQUID.strip + {% snippet "input" |type| %} + + {% endsnippet %} + {% snippet "no_leak" %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + {%- render "no_leak" -%} + LIQUID + expected = <<~OUTPUT + + + + + + OUTPUT + + assert_template_result(expected, template) + end +end From 1eca707c4a40a035b924500726720dbffd5c4214 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 26 Sep 2025 17:46:44 -0600 Subject: [PATCH 264/343] Update inline snippets syntax Previously, inline snippets syntax looked a bit different, they: - used strings as tag identifiers - defined tag arguments {% snippet "input" |type| %} This PR updates snippets to better reflect the currently proposed syntax Co-authored-by: Orlando Qiu --- lib/liquid/tags/render.rb | 12 +- lib/liquid/tags/snippet.rb | 29 +--- test/integration/tags/snippet_test.rb | 216 +++++++++++++++++++++++--- 3 files changed, 204 insertions(+), 53 deletions(-) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 5bff60737..c142caca5 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -54,15 +54,15 @@ def render_tag(context, output) # Inline snippets take precedence over external snippets if (inline_snippet = context.registers[:inline_snippet][template_name]) inner_context = context.new_isolated_subcontext - snippet_body = inline_snippet[:body] - snippet_args = inline_snippet[:args] - # Validate and set the arguments in the inner context - @attributes.each do |key, value| - unless snippet_args.include?(key) - raise Liquid::ArgumentError, "Invalid argument `#{key}` for snippet `#{template_name}`" + + context.scopes.each do |scope| + scope.each do |key, value| + inner_context[key] = value end + end + @attributes.each do |key, value| inner_context[key] = context.evaluate(value) end diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 72c6c2822..7f9072742 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -1,28 +1,25 @@ # frozen_string_literal: true module Liquid - # @liquid_public_docs # @liquid_type tag # @liquid_category theme # @liquid_name snippet # @liquid_summary - # Creates a new inline snippet using a string value as the identifier. + # Creates a new inline snippet. # @liquid_description # You can create inline snippets to make your Liquid code more modular. # @liquid_syntax - # {% snippet "input" %} + # {% snippet input %} # value # {% endsnippet %} + class Snippet < Block - SYNTAX = /(#{QuotedString})(?:\s*\|\s*([\w\s,]+)\s*\|)?/o + SYNTAX = /(#{VariableSignature}+)/o + def initialize(tag_name, markup, options) super - if markup =~ SYNTAX @to = Regexp.last_match(1) - args = Regexp.last_match(2) - - @args = args ? args.split(/\s*,\s*/) : [] else raise SyntaxError, options[:locale].t("errors.syntax.snippet") end @@ -30,22 +27,10 @@ def initialize(tag_name, markup, options) def render(context) context.registers[:inline_snippet] ||= {} - context.registers[:inline_snippet][snippet_id] = { - body: snippet_body, - args: @args, + context.registers[:inline_snippet][@to] = { + body: @body, } '' end - - private - - def snippet_id - @to[1, @to.size - 2] - end - - def snippet_body - body = @body - body - end end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index fd1cbcdbe..245862278 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -7,7 +7,7 @@ class SnippetTest < Minitest::Test def test_valid_inline_snippet template = <<~LIQUID.strip - {% snippet "input" %} + {% snippet input %} Hey {% endsnippet %} LIQUID @@ -16,20 +16,9 @@ def test_valid_inline_snippet assert_template_result(expected, template) end - def test_invalid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} - Hey - {% endsnippet %} - LIQUID - expected = "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" - - assert_match_syntax_error(expected, template) - end - def test_render_inline_snippet template = <<~LIQUID.strip - {% snippet "hey" %} + {% snippet hey %} Hey {% endsnippet %} @@ -45,11 +34,11 @@ def test_render_inline_snippet def test_render_multiple_inline_snippets template = <<~LIQUID.strip - {% snippet "input" %} + {% snippet input %} {% endsnippet %} - {% snippet "banner" %} + {% snippet banner %} Welcome to my store! @@ -74,7 +63,27 @@ def test_render_multiple_inline_snippets def test_render_inline_snippet_with_argument template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} + + {% endsnippet %} + + {%- render "input", type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_doc_tag + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + {% endsnippet %} @@ -82,6 +91,8 @@ def test_render_inline_snippet_with_argument LIQUID expected = <<~OUTPUT + + OUTPUT @@ -90,7 +101,12 @@ def test_render_inline_snippet_with_argument def test_render_inline_snippet_with_multiple_arguments template = <<~LIQUID.strip - {% snippet "input" |type, value| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + {% endsnippet %} @@ -98,6 +114,8 @@ def test_render_inline_snippet_with_multiple_arguments LIQUID expected = <<~OUTPUT + + OUTPUT @@ -106,24 +124,25 @@ def test_render_inline_snippet_with_multiple_arguments def test_render_inline_snippets_using_same_argument_name template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} {% endsnippet %} - {% snippet "inputs" |type, value| %} - + {% snippet inputs %} + {% endsnippet %} {%- render "input", type: "text" -%} {%- render "inputs", type: "password", value: "pass" -%} LIQUID + expected = <<~OUTPUT - + OUTPUT assert_template_result(expected, template) @@ -131,7 +150,12 @@ def test_render_inline_snippets_using_same_argument_name def test_render_inline_snippet_empty_string_when_missing_argument template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + {% endsnippet %} @@ -139,6 +163,8 @@ def test_render_inline_snippet_empty_string_when_missing_argument LIQUID expected = <<~OUTPUT + + OUTPUT @@ -147,7 +173,12 @@ def test_render_inline_snippet_empty_string_when_missing_argument def test_render_inline_snippet_shouldnt_leak_context template = <<~LIQUID.strip - {% snippet "input" |type, value| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + {% endsnippet %} @@ -158,6 +189,8 @@ def test_render_inline_snippet_shouldnt_leak_context LIQUID expected = <<~OUTPUT + + OUTPUT @@ -167,10 +200,15 @@ def test_render_inline_snippet_shouldnt_leak_context def test_render_multiple_inline_snippets_without_leaking_context template = <<~LIQUID.strip - {% snippet "input" |type| %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + {% endsnippet %} - {% snippet "no_leak" %} + + {% snippet no_leak %} {% endsnippet %} @@ -180,6 +218,9 @@ def test_render_multiple_inline_snippets_without_leaking_context expected = <<~OUTPUT + + + @@ -187,4 +228,129 @@ def test_render_multiple_inline_snippets_without_leaking_context assert_template_result(expected, template) end + + def test_render_parent_context_variable + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} + {% doc %} + @param {string} message - Message. + {% enddoc %} + +
+ {{ message }} +
+ {% endsnippet %} + + {%- render "header", message: "Welcome to my site" -%} + LIQUID + expected = <<~OUTPUT + + + + + +
+ Welcome to my site +
+ OUTPUT + + assert_template_result(expected, template) + end + + def test_deeply_nested_snippets + template = <<~LIQUID.strip + {% assign color_scheme = 'first-color' %} + {% snippet first %} + {% assign color_scheme = 'second-color' %} + {% snippet second %} + {% assign color_scheme = 'third-color' %} + {% snippet third %} +
+ This is a header +
+ {% endsnippet %} + {%- render "third" -%} + {% endsnippet %} + {%- render "second" -%} + {% endsnippet %} + + {%- render "first" -%} + LIQUID + expected = <<~OUTPUT + + + + + + +
+ This is a header +
+ OUTPUT + + assert_template_result(expected, template) + end + + def test_render_snippet_with_variables_in_both_scopes + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} + {% assign color_scheme = 'light' %} +
+ {{ message }} +
+ {% endsnippet %} + + {{ color_scheme }} + + {%- render "header", message: 'Welcome to my site' -%} + LIQUID + expected = <<~OUTPUT + + + + + dark + +
+ Welcome to my site +
+ OUTPUT + + assert_template_result(expected, template) + end + + # def test_render_snippets_as_arguments + # template = <<~LIQUID.strip + # {% assign color_scheme = 'dark' %} + + # {% snippet main_header %} + # {% assign color_scheme = 'auto' %} + + #
+ # {%- render "header", message: 'Welcome to my site' -%} + #
+ # {% endsnippet %} + + # {% snippet header %} + #
+ # {{ message }} + #
+ # {% endsnippet %} + + # {%- render "main_header", header: header -%} + # LIQUID + # expected = <<~OUTPUT + #
+ #
+ # Welcome to my site + #
+ #
+ # OUTPUT + + # assert_template_result(expected, template) + # end end From 12bbbc4537753e519eae5afeb1573f1a7d0f6e2a Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 30 Sep 2025 16:29:40 -0600 Subject: [PATCH 265/343] Create SnippetDrop and set in scope --- lib/liquid.rb | 1 + lib/liquid/snippet_drop.rb | 16 +++ lib/liquid/tags/render.rb | 19 +-- lib/liquid/tags/snippet.rb | 14 ++- test/integration/tags/snippet_test.rb | 167 +++++++++++++++++++------- 5 files changed, 161 insertions(+), 56 deletions(-) create mode 100644 lib/liquid/snippet_drop.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..09cafe947 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -67,6 +67,7 @@ module Liquid require 'liquid/drop' require 'liquid/tablerowloop_drop' require 'liquid/forloop_drop' +require 'liquid/snippet_drop' require 'liquid/extensions' require 'liquid/errors' require 'liquid/interrupts' diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb new file mode 100644 index 000000000..fb48d068d --- /dev/null +++ b/lib/liquid/snippet_drop.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Liquid + class SnippetDrop < Drop + attr_reader :body + + def initialize(body) + super() + @body = body + end + + def to_s + 'SnippetDrop' + end + end +end diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index c142caca5..03f231eaa 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -42,6 +42,10 @@ def for_loop? @is_for_loop end + def inherit_context? + @inherit_context + end + def render_to_output_buffer(context, output) render_tag(context, output) end @@ -51,14 +55,15 @@ def render_tag(context, output) template_name = @template_name_expr raise ::ArgumentError unless template_name.is_a?(String) - # Inline snippets take precedence over external snippets - if (inline_snippet = context.registers[:inline_snippet][template_name]) + if context[template_name].is_a?(Liquid::SnippetDrop) + snippet_drop = context[template_name] inner_context = context.new_isolated_subcontext - snippet_body = inline_snippet[:body] - context.scopes.each do |scope| - scope.each do |key, value| - inner_context[key] = value + if inherit_context? + context.scopes.each do |scope| + scope.each do |key, value| + inner_context[key] = value + end end end @@ -66,7 +71,7 @@ def render_tag(context, output) inner_context[key] = context.evaluate(value) end - return output << snippet_body.render(inner_context) + return output << snippet_drop.body.render(inner_context) end partial = PartialCache.load( diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 7f9072742..e1d978b61 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -25,12 +25,14 @@ def initialize(tag_name, markup, options) end end - def render(context) - context.registers[:inline_snippet] ||= {} - context.registers[:inline_snippet][@to] = { - body: @body, - } - '' + def render_to_output_buffer(context, output) + snippet_drop = SnippetDrop.new(@body) + context.scopes.last[@to] = snippet_drop + output + end + + def blank? + true end end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 245862278..27e50ec68 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -229,21 +229,18 @@ def test_render_multiple_inline_snippets_without_leaking_context assert_template_result(expected, template) end - def test_render_parent_context_variable + def test_render_inline_snippet_without_outside_context template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} {% snippet header %} - {% doc %} - @param {string} message - Message. - {% enddoc %} -
{{ message }}
{% endsnippet %} - {%- render "header", message: "Welcome to my site" -%} + + {% render "header", message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -251,32 +248,27 @@ def test_render_parent_context_variable -
- Welcome to my site + +
+ Welcome!
OUTPUT assert_template_result(expected, template) end - def test_deeply_nested_snippets + def test_render_inline_snippet_with_outside_context template = <<~LIQUID.strip - {% assign color_scheme = 'first-color' %} - {% snippet first %} - {% assign color_scheme = 'second-color' %} - {% snippet second %} - {% assign color_scheme = 'third-color' %} - {% snippet third %} + {% assign color_scheme = 'dark' %} + + {% snippet header %}
- This is a header + {{ message }}
{% endsnippet %} - {%- render "third" -%} - {% endsnippet %} - {%- render "second" -%} - {% endsnippet %} - {%- render "first" -%} + + {% render "header", ..., message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -285,15 +277,15 @@ def test_deeply_nested_snippets -
- This is a header +
+ Welcome!
OUTPUT assert_template_result(expected, template) end - def test_render_snippet_with_variables_in_both_scopes + def test_inline_snippet_local_scope_takes_precedence template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} @@ -306,7 +298,9 @@ def test_render_snippet_with_variables_in_both_scopes {{ color_scheme }} - {%- render "header", message: 'Welcome to my site' -%} + {% render "header", ..., message: 'Welcome!' %} + + {{ color_scheme }} LIQUID expected = <<~OUTPUT @@ -315,39 +309,126 @@ def test_render_snippet_with_variables_in_both_scopes dark + +
- Welcome to my site + Welcome!
+ + + dark OUTPUT assert_template_result(expected, template) end - # def test_render_snippets_as_arguments - # template = <<~LIQUID.strip - # {% assign color_scheme = 'dark' %} + def test_render_captured_snippet + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - # {% snippet main_header %} - # {% assign color_scheme = 'auto' %} + {% snippet header %} +
+ {{ message }} +
+ {% endsnippet %} + + {% capture up_header %} + {% render "header", ..., message: 'Welcome!' %} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} + LIQUID + expected = <<~OUTPUT + + + + + + + + + +
+ WELCOME! +
- #
- # {%- render "header", message: 'Welcome to my site' -%} - #
- # {% endsnippet %} + + SNIPPETDROP + + SnippetDrop + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
+ {{ message }} +
+ {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
+ {% render "header", ..., message: 'Welcome!' %} +
+ {% endsnippet %} + + {% render "main", header: header %} + LIQUID + + expected = <<~OUTPUT + + + + + + + + + + +
+ +
+ Welcome! +
+ +
+ OUTPUT + + assert_template_result(expected, template) + end + + # def test_render_inline_snippet_inside_loop + # template = <<~LIQUID.strip + # {% assign color_scheme = 'dark' %} + # {% assign array = '1,2,3' | split: ',' %} + + # {% for i in array %} # {% snippet header %} - #
- # {{ message }} - #
+ #
+ # {{ message }} {{ i }} + #
# {% endsnippet %} + # {% endfor %} - # {%- render "main_header", header: header -%} + # {% render "header", ..., message: '👉' %} # LIQUID # expected = <<~OUTPUT - #
- #
- # Welcome to my site - #
+ + #
+ # 👉 3 #
# OUTPUT From c7ad1c90ca683332e21d08a85f2e90a538f20a01 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 2 Oct 2025 19:26:17 -0600 Subject: [PATCH 266/343] Change inline snippet identifier from string to variable Currently, snippet files identified by strings. This PR makes changes to render to allow for new inline snippets to use variables as identifiers instead --- example/server/templates/index.liquid | 87 ++++++++------------------- lib/liquid/tags/render.rb | 16 +++-- test/integration/tags/snippet_test.rb | 56 ++++++++++------- 3 files changed, 70 insertions(+), 89 deletions(-) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 529055089..d4ea6afcf 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,71 +1,32 @@ - - - + + - - - Simple Code Editor - - - - - - - - - -
- {% snippet "main" %} - - {% # Snippet input %} - {% snippet "input" |type, name| %} -
- - -
- {% endsnippet %} + + + Inline Snippets - {% snippet "league" %} -

Welcome to the league of super evil

- {% endsnippet %} - - {% render "league" %} - {% render "input", type: "text" %} - {% render "input", type: "password" %} + +
+ + + {% snippet main %} + {% assign foo = false %} +

Hi {{ arg | upcase }}!!!

+ +

This is an inline snippet

+ +
    +
  • wow a link
  • +
  • 1 + 1 = {{ 1 | plus: 1 }}
  • +
  • {% if true %}Yes!{% endif %}
  • +
  • {% if foo %}NO{% endif %}
  • +
  • {{ missing_var | default: 'fallback' }}
  • +
{% endsnippet %} - {% render 'main' %} -
- + {% render main, arg: 'lsf' %} - +
diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 03f231eaa..288e226d2 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -27,7 +27,7 @@ module Liquid # @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension. class Render < Tag FOR = 'for' - SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o + SYNTAX = /(#{QuotedString}+|#{VariableSegment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o disable_tags "include" @@ -51,12 +51,15 @@ def render_to_output_buffer(context, output) end def render_tag(context, output) - # The expression should be a String literal, which parses to a String object template_name = @template_name_expr - raise ::ArgumentError unless template_name.is_a?(String) - if context[template_name].is_a?(Liquid::SnippetDrop) - snippet_drop = context[template_name] + # For inline snippets, @template_name_expr is a VariableLookup + if template_name.is_a?(VariableLookup) + + snippet_drop = context[template_name.name] + + raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) + inner_context = context.new_isolated_subcontext if inherit_context? @@ -74,6 +77,9 @@ def render_tag(context, output) return output << snippet_drop.body.render(inner_context) end + # Otherwise, the expression should be a String literal, which parses to a String object + raise ::ArgumentError unless template_name.is_a?(String) + partial = PartialCache.load( template_name, context: context, diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 27e50ec68..5dcd5465a 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -22,7 +22,7 @@ def test_render_inline_snippet Hey {% endsnippet %} - {%- render "hey" -%} + {%- render hey -%} LIQUID expected = <<~OUTPUT @@ -32,6 +32,22 @@ def test_render_inline_snippet assert_template_result(expected, template) end + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

Today is {{ "hello" | capitalize }}

+ {% endsnippet %} + + {%- render hey -%} + LIQUID + expected = <<~OUTPUT + +

Today is Hello

+ OUTPUT + + assert_template_result(expected, template) + end + def test_render_multiple_inline_snippets template = <<~LIQUID.strip {% snippet input %} @@ -44,8 +60,8 @@ def test_render_multiple_inline_snippets {% endsnippet %} - {%- render "input" -%} - {%- render "banner" -%} + {%- render input -%} + {%- render banner -%} LIQUID expected = <<~OUTPUT @@ -67,7 +83,7 @@ def test_render_inline_snippet_with_argument {% endsnippet %} - {%- render "input", type: "text" -%} + {%- render input, type: "text" -%} LIQUID expected = <<~OUTPUT @@ -87,7 +103,7 @@ def test_render_inline_snippet_with_doc_tag {% endsnippet %} - {%- render "input", type: "text" -%} + {%- render input, type: "text" -%} LIQUID expected = <<~OUTPUT @@ -110,7 +126,7 @@ def test_render_inline_snippet_with_multiple_arguments {% endsnippet %} - {%- render "input", type: "text", value: "Hello" -%} + {%- render input, type: "text", value: "Hello" -%} LIQUID expected = <<~OUTPUT @@ -132,8 +148,8 @@ def test_render_inline_snippets_using_same_argument_name {% endsnippet %} - {%- render "input", type: "text" -%} - {%- render "inputs", type: "password", value: "pass" -%} + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} LIQUID expected = <<~OUTPUT @@ -159,7 +175,7 @@ def test_render_inline_snippet_empty_string_when_missing_argument {% endsnippet %} - {%- render "input", type: "text" -%} + {%- render input, type: "text" -%} LIQUID expected = <<~OUTPUT @@ -182,7 +198,7 @@ def test_render_inline_snippet_shouldnt_leak_context {% endsnippet %} - {%- render "input", type: "text", value: "Hello" -%} + {%- render input, type: "text", value: "Hello" -%} {{ type }} {{ value }} @@ -212,8 +228,8 @@ def test_render_multiple_inline_snippets_without_leaking_context {% endsnippet %} - {%- render "input", type: "text" -%} - {%- render "no_leak" -%} + {%- render input, type: "text" -%} + {%- render no_leak -%} LIQUID expected = <<~OUTPUT @@ -240,7 +256,7 @@ def test_render_inline_snippet_without_outside_context {% endsnippet %} - {% render "header", message: 'Welcome!' %} + {% render header, message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -268,7 +284,7 @@ def test_render_inline_snippet_with_outside_context {% endsnippet %} - {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -298,7 +314,7 @@ def test_inline_snippet_local_scope_takes_precedence {{ color_scheme }} - {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %} {{ color_scheme }} LIQUID @@ -333,7 +349,7 @@ def test_render_captured_snippet {% endsnippet %} {% capture up_header %} - {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %} {% endcapture %} {{ up_header | upcase }} @@ -351,7 +367,6 @@ def test_render_captured_snippet -
WELCOME!
@@ -380,11 +395,11 @@ def test_render_snippets_as_arguments {% assign color_scheme = 'auto' %}
- {% render "header", ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %}
{% endsnippet %} - {% render "main", header: header %} + {% render main, header: header %} LIQUID expected = <<~OUTPUT @@ -397,7 +412,6 @@ def test_render_snippets_as_arguments -
@@ -423,7 +437,7 @@ def test_render_snippets_as_arguments # {% endsnippet %} # {% endfor %} - # {% render "header", ..., message: '👉' %} + # {% render header, ..., message: '👉' %} # LIQUID # expected = <<~OUTPUT From 9bcfd32e65a6b8ef7ef823d4a7b0335953ae6eab Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 3 Oct 2025 07:32:34 -0600 Subject: [PATCH 267/343] Support ... inline snippet syntax --- example/server/templates/index.liquid | 10 ++- lib/liquid/lexer.rb | 10 ++- lib/liquid/tags/render.rb | 24 ++++- test/integration/tags/snippet_test.rb | 122 +++++++++++++++++++++++--- 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index d4ea6afcf..0862f2168 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -8,7 +8,8 @@
- + {% assign foo = true %} + {% assign link = "variable" %} {% snippet main %} {% assign foo = false %} @@ -17,15 +18,16 @@

This is an inline snippet

    -
  • wow a link
  • +
  • wow a {{ link }}
  • 1 + 1 = {{ 1 | plus: 1 }}
  • {% if true %}Yes!{% endif %}
  • -
  • {% if foo %}NO{% endif %}
  • +
  • foo = {% if foo %}true{%else%}false{% endif %}
  • {{ missing_var | default: 'fallback' }}
{% endsnippet %} - {% render main, arg: 'lsf' %} + {% render main, arg: 'lsf', ... %} + {{ foo }}
diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index f1740dbad..94a3d9faf 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -17,6 +17,7 @@ class Lexer DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze + DOTDOTDOT = [:dotdotdot, "..."].freeze DOT_ORD = ".".ord DOUBLE_STRING_LITERAL = /"[^\"]*"/ EOS = [:end_of_string].freeze @@ -113,10 +114,15 @@ def tokenize(ss) if (special = SPECIAL_TABLE[peeked]) ss.scan_byte - # Special case for ".." + # Special case for ".." and "..." if special == DOT && ss.peek_byte == DOT_ORD ss.scan_byte - output << DOTDOT + if ss.peek_byte == DOT_ORD + ss.scan_byte + output << DOTDOTDOT + else + output << DOTDOT + end elsif special == DASH # Special case for negative numbers if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 288e226d2..e84397b0f 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -62,7 +62,13 @@ def render_tag(context, output) inner_context = context.new_isolated_subcontext - if inherit_context? + if is_file + inner_context.template_name = partial.name + inner_context.partial = true + end + + if is_inline && inherit_context? + context.scopes.each do |scope| scope.each do |key, value| inner_context[key] = value @@ -125,6 +131,14 @@ def rigid_parse(markup) p.consume?(:comma) + @inherit_context = false + # ... inline snippets syntax + if p.consume?(:dotdotdot) + p.consume?(:comma) + + @inherit_context = true + end + @attributes = {} while p.look(:id) key = p.consume @@ -137,7 +151,12 @@ def rigid_parse(markup) end def rigid_template_name(p) - p.consume(:string) + if p.look(:string) + p.consume(:string) + # inline snippets use variable identifiers + elsif p.look(:id) + p.consume(:id) + end end def strict_parse(markup) @@ -155,6 +174,7 @@ def lax_parse(markup) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) @is_for_loop = (with_or_for == FOR) + @inherit_context = markup.include?('...') @attributes = {} markup.scan(TagAttributes) do |key, value| diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 5dcd5465a..9824a16e9 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -115,6 +115,23 @@ def test_render_inline_snippet_with_doc_tag assert_template_result(expected, template) end + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

{{ greeting }}

+ {% endsnippet %} + + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT + +

HELLO

+ OUTPUT + + assert_template_result(expected, template) + end + def test_render_inline_snippet_with_multiple_arguments template = <<~LIQUID.strip {% snippet input %} @@ -301,8 +318,36 @@ def test_render_inline_snippet_with_outside_context assert_template_result(expected, template) end + def test_render_inline_snippet_with_outside_context_rigid + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
+ {{ message }} +
+ {% endsnippet %} + + + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
+ Welcome! +
+ OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID.strip + template = <<~LIQUID {% assign color_scheme = 'dark' %} {% snippet header %} @@ -339,17 +384,17 @@ def test_inline_snippet_local_scope_takes_precedence end def test_render_captured_snippet - template = <<~LIQUID.strip + template = <<~LIQUID {% assign color_scheme = 'dark' %} {% snippet header %} -
- {{ message }} -
+
+ {{ message }} +
{% endsnippet %} {% capture up_header %} - {% render header, ..., message: 'Welcome!' %} + {%- render header, ..., message: 'Welcome!' -%} {% endcapture %} {{ up_header | upcase }} @@ -366,11 +411,9 @@ def test_render_captured_snippet - -
- WELCOME! -
- +
+ WELCOME! +
SNIPPETDROP @@ -395,7 +438,7 @@ def test_render_snippets_as_arguments {% assign color_scheme = 'auto' %}
- {% render header, ..., message: 'Welcome!' %} + {% render header, ..., message: 'Welcome!' %}
{% endsnippet %} @@ -448,4 +491,59 @@ def test_render_snippets_as_arguments # assert_template_result(expected, template) # end + + # def test_render_inline_snippet_forloop + # template = <<~LIQUID.strip + # {% snippet item %} + #
  • {{ forloop.index }}: {{ item }}
  • + # {% endsnippet %} + + # {% assign items = "A,B,C" | split: "," %} + # {%- render item for items -%} + # LIQUID + # expected = <<~OUTPUT + + #
  • 1: A
  • + + #
  • 2: B
  • + + #
  • 3: C
  • + # OUTPUT + + # assert_template_result(expected, template) + # end + + # def test_render_inline_snippet_with + # template = <<~LIQUID.strip + # {% snippet header %} + #
    {{ header }}
    + # {% endsnippet %} + + # {% assign product = "Apple" %} + # {%- render header with product -%} + # LIQUID + # expected = <<~OUTPUT + + #
    Apple
    + # OUTPUT + + # assert_template_result(expected, template) + # end + + # def test_render_inline_snippet_alias + # template = <<~LIQUID.strip + # {% snippet product_card %} + #
    {{ item }}
    + # {% endsnippet %} + + # {% assign featured = "Apple" %} + # {%- render product_card with featured as item -%} + # LIQUID + # expected = <<~OUTPUT + + #
    Apple
    + # OUTPUT + + # assert_template_result(expected, template) + # end end From 99116638fdc3093118e936bac5326eab789b0adc Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 3 Oct 2025 13:19:47 -0600 Subject: [PATCH 268/343] Support with, for, and as inline snippet syntax This commit updates the render method to share parts of the snippet and block rendering logic to enable inline snippets to support `with`, `for`, and `as` syntax --- Gemfile.lock | 17 +- example/server/templates/index.liquid | 17 +- lib/liquid/tags/render.rb | 44 ++--- lib/liquid/tags/snippet.rb | 5 +- test/integration/tags/render_tag_test.rb | 4 +- test/integration/tags/snippet_test.rb | 199 ++++++++++++----------- 6 files changed, 147 insertions(+), 139 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index eff33ff57..381c2b7e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,15 +1,9 @@ -GIT - remote: https://github.com/Shopify/liquid-c.git - revision: 5a786af7284df55e013ea20551c4b688d02e8326 - ref: main - specs: - liquid-c (4.2.0) - liquid (>= 5.0.1) - PATH remote: . specs: - liquid (5.6.0.alpha) + liquid (5.8.7) + bigdecimal + strscan (>= 3.1.1) GEM remote: https://rubygems.org/ @@ -17,8 +11,10 @@ GEM ast (2.4.2) base64 (0.2.0) benchmark-ips (2.13.0) + bigdecimal (3.2.3) json (2.7.2) language_server-protocol (3.17.0.3) + lru_redux (1.1.0) memory_profiler (1.0.1) minitest (5.22.3) parallel (1.24.0) @@ -50,6 +46,7 @@ GEM rubocop (~> 1.44) ruby-progressbar (1.13.0) stackprof (0.2.26) + strscan (3.1.5) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) unicode-display_width (2.5.0) @@ -62,7 +59,7 @@ DEPENDENCIES base64 benchmark-ips liquid! - liquid-c! + lru_redux memory_profiler minitest rake (~> 13.0) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index 0862f2168..a7a637dbf 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -5,12 +5,12 @@ Inline Snippets -
    + {% assign foo = true %} - {% assign link = "variable" %} - + {% assign linktext = "variable" %} + {% snippet main %} {% assign foo = false %}

    Hi {{ arg | upcase }}!!!

    @@ -18,7 +18,7 @@

    This is an inline snippet

      -
    • wow a {{ link }}
    • +
    • wow a {{ linktext }}
    • 1 + 1 = {{ 1 | plus: 1 }}
    • {% if true %}Yes!{% endif %}
    • foo = {% if foo %}true{%else%}false{% endif %}
    • @@ -29,6 +29,15 @@ {% render main, arg: 'lsf', ... %} {{ foo }} + {% snippet listitem %} +
    • {{ forloop.index }}: {{ emoji }}
    • + {% endsnippet %} + + {% assign emojis = "🌼,🌳,🌸" | split: "," %} +
        + {%- render listitem for emojis as emoji -%} +
      +
    diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index e84397b0f..db8fa768e 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -52,14 +52,24 @@ def render_to_output_buffer(context, output) def render_tag(context, output) template_name = @template_name_expr + is_inline = template_name.is_a?(VariableLookup) + is_file = template_name.is_a?(String) - # For inline snippets, @template_name_expr is a VariableLookup - if template_name.is_a?(VariableLookup) + if is_inline + template_name = template_name.name + snippet_drop = context[template_name] + raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) - snippet_drop = context[template_name.name] + partial = snippet_drop.body + else + raise ::ArgumentError unless is_file - raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) + partial = PartialCache.load(template_name, context: context, parse_context: parse_context) + end + + context_variable_name = @alias_name || template_name.split('/').last + render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext if is_file @@ -68,7 +78,6 @@ def render_tag(context, output) end if is_inline && inherit_context? - context.scopes.each do |scope| scope.each do |key, value| inner_context[key] = value @@ -80,30 +89,9 @@ def render_tag(context, output) inner_context[key] = context.evaluate(value) end - return output << snippet_drop.body.render(inner_context) - end - - # Otherwise, the expression should be a String literal, which parses to a String object - raise ::ArgumentError unless template_name.is_a?(String) - - partial = PartialCache.load( - template_name, - context: context, - parse_context: parse_context, - ) - - context_variable_name = @alias_name || template_name.split('/').last - - render_partial_func = ->(var, forloop) { - inner_context = context.new_isolated_subcontext - inner_context.template_name = partial.name - inner_context.partial = true - inner_context['forloop'] = forloop if forloop - - @attributes.each do |key, value| - inner_context[key] = context.evaluate(value) - end inner_context[context_variable_name] = var unless var.nil? + inner_context['forloop'] = forloop if forloop + partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) } diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index e1d978b61..aede69b78 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -18,8 +18,9 @@ class Snippet < Block def initialize(tag_name, markup, options) super - if markup =~ SYNTAX - @to = Regexp.last_match(1) + p = @parse_context.new_parser(markup) + if p.look(:id) + @to = p.consume(:id) else raise SyntaxError, options[:locale].t("errors.syntax.snippet") end diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index d6453fb51..e4ce0c294 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -102,7 +102,9 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end def test_dynamically_choosen_templates_are_not_allowed - assert_syntax_error("{% assign name = 'snippet' %}{% render name %}") + assert_raises(::ArgumentError) do + Template.parse('{% assign name = "snippet" %}{% render name %}').render! + end end def test_rigid_parsing_errors diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 9824a16e9..8699b359e 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -319,31 +319,31 @@ def test_render_inline_snippet_with_outside_context end def test_render_inline_snippet_with_outside_context_rigid - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - {% render header, ..., message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT -
    - Welcome! -
    - OUTPUT +
    + Welcome! +
    + OUTPUT - assert_template_result(expected, template, error_mode: :rigid) + assert_template_result(expected, template, error_mode: :rigid) end def test_inline_snippet_local_scope_takes_precedence @@ -467,83 +467,94 @@ def test_render_snippets_as_arguments assert_template_result(expected, template) end - # def test_render_inline_snippet_inside_loop - # template = <<~LIQUID.strip - # {% assign color_scheme = 'dark' %} - # {% assign array = '1,2,3' | split: ',' %} - - # {% for i in array %} - # {% snippet header %} - #
    - # {{ message }} {{ i }} - #
    - # {% endsnippet %} - # {% endfor %} - - # {% render header, ..., message: '👉' %} - # LIQUID - # expected = <<~OUTPUT - - #
    - # 👉 3 - #
    - # OUTPUT - - # assert_template_result(expected, template) - # end - - # def test_render_inline_snippet_forloop - # template = <<~LIQUID.strip - # {% snippet item %} - #
  • {{ forloop.index }}: {{ item }}
  • - # {% endsnippet %} - - # {% assign items = "A,B,C" | split: "," %} - # {%- render item for items -%} - # LIQUID - # expected = <<~OUTPUT - - #
  • 1: A
  • - - #
  • 2: B
  • - - #
  • 3: C
  • - # OUTPUT - - # assert_template_result(expected, template) - # end - - # def test_render_inline_snippet_with - # template = <<~LIQUID.strip - # {% snippet header %} - #
    {{ header }}
    - # {% endsnippet %} - - # {% assign product = "Apple" %} - # {%- render header with product -%} - # LIQUID - # expected = <<~OUTPUT - - #
    Apple
    - # OUTPUT - - # assert_template_result(expected, template) - # end - - # def test_render_inline_snippet_alias - # template = <<~LIQUID.strip - # {% snippet product_card %} - #
    {{ item }}
    - # {% endsnippet %} - - # {% assign featured = "Apple" %} - # {%- render product_card with featured as item -%} - # LIQUID - # expected = <<~OUTPUT - - #
    Apple
    - # OUTPUT - - # assert_template_result(expected, template) - # end + def test_render_inline_snippet_forloop + template = <<~LIQUID.strip + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • + {% endsnippet %} + + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} + LIQUID + expected = <<~OUTPUT + + + +
  • 1: A
  • + +
  • 2: B
  • + +
  • 3: C
  • + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with + template = <<~LIQUID.strip + {% snippet header %} +
    {{ header }}
    + {% endsnippet %} + + {% assign product = "Apple" %} + {%- render header with product -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_inside_loop + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign array = '1,2,3' | split: ',' %} + + {% for i in array %} + {% snippet header %} +
    + {{ message }} {{ i }} +
    + {% endsnippet %} + {% endfor %} + + {% render header, ..., message: '👉' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
    + 👉#{" "} +
    + OUTPUT + + assert_template_result(expected, template) + end end From 489a03118ce27053fd162f0160e029a2f0af3ba5 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Mon, 20 Oct 2025 10:53:01 -0600 Subject: [PATCH 269/343] Remove inline snippet specific example files --- Gemfile | 4 -- Gemfile.lock | 74 --------------------------- example/server/parser_attempt.rb | 59 --------------------- example/server/templates/index.liquid | 44 ++-------------- 4 files changed, 3 insertions(+), 178 deletions(-) delete mode 100644 Gemfile.lock delete mode 100644 example/server/parser_attempt.rb diff --git a/Gemfile b/Gemfile index fc327c921..a404c0d4b 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,3 @@ group :test do gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end - -group :development do - gem "webrick" -end diff --git a/Gemfile.lock b/Gemfile.lock deleted file mode 100644 index 381c2b7e3..000000000 --- a/Gemfile.lock +++ /dev/null @@ -1,74 +0,0 @@ -PATH - remote: . - specs: - liquid (5.8.7) - bigdecimal - strscan (>= 3.1.1) - -GEM - remote: https://rubygems.org/ - specs: - ast (2.4.2) - base64 (0.2.0) - benchmark-ips (2.13.0) - bigdecimal (3.2.3) - json (2.7.2) - language_server-protocol (3.17.0.3) - lru_redux (1.1.0) - memory_profiler (1.0.1) - minitest (5.22.3) - parallel (1.24.0) - parser (3.3.0.5) - ast (~> 2.4.1) - racc - racc (1.7.3) - rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.0) - rexml (3.2.6) - rubocop (1.61.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) - parser (>= 3.3.0.2) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.30.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.2) - parser (>= 3.3.0.4) - rubocop-performance (1.19.1) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) - rubocop-shopify (2.12.0) - rubocop (~> 1.44) - ruby-progressbar (1.13.0) - stackprof (0.2.26) - strscan (3.1.5) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) - unicode-display_width (2.5.0) - webrick (1.8.1) - -PLATFORMS - ruby - -DEPENDENCIES - base64 - benchmark-ips - liquid! - lru_redux - memory_profiler - minitest - rake (~> 13.0) - rubocop (~> 1.61.0) - rubocop-performance - rubocop-shopify (~> 2.12.0) - stackprof - terminal-table - webrick - -BUNDLED WITH - 2.5.7 diff --git a/example/server/parser_attempt.rb b/example/server/parser_attempt.rb deleted file mode 100644 index 68e214dab..000000000 --- a/example/server/parser_attempt.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -require 'bundler/inline' - -gemfile(true) do - source "https://rubygems.org" - gem 'liquid' -end - -require 'liquid' - -class Parser - def initialize(template) - @template = template - end - - def parse - @parsed_template = Liquid::Template.parse(@template) - end - - def test_parse - document = @parsed_template.root - - variables = [] - - if document.is_a?(Liquid::Document) - body = document.body - - if body.is_a?(Liquid::BlockBody) - body.nodelist.each do |node| - next unless node.is_a?(Liquid::Variable) - - puts node.inspect - variable_name = node.name.name - variables << variable_name - end - end - end - puts "Variables: #{variables}" - end - - def render - @parsed_template.render - end -end - -starter_template = "{{ foo }}" -starter_template_2 = "{{foo}}, {{bar}}" -starter_template_2_1 = "{{ foo }} and {{ bar }}" -starter_template_3 = "{% assign foo = 'bar' %}{{ foo }}" -# Let's start small here -template = <<~LIQUID - {% assign foo = 'bar' %} - {{ foo }} -LIQUID - -parser = Parser.new(starter_template) -parser.parse -parser.test_parse diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index a7a637dbf..e18cc306d 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,43 +1,5 @@ - - - - - - Inline Snippets +

    Hello world!

    - -
    +

    It is {{ date }}

    - {% assign foo = true %} - {% assign linktext = "variable" %} - - {% snippet main %} - {% assign foo = false %} -

    Hi {{ arg | upcase }}!!!

    - -

    This is an inline snippet

    - -
      -
    • wow a {{ linktext }}
    • -
    • 1 + 1 = {{ 1 | plus: 1 }}
    • -
    • {% if true %}Yes!{% endif %}
    • -
    • foo = {% if foo %}true{%else%}false{% endif %}
    • -
    • {{ missing_var | default: 'fallback' }}
    • -
    - {% endsnippet %} - - {% render main, arg: 'lsf', ... %} - {{ foo }} - - {% snippet listitem %} -
  • {{ forloop.index }}: {{ emoji }}
  • - {% endsnippet %} - - {% assign emojis = "🌼,🌳,🌸" | split: "," %} -
      - {%- render listitem for emojis as emoji -%} -
    - -
    - - +

    Check out the Products screen

    From 65fb80a347e5f7dabc2a867ce2c998a82beae2ae Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Mon, 20 Oct 2025 12:57:36 -0600 Subject: [PATCH 270/343] Render arguments should maintain correct precedence --- lib/liquid.rb | 1 + lib/liquid/tags/render.rb | 42 +- test/integration/tags/snippet_test.rb | 1393 ++++++++++++++++++------- 3 files changed, 1056 insertions(+), 380 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 09cafe947..2bc0b4a72 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -40,6 +40,7 @@ module Liquid QuotedString = /"[^"]*"|'[^']*'/ QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o TagAttributes = /(\w[\w-]*)\s*\:\s*(#{QuotedFragment})/o + ContextInheritance = /\.\.\./ AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index db8fa768e..dd1a7a18f 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -42,10 +42,6 @@ def for_loop? @is_for_loop end - def inherit_context? - @inherit_context - end - def render_to_output_buffer(context, output) render_tag(context, output) end @@ -77,20 +73,21 @@ def render_tag(context, output) inner_context.partial = true end - if is_inline && inherit_context? - context.scopes.each do |scope| - scope.each do |key, value| - inner_context[key] = value - end - end - end + inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| - inner_context[key] = context.evaluate(value) + if key == "..." && is_inline + context.scopes.each do |scope| + scope.each do |k, v| + inner_context[k] = v + end + end + else + inner_context[key] = context.evaluate(value) + end end inner_context[context_variable_name] = var unless var.nil? - inner_context['forloop'] = forloop if forloop partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) @@ -119,14 +116,6 @@ def rigid_parse(markup) p.consume?(:comma) - @inherit_context = false - # ... inline snippets syntax - if p.consume?(:dotdotdot) - p.consume?(:comma) - - @inherit_context = true - end - @attributes = {} while p.look(:id) key = p.consume @@ -162,11 +151,16 @@ def lax_parse(markup) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) @is_for_loop = (with_or_for == FOR) - @inherit_context = markup.include?('...') @attributes = {} - markup.scan(TagAttributes) do |key, value| - @attributes[key] = parse_expression(value) + markup.scan(/(#{ContextInheritance})|#{TagAttributes.source}/) do |context_marker, key, value| + if context_marker + @attributes.delete("...") + @attributes["..."] = true + elsif key && value + @attributes.delete(key) + @attributes[key] = parse_expression(value) + end end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 8699b359e..38fe599a1 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -5,556 +5,1237 @@ class SnippetTest < Minitest::Test include Liquid - def test_valid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} + class LaxMode < SnippetTest + def test_valid_inline_snippet + template = <<~LIQUID.strip + {% snippet input %} + Hey + {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template) + end + + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet hey %} Hey - {% endsnippet %} - LIQUID - expected = '' + {% endsnippet %} - assert_template_result(expected, template) - end + {%- render hey -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet - template = <<~LIQUID.strip - {% snippet hey %} - Hey - {% endsnippet %} + Hey + OUTPUT - {%- render hey -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end - Hey - OUTPUT + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

    Today is {{ "hello" | capitalize }}

    + {% endsnippet %} - assert_template_result(expected, template) - end + {%- render hey -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet_with_variable - template = <<~LIQUID.strip - {% snippet hey %} -

    Today is {{ "hello" | capitalize }}

    - {% endsnippet %} +

    Today is Hello

    + OUTPUT - {%- render hey -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end -

    Today is Hello

    - OUTPUT + def test_render_multiple_inline_snippets + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} - assert_template_result(expected, template) - end + {% snippet banner %} + + Welcome to my store! + + {% endsnippet %} - def test_render_multiple_inline_snippets - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} + {%- render input -%} + {%- render banner -%} + LIQUID + expected = <<~OUTPUT - {% snippet banner %} - - Welcome to my store! - - {% endsnippet %} - {%- render input -%} - {%- render banner -%} - LIQUID - expected = <<~OUTPUT + + + Welcome to my store! + + OUTPUT - + assert_template_result(expected, template) + end - - Welcome to my store! - - OUTPUT + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} - assert_template_result(expected, template) - end + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet_with_argument - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} + + OUTPUT - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_with_doc_tag + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_with_doc_tag - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - - {% endsnippet %} - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT + + OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

    {{ greeting }}

    + {% endsnippet %} - assert_template_result(expected, template) - end + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT - def test_render_inline_snippet_with_evaluated_assign - template = <<~LIQUID.strip - {% snippet input %} -

    {{ greeting }}

    - {% endsnippet %} +

    HELLO

    + OUTPUT - {%- assign greeting = "hello" | upcase -%} - {%- render input, greeting: greeting -%} - LIQUID - expected = <<~OUTPUT + assert_template_result(expected, template) + end -

    HELLO

    - OUTPUT + def test_render_inline_snippet_with_multiple_arguments + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_with_multiple_arguments - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} + {%- render input, type: "text", value: "Hello" -%} + LIQUID + expected = <<~OUTPUT - - {% endsnippet %} - {%- render input, type: "text", value: "Hello" -%} - LIQUID - expected = <<~OUTPUT + + OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippets_using_same_argument_name + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} - assert_template_result(expected, template) - end + {% snippet inputs %} + + {% endsnippet %} - def test_render_inline_snippets_using_same_argument_name - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} + LIQUID - {% snippet inputs %} - - {% endsnippet %} + expected = <<~OUTPUT - {%- render input, type: "text" -%} - {%- render inputs, type: "password", value: "pass" -%} - LIQUID - expected = <<~OUTPUT + + + OUTPUT - + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_empty_string_when_missing_argument + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_empty_string_when_missing_argument - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - - {% endsnippet %} - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT + + OUTPUT + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_shouldnt_leak_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_inline_snippet_shouldnt_leak_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} + {%- render input, type: "text", value: "Hello" -%} - - {% endsnippet %} + {{ type }} + {{ value }} + LIQUID + expected = <<~OUTPUT - {%- render input, type: "text", value: "Hello" -%} - {{ type }} - {{ value }} - LIQUID - expected = <<~OUTPUT + + OUTPUT - + assert_template_result(expected, template) + end - OUTPUT + def test_render_multiple_inline_snippets_without_leaking_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} - assert_template_result(expected, template) - end + + {% endsnippet %} - def test_render_multiple_inline_snippets_without_leaking_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} + {% snippet no_leak %} + + {% endsnippet %} - - {% endsnippet %} + {%- render input, type: "text" -%} + {%- render no_leak -%} + LIQUID + expected = <<~OUTPUT - {% snippet no_leak %} - - {% endsnippet %} - {%- render input, type: "text" -%} - {%- render no_leak -%} - LIQUID - expected = <<~OUTPUT + + + OUTPUT - + assert_template_result(expected, template) + end - - OUTPUT + def test_render_inline_snippet_without_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - assert_template_result(expected, template) - end + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_without_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% render header, message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% render header, message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + assert_template_result(expected, template) + end -
    - Welcome! -
    - OUTPUT + def test_render_inline_snippet_with_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} - assert_template_result(expected, template) - end + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_with_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% render header, ..., message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + assert_template_result(expected, template) + end -
    - Welcome! -
    - OUTPUT + def test_inline_snippet_local_scope_takes_precedence + template = <<~LIQUID + {% assign color_scheme = 'dark' %} - assert_template_result(expected, template) - end + {% snippet header %} + {% assign color_scheme = 'light' %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_with_outside_context_rigid - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} + {{ color_scheme }} - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} + {% render header, ..., message: 'Welcome!' %} + {{ color_scheme }} + LIQUID + expected = <<~OUTPUT - {% render header, ..., message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT + dark -
    - Welcome! -
    - OUTPUT +
    + Welcome! +
    - assert_template_result(expected, template, error_mode: :rigid) - end - def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID - {% assign color_scheme = 'dark' %} + dark + OUTPUT - {% snippet header %} - {% assign color_scheme = 'light' %} -
    - {{ message }} -
    - {% endsnippet %} + assert_template_result(expected, template) + end - {{ color_scheme }} + def test_render_inline_snippet_with_correct_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} - {% render header, ..., message: 'Welcome!' %} + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - {{ color_scheme }} - LIQUID - expected = <<~OUTPUT + {% render header, message: 'Welcome!', ... %} + LIQUID + expected = <<~OUTPUT - dark -
    - Welcome! -
    +
    + Goodbye! +
    + OUTPUT - dark - OUTPUT + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_correct_argument_order + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - assert_template_result(expected, template) - end - def test_render_captured_snippet - template = <<~LIQUID - {% assign color_scheme = 'dark' %} + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - {% capture up_header %} - {%- render header, ..., message: 'Welcome!' -%} - {% endcapture %} - {{ up_header | upcase }} - {{ header | upcase }} - {{ header }} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with_correct_duplicate_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + LIQUID + expected = <<~OUTPUT + -
    - WELCOME! -
    + +
    + Hi! +
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_captured_snippet + template = <<~LIQUID + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% capture up_header %} + {%- render header, ..., message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + WELCOME! +
    + + + SNIPPETDROP + + SnippetDrop + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
    + {% render header, ..., message: 'Welcome!' %} +
    + {% endsnippet %} + + {% render main, header: header %} + LIQUID + + expected = <<~OUTPUT - SNIPPETDROP - SnippetDrop - OUTPUT - assert_template_result(expected, template) + + + + + +
    + +
    + Welcome! +
    + +
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_forloop + template = <<~LIQUID.strip + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • + {% endsnippet %} + + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} + LIQUID + expected = <<~OUTPUT + + + +
  • 1: A
  • + +
  • 2: B
  • + +
  • 3: C
  • + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_with + template = <<~LIQUID.strip + {% snippet header %} +
    {{ header }}
    + {% endsnippet %} + + {% assign product = "Apple" %} + {%- render header with product -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_inside_loop + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign array = '1,2,3' | split: ',' %} + + {% for i in array %} + {% snippet header %} +
    + {{ message }} {{ i }} +
    + {% endsnippet %} + {% endfor %} + + {% render header, ..., message: '👉' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
    + 👉#{" "} +
    + OUTPUT + + assert_template_result(expected, template) + end end - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} + class RigidMode < SnippetTest + def test_valid_inline_snippet + template = <<~LIQUID.strip + {% snippet input %} + Hey + {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet hey %} + Hey + {% endsnippet %} + + {%- render hey -%} + LIQUID + expected = <<~OUTPUT + + Hey + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

    Today is {{ "hello" | capitalize }}

    + {% endsnippet %} + + {%- render hey -%} + LIQUID + expected = <<~OUTPUT + +

    Today is Hello

    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_multiple_inline_snippets + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {% snippet banner %} + + Welcome to my store! + + {% endsnippet %} + + {%- render input -%} + {%- render banner -%} + LIQUID + expected = <<~OUTPUT + + + + + + + Welcome to my store! + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_doc_tag + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

    {{ greeting }}

    + {% endsnippet %} + + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT + +

    HELLO

    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_multiple_arguments + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text", value: "Hello" -%} + LIQUID + expected = <<~OUTPUT + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end - {% snippet header %} + def test_render_inline_snippets_using_same_argument_name + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {% snippet inputs %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} + LIQUID + + expected = <<~OUTPUT + + + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_empty_string_when_missing_argument + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_shouldnt_leak_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + + {% endsnippet %} + + {%- render input, type: "text", value: "Hello" -%} + + {{ type }} + {{ value }} + LIQUID + expected = <<~OUTPUT + + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_multiple_inline_snippets_without_leaking_context + template = <<~LIQUID.strip + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + + + {% endsnippet %} + + {% snippet no_leak %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + {%- render no_leak -%} + LIQUID + expected = <<~OUTPUT + + + + + + + + + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_without_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %}
    {{ message }}
    - {% endsnippet %} + {% endsnippet %} - {% snippet main %} - {% assign color_scheme = 'auto' %} -
    - {% render header, ..., message: 'Welcome!' %} -
    - {% endsnippet %} + {% render header, message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT - {% render main, header: header %} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_outside_context + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT -
    -
    + +
    Welcome!
    + OUTPUT -
    - OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end - assert_template_result(expected, template) - end + def test_inline_snippet_local_scope_takes_precedence + template = <<~LIQUID + {% assign color_scheme = 'dark' %} + + {% snippet header %} + {% assign color_scheme = 'light' %} +
    + {{ message }} +
    + {% endsnippet %} - def test_render_inline_snippet_forloop - template = <<~LIQUID.strip - {% snippet item %} -
  • {{ forloop.index }}: {{ item }}
  • - {% endsnippet %} + {{ color_scheme }} - {% assign items = "A,B,C" | split: "," %} - {%- render item for items -%} - LIQUID - expected = <<~OUTPUT + {% render header, ..., message: 'Welcome!' %} + {{ color_scheme }} + LIQUID + expected = <<~OUTPUT -
  • 1: A
  • -
  • 2: B
  • -
  • 3: C
  • - OUTPUT + dark - assert_template_result(expected, template) - end - def test_render_inline_snippet_with - template = <<~LIQUID.strip - {% snippet header %} -
    {{ header }}
    - {% endsnippet %} - {% assign product = "Apple" %} - {%- render header with product -%} - LIQUID - expected = <<~OUTPUT +
    + Welcome! +
    + dark + OUTPUT -
    Apple
    - OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end - assert_template_result(expected, template) - end + def test_render_inline_snippet_with_correct_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} - def test_render_inline_snippet_alias - template = <<~LIQUID.strip - {% snippet product_card %} -
    {{ item }}
    - {% endsnippet %} + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} - {% assign featured = "Apple" %} - {%- render product_card with featured as item -%} - LIQUID - expected = <<~OUTPUT + {% render header, message: 'Welcome!', ... %} + LIQUID + expected = <<~OUTPUT -
    Apple
    - OUTPUT - assert_template_result(expected, template) - end - def test_render_inline_snippet_inside_loop - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign array = '1,2,3' | split: ',' %} - {% for i in array %} - {% snippet header %} -
    - {{ message }} {{ i }} -
    - {% endsnippet %} - {% endfor %} - {% render header, ..., message: '👉' %} - LIQUID - expected = <<~OUTPUT +
    + Goodbye! +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_correct_argument_order + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, ..., message: 'Welcome!' %} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + Welcome! +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_correct_duplicate_argument_precedence + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign message = 'Goodbye!' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + + {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + Hi! +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_captured_snippet + template = <<~LIQUID + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% capture up_header %} + {%- render header, ..., message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} + LIQUID + expected = <<~OUTPUT + + + + + + + +
    + WELCOME! +
    + + + SNIPPETDROP + + SnippetDrop + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
    + {% render header, ..., message: 'Welcome!' %} +
    + {% endsnippet %} + + {% render main, header: header %} + LIQUID + + expected = <<~OUTPUT -
    - 👉#{" "} -
    - OUTPUT - assert_template_result(expected, template) + + + +
    + +
    + Welcome! +
    + +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_forloop + template = <<~LIQUID.strip + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • + {% endsnippet %} + + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} + LIQUID + expected = <<~OUTPUT + + + +
  • 1: A
  • + +
  • 2: B
  • + +
  • 3: C
  • + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with + template = <<~LIQUID.strip + {% snippet header %} +
    {{ header }}
    + {% endsnippet %} + + {% assign product = "Apple" %} + {%- render header with product -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT + + + +
    Apple
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_inside_loop + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + {% assign array = '1,2,3' | split: ',' %} + + {% for i in array %} + {% snippet header %} +
    + {{ message }} {{ i }} +
    + {% endsnippet %} + {% endfor %} + + {% render header, ..., message: '👉' %} + LIQUID + expected = <<~OUTPUT + + + + + + +
    + 👉#{" "} +
    + OUTPUT + + assert_template_result(expected, template, error_mode: :rigid) + end end end From 0ceeefba02cec894e3a58a749410de12ef7ff969 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 12:17:41 -0600 Subject: [PATCH 271/343] Implement resource limits and remove leftover string references --- lib/liquid.rb | 1 - lib/liquid/locales/en.yml | 2 +- lib/liquid/tags/render.rb | 11 ++++------- lib/liquid/tags/snippet.rb | 8 +++++--- test/integration/tags/snippet_test.rb | 8 ++++++++ 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/lib/liquid.rb b/lib/liquid.rb index 2bc0b4a72..09cafe947 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -40,7 +40,6 @@ module Liquid QuotedString = /"[^"]*"|'[^']*'/ QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o TagAttributes = /(\w[\w-]*)\s*\:\s*(#{QuotedFragment})/o - ContextInheritance = /\.\.\./ AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index b2196686f..6cff46a76 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -5,7 +5,7 @@ block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" - snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [quoted string]" + snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [var]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index dd1a7a18f..3e9237f75 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -128,12 +128,9 @@ def rigid_parse(markup) end def rigid_template_name(p) - if p.look(:string) - p.consume(:string) - # inline snippets use variable identifiers - elsif p.look(:id) - p.consume(:id) - end + return p.consume(:string) if p.look(:string) + + p.consume(:id) if p.look(:id) end def strict_parse(markup) @@ -153,7 +150,7 @@ def lax_parse(markup) @is_for_loop = (with_or_for == FOR) @attributes = {} - markup.scan(/(#{ContextInheritance})|#{TagAttributes.source}/) do |context_marker, key, value| + markup.scan(/(\.\.\.)(?=\s|,|$)|#{TagAttributes.source}/) do |context_marker, key, value| if context_marker @attributes.delete("...") @attributes["..."] = true diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index aede69b78..cf6ef7569 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -2,7 +2,7 @@ module Liquid # @liquid_type tag - # @liquid_category theme + # @liquid_category variable # @liquid_name snippet # @liquid_summary # Creates a new inline snippet. @@ -14,8 +14,6 @@ module Liquid # {% endsnippet %} class Snippet < Block - SYNTAX = /(#{VariableSignature}+)/o - def initialize(tag_name, markup, options) super p = @parse_context.new_parser(markup) @@ -29,6 +27,10 @@ def initialize(tag_name, markup, options) def render_to_output_buffer(context, output) snippet_drop = SnippetDrop.new(@body) context.scopes.last[@to] = snippet_drop + + snippet_size = @body.nodelist.sum { |node| node.to_s.bytesize } + context.resource_limits.increment_assign_score(snippet_size) + output end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 38fe599a1..4906437dc 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -1238,4 +1238,12 @@ def test_render_inline_snippet_inside_loop assert_template_result(expected, template, error_mode: :rigid) end end + + class ResourceLimits < SnippetTest + def test_increment_assign_score_by_bytes_not_characters + t = Template.parse("{% snippet foo %}すごい{% endsnippet %}") + t.render! + assert_equal(9, t.resource_limits.assign_score) + end + end end From d4d2237b90096ce85f1c99e4d04e92772bfd4967 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 16:53:15 -0600 Subject: [PATCH 272/343] Support prop spreading --- lib/liquid/parser.rb | 8 + lib/liquid/tags/render.rb | 60 ++++-- test/integration/tags/snippet_test.rb | 274 ++++++++++++++++++++++++++ 3 files changed, 329 insertions(+), 13 deletions(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 645dfa3a1..93cda9083 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -12,6 +12,14 @@ def jump(point) @p = point end + def read(type = nil) + token = @tokens[@p] + if type && token[0] != type + raise SyntaxError, "Expected #{type} but found #{@tokens[@p].first}" + end + token[1] + end + def consume(type = nil) token = @tokens[@p] if type && token[0] != type diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 3e9237f75..177c8f4dd 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,10 +76,25 @@ def render_tag(context, output) inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| - if key == "..." && is_inline - context.scopes.each do |scope| - scope.each do |k, v| - inner_context[k] = v + if key.start_with?("...") && is_inline + if key == "..." + context.scopes.each do |scope| + scope.each do |k, v| + inner_context[k] = v + end + end + else + obj = context.evaluate(value) + if obj.is_a?(Liquid::Drop) + (obj.class.invokable_methods - ['to_liquid']).each do |method_name| + inner_context[method_name] = obj.invoke_drop(method_name) + end + elsif obj.is_a?(Hash) + obj.each do |k, v| + inner_context[k] = v + end + else + raise ::ArgumentError end end else @@ -117,11 +132,24 @@ def rigid_parse(markup) p.consume?(:comma) @attributes = {} - while p.look(:id) - key = p.consume - p.consume(:colon) - @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) + while p.look(:dotdotdot) || p.look(:id) + if p.consume?(:dotdotdot) + if p.look(:id) + identifier = p.read(:id) + key = "...#{identifier}" + @attributes.delete(key) + @attributes[key] = safe_parse_expression(p) + else + @attributes.delete("...") + @attributes["..."] = true + end + else + key = p.consume + p.consume(:colon) + @attributes.delete(key) + @attributes[key] = safe_parse_expression(p) + end + p.consume?(:comma) # optional comma end p.consume(:end_of_string) @@ -150,10 +178,16 @@ def lax_parse(markup) @is_for_loop = (with_or_for == FOR) @attributes = {} - markup.scan(/(\.\.\.)(?=\s|,|$)|#{TagAttributes.source}/) do |context_marker, key, value| - if context_marker - @attributes.delete("...") - @attributes["..."] = true + markup.scan(/(\.\.\.)(\w+)?(?=\s|,|$)|#{TagAttributes.source}/) do |spread, identifier, key, value| + if spread + if identifier + spread_key = "...#{identifier}" + @attributes.delete(spread_key) + @attributes[spread_key] = parse_expression(identifier) + else + @attributes.delete("...") + @attributes["..."] = true + end elsif key && value @attributes.delete(key) @attributes[key] = parse_expression(value) diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 4906437dc..8830285ab 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -446,6 +446,154 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence assert_template_result(expected, template) end + def test_render_inline_snippet_with_spread_hash + template = <<~LIQUID.strip + {% snippet header %} +
    + {{ word }} {{ number }} +
    + {% endsnippet %} + + {% render header, ...details %} + LIQUID + + expected = <<~OUTPUT + + + +
    + potato 5 +
    + OUTPUT + + assert_template_result(expected, template, { 'details' => { 'word' => 'potato', 'number' => 5 } }) + end + + def test_render_inline_snippet_with_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
    + {{ title }} - ${{ price }} by {{ vendor }} +
    + {% endsnippet %} + + {% render card, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
    + Cool Product - $99 by Acme +
    + OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }) + end + + def test_render_inline_snippet_with_overwritten_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
    + {{ title }} - ${{ price }} by {{ vendor }} +
    + {% endsnippet %} + + {% render card, ...product, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
    + Cool Product - $10 by Acme +
    + OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }) + end + + def test_render_inline_snippet_spread_before_explicit_args + template = <<~LIQUID.strip + {% snippet card %} +
    {{ price }}
    + {% endsnippet %} + + {% render card, ...details, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
    10
    + OUTPUT + + assert_template_result(expected, template, { 'details' => { 'price' => 99 } }) + end + + def test_render_inline_snippet_multiple_spreads + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
    {{ title }} - {{ price }} {{ color }}
    + {% endsnippet %} + + {% render card, ...defaults, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
    Cool Product - 10
    + OUTPUT + + assert_template_result( + expected, + template, + { + 'defaults' => { 'title' => 'Default', 'price' => 10 }, + 'product' => product_drop.new, + }, + ) + end + def test_render_captured_snippet template = <<~LIQUID {% assign color_scheme = 'dark' %} @@ -1063,6 +1211,132 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence assert_template_result(expected, template, error_mode: :rigid) end + def test_render_inline_snippet_with_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
    + {{ title }} - ${{ price }} by {{ vendor }} +
    + {% endsnippet %} + + {% render card, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
    + Cool Product - $99 by Acme +
    + OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }, error_mode: :rigid) + end + + def test_render_inline_snippet_with_overwritten_spread_drop + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + + def price + 99 + end + + def vendor + 'Acme' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
    + {{ title }} - ${{ price }} by {{ vendor }} +
    + {% endsnippet %} + + {% render card, ...product, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
    + Cool Product - $10 by Acme +
    + OUTPUT + + assert_template_result(expected, template, { 'product' => product_drop.new }, error_mode: :rigid) + end + + def test_render_inline_snippet_spread_before_explicit_args + template = <<~LIQUID.strip + {% snippet card %} +
    {{ price }}
    + {% endsnippet %} + + {% render card, ...details, price: 10 %} + LIQUID + + expected = <<~OUTPUT + + + +
    10
    + OUTPUT + + assert_template_result(expected, template, { 'details' => { 'price' => 99 } }, error_mode: :rigid) + end + + def test_render_inline_snippet_multiple_spreads + product_drop = Class.new(Liquid::Drop) do + def title + 'Cool Product' + end + end + + template = <<~LIQUID.strip + {% snippet card %} +
    {{ title }} - {{ price }} {{ color }}
    + {% endsnippet %} + + {% render card, ...defaults, ...product %} + LIQUID + + expected = <<~OUTPUT + + + +
    Cool Product - 10
    + OUTPUT + + assert_template_result( + expected, + template, + { + 'defaults' => { 'title' => 'Default', 'price' => 10 }, + 'product' => product_drop.new, + }, + error_mode: :rigid, + ) + end + def test_render_captured_snippet template = <<~LIQUID {% assign color_scheme = 'dark' %} From 40e45e32ac867f742a64da6b5272954909c74586 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 21 Oct 2025 21:08:35 -0600 Subject: [PATCH 273/343] Raise syntax error on incorrect render identifier type --- lib/liquid/locales/en.yml | 1 + lib/liquid/tags/render.rb | 4 +++- test/integration/tags/snippet_test.rb | 20 ++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 6cff46a76..31eec5fd5 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -20,6 +20,7 @@ invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" invalid_template_encoding: "Invalid template encoding" render: "Syntax error in tag 'render' - Template name must be a quoted string" + render_invalid_template_name: "Syntax error in tag 'render' - Expected a string or identifier, found %{found}" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range" tag_never_closed: "'%{block_name}' tag was never closed" diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 177c8f4dd..a6fd0c481 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -157,8 +157,10 @@ def rigid_parse(markup) def rigid_template_name(p) return p.consume(:string) if p.look(:string) + return p.consume(:id) if p.look(:id) - p.consume(:id) if p.look(:id) + found = p.look(:end_of_string) ? "nothing" : p.read + raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found) end def strict_parse(markup) diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 8830285ab..18b41ad48 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -1511,6 +1511,26 @@ def test_render_inline_snippet_inside_loop assert_template_result(expected, template, error_mode: :rigid) end + + def test_render_with_invalid_identifier_type + template = "{% render 123 %}" + + exception = assert_raises(SyntaxError) do + Liquid::Template.parse(template, error_mode: :rigid) + end + + assert_match("Expected a string or identifier, found 123", exception.message) + end + + def test_render_with_no_identifier + template = "{% render %}" + + exception = assert_raises(SyntaxError) do + Liquid::Template.parse(template, error_mode: :rigid) + end + + assert_match("Expected a string or identifier, found nothing", exception.message) + end end class ResourceLimits < SnippetTest From 0cc6cdd5531fdf6e5f82966b3c52c7ecc7a3be09 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Wed, 22 Oct 2025 16:21:17 -0600 Subject: [PATCH 274/343] Remove ... syntax references --- lib/liquid/lexer.rb | 10 +- lib/liquid/tags/render.rb | 63 +- test/integration/tags/snippet_test.rb | 1138 ++++++------------------- 3 files changed, 290 insertions(+), 921 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index 94a3d9faf..f1740dbad 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -17,7 +17,6 @@ class Lexer DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze - DOTDOTDOT = [:dotdotdot, "..."].freeze DOT_ORD = ".".ord DOUBLE_STRING_LITERAL = /"[^\"]*"/ EOS = [:end_of_string].freeze @@ -114,15 +113,10 @@ def tokenize(ss) if (special = SPECIAL_TABLE[peeked]) ss.scan_byte - # Special case for ".." and "..." + # Special case for ".." if special == DOT && ss.peek_byte == DOT_ORD ss.scan_byte - if ss.peek_byte == DOT_ORD - ss.scan_byte - output << DOTDOTDOT - else - output << DOTDOT - end + output << DOTDOT elsif special == DASH # Special case for negative numbers if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index a6fd0c481..55ab7d3f2 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -76,30 +76,7 @@ def render_tag(context, output) inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| - if key.start_with?("...") && is_inline - if key == "..." - context.scopes.each do |scope| - scope.each do |k, v| - inner_context[k] = v - end - end - else - obj = context.evaluate(value) - if obj.is_a?(Liquid::Drop) - (obj.class.invokable_methods - ['to_liquid']).each do |method_name| - inner_context[method_name] = obj.invoke_drop(method_name) - end - elsif obj.is_a?(Hash) - obj.each do |k, v| - inner_context[k] = v - end - else - raise ::ArgumentError - end - end - else - inner_context[key] = context.evaluate(value) - end + inner_context[key] = context.evaluate(value) end inner_context[context_variable_name] = var unless var.nil? @@ -132,23 +109,11 @@ def rigid_parse(markup) p.consume?(:comma) @attributes = {} - while p.look(:dotdotdot) || p.look(:id) - if p.consume?(:dotdotdot) - if p.look(:id) - identifier = p.read(:id) - key = "...#{identifier}" - @attributes.delete(key) - @attributes[key] = safe_parse_expression(p) - else - @attributes.delete("...") - @attributes["..."] = true - end - else - key = p.consume - p.consume(:colon) - @attributes.delete(key) - @attributes[key] = safe_parse_expression(p) - end + while p.look(:id) + key = p.consume + p.consume(:colon) + @attributes[key] = safe_parse_expression(p) + p.consume?(:comma) # optional comma end @@ -180,20 +145,8 @@ def lax_parse(markup) @is_for_loop = (with_or_for == FOR) @attributes = {} - markup.scan(/(\.\.\.)(\w+)?(?=\s|,|$)|#{TagAttributes.source}/) do |spread, identifier, key, value| - if spread - if identifier - spread_key = "...#{identifier}" - @attributes.delete(spread_key) - @attributes[spread_key] = parse_expression(identifier) - else - @attributes.delete("...") - @attributes["..."] = true - end - elsif key && value - @attributes.delete(key) - @attributes[key] = parse_expression(value) - end + markup.scan(TagAttributes) do |key, value| + @attributes[key] = parse_expression(value) end end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 18b41ad48..1476f9da7 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -205,6 +205,49 @@ def test_render_inline_snippet_empty_string_when_missing_argument assert_template_result(expected, template) end + def test_render_snippets_as_arguments + template = <<~LIQUID.strip + {% assign color_scheme = 'dark' %} + + {% snippet header %} +
    + {{ message }} +
    + {% endsnippet %} + + {% snippet main %} + {% assign color_scheme = 'auto' %} + +
    + {% render header, message: 'Welcome!' %} +
    + {% endsnippet %} + + {% render main, header: header %} + LIQUID + + expected = <<~OUTPUT + + + + + + + + + +
    + +
    + Welcome! +
    + +
    + OUTPUT + + assert_template_result(expected, template) + end + def test_render_inline_snippet_shouldnt_leak_context template = <<~LIQUID.strip {% snippet input %} @@ -263,7 +306,7 @@ def test_render_multiple_inline_snippets_without_leaking_context assert_template_result(expected, template) end - def test_render_inline_snippet_without_outside_context + def test_render_inline_snippet_ignores_outside_context template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} @@ -291,18 +334,23 @@ def test_render_inline_snippet_without_outside_context assert_template_result(expected, template) end - def test_render_inline_snippet_with_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - + def test_render_captured_snippet + template = <<~LIQUID {% snippet header %} -
    +
    {{ message }}
    {% endsnippet %} + {% capture up_header %} + {%- render header, message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} - {% render header, ..., message: 'Welcome!' %} + {{ header }} LIQUID expected = <<~OUTPUT @@ -310,10 +358,14 @@ def test_render_inline_snippet_with_outside_context +
    + WELCOME! +
    + + + SNIPPETDROP -
    - Welcome! -
    + SnippetDrop OUTPUT assert_template_result(expected, template) @@ -332,7 +384,7 @@ def test_inline_snippet_local_scope_takes_precedence {{ color_scheme }} - {% render header, ..., message: 'Welcome!' %} + {% render header, message: 'Welcome!', color_scheme: color_scheme %} {{ color_scheme }} LIQUID @@ -356,814 +408,354 @@ def test_inline_snippet_local_scope_takes_precedence assert_template_result(expected, template) end - def test_render_inline_snippet_with_correct_argument_precedence + def test_render_inline_snippet_forloop template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} - - {% snippet header %} -
    - {{ message }} -
    + {% snippet item %} +
  • {{ forloop.index }}: {{ item }}
  • {% endsnippet %} - - {% render header, message: 'Welcome!', ... %} + {% assign items = "A,B,C" | split: "," %} + {%- render item for items -%} LIQUID expected = <<~OUTPUT +
  • 1: A
  • +
  • 2: B
  • - - -
    - Goodbye! -
    +
  • 3: C
  • OUTPUT assert_template_result(expected, template) end - def test_render_inline_snippet_with_correct_argument_order + def test_render_inline_snippet_with template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} - {% snippet header %} -
    - {{ message }} -
    +
    {{ header }}
    {% endsnippet %} - - {% render header, ..., message: 'Welcome!' %} + {% assign product = "Apple" %} + {%- render header with product -%} LIQUID expected = <<~OUTPUT +
    Apple
    + OUTPUT + + assert_template_result(expected, template) + end + + def test_render_inline_snippet_alias + template = <<~LIQUID.strip + {% snippet product_card %} +
    {{ item }}
    + {% endsnippet %} + {% assign featured = "Apple" %} + {%- render product_card with featured as item -%} + LIQUID + expected = <<~OUTPUT -
    - Welcome! -
    +
    Apple
    OUTPUT assert_template_result(expected, template) end + end - def test_render_inline_snippet_with_correct_duplicate_argument_precedence + class RigidMode < SnippetTest + def test_valid_inline_snippet template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} - - {% snippet header %} -
    - {{ message }} -
    + {% snippet input %} + Hey {% endsnippet %} + LIQUID + expected = '' + + assert_template_result(expected, template, error_mode: :rigid) + end + def test_render_inline_snippet + template = <<~LIQUID.strip + {% snippet hey %} + Hey + {% endsnippet %} - {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + {%- render hey -%} LIQUID expected = <<~OUTPUT + Hey + OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end + def test_render_inline_snippet_with_variable + template = <<~LIQUID.strip + {% snippet hey %} +

    Today is {{ "hello" | capitalize }}

    + {% endsnippet %} + {%- render hey -%} + LIQUID + expected = <<~OUTPUT - - -
    - Hi! -
    +

    Today is Hello

    OUTPUT - assert_template_result(expected, template) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_spread_hash + def test_render_multiple_inline_snippets template = <<~LIQUID.strip - {% snippet header %} -
    - {{ word }} {{ number }} -
    + {% snippet input %} + {% endsnippet %} - {% render header, ...details %} - LIQUID + {% snippet banner %} + + Welcome to my store! + + {% endsnippet %} + {%- render input -%} + {%- render banner -%} + LIQUID expected = <<~OUTPUT -
    - potato 5 -
    + + + + Welcome to my store! + OUTPUT - assert_template_result(expected, template, { 'details' => { 'word' => 'potato', 'number' => 5 } }) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_spread_drop - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end + def test_render_inline_snippet_with_argument + template = <<~LIQUID.strip + {% snippet input %} + + {% endsnippet %} + + {%- render input, type: "text" -%} + LIQUID + expected = <<~OUTPUT - def price - 99 - end + + OUTPUT - def vendor - 'Acme' - end - end + assert_template_result(expected, template, error_mode: :rigid) + end + def test_render_inline_snippet_with_doc_tag template = <<~LIQUID.strip - {% snippet card %} -
    - {{ title }} - ${{ price }} by {{ vendor }} -
    + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} + + {% endsnippet %} - {% render card, ...product %} + {%- render input, type: "text" -%} LIQUID - expected = <<~OUTPUT -
    - Cool Product - $99 by Acme -
    + OUTPUT - assert_template_result(expected, template, { 'product' => product_drop.new }) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_overwritten_spread_drop - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end + def test_render_inline_snippet_with_evaluated_assign + template = <<~LIQUID.strip + {% snippet input %} +

    {{ greeting }}

    + {% endsnippet %} - def price - 99 - end + {%- assign greeting = "hello" | upcase -%} + {%- render input, greeting: greeting -%} + LIQUID + expected = <<~OUTPUT - def vendor - 'Acme' - end - end +

    HELLO

    + OUTPUT + assert_template_result(expected, template, error_mode: :rigid) + end + + def test_render_inline_snippet_with_multiple_arguments template = <<~LIQUID.strip - {% snippet card %} -
    - {{ title }} - ${{ price }} by {{ vendor }} -
    + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + {% endsnippet %} - {% render card, ...product, price: 10 %} + {%- render input, type: "text", value: "Hello" -%} LIQUID - expected = <<~OUTPUT -
    - Cool Product - $10 by Acme -
    + OUTPUT - assert_template_result(expected, template, { 'product' => product_drop.new }) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_spread_before_explicit_args + def test_render_inline_snippets_using_same_argument_name template = <<~LIQUID.strip - {% snippet card %} -
    {{ price }}
    + {% snippet input %} + + {% endsnippet %} + + {% snippet inputs %} + {% endsnippet %} - {% render card, ...details, price: 10 %} + {%- render input, type: "text" -%} + {%- render inputs, type: "password", value: "pass" -%} LIQUID expected = <<~OUTPUT -
    10
    + + + OUTPUT - assert_template_result(expected, template, { 'details' => { 'price' => 99 } }) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_multiple_spreads - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end - end - + def test_render_inline_snippet_empty_string_when_missing_argument template = <<~LIQUID.strip - {% snippet card %} -
    {{ title }} - {{ price }} {{ color }}
    + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} + + {% endsnippet %} - {% render card, ...defaults, ...product %} + {%- render input, type: "text" -%} LIQUID - expected = <<~OUTPUT -
    Cool Product - 10
    + OUTPUT - assert_template_result( - expected, - template, - { - 'defaults' => { 'title' => 'Default', 'price' => 10 }, - 'product' => product_drop.new, - }, - ) + assert_template_result(expected, template, error_mode: :rigid) end - def test_render_captured_snippet - template = <<~LIQUID + def test_render_snippets_as_arguments + template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% capture up_header %} - {%- render header, ..., message: 'Welcome!' -%} - {% endcapture %} - - {{ up_header | upcase }} - - {{ header | upcase }} - - {{ header }} - LIQUID - expected = <<~OUTPUT - - - - - - - -
    - WELCOME! -
    - - - SNIPPETDROP - - SnippetDrop - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    +
    {{ message }} -
    - {% endsnippet %} - - {% snippet main %} - {% assign color_scheme = 'auto' %} - -
    - {% render header, ..., message: 'Welcome!' %} -
    - {% endsnippet %} - - {% render main, header: header %} - LIQUID - - expected = <<~OUTPUT - - - - - - - - - -
    - -
    - Welcome! -
    - -
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_forloop - template = <<~LIQUID.strip - {% snippet item %} -
  • {{ forloop.index }}: {{ item }}
  • - {% endsnippet %} - - {% assign items = "A,B,C" | split: "," %} - {%- render item for items -%} - LIQUID - expected = <<~OUTPUT - - - -
  • 1: A
  • - -
  • 2: B
  • - -
  • 3: C
  • - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with - template = <<~LIQUID.strip - {% snippet header %} -
    {{ header }}
    - {% endsnippet %} - - {% assign product = "Apple" %} - {%- render header with product -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_alias - template = <<~LIQUID.strip - {% snippet product_card %} -
    {{ item }}
    - {% endsnippet %} - - {% assign featured = "Apple" %} - {%- render product_card with featured as item -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_inside_loop - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign array = '1,2,3' | split: ',' %} - - {% for i in array %} - {% snippet header %} -
    - {{ message }} {{ i }} -
    - {% endsnippet %} - {% endfor %} - - {% render header, ..., message: '👉' %} - LIQUID - expected = <<~OUTPUT - - - - - - -
    - 👉#{" "} -
    - OUTPUT - - assert_template_result(expected, template) - end - end - - class RigidMode < SnippetTest - def test_valid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} - Hey - {% endsnippet %} - LIQUID - expected = '' - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet - template = <<~LIQUID.strip - {% snippet hey %} - Hey - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - - Hey - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_variable - template = <<~LIQUID.strip - {% snippet hey %} -

    Today is {{ "hello" | capitalize }}

    - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - -

    Today is Hello

    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_multiple_inline_snippets - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet banner %} - - Welcome to my store! - - {% endsnippet %} - - {%- render input -%} - {%- render banner -%} - LIQUID - expected = <<~OUTPUT - - - - - - - Welcome to my store! - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_argument - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_doc_tag - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_evaluated_assign - template = <<~LIQUID.strip - {% snippet input %} -

    {{ greeting }}

    - {% endsnippet %} - - {%- assign greeting = "hello" | upcase -%} - {%- render input, greeting: greeting -%} - LIQUID - expected = <<~OUTPUT - -

    HELLO

    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_multiple_arguments - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippets_using_same_argument_name - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet inputs %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render inputs, type: "password", value: "pass" -%} - LIQUID - - expected = <<~OUTPUT - - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_empty_string_when_missing_argument - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_shouldnt_leak_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - - {{ type }} - {{ value }} - LIQUID - expected = <<~OUTPUT - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_multiple_inline_snippets_without_leaking_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {% snippet no_leak %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render no_leak -%} - LIQUID - expected = <<~OUTPUT - - - - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_without_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    +
    {% endsnippet %} + {% snippet main %} + {% assign color_scheme = 'auto' %} +
    {% render header, message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT - - - - - - -
    - Welcome! -
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }}
    {% endsnippet %} - - {% render header, ..., message: 'Welcome!' %} + {% render main, header: header %} LIQUID - expected = <<~OUTPUT - - - + expected = <<~OUTPUT -
    - Welcome! -
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID - {% assign color_scheme = 'dark' %} - - {% snippet header %} - {% assign color_scheme = 'light' %} -
    - {{ message }} -
    - {% endsnippet %} - - {{ color_scheme }} - {% render header, ..., message: 'Welcome!' %} - {{ color_scheme }} - LIQUID - expected = <<~OUTPUT - dark +
    +
    + Welcome! +
    -
    - Welcome!
    - - - dark OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_correct_argument_precedence + def test_render_inline_snippet_shouldnt_leak_context template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + @param {string} value - Input value. + {% enddoc %} - {% snippet header %} -
    - {{ message }} -
    + {% endsnippet %} + {%- render input, type: "text", value: "Hello" -%} - {% render header, message: 'Welcome!', ... %} + {{ type }} + {{ value }} LIQUID expected = <<~OUTPUT + - - - -
    - Goodbye! -
    OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_correct_argument_order + def test_render_multiple_inline_snippets_without_leaking_context template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} + {% snippet input %} + {% doc %} + @param {string} type - Input type. + {% enddoc %} - {% snippet header %} -
    - {{ message }} -
    + {% endsnippet %} + {% snippet no_leak %} + + {% endsnippet %} - {% render header, ..., message: 'Welcome!' %} + {%- render input, type: "text" -%} + {%- render no_leak -%} LIQUID expected = <<~OUTPUT @@ -1171,20 +763,17 @@ def test_render_inline_snippet_with_correct_argument_order + - -
    - Welcome! -
    + OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_correct_duplicate_argument_precedence + def test_render_inline_snippet_ignores_outside_context template = <<~LIQUID.strip {% assign color_scheme = 'dark' %} - {% assign message = 'Goodbye!' %} {% snippet header %}
    @@ -1193,7 +782,7 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence {% endsnippet %} - {% render header, message: 'Welcome!', ..., message: 'Hi!' %} + {% render header, message: 'Welcome!' %} LIQUID expected = <<~OUTPUT @@ -1202,220 +791,46 @@ def test_render_inline_snippet_with_correct_duplicate_argument_precedence - -
    - Hi! +
    + Welcome!
    OUTPUT assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_with_spread_drop - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end - - def price - 99 - end - - def vendor - 'Acme' - end - end - - template = <<~LIQUID.strip - {% snippet card %} -
    - {{ title }} - ${{ price }} by {{ vendor }} -
    - {% endsnippet %} - - {% render card, ...product %} - LIQUID - - expected = <<~OUTPUT - - - -
    - Cool Product - $99 by Acme -
    - OUTPUT - - assert_template_result(expected, template, { 'product' => product_drop.new }, error_mode: :rigid) - end - - def test_render_inline_snippet_with_overwritten_spread_drop - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end - - def price - 99 - end - - def vendor - 'Acme' - end - end - - template = <<~LIQUID.strip - {% snippet card %} -
    - {{ title }} - ${{ price }} by {{ vendor }} -
    - {% endsnippet %} - - {% render card, ...product, price: 10 %} - LIQUID - - expected = <<~OUTPUT - - - -
    - Cool Product - $10 by Acme -
    - OUTPUT - - assert_template_result(expected, template, { 'product' => product_drop.new }, error_mode: :rigid) - end - - def test_render_inline_snippet_spread_before_explicit_args - template = <<~LIQUID.strip - {% snippet card %} -
    {{ price }}
    - {% endsnippet %} - - {% render card, ...details, price: 10 %} - LIQUID - - expected = <<~OUTPUT - - - -
    10
    - OUTPUT - - assert_template_result(expected, template, { 'details' => { 'price' => 99 } }, error_mode: :rigid) - end - - def test_render_inline_snippet_multiple_spreads - product_drop = Class.new(Liquid::Drop) do - def title - 'Cool Product' - end - end - - template = <<~LIQUID.strip - {% snippet card %} -
    {{ title }} - {{ price }} {{ color }}
    - {% endsnippet %} - - {% render card, ...defaults, ...product %} - LIQUID - - expected = <<~OUTPUT - - - -
    Cool Product - 10
    - OUTPUT - - assert_template_result( - expected, - template, - { - 'defaults' => { 'title' => 'Default', 'price' => 10 }, - 'product' => product_drop.new, - }, - error_mode: :rigid, - ) - end - - def test_render_captured_snippet + def test_inline_snippet_local_scope_takes_precedence template = <<~LIQUID {% assign color_scheme = 'dark' %} {% snippet header %} + {% assign color_scheme = 'light' %}
    {{ message }}
    {% endsnippet %} - {% capture up_header %} - {%- render header, ..., message: 'Welcome!' -%} - {% endcapture %} - - {{ up_header | upcase }} + {{ color_scheme }} - {{ header | upcase }} + {% render header, message: 'Welcome!', color_scheme: color_scheme %} - {{ header }} + {{ color_scheme }} LIQUID expected = <<~OUTPUT + dark -
    - WELCOME! -
    - - - SNIPPETDROP - - SnippetDrop - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% snippet main %} - {% assign color_scheme = 'auto' %} - -
    - {% render header, ..., message: 'Welcome!' %} +
    + Welcome!
    - {% endsnippet %} - - {% render main, header: header %} - LIQUID - - expected = <<~OUTPUT - - - - - - - -
    - -
    - Welcome! -
    - -
    + dark OUTPUT assert_template_result(expected, template, error_mode: :rigid) @@ -1482,20 +897,23 @@ def test_render_inline_snippet_alias assert_template_result(expected, template, error_mode: :rigid) end - def test_render_inline_snippet_inside_loop - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - {% assign array = '1,2,3' | split: ',' %} - - {% for i in array %} + def test_render_captured_snippet + template = <<~LIQUID {% snippet header %} -
    - {{ message }} {{ i }} +
    + {{ message }}
    {% endsnippet %} - {% endfor %} - {% render header, ..., message: '👉' %} + {% capture up_header %} + {%- render header, message: 'Welcome!' -%} + {% endcapture %} + + {{ up_header | upcase }} + + {{ header | upcase }} + + {{ header }} LIQUID expected = <<~OUTPUT @@ -1503,10 +921,14 @@ def test_render_inline_snippet_inside_loop +
    + WELCOME! +
    -
    - 👉#{" "} -
    + + SNIPPETDROP + + SnippetDrop OUTPUT assert_template_result(expected, template, error_mode: :rigid) From db350c54ff24f2bb9c6878f2d85348fb9d34f9ba Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 23 Oct 2025 13:06:49 -0600 Subject: [PATCH 275/343] Allow render tag to recognize drops that respond to to_partial --- example/server/templates/index.liquid | 5 ++-- lib/liquid/locales/en.yml | 1 + lib/liquid/snippet_drop.rb | 9 ++++-- lib/liquid/tags/render.rb | 37 +++++++++--------------- lib/liquid/tags/snippet.rb | 2 +- test/integration/tags/render_tag_test.rb | 12 ++++++-- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/example/server/templates/index.liquid b/example/server/templates/index.liquid index e18cc306d..4872aa845 100644 --- a/example/server/templates/index.liquid +++ b/example/server/templates/index.liquid @@ -1,5 +1,6 @@

    Hello world!

    -

    It is {{ date }}

    +

    It is {{date}}

    -

    Check out the Products screen

    + +

    Check out the Products screen

    diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 31eec5fd5..7a9fb71e4 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -31,5 +31,6 @@ variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}" argument: include: "Argument error in tag 'include' - Illegal template name" + render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb index fb48d068d..116488268 100644 --- a/lib/liquid/snippet_drop.rb +++ b/lib/liquid/snippet_drop.rb @@ -2,11 +2,16 @@ module Liquid class SnippetDrop < Drop - attr_reader :body + attr_reader :body, :name - def initialize(body) + def initialize(body, name) super() @body = body + @name = name + end + + def to_partial + @body end def to_s diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 55ab7d3f2..c337f4dac 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -47,40 +47,30 @@ def render_to_output_buffer(context, output) end def render_tag(context, output) - template_name = @template_name_expr - is_inline = template_name.is_a?(VariableLookup) - is_file = template_name.is_a?(String) - - if is_inline - template_name = template_name.name - snippet_drop = context[template_name] - raise ::ArgumentError unless snippet_drop.is_a?(Liquid::SnippetDrop) - - partial = snippet_drop.body + template = context.evaluate(@template_name_expr) + + if template.respond_to?(:to_partial) + partial = template.to_partial + template_name = template.name + elsif @template_name_expr.is_a?(String) + partial = PartialCache.load(template, context: context, parse_context: parse_context) + template_name = partial.name else - raise ::ArgumentError unless is_file - - partial = PartialCache.load(template_name, context: context, parse_context: parse_context) + raise ::ArgumentError, parse_context.locale.t("errors.argument.render") end context_variable_name = @alias_name || template_name.split('/').last render_partial_func = ->(var, forloop) { - inner_context = context.new_isolated_subcontext - - if is_file - inner_context.template_name = partial.name - inner_context.partial = true - end - - inner_context['forloop'] = forloop if forloop + inner_context = context.new_isolated_subcontext + inner_context.template_name = template_name + inner_context.partial = true + inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| inner_context[key] = context.evaluate(value) end - inner_context[context_variable_name] = var unless var.nil? - partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) } @@ -113,7 +103,6 @@ def rigid_parse(markup) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) # optional comma end diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index cf6ef7569..2d49cdf68 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -25,7 +25,7 @@ def initialize(tag_name, markup, options) end def render_to_output_buffer(context, output) - snippet_drop = SnippetDrop.new(@body) + snippet_drop = SnippetDrop.new(@body, @to) context.scopes.last[@to] = snippet_drop snippet_size = @body.nodelist.sum { |node| node.to_s.bytesize } diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index e4ce0c294..4dedaaaea 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -101,10 +101,11 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end end - def test_dynamically_choosen_templates_are_not_allowed - assert_raises(::ArgumentError) do + def test_dynamically_chosen_templates_are_not_allowed + error = assert_raises(::ArgumentError) do Template.parse('{% assign name = "snippet" %}{% render name %}').render! end + assert_equal("Argument error in tag 'render' - Dynamically chosen templates are not allowed", error.message) end def test_rigid_parsing_errors @@ -296,6 +297,13 @@ def test_render_tag_with_drop ) end + def test_render_tag_with_snippet_drop + assert_template_result( + "Hello from snippet", + "{% snippet my_snippet %}Hello from snippet{% endsnippet %}{% render my_snippet %}", + ) + end + def test_render_tag_renders_error_with_template_name assert_template_result( 'Liquid error (foo line 1): standard error', From 42051311488f108af1f3c44d388f37b7d59d92fe Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 23 Oct 2025 13:09:39 -0600 Subject: [PATCH 276/343] Extract snippet resource scoring logic into assign_score_of --- lib/liquid/tags/snippet.rb | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 2d49cdf68..13eb531f3 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -27,15 +27,18 @@ def initialize(tag_name, markup, options) def render_to_output_buffer(context, output) snippet_drop = SnippetDrop.new(@body, @to) context.scopes.last[@to] = snippet_drop - - snippet_size = @body.nodelist.sum { |node| node.to_s.bytesize } - context.resource_limits.increment_assign_score(snippet_size) - + context.resource_limits.increment_assign_score(assign_score_of(snippet_drop)) output end def blank? true end + + private + + def assign_score_of(snippet_drop) + snippet_drop.body.nodelist.sum { |node| node.to_s.bytesize } + end end end From ae05ba071cf2a9c7d60ebd5a55a51fac674b29fc Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Fri, 24 Oct 2025 10:39:13 -0600 Subject: [PATCH 277/343] Add liquid_public_docs yard tag to snippet tag --- lib/liquid/tags/snippet.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 13eb531f3..c53da9f13 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module Liquid + # @liquid_public_docs # @liquid_type tag # @liquid_category variable # @liquid_name snippet @@ -12,7 +13,6 @@ module Liquid # {% snippet input %} # value # {% endsnippet %} - class Snippet < Block def initialize(tag_name, markup, options) super From 98fbd985d8792481189e2c937df6609c33d1fa7c Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Mon, 27 Oct 2025 12:41:59 -0600 Subject: [PATCH 278/343] Remove unneeded read method --- lib/liquid/parser.rb | 8 -------- lib/liquid/tags/render.rb | 2 +- test/integration/tags/snippet_test.rb | 10 ++++++++++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 93cda9083..645dfa3a1 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -12,14 +12,6 @@ def jump(point) @p = point end - def read(type = nil) - token = @tokens[@p] - if type && token[0] != type - raise SyntaxError, "Expected #{type} but found #{@tokens[@p].first}" - end - token[1] - end - def consume(type = nil) token = @tokens[@p] if type && token[0] != type diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index c337f4dac..cabbfe2e0 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -113,7 +113,7 @@ def rigid_template_name(p) return p.consume(:string) if p.look(:string) return p.consume(:id) if p.look(:id) - found = p.look(:end_of_string) ? "nothing" : p.read + found = p.consume || "nothing" raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found) end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 1476f9da7..7a0383ad2 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -953,6 +953,16 @@ def test_render_with_no_identifier assert_match("Expected a string or identifier, found nothing", exception.message) end + + def test_render_with_invalid_identifier + template = "{% render 123 %}" + + exception = assert_raises(SyntaxError) do + Liquid::Template.parse(template, error_mode: :rigid) + end + + assert_match("Expected a string or identifier, found 123", exception.message) + end end class ResourceLimits < SnippetTest From 5ceb0e9cec9f6f49b479a6df655e53ce1f5cf715 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Tue, 28 Oct 2025 11:30:24 -0600 Subject: [PATCH 279/343] Raise error on invalid snippet name --- lib/liquid/tags/snippet.rb | 3 ++- test/integration/tags/snippet_test.rb | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index c53da9f13..7853adc49 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -10,7 +10,7 @@ module Liquid # @liquid_description # You can create inline snippets to make your Liquid code more modular. # @liquid_syntax - # {% snippet input %} + # {% snippet snippet_name %} # value # {% endsnippet %} class Snippet < Block @@ -19,6 +19,7 @@ def initialize(tag_name, markup, options) p = @parse_context.new_parser(markup) if p.look(:id) @to = p.consume(:id) + p.consume(:end_of_string) else raise SyntaxError, options[:locale].t("errors.syntax.snippet") end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 7a0383ad2..37fa67ecc 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -468,6 +468,18 @@ def test_render_inline_snippet_alias assert_template_result(expected, template) end + + def test_snippet_with_invalid_identifier + template = <<~LIQUID + {% snippet header foo bar %} + Invalid + {% endsnippet %} + LIQUID + + exception = assert_raises(SyntaxError) { Liquid::Template.parse(template) } + + assert_match("Expected end_of_string but found id", exception.message) + end end class RigidMode < SnippetTest @@ -934,7 +946,7 @@ def test_render_captured_snippet assert_template_result(expected, template, error_mode: :rigid) end - def test_render_with_invalid_identifier_type + def test_render_with_invalid_identifier template = "{% render 123 %}" exception = assert_raises(SyntaxError) do @@ -954,14 +966,18 @@ def test_render_with_no_identifier assert_match("Expected a string or identifier, found nothing", exception.message) end - def test_render_with_invalid_identifier - template = "{% render 123 %}" + def test_snippet_with_invalid_identifier + template = <<~LIQUID + {% snippet header foo bar %} + Invalid + {% endsnippet %} + LIQUID exception = assert_raises(SyntaxError) do Liquid::Template.parse(template, error_mode: :rigid) end - assert_match("Expected a string or identifier, found 123", exception.message) + assert_match("Expected end_of_string but found id", exception.message) end end From 12fd93fbe2de580d2615bfeb8f1c67c0a805edbf Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Wed, 29 Oct 2025 11:14:15 -0600 Subject: [PATCH 280/343] Missing inline snippets should display same error as filebased --- lib/liquid/locales/en.yml | 2 + lib/liquid/snippet_drop.rb | 5 +- lib/liquid/tags/render.rb | 8 +- lib/liquid/tags/snippet.rb | 2 +- test/integration/tags/render_tag_test.rb | 7 -- test/integration/tags/snippet_test.rb | 96 ++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 14 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 7a9fb71e4..53421f4c1 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -34,3 +34,5 @@ render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" + file_system: + includes: "This liquid context does not allow includes" diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb index 116488268..98686999c 100644 --- a/lib/liquid/snippet_drop.rb +++ b/lib/liquid/snippet_drop.rb @@ -2,12 +2,13 @@ module Liquid class SnippetDrop < Drop - attr_reader :body, :name + attr_reader :body, :name, :filename - def initialize(body, name) + def initialize(body, name, filename) super() @body = body @name = name + @filename = filename end def to_partial diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index cabbfe2e0..7f9672820 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -51,16 +51,16 @@ def render_tag(context, output) if template.respond_to?(:to_partial) partial = template.to_partial - template_name = template.name + template_name = template.filename + context_variable_name = @alias_name || template.name elsif @template_name_expr.is_a?(String) partial = PartialCache.load(template, context: context, parse_context: parse_context) template_name = partial.name + context_variable_name = @alias_name || template_name.split('/').last else - raise ::ArgumentError, parse_context.locale.t("errors.argument.render") + raise Liquid::ArgumentError, parse_context.locale.t("errors.file_system.includes") end - context_variable_name = @alias_name || template_name.split('/').last - render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext inner_context.template_name = template_name diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb index 7853adc49..87e2a0ded 100644 --- a/lib/liquid/tags/snippet.rb +++ b/lib/liquid/tags/snippet.rb @@ -26,7 +26,7 @@ def initialize(tag_name, markup, options) end def render_to_output_buffer(context, output) - snippet_drop = SnippetDrop.new(@body, @to) + snippet_drop = SnippetDrop.new(@body, @to, context.template_name) context.scopes.last[@to] = snippet_drop context.resource_limits.increment_assign_score(assign_score_of(snippet_drop)) output diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 4dedaaaea..6fe19abb4 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -101,13 +101,6 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end end - def test_dynamically_chosen_templates_are_not_allowed - error = assert_raises(::ArgumentError) do - Template.parse('{% assign name = "snippet" %}{% render name %}').render! - end - assert_equal("Argument error in tag 'render' - Dynamically chosen templates are not allowed", error.message) - end - def test_rigid_parsing_errors with_error_modes(:lax, :strict) do assert_template_result( diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 37fa67ecc..571890baf 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -480,6 +480,54 @@ def test_snippet_with_invalid_identifier assert_match("Expected end_of_string but found id", exception.message) end + + def test_render_with_non_existent_tag + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% snippet foo %} + {% render non_existent %} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end + + def test_render_handles_errors + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) + {% snippet foo %} + {% render non_existent %} will raise an error. + + Bla bla test. + + This is an argument error: {{ 'test' | slice: 'not a number' }} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes will raise an error. + + Bla bla test. + + This is an argument error: Liquid error (index line 6): invalid integer + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end end class RigidMode < SnippetTest @@ -956,6 +1004,54 @@ def test_render_with_invalid_identifier assert_match("Expected a string or identifier, found 123", exception.message) end + def test_render_with_non_existent_tag + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true, error_mode: :rigid) + {% snippet foo %} + {% render non_existent %} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end + + def test_render_handles_errors + template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true, error_mode: :rigid) + {% snippet foo %} + {% render non_existent %} will raise an error. + + Bla bla test. + + This is an argument error: {{ 'test' | slice: 'not a number' }} + {% endsnippet %} + + {% render foo %} + LIQUID + + expected = <<~TEXT + + + + Liquid error (index line 2): This liquid context does not allow includes will raise an error. + + Bla bla test. + + This is an argument error: Liquid error (index line 6): invalid integer + TEXT + template.name = "index" + + assert_equal(expected, template.render('errors' => ErrorDrop.new)) + end + def test_render_with_no_identifier template = "{% render %}" From a16ec56a402e898145354e8a213bb7f0a2408b43 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 30 Oct 2025 11:56:30 +0100 Subject: [PATCH 281/343] Update error handling for keeping backward-compatibility on error messages in the render tag --- lib/liquid/locales/en.yml | 2 -- lib/liquid/tags/render.rb | 2 +- test/integration/tags/snippet_test.rb | 8 ++++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 53421f4c1..7a9fb71e4 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -34,5 +34,3 @@ render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" - file_system: - includes: "This liquid context does not allow includes" diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index 7f9672820..dec725ed4 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -58,7 +58,7 @@ def render_tag(context, output) template_name = partial.name context_variable_name = @alias_name || template_name.split('/').last else - raise Liquid::ArgumentError, parse_context.locale.t("errors.file_system.includes") + raise ::ArgumentError end render_partial_func = ->(var, forloop) { diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb index 571890baf..6d1cb35d7 100644 --- a/test/integration/tags/snippet_test.rb +++ b/test/integration/tags/snippet_test.rb @@ -494,7 +494,7 @@ def test_render_with_non_existent_tag - Liquid error (index line 2): This liquid context does not allow includes + Liquid error (index line 2): internal TEXT template.name = "index" @@ -518,7 +518,7 @@ def test_render_handles_errors - Liquid error (index line 2): This liquid context does not allow includes will raise an error. + Liquid error (index line 2): internal will raise an error. Bla bla test. @@ -1017,7 +1017,7 @@ def test_render_with_non_existent_tag - Liquid error (index line 2): This liquid context does not allow includes + Liquid error (index line 2): internal TEXT template.name = "index" @@ -1041,7 +1041,7 @@ def test_render_handles_errors - Liquid error (index line 2): This liquid context does not allow includes will raise an error. + Liquid error (index line 2): internal will raise an error. Bla bla test. From 248f3a412f59da16fc184bc0bbd92522a1f62393 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Thu, 30 Oct 2025 12:06:21 +0100 Subject: [PATCH 282/343] Bump to 5.10.0 --- History.md | 9 +++++++++ lib/liquid/version.rb | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/History.md b/History.md index 763b80533..183a8ab93 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,14 @@ # Liquid Change Log +## 5.10.0 +* Introduce support for Inline Snippets [Julia Boutin] + ``` + {%- snippet snowdevil -%} + Snowdevil + {%- endsnippet -%} + {% render snowdevil %} + ``` + ## 5.9.0 * Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro] diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 6f3e83e7d..c16430f2f 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.9.0" + VERSION = "5.10.0" end From 9973f3399e6531ac381e798039f41f33d65aa508 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 30 Oct 2025 15:26:27 -0700 Subject: [PATCH 283/343] Don't raise if no variable found when using context.key? Previously if you set `strict_variables` to `true` on the context using `key?('key_name')` would raise a `Liquid::UndefinedVariable` error. Raising this error makes sense if you're trying to access the variable directly with something like `context['key_name']` but by using `key?` you're safely checking if it exists first. You should be able to enable `strict_variables` and use `key?` in combination with each other to ensure code safety. --- lib/liquid/context.rb | 2 +- test/integration/context_test.rb | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 1ef272d46..433b6d003 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -184,7 +184,7 @@ def [](expression) end def key?(key) - self[key] != nil + find_variable(key, raise_on_not_found: false) != nil end def evaluate(object) diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index f59147e29..d230734f6 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -639,6 +639,21 @@ def test_has_key_will_not_add_an_error_for_missing_keys end end + def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled + context = Context.new + context.strict_variables = true + assert_raises(Liquid::UndefinedVariable) do + context['unknown'] + end + end + + def test_has_key_will_not_raise_for_missing_keys_when_strict_variables_is_enabled + context = Context.new + context.strict_variables = true + refute(context.key?('unknown')) + assert_empty(context.errors) + end + def test_context_always_uses_static_registers registers = { my_register: :my_value, From 32b50ecafecae41b924f3e3120c6dc587efdd800 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 19 Nov 2025 18:03:23 +0100 Subject: [PATCH 284/343] Bump Liquid to 5.11.0 (#2012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit reverts the Inline Snippets tag (#2001) and bumps Liquid to 5.11. For now, the inclusion of Inline Snippets in the latest Liquid release is being treated as a bug. While #2001 does implement the scope contained in RFC#1916, we need to take a step back to make sure we’re setting our sights high enough with this feature, and that we’re truly supporting theme developers in the ways they need. If you have any feedback, please leave a comment on RFC#1916. - Liquid Developer Tools --- History.md | 10 +- README.md | 8 +- Rakefile | 16 +- bin/render | 2 +- lib/liquid.rb | 1 - lib/liquid/environment.rb | 2 +- lib/liquid/locales/en.yml | 3 - lib/liquid/parse_context.rb | 8 +- lib/liquid/parser_switching.rb | 28 +- lib/liquid/snippet_drop.rb | 22 - lib/liquid/tags.rb | 2 - lib/liquid/tags/case.rb | 8 +- lib/liquid/tags/cycle.rb | 2 +- lib/liquid/tags/for.rb | 2 +- lib/liquid/tags/if.rb | 2 +- lib/liquid/tags/include.rb | 2 +- lib/liquid/tags/render.rb | 42 +- lib/liquid/tags/snippet.rb | 45 - lib/liquid/tags/table_row.rb | 2 +- lib/liquid/template.rb | 2 +- lib/liquid/variable.rb | 6 +- lib/liquid/version.rb | 2 +- test/integration/tags/cycle_tag_test.rb | 8 +- test/integration/tags/include_tag_test.rb | 10 +- test/integration/tags/render_tag_test.rb | 19 +- test/integration/tags/snippet_test.rb | 1087 --------------------- test/integration/tags/table_row_test.rb | 56 +- test/integration/variable_test.rb | 10 +- test/unit/condition_unit_test.rb | 10 +- test/unit/parse_context_unit_test.rb | 54 +- test/unit/partial_cache_unit_test.rb | 4 +- test/unit/tags/case_tag_unit_test.rb | 12 +- test/unit/variable_unit_test.rb | 4 +- 33 files changed, 164 insertions(+), 1327 deletions(-) delete mode 100644 lib/liquid/snippet_drop.rb delete mode 100644 lib/liquid/tags/snippet.rb delete mode 100644 test/integration/tags/snippet_test.rb diff --git a/History.md b/History.md index 183a8ab93..94feaabfd 100644 --- a/History.md +++ b/History.md @@ -1,13 +1,11 @@ # Liquid Change Log +## 5.11.0 +* Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro] +* Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro] + ## 5.10.0 * Introduce support for Inline Snippets [Julia Boutin] - ``` - {%- snippet snowdevil -%} - Snowdevil - {%- endsnippet -%} - {% render snowdevil %} - ``` ## 5.9.0 * Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro] diff --git a/README.md b/README.md index 34884464d..5066dc659 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,10 @@ Liquid also comes with different parsers that can be used when editing templates when templates are invalid. You can enable this new parser like this: ```ruby -Liquid::Environment.default.error_mode = :rigid # Raises a SyntaxError when invalid syntax is used in all tags -Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags -Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal -Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. +Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags +Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags +Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal +Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. ``` If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: diff --git a/Rakefile b/Rakefile index 878536bf5..83afcbfa0 100755 --- a/Rakefile +++ b/Rakefile @@ -33,7 +33,7 @@ task :rubocop do end end -desc('runs test suite with lax, strict, and rigid parsers') +desc('runs test suite with lax, strict, and strict2 parsers') task :test do ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['base_test'].invoke @@ -42,7 +42,7 @@ task :test do Rake::Task['base_test'].reenable Rake::Task['base_test'].invoke - ENV['LIQUID_PARSER_MODE'] = 'rigid' + ENV['LIQUID_PARSER_MODE'] = 'strict2' Rake::Task['base_test'].reenable Rake::Task['base_test'].invoke @@ -55,7 +55,7 @@ task :test do Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke - ENV['LIQUID_PARSER_MODE'] = 'rigid' + ENV['LIQUID_PARSER_MODE'] = 'strict2' Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke end @@ -88,13 +88,13 @@ namespace :benchmark do ruby "./performance/benchmark.rb strict" end - desc "Run the liquid benchmark with rigid parsing" - task :rigid do - ruby "./performance/benchmark.rb rigid" + desc "Run the liquid benchmark with strict2 parsing" + task :strict2 do + ruby "./performance/benchmark.rb strict2" end - desc "Run the liquid benchmark with lax, strict, and rigid parsing" - task run: [:lax, :strict, :rigid] + desc "Run the liquid benchmark with lax, strict, and strict2 parsing" + task run: [:lax, :strict, :strict2] desc "Run unit benchmarks" namespace :unit do diff --git a/bin/render b/bin/render index 26d9b53d8..012b27179 100755 --- a/bin/render +++ b/bin/render @@ -41,6 +41,6 @@ def assigns end puts Liquid::Template - .parse(source, error_mode: :rigid) + .parse(source, error_mode: :strict2) .tap { |t| t.registers[:file_system] = VirtualFileSystem.new } .render(assigns) diff --git a/lib/liquid.rb b/lib/liquid.rb index 09cafe947..4d0a71a64 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -67,7 +67,6 @@ module Liquid require 'liquid/drop' require 'liquid/tablerowloop_drop' require 'liquid/forloop_drop' -require 'liquid/snippet_drop' require 'liquid/extensions' require 'liquid/errors' require 'liquid/interrupts' diff --git a/lib/liquid/environment.rb b/lib/liquid/environment.rb index 59719c4ad..100bd0bbd 100644 --- a/lib/liquid/environment.rb +++ b/lib/liquid/environment.rb @@ -34,7 +34,7 @@ class << self # @param file_system The default file system that is used # to load templates from. # @param error_mode [Symbol] The default error mode for all templates - # (either :rigid, :strict, :warn, or :lax). + # (either :strict2, :strict, :warn, or :lax). # @param exception_renderer [Proc] The exception renderer that is used to # render exceptions. # @yieldparam environment [Environment] The environment instance that is being built. diff --git a/lib/liquid/locales/en.yml b/lib/liquid/locales/en.yml index 7a9fb71e4..b99d490c8 100644 --- a/lib/liquid/locales/en.yml +++ b/lib/liquid/locales/en.yml @@ -5,7 +5,6 @@ block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" - snippet: "Syntax Error in 'snippet' - Valid syntax: snippet [var]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " @@ -20,7 +19,6 @@ invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" invalid_template_encoding: "Invalid template encoding" render: "Syntax error in tag 'render' - Template name must be a quoted string" - render_invalid_template_name: "Syntax error in tag 'render' - Expected a string or identifier, found %{found}" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range" tag_never_closed: "'%{block_name}' tag was never closed" @@ -31,6 +29,5 @@ variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}" argument: include: "Argument error in tag 'include' - Illegal template name" - render: "Argument error in tag 'render' - Dynamically chosen templates are not allowed" disabled: tag: "usage is not allowed in this context" diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 476123393..855acc64e 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -55,15 +55,15 @@ def safe_parse_expression(parser) end def parse_expression(markup, safe: false) - if !safe && @error_mode == :rigid + if !safe && @error_mode == :strict2 # parse_expression is a widely used API. To maintain backward - # compatibility while raising awareness about rigid parser standards, + # compatibility while raising awareness about strict2 parser standards, # the safe flag supports API users make a deliberate decision. # - # In rigid mode, markup MUST come from a string returned by the parser + # 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 # prevent redundant parser overhead. - raise Liquid::InternalError, "unsafe parse_expression cannot be used in rigid mode" + raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode" end Expression.parse(markup, @string_scanner, @expression_cache) diff --git a/lib/liquid/parser_switching.rb b/lib/liquid/parser_switching.rb index 7c662e928..e419dc999 100644 --- a/lib/liquid/parser_switching.rb +++ b/lib/liquid/parser_switching.rb @@ -7,16 +7,19 @@ module ParserSwitching # It's basically doing the same thing the {#parse_with_selected_parser}, # except this will try the strict parser regardless of the error mode, # and fall back to the lax parser if the error mode is lax or warn, - # except when in rigid mode where it uses the rigid parser. + # except when in strict2 mode where it uses the strict2 parser. # # @deprecated Use {#parse_with_selected_parser} instead. def strict_parse_with_error_mode_fallback(markup) - return rigid_parse_with_error_context(markup) if rigid_mode? + return strict2_parse_with_error_context(markup) if strict2_mode? strict_parse_with_error_context(markup) rescue SyntaxError => e case parse_context.error_mode when :rigid + rigid_warn + raise + when :strict2 raise when :strict raise @@ -28,12 +31,13 @@ def strict_parse_with_error_mode_fallback(markup) def parse_with_selected_parser(markup) case parse_context.error_mode - when :rigid then rigid_parse_with_error_context(markup) - when :strict then strict_parse_with_error_context(markup) - when :lax then lax_parse(markup) + when :rigid then rigid_warn && strict2_parse_with_error_context(markup) + when :strict2 then strict2_parse_with_error_context(markup) + when :strict then strict_parse_with_error_context(markup) + when :lax then lax_parse(markup) when :warn begin - rigid_parse_with_error_context(markup) + strict2_parse_with_error_context(markup) rescue SyntaxError => e parse_context.warnings << e lax_parse(markup) @@ -41,14 +45,18 @@ def parse_with_selected_parser(markup) end end - def rigid_mode? - parse_context.error_mode == :rigid + def strict2_mode? + parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid end private - def rigid_parse_with_error_context(markup) - rigid_parse(markup) + def rigid_warn + Deprecations.warn(':rigid', ':strict2') + end + + def strict2_parse_with_error_context(markup) + strict2_parse(markup) rescue SyntaxError => e e.line_number = line_number e.markup_context = markup_context(markup) diff --git a/lib/liquid/snippet_drop.rb b/lib/liquid/snippet_drop.rb deleted file mode 100644 index 98686999c..000000000 --- a/lib/liquid/snippet_drop.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Liquid - class SnippetDrop < Drop - attr_reader :body, :name, :filename - - def initialize(body, name, filename) - super() - @body = body - @name = name - @filename = filename - end - - def to_partial - @body - end - - def to_s - 'SnippetDrop' - end - end -end diff --git a/lib/liquid/tags.rb b/lib/liquid/tags.rb index 3ec5e7cfb..dff7553f7 100644 --- a/lib/liquid/tags.rb +++ b/lib/liquid/tags.rb @@ -20,7 +20,6 @@ require_relative "tags/render" require_relative "tags/cycle" require_relative "tags/doc" -require_relative "tags/snippet" module Liquid module Tags @@ -45,7 +44,6 @@ module Tags 'echo' => Echo, 'tablerow' => TableRow, 'doc' => Doc, - 'snippet' => Snippet, }.freeze end end diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 926e4107a..b32ea1ae7 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -86,7 +86,7 @@ def render_to_output_buffer(context, output) private - def rigid_parse(markup) + def strict2_parse(markup) parser = @parse_context.new_parser(markup) @left = safe_parse_expression(parser) parser.consume(:end_of_string) @@ -107,14 +107,14 @@ def lax_parse(markup) def record_when_condition(markup) body = new_body - if rigid_mode? - parse_rigid_when(markup, body) + if strict2_mode? + parse_strict2_when(markup, body) else parse_lax_when(markup, body) end end - def parse_rigid_when(markup, body) + def parse_strict2_when(markup, body) parser = @parse_context.new_parser(markup) loop do diff --git a/lib/liquid/tags/cycle.rb b/lib/liquid/tags/cycle.rb index 7e17a9662..b7d3069c8 100644 --- a/lib/liquid/tags/cycle.rb +++ b/lib/liquid/tags/cycle.rb @@ -56,7 +56,7 @@ def render_to_output_buffer(context, output) private # cycle [name:] expression(, expression)* - def rigid_parse(markup) + def strict2_parse(markup) p = @parse_context.new_parser(markup) @variables = [] diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index da06d64ad..cbea85bcb 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -111,7 +111,7 @@ def strict_parse(markup) private - def rigid_parse(markup) + def strict2_parse(markup) strict_parse(markup) end diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index e25d62505..c423c1e84 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -66,7 +66,7 @@ def render_to_output_buffer(context, output) private - def rigid_parse(markup) + def strict2_parse(markup) strict_parse(markup) end diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 5b2aaa432..969482d49 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -84,7 +84,7 @@ def render_to_output_buffer(context, output) alias_method :parse_context, :options private :parse_context - def rigid_parse(markup) + def strict2_parse(markup) p = @parse_context.new_parser(markup) @template_name_expr = safe_parse_expression(p) diff --git a/lib/liquid/tags/render.rb b/lib/liquid/tags/render.rb index dec725ed4..6e1559cc2 100644 --- a/lib/liquid/tags/render.rb +++ b/lib/liquid/tags/render.rb @@ -27,7 +27,7 @@ module Liquid # @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension. class Render < Tag FOR = 'for' - SYNTAX = /(#{QuotedString}+|#{VariableSegment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o + SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o disable_tags "include" @@ -47,23 +47,21 @@ def render_to_output_buffer(context, output) end def render_tag(context, output) - template = context.evaluate(@template_name_expr) - - if template.respond_to?(:to_partial) - partial = template.to_partial - template_name = template.filename - context_variable_name = @alias_name || template.name - elsif @template_name_expr.is_a?(String) - partial = PartialCache.load(template, context: context, parse_context: parse_context) - template_name = partial.name - context_variable_name = @alias_name || template_name.split('/').last - else - raise ::ArgumentError - end + # The expression should be a String literal, which parses to a String object + template_name = @template_name_expr + raise ::ArgumentError unless template_name.is_a?(String) + + partial = PartialCache.load( + template_name, + context: context, + parse_context: parse_context, + ) + + context_variable_name = @alias_name || template_name.split('/').last render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext - inner_context.template_name = template_name + inner_context.template_name = partial.name inner_context.partial = true inner_context['forloop'] = forloop if forloop @@ -87,10 +85,10 @@ def render_tag(context, output) end # render (string) (with|for expression)? (as id)? (key: value)* - def rigid_parse(markup) + def strict2_parse(markup) p = @parse_context.new_parser(markup) - @template_name_expr = parse_expression(rigid_template_name(p), safe: true) + @template_name_expr = parse_expression(strict2_template_name(p), safe: true) with_or_for = p.id?("for") || p.id?("with") @variable_name_expr = safe_parse_expression(p) if with_or_for @alias_name = p.consume(:id) if p.id?("as") @@ -103,18 +101,14 @@ def rigid_parse(markup) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) - p.consume?(:comma) # optional comma + p.consume?(:comma) end p.consume(:end_of_string) end - def rigid_template_name(p) - return p.consume(:string) if p.look(:string) - return p.consume(:id) if p.look(:id) - - found = p.consume || "nothing" - raise SyntaxError, options[:locale].t("errors.syntax.render_invalid_template_name", found: found) + def strict2_template_name(p) + p.consume(:string) end def strict_parse(markup) diff --git a/lib/liquid/tags/snippet.rb b/lib/liquid/tags/snippet.rb deleted file mode 100644 index 87e2a0ded..000000000 --- a/lib/liquid/tags/snippet.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module Liquid - # @liquid_public_docs - # @liquid_type tag - # @liquid_category variable - # @liquid_name snippet - # @liquid_summary - # Creates a new inline snippet. - # @liquid_description - # You can create inline snippets to make your Liquid code more modular. - # @liquid_syntax - # {% snippet snippet_name %} - # value - # {% endsnippet %} - class Snippet < Block - def initialize(tag_name, markup, options) - super - p = @parse_context.new_parser(markup) - if p.look(:id) - @to = p.consume(:id) - p.consume(:end_of_string) - else - raise SyntaxError, options[:locale].t("errors.syntax.snippet") - end - end - - def render_to_output_buffer(context, output) - snippet_drop = SnippetDrop.new(@body, @to, context.template_name) - context.scopes.last[@to] = snippet_drop - context.resource_limits.increment_assign_score(assign_score_of(snippet_drop)) - output - end - - def blank? - true - end - - private - - def assign_score_of(snippet_drop) - snippet_drop.body.nodelist.sum { |node| node.to_s.bytesize } - end - end -end diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index 7348b5d91..b69f91486 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -34,7 +34,7 @@ def initialize(tag_name, markup, options) parse_with_selected_parser(markup) end - def rigid_parse(markup) + def strict2_parse(markup) p = @parse_context.new_parser(markup) @variable_name = p.consume(:id) diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 5d349abd6..b007765ce 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -25,7 +25,7 @@ class << self # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. # :warn is the default and will give deprecation warnings when invalid syntax is used. # :strict enforces correct syntax for most tags - # :rigid enforces correct syntax for all tags + # :strict2 enforces correct syntax for all tags def error_mode=(mode) Deprecations.warn("Template.error_mode=", "Environment#error_mode=") Environment.default.error_mode = mode diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 3fd947a7d..6b5fb412b 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -74,14 +74,14 @@ def strict_parse(markup) p.consume(:end_of_string) end - def rigid_parse(markup) + def strict2_parse(markup) @filters = [] p = @parse_context.new_parser(markup) return if p.look(:end_of_string) @name = parse_context.safe_parse_expression(p) - @filters << rigid_parse_filter_expressions(p) while p.consume?(:pipe) + @filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe) p.consume(:end_of_string) end @@ -156,7 +156,7 @@ def lax_parse_filter_expressions(filter_name, unparsed_args) # argument = (positional_argument | keyword_argument) # positional_argument = expression # keyword_argument = id ":" expression - def rigid_parse_filter_expressions(p) + def strict2_parse_filter_expressions(p) filtername = p.consume(:id) filter_args = [] keyword_args = {} diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index c16430f2f..c396a3db7 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.10.0" + VERSION = "5.11.0" end diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index f3f865f08..dfb5984d8 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -101,7 +101,7 @@ def test_cycle_tag_with_error_mode assert_template_result("a", template2) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } @@ -129,7 +129,7 @@ def test_cycle_with_trailing_elements assert_template_result("N", template5) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) } @@ -157,7 +157,7 @@ def test_cycle_name_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -174,7 +174,7 @@ def test_cycle_variable_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 3ccd17794..44c0dcda7 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -204,7 +204,7 @@ def test_dynamically_choosen_template ) end - def test_rigid_parsing_errors + def test_strict2_parsing_errors with_error_modes(:lax, :strict) do assert_template_result( 'hello value1 value2', @@ -213,7 +213,7 @@ def test_rigid_parsing_errors ) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_syntax_error( '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', ) @@ -408,7 +408,7 @@ def test_include_template_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -421,7 +421,7 @@ def test_include_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -434,7 +434,7 @@ def test_include_attribute_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 6fe19abb4..0bd09d088 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -101,7 +101,11 @@ def test_sub_contexts_count_towards_the_same_recursion_limit end end - def test_rigid_parsing_errors + def test_dynamically_choosen_templates_are_not_allowed + assert_syntax_error("{% assign name = 'snippet' %}{% render name %}") + end + + def test_strict2_parsing_errors with_error_modes(:lax, :strict) do assert_template_result( 'hello value1 value2', @@ -110,7 +114,7 @@ def test_rigid_parsing_errors ) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_syntax_error( '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', ) @@ -290,13 +294,6 @@ def test_render_tag_with_drop ) end - def test_render_tag_with_snippet_drop - assert_template_result( - "Hello from snippet", - "{% snippet my_snippet %}Hello from snippet{% endsnippet %}{% render my_snippet %}", - ) - end - def test_render_tag_renders_error_with_template_name assert_template_result( 'Liquid error (foo line 1): standard error', @@ -325,7 +322,7 @@ def test_render_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end @@ -338,7 +335,7 @@ def test_render_attribute_with_invalid_expression refute_nil(Template.parse(template)) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/tags/snippet_test.rb b/test/integration/tags/snippet_test.rb deleted file mode 100644 index 6d1cb35d7..000000000 --- a/test/integration/tags/snippet_test.rb +++ /dev/null @@ -1,1087 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class SnippetTest < Minitest::Test - include Liquid - - class LaxMode < SnippetTest - def test_valid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} - Hey - {% endsnippet %} - LIQUID - expected = '' - - assert_template_result(expected, template) - end - - def test_render_inline_snippet - template = <<~LIQUID.strip - {% snippet hey %} - Hey - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - - Hey - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with_variable - template = <<~LIQUID.strip - {% snippet hey %} -

    Today is {{ "hello" | capitalize }}

    - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - -

    Today is Hello

    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_multiple_inline_snippets - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet banner %} - - Welcome to my store! - - {% endsnippet %} - - {%- render input -%} - {%- render banner -%} - LIQUID - expected = <<~OUTPUT - - - - - - - Welcome to my store! - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with_argument - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with_doc_tag - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with_evaluated_assign - template = <<~LIQUID.strip - {% snippet input %} -

    {{ greeting }}

    - {% endsnippet %} - - {%- assign greeting = "hello" | upcase -%} - {%- render input, greeting: greeting -%} - LIQUID - expected = <<~OUTPUT - -

    HELLO

    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with_multiple_arguments - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippets_using_same_argument_name - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet inputs %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render inputs, type: "password", value: "pass" -%} - LIQUID - - expected = <<~OUTPUT - - - - - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_empty_string_when_missing_argument - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% snippet main %} - {% assign color_scheme = 'auto' %} - -
    - {% render header, message: 'Welcome!' %} -
    - {% endsnippet %} - - {% render main, header: header %} - LIQUID - - expected = <<~OUTPUT - - - - - - - - - -
    - -
    - Welcome! -
    - -
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_shouldnt_leak_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - - {{ type }} - {{ value }} - LIQUID - expected = <<~OUTPUT - - - - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_multiple_inline_snippets_without_leaking_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {% snippet no_leak %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render no_leak -%} - LIQUID - expected = <<~OUTPUT - - - - - - - - - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_ignores_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - - {% render header, message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT - - - - - - -
    - Welcome! -
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_captured_snippet - template = <<~LIQUID - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% capture up_header %} - {%- render header, message: 'Welcome!' -%} - {% endcapture %} - - {{ up_header | upcase }} - - {{ header | upcase }} - - {{ header }} - LIQUID - expected = <<~OUTPUT - - - - - -
    - WELCOME! -
    - - - SNIPPETDROP - - SnippetDrop - OUTPUT - - assert_template_result(expected, template) - end - - def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID - {% assign color_scheme = 'dark' %} - - {% snippet header %} - {% assign color_scheme = 'light' %} -
    - {{ message }} -
    - {% endsnippet %} - - {{ color_scheme }} - - {% render header, message: 'Welcome!', color_scheme: color_scheme %} - - {{ color_scheme }} - LIQUID - expected = <<~OUTPUT - - - - - dark - - - -
    - Welcome! -
    - - - dark - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_forloop - template = <<~LIQUID.strip - {% snippet item %} -
  • {{ forloop.index }}: {{ item }}
  • - {% endsnippet %} - - {% assign items = "A,B,C" | split: "," %} - {%- render item for items -%} - LIQUID - expected = <<~OUTPUT - - - -
  • 1: A
  • - -
  • 2: B
  • - -
  • 3: C
  • - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_with - template = <<~LIQUID.strip - {% snippet header %} -
    {{ header }}
    - {% endsnippet %} - - {% assign product = "Apple" %} - {%- render header with product -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_render_inline_snippet_alias - template = <<~LIQUID.strip - {% snippet product_card %} -
    {{ item }}
    - {% endsnippet %} - - {% assign featured = "Apple" %} - {%- render product_card with featured as item -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template) - end - - def test_snippet_with_invalid_identifier - template = <<~LIQUID - {% snippet header foo bar %} - Invalid - {% endsnippet %} - LIQUID - - exception = assert_raises(SyntaxError) { Liquid::Template.parse(template) } - - assert_match("Expected end_of_string but found id", exception.message) - end - - def test_render_with_non_existent_tag - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) - {% snippet foo %} - {% render non_existent %} - {% endsnippet %} - - {% render foo %} - LIQUID - - expected = <<~TEXT - - - - Liquid error (index line 2): internal - TEXT - template.name = "index" - - assert_equal(expected, template.render('errors' => ErrorDrop.new)) - end - - def test_render_handles_errors - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) - {% snippet foo %} - {% render non_existent %} will raise an error. - - Bla bla test. - - This is an argument error: {{ 'test' | slice: 'not a number' }} - {% endsnippet %} - - {% render foo %} - LIQUID - - expected = <<~TEXT - - - - Liquid error (index line 2): internal will raise an error. - - Bla bla test. - - This is an argument error: Liquid error (index line 6): invalid integer - TEXT - template.name = "index" - - assert_equal(expected, template.render('errors' => ErrorDrop.new)) - end - end - - class RigidMode < SnippetTest - def test_valid_inline_snippet - template = <<~LIQUID.strip - {% snippet input %} - Hey - {% endsnippet %} - LIQUID - expected = '' - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet - template = <<~LIQUID.strip - {% snippet hey %} - Hey - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - - Hey - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_variable - template = <<~LIQUID.strip - {% snippet hey %} -

    Today is {{ "hello" | capitalize }}

    - {% endsnippet %} - - {%- render hey -%} - LIQUID - expected = <<~OUTPUT - -

    Today is Hello

    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_multiple_inline_snippets - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet banner %} - - Welcome to my store! - - {% endsnippet %} - - {%- render input -%} - {%- render banner -%} - LIQUID - expected = <<~OUTPUT - - - - - - - Welcome to my store! - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_argument - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_doc_tag - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_evaluated_assign - template = <<~LIQUID.strip - {% snippet input %} -

    {{ greeting }}

    - {% endsnippet %} - - {%- assign greeting = "hello" | upcase -%} - {%- render input, greeting: greeting -%} - LIQUID - expected = <<~OUTPUT - -

    HELLO

    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with_multiple_arguments - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippets_using_same_argument_name - template = <<~LIQUID.strip - {% snippet input %} - - {% endsnippet %} - - {% snippet inputs %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render inputs, type: "password", value: "pass" -%} - LIQUID - - expected = <<~OUTPUT - - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_empty_string_when_missing_argument - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text" -%} - LIQUID - expected = <<~OUTPUT - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_snippets_as_arguments - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% snippet main %} - {% assign color_scheme = 'auto' %} - -
    - {% render header, message: 'Welcome!' %} -
    - {% endsnippet %} - - {% render main, header: header %} - LIQUID - - expected = <<~OUTPUT - - - - - - - - - -
    - -
    - Welcome! -
    - -
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_shouldnt_leak_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - @param {string} value - Input value. - {% enddoc %} - - - {% endsnippet %} - - {%- render input, type: "text", value: "Hello" -%} - - {{ type }} - {{ value }} - LIQUID - expected = <<~OUTPUT - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_multiple_inline_snippets_without_leaking_context - template = <<~LIQUID.strip - {% snippet input %} - {% doc %} - @param {string} type - Input type. - {% enddoc %} - - - {% endsnippet %} - - {% snippet no_leak %} - - {% endsnippet %} - - {%- render input, type: "text" -%} - {%- render no_leak -%} - LIQUID - expected = <<~OUTPUT - - - - - - - - - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_ignores_outside_context - template = <<~LIQUID.strip - {% assign color_scheme = 'dark' %} - - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - - {% render header, message: 'Welcome!' %} - LIQUID - expected = <<~OUTPUT - - - - - - -
    - Welcome! -
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_inline_snippet_local_scope_takes_precedence - template = <<~LIQUID - {% assign color_scheme = 'dark' %} - - {% snippet header %} - {% assign color_scheme = 'light' %} -
    - {{ message }} -
    - {% endsnippet %} - - {{ color_scheme }} - - {% render header, message: 'Welcome!', color_scheme: color_scheme %} - - {{ color_scheme }} - LIQUID - expected = <<~OUTPUT - - - - - dark - - - -
    - Welcome! -
    - - - dark - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_forloop - template = <<~LIQUID.strip - {% snippet item %} -
  • {{ forloop.index }}: {{ item }}
  • - {% endsnippet %} - - {% assign items = "A,B,C" | split: "," %} - {%- render item for items -%} - LIQUID - expected = <<~OUTPUT - - - -
  • 1: A
  • - -
  • 2: B
  • - -
  • 3: C
  • - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_with - template = <<~LIQUID.strip - {% snippet header %} -
    {{ header }}
    - {% endsnippet %} - - {% assign product = "Apple" %} - {%- render header with product -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_inline_snippet_alias - template = <<~LIQUID.strip - {% snippet product_card %} -
    {{ item }}
    - {% endsnippet %} - - {% assign featured = "Apple" %} - {%- render product_card with featured as item -%} - LIQUID - expected = <<~OUTPUT - - - -
    Apple
    - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_captured_snippet - template = <<~LIQUID - {% snippet header %} -
    - {{ message }} -
    - {% endsnippet %} - - {% capture up_header %} - {%- render header, message: 'Welcome!' -%} - {% endcapture %} - - {{ up_header | upcase }} - - {{ header | upcase }} - - {{ header }} - LIQUID - expected = <<~OUTPUT - - - - - -
    - WELCOME! -
    - - - SNIPPETDROP - - SnippetDrop - OUTPUT - - assert_template_result(expected, template, error_mode: :rigid) - end - - def test_render_with_invalid_identifier - template = "{% render 123 %}" - - exception = assert_raises(SyntaxError) do - Liquid::Template.parse(template, error_mode: :rigid) - end - - assert_match("Expected a string or identifier, found 123", exception.message) - end - - def test_render_with_non_existent_tag - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true, error_mode: :rigid) - {% snippet foo %} - {% render non_existent %} - {% endsnippet %} - - {% render foo %} - LIQUID - - expected = <<~TEXT - - - - Liquid error (index line 2): internal - TEXT - template.name = "index" - - assert_equal(expected, template.render('errors' => ErrorDrop.new)) - end - - def test_render_handles_errors - template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true, error_mode: :rigid) - {% snippet foo %} - {% render non_existent %} will raise an error. - - Bla bla test. - - This is an argument error: {{ 'test' | slice: 'not a number' }} - {% endsnippet %} - - {% render foo %} - LIQUID - - expected = <<~TEXT - - - - Liquid error (index line 2): internal will raise an error. - - Bla bla test. - - This is an argument error: Liquid error (index line 6): invalid integer - TEXT - template.name = "index" - - assert_equal(expected, template.render('errors' => ErrorDrop.new)) - end - - def test_render_with_no_identifier - template = "{% render %}" - - exception = assert_raises(SyntaxError) do - Liquid::Template.parse(template, error_mode: :rigid) - end - - assert_match("Expected a string or identifier, found nothing", exception.message) - end - - def test_snippet_with_invalid_identifier - template = <<~LIQUID - {% snippet header foo bar %} - Invalid - {% endsnippet %} - LIQUID - - exception = assert_raises(SyntaxError) do - Liquid::Template.parse(template, error_mode: :rigid) - end - - assert_match("Expected end_of_string but found id", exception.message) - end - end - - class ResourceLimits < SnippetTest - def test_increment_assign_score_by_bytes_not_characters - t = Template.parse("{% snippet foo %}すごい{% endsnippet %}") - t.render! - assert_equal(9, t.resource_limits.assign_score) - end - end -end diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 44cdcdd99..45628ce9d 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -259,7 +259,7 @@ def test_table_row_does_not_leak_interrupts ) end - def test_tablerow_with_cols_attribute_in_rigid_mode + def test_tablerow_with_cols_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %} LIQUID @@ -270,12 +270,12 @@ def test_tablerow_with_cols_attribute_in_rigid_mode 456 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template) end end - def test_tablerow_with_limit_attribute_in_rigid_mode + def test_tablerow_with_limit_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %} LIQUID @@ -285,12 +285,12 @@ def test_tablerow_with_limit_attribute_in_rigid_mode 123 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template) end end - def test_tablerow_with_offset_attribute_in_rigid_mode + def test_tablerow_with_offset_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %} LIQUID @@ -300,12 +300,12 @@ def test_tablerow_with_offset_attribute_in_rigid_mode 345 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template) end end - def test_tablerow_with_range_attribute_in_rigid_mode + def test_tablerow_with_range_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %} LIQUID @@ -315,12 +315,12 @@ def test_tablerow_with_range_attribute_in_rigid_mode 123 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template) end end - def test_tablerow_with_multiple_attributes_in_rigid_mode + def test_tablerow_with_multiple_attributes_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %} LIQUID @@ -331,12 +331,12 @@ def test_tablerow_with_multiple_attributes_in_rigid_mode 45 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template) end end - def test_tablerow_with_variable_collection_in_rigid_mode + def test_tablerow_with_variable_collection_in_strict2_mode template = <<~LIQUID.chomp {% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %} LIQUID @@ -347,12 +347,12 @@ def test_tablerow_with_variable_collection_in_rigid_mode 34 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] }) end end - def test_tablerow_with_dotted_access_in_rigid_mode + def test_tablerow_with_dotted_access_in_strict2_mode template = <<~LIQUID.chomp {% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %} LIQUID @@ -363,12 +363,12 @@ def test_tablerow_with_dotted_access_in_rigid_mode 34 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } }) end end - def test_tablerow_with_bracketed_access_in_rigid_mode + def test_tablerow_with_bracketed_access_in_strict2_mode template = <<~LIQUID.chomp {% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %} LIQUID @@ -378,12 +378,12 @@ def test_tablerow_with_bracketed_access_in_rigid_mode 1020 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } }) end end - def test_tablerow_without_attributes_in_rigid_mode + def test_tablerow_without_attributes_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..3) %}{{ i }}{% endtablerow %} LIQUID @@ -393,30 +393,30 @@ def test_tablerow_without_attributes_in_rigid_mode 123 OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template) end end - def test_tablerow_without_in_keyword_in_rigid_mode + def test_tablerow_without_in_keyword_in_strict2_mode template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}' - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message) end end - def test_tablerow_with_multiple_invalid_attributes_reports_first_in_rigid_mode + def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}' - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message) end end - def test_tablerow_with_empty_collection_in_rigid_mode + def test_tablerow_with_empty_collection_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %} LIQUID @@ -426,12 +426,12 @@ def test_tablerow_with_empty_collection_in_rigid_mode OUTPUT - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result(expected, template, { 'empty_array' => [] }) end end - def test_tablerow_with_invalid_attribute_strict_vs_rigid + def test_tablerow_with_invalid_attribute_strict_vs_strict2 template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}' expected = <<~OUTPUT @@ -443,13 +443,13 @@ def test_tablerow_with_invalid_attribute_strict_vs_rigid assert_template_result(expected, template) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Invalid attribute 'invalid_attr'/, error.message) end end - def test_tablerow_with_invalid_expression_strict_vs_rigid + def test_tablerow_with_invalid_expression_strict_vs_strict2 template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}' with_error_modes(:lax, :strict) do @@ -460,7 +460,7 @@ def test_tablerow_with_invalid_expression_strict_vs_rigid assert_template_result(expected, template) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index f0f2bc409..e272b96b9 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -218,7 +218,7 @@ def test_filter_with_single_trailing_comma assert_match(/is not a valid expression/, error.message) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result('helloworld', template) end end @@ -231,7 +231,7 @@ def test_multiple_filters_with_trailing_commas assert_match(/is not a valid expression/, error.message) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result('hello12', template) end end @@ -244,7 +244,7 @@ def test_filter_with_colon_but_no_arguments assert_match(/is not a valid expression/, error.message) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result('TEST', template) end end @@ -257,7 +257,7 @@ def test_filter_chain_with_colon_no_args assert_match(/is not a valid expression/, error.message) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result('TESTX', template) end end @@ -270,7 +270,7 @@ def test_combining_trailing_comma_and_empty_args assert_match(/is not a valid expression/, error.message) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do assert_template_result('TESTX', template) end end diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index f5206ff53..eb466f7a9 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -176,19 +176,19 @@ def test_parse_expression_in_strict_mode assert_equal(['title'], result.lookups) end - def test_parse_expression_in_rigid_mode_raises_internal_error - environment = Environment.build(error_mode: :rigid) + def test_parse_expression_in_strict2_mode_raises_internal_error + environment = Environment.build(error_mode: :strict2) parse_context = ParseContext.new(environment: environment) error = assert_raises(Liquid::InternalError) do Condition.parse_expression(parse_context, 'product.title') end - assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message) + assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message) end - def test_parse_expression_with_safe_true_in_rigid_mode - environment = Environment.build(error_mode: :rigid) + def test_parse_expression_with_safe_true_in_strict2_mode + environment = Environment.build(error_mode: :strict2) parse_context = ParseContext.new(environment: environment) result = Condition.parse_expression(parse_context, 'product.title', safe: true) diff --git a/test/unit/parse_context_unit_test.rb b/test/unit/parse_context_unit_test.rb index b1c8fe470..f53cd1148 100644 --- a/test/unit/parse_context_unit_test.rb +++ b/test/unit/parse_context_unit_test.rb @@ -9,32 +9,32 @@ def test_safe_parse_expression_with_variable_lookup parser_strict = strict_parse_context.new_parser('product.title') result_strict = strict_parse_context.safe_parse_expression(parser_strict) - parser_rigid = rigid_parse_context.new_parser('product.title') - result_rigid = rigid_parse_context.safe_parse_expression(parser_rigid) + parser_strict2 = strict2_parse_context.new_parser('product.title') + result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2) assert_instance_of(VariableLookup, result_strict) assert_equal('product', result_strict.name) assert_equal(['title'], result_strict.lookups) - assert_instance_of(VariableLookup, result_rigid) - assert_equal('product', result_rigid.name) - assert_equal(['title'], result_rigid.lookups) + assert_instance_of(VariableLookup, result_strict2) + assert_equal('product', result_strict2.name) + assert_equal(['title'], result_strict2.lookups) end def test_safe_parse_expression_raises_syntax_error_for_invalid_expression parser_strict = strict_parse_context.new_parser('') - parser_rigid = rigid_parse_context.new_parser('') + parser_strict2 = strict2_parse_context.new_parser('') error_strict = assert_raises(Liquid::SyntaxError) do strict_parse_context.safe_parse_expression(parser_strict) end assert_match(/is not a valid expression/, error_strict.message) - error_rigid = assert_raises(Liquid::SyntaxError) do - rigid_parse_context.safe_parse_expression(parser_rigid) + error_strict2 = assert_raises(Liquid::SyntaxError) do + strict2_parse_context.safe_parse_expression(parser_strict2) end - assert_match(/is not a valid expression/, error_rigid.message) + assert_match(/is not a valid expression/, error_strict2.message) end def test_parse_expression_with_variable_lookup @@ -45,10 +45,10 @@ def test_parse_expression_with_variable_lookup assert_equal(['title'], result_strict.lookups) error = assert_raises(Liquid::InternalError) do - rigid_parse_context.parse_expression('product.title') + strict2_parse_context.parse_expression('product.title') end - assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message) + assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message) end def test_parse_expression_with_safe_true @@ -58,11 +58,11 @@ def test_parse_expression_with_safe_true assert_equal('product', result_strict.name) assert_equal(['title'], result_strict.lookups) - result_rigid = rigid_parse_context.parse_expression('product.title', safe: true) + result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true) - assert_instance_of(VariableLookup, result_rigid) - assert_equal('product', result_rigid.name) - assert_equal(['title'], result_rigid.lookups) + assert_instance_of(VariableLookup, result_strict2) + assert_equal('product', result_strict2.name) + assert_equal(['title'], result_strict2.lookups) end def test_parse_expression_with_empty_string @@ -70,40 +70,40 @@ def test_parse_expression_with_empty_string assert_nil(result_strict) error = assert_raises(Liquid::InternalError) do - rigid_parse_context.parse_expression('') + strict2_parse_context.parse_expression('') end - assert_match(/unsafe parse_expression cannot be used in rigid mode/, error.message) + assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message) end def test_parse_expression_with_empty_string_and_safe_true result_strict = strict_parse_context.parse_expression('', safe: true) assert_nil(result_strict) - result_rigid = rigid_parse_context.parse_expression('', safe: true) - assert_nil(result_rigid) + result_strict2 = strict2_parse_context.parse_expression('', safe: true) + assert_nil(result_strict2) end def test_safe_parse_expression_advances_parser_pointer - parser = rigid_parse_context.new_parser('foo, bar') + parser = strict2_parse_context.new_parser('foo, bar') # safe_parse_expression consumes "foo" - first_result = rigid_parse_context.safe_parse_expression(parser) + first_result = strict2_parse_context.safe_parse_expression(parser) assert_instance_of(VariableLookup, first_result) assert_equal('foo', first_result.name) parser.consume(:comma) # safe_parse_expression consumes "bar" - second_result = rigid_parse_context.safe_parse_expression(parser) + second_result = strict2_parse_context.safe_parse_expression(parser) assert_instance_of(VariableLookup, second_result) assert_equal('bar', second_result.name) parser.consume(:end_of_string) end - def test_parse_expression_with_whitespace_in_rigid_mode - result = rigid_parse_context.parse_expression(' ', safe: true) + def test_parse_expression_with_whitespace_in_strict2_mode + result = strict2_parse_context.parse_expression(' ', safe: true) assert_nil(result) end @@ -115,9 +115,9 @@ def strict_parse_context ) end - def rigid_parse_context - @rigid_parse_context ||= ParseContext.new( - environment: Environment.build(error_mode: :rigid), + def strict2_parse_context + @strict2_parse_context ||= ParseContext.new( + environment: Environment.build(error_mode: :strict2), ) end end diff --git a/test/unit/partial_cache_unit_test.rb b/test/unit/partial_cache_unit_test.rb index 31790b0d8..623241b40 100644 --- a/test/unit/partial_cache_unit_test.rb +++ b/test/unit/partial_cache_unit_test.rb @@ -184,7 +184,7 @@ def test_includes_error_mode_into_template_cache }, ) - [:lax, :warn, :strict, :rigid].each do |error_mode| + [:lax, :warn, :strict, :strict2].each do |error_mode| Liquid::PartialCache.load( 'my_partial', context: context, @@ -193,7 +193,7 @@ def test_includes_error_mode_into_template_cache end assert_equal( - ["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:rigid"], + ["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"], context.registers[:cached_partials].keys, ) end diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb index 5e181f3eb..9d7d34a39 100644 --- a/test/unit/tags/case_tag_unit_test.rb +++ b/test/unit/tags/case_tag_unit_test.rb @@ -24,7 +24,7 @@ def test_case_with_trailing_element assert_template_result("one", template) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Expected end_of_string but found/, error.message) @@ -45,7 +45,7 @@ def test_case_when_with_trailing_element assert_template_result("one", template) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Expected end_of_string but found/, error.message) @@ -62,7 +62,7 @@ def test_case_when_with_comma {%- endcase -%} LIQUID - with_error_modes(:lax, :strict, :rigid) do + with_error_modes(:lax, :strict, :strict2) do assert_template_result("one", template) end end @@ -77,7 +77,7 @@ def test_case_when_with_or {%- endcase -%} LIQUID - with_error_modes(:lax, :strict, :rigid) do + with_error_modes(:lax, :strict, :strict2) do assert_template_result("one", template) end end @@ -97,7 +97,7 @@ def test_case_with_invalid_expression assert_template_result("one", template, assigns) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) @@ -119,7 +119,7 @@ def test_case_when_with_invalid_expression assert_template_result("one", template, assigns) end - with_error_modes(:rigid) do + with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) diff --git a/test/unit/variable_unit_test.rb b/test/unit/variable_unit_test.rb index cb3a6d8ce..03f5862aa 100644 --- a/test/unit/variable_unit_test.rb +++ b/test/unit/variable_unit_test.rb @@ -161,8 +161,8 @@ def test_strict_filter_argument_parsing end end - def test_rigid_filter_argument_parsing - with_error_modes(:rigid) do + def test_strict2_filter_argument_parsing + with_error_modes(:strict2) do # optional colon var = create_variable(%(n | f1 | f2:)) assert_equal([['f1', []], ['f2', []]], var.filters) From fa27bfe6e0eff396407bfb031ef1de5f5014e206 Mon Sep 17 00:00:00 2001 From: Julia Boutin Date: Thu, 27 Nov 2025 15:32:37 -0700 Subject: [PATCH 285/343] Preserve literal semantics in strict2 case/when Previously, strict2 case/when used `safe_parse_expression` to parse when expressions causing `blank`/`empty` to be treated as string literals (Expression::LITERALS maps 'empty' => ''), rather than method literals This caused unexpected behavior: ``` {%- case empty_obj -%} {%- when empty -%} previously: doesn't render (empty_obj == '' is false) now: renders (empty_obj.empty? is true) {%- endcase -%} ``` This commit instead calls `Condition.parse_expression` with `safe: true`, which will correctly handle `blank` and `empty` --- lib/liquid/tags/case.rb | 2 +- test/unit/tags/case_tag_unit_test.rb | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index b32ea1ae7..e87e402e5 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -118,7 +118,7 @@ def parse_strict2_when(markup, body) parser = @parse_context.new_parser(markup) loop do - expr = safe_parse_expression(parser) + expr = Condition.parse_expression(parse_context, parser.expression, safe: true) block = Condition.new(@left, '==', expr) block.attach(body) @blocks << block diff --git a/test/unit/tags/case_tag_unit_test.rb b/test/unit/tags/case_tag_unit_test.rb index 9d7d34a39..11458db80 100644 --- a/test/unit/tags/case_tag_unit_test.rb +++ b/test/unit/tags/case_tag_unit_test.rb @@ -82,6 +82,26 @@ def test_case_when_with_or end end + def test_case_when_empty + template = <<~LIQUID + {%- case x -%} + {%- when 2 or empty -%} + 2 or empty + {%- else -%} + not 2 or empty + {%- endcase -%} + LIQUID + + with_error_modes(:lax, :strict, :strict2) do + assert_template_result("2 or empty", template, { 'x' => 2 }) + assert_template_result("2 or empty", template, { 'x' => {} }) + assert_template_result("2 or empty", template, { 'x' => [] }) + assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } }) + assert_template_result("not 2 or empty", template, { 'x' => ['a'] }) + assert_template_result("not 2 or empty", template, { 'x' => 4 }) + end + end + def test_case_with_invalid_expression template = <<~LIQUID {%- case foo=>bar -%} From 22e979a6fae0038c703a95c7421f159f71934a38 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Fri, 5 Dec 2025 15:54:52 -0500 Subject: [PATCH 286/343] Use floating-point format for BigDecimal stringification (#2022) ## Summary - BigDecimal values now stringify using `to_s("F")` format instead of the default `to_s` ## Why Ruby's default `BigDecimal#to_s` produces engineering/scientific notation for certain values: ```ruby BigDecimal("0.00001").to_s # => "0.1E-4" BigDecimal("12345678.9").to_s # => "0.123456789E8" ``` This is rarely the desired output in templates. Using `to_s("F")` produces the expected floating-point format: ```ruby BigDecimal("0.00001").to_s("F") # => "0.00001" BigDecimal("12345678.9").to_s("F") # => "12345678.9" ``` --- lib/liquid/utils.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 2bdb0c521..eb0a02a9f 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -95,6 +95,8 @@ def self.to_liquid_value(obj) def self.to_s(obj, seen = {}) case obj + when BigDecimal + obj.to_s("F") when Hash # If the custom hash implementation overrides `#to_s`, use their # custom implementation. Otherwise we use Liquid's default From cbeff64708ed74d2297d803e27b7b9b8b29af3d6 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 18 Dec 2025 13:56:02 -0500 Subject: [PATCH 287/343] Make new tests serializable to liquid-spec (No anonymous classes) --- test/integration/hash_rendering_test.rb | 10 ++-------- test/integration/standard_filter_test.rb | 8 +------- test/test_helper.rb | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/test/integration/hash_rendering_test.rb b/test/integration/hash_rendering_test.rb index 47e6288b3..0c1b33e48 100644 --- a/test/integration/hash_rendering_test.rb +++ b/test/integration/hash_rendering_test.rb @@ -88,17 +88,11 @@ def test_render_hash_with_hash_key end def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s - my_hash = Class.new(Hash) do - def to_s - "kewl" - end - end.new - - assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => my_hash }) + assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new }) end def test_rendering_hash_without_custom_to_s_uses_default_inspect - my_hash = Class.new(Hash).new + my_hash = HashWithoutCustomToS.new my_hash[:foo] = :bar assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash }) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index e1370fae1..e27605ff5 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -294,13 +294,7 @@ def test_join end def test_join_calls_to_liquid_on_each_element - drop = Class.new(Liquid::Drop) do - def to_liquid - 'i did it' - end - end - - assert_equal('i did it, i did it', @filters.join([drop.new, drop.new], ", ")) + assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", ")) end def test_sort diff --git a/test/test_helper.rb b/test/test_helper.rb index 293ea4766..a6d3e16e5 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -199,6 +199,26 @@ def exception end end +class CustomToLiquidDrop < Liquid::Drop + def initialize(value) + @value = value + super() + end + + def to_liquid + @value + end +end + +class HashWithCustomToS < Hash + def to_s + "kewl" + end +end + +class HashWithoutCustomToS < Hash +end + class StubFileSystem attr_reader :file_read_count From bad29caaae0e374366d28eadf3b45fb07ae4ec01 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 18 Dec 2025 15:39:13 -0500 Subject: [PATCH 288/343] Fixup GH action --- .github/workflows/liquid.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index e496f55f8..bd0df8e5b 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -22,17 +22,17 @@ jobs: rubyopt: "--enable-frozen-string-literal", } - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } - - { ruby: ruby-head, allowed-failure: false } + - { ruby: head, allowed-failure: false } - { - ruby: ruby-head, + ruby: head, allowed-failure: false, rubyopt: "--enable-frozen-string-literal", } - - { ruby: ruby-head, allowed-failure: false, rubyopt: "--yjit" } + - { ruby: head, allowed-failure: false, rubyopt: "--yjit" } name: Test Ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0 + - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 with: ruby-version: ${{ matrix.entry.ruby }} bundler-cache: true @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: ruby/setup-ruby@dffc446db9ba5a0c4446edb5bca1c5c473a806c5 # v1.235.0 + - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 with: bundler-cache: true - run: bundle exec rake memory_profile:run From 33bac87a5cf4192ce478162849465cbde1f15bc5 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 20:14:21 -0500 Subject: [PATCH 289/343] Address liquid-spec issues without ActiveSupport loaded Implement ActiveSupport-compatible behaviors internally so Liquid works correctly without ActiveSupport being loaded: 1. String first/last via property access (name.first, name.last) - VariableLookup now handles string[0] and string[-1] for first/last 2. String first/last via filters (name | first, name | last) - StandardFilters#first and #last now handle strings 3. blank?/empty? comparisons for types without these methods - Condition now implements liquid_blank? and liquid_empty? internally - blank? matches ActiveSupport: nil, false, empty/whitespace strings, empty arrays/hashes are all blank - empty? checks length == 0 only (whitespace is NOT empty) This fixes spec failures for templates like: - {{ name.first }} / {{ name | first }} on strings - {% if x == blank %} for whitespace strings, empty hashes/arrays - {% case ' ' %}{% when blank %} matching whitespace --- lib/liquid/condition.rb | 62 +++++++-- lib/liquid/standardfilters.rb | 2 + lib/liquid/variable_lookup.rb | 4 + test/integration/standard_filter_test.rb | 32 +++++ test/unit/condition_unit_test.rb | 156 +++++++++++++++++++++++ 5 files changed, 246 insertions(+), 10 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index 9ab350f07..2acf5ae35 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -113,24 +113,66 @@ def inspect def equal_variables(left, right) if left.is_a?(MethodLiteral) - if right.respond_to?(left.method_name) - return right.send(left.method_name) - else - return nil - end + return call_method_literal(left, right) end if right.is_a?(MethodLiteral) - if left.respond_to?(right.method_name) - return left.send(right.method_name) - else - return nil - end + return call_method_literal(right, left) end left == right end + def call_method_literal(literal, value) + method_name = literal.method_name + + # If the object responds to the method, use it + if value.respond_to?(method_name) + return value.send(method_name) + end + + # Implement blank?/empty? for common types that don't have it + # (ActiveSupport adds these, but Liquid should work without it) + case method_name + when :blank? + liquid_blank?(value) + when :empty? + liquid_empty?(value) + else + nil + end + end + + # Implement blank? semantics matching ActiveSupport + def liquid_blank?(value) + case value + when NilClass, FalseClass + true + when TrueClass, Numeric + false + when String + # Blank if empty or whitespace only + value.empty? || value.match?(/\A\s*\z/) + when Array, Hash + value.empty? + else + # Fall back to empty? if available, otherwise false + value.respond_to?(:empty?) ? value.empty? : false + end + end + + # Implement empty? semantics + def liquid_empty?(value) + case value + when NilClass + true + when String, Array, Hash + value.empty? + else + value.respond_to?(:empty?) ? value.empty? : false + end + end + def interpret_condition(left, right, op, context) # If the operator is empty this means that the decision statement is just # a single variable. We can just poll this variable from the context and diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 6e072fcf5..d17a699c6 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -768,6 +768,7 @@ def date(input, format) # @liquid_syntax array | first # @liquid_return [untyped] def first(array) + return array[0] if array.is_a?(String) array.first if array.respond_to?(:first) end @@ -779,6 +780,7 @@ def first(array) # @liquid_syntax array | last # @liquid_return [untyped] def last(array) + return array[-1] if array.is_a?(String) array.last if array.respond_to?(:last) end diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index 340c0b66d..bc9bbd975 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -70,6 +70,10 @@ def evaluate(context) elsif lookup_command?(i) && object.respond_to?(key) object = object.send(key).to_liquid + # Handle string first/last like ActiveSupport does (returns first/last character) + elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last") + object = key == "first" ? object[0] : object[-1] + # No key was present with the desired value and it wasn't one of the directly supported # keywords either. The only thing we got left is to return nil or # raise an exception if `strict_variables` option is set to true diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index e27605ff5..eb95276c7 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -627,6 +627,38 @@ def test_first_last assert_nil(@filters.last([])) end + def test_first_last_on_strings + # Ruby's String class does not have first/last methods by default. + # ActiveSupport adds String#first and String#last to return the first/last character. + # Liquid must work without ActiveSupport, so the first/last filters handle strings specially. + # + # This enables template patterns like: + # {{ product.title | first }} => "S" (for "Snowboard") + # {{ customer.name | last }} => "h" (for "Smith") + assert_equal('f', @filters.first('foo')) + assert_equal('o', @filters.last('foo')) + assert_nil(@filters.first('')) + assert_nil(@filters.last('')) + end + + def test_first_last_on_unicode_strings + # Unicode strings should return the first/last grapheme cluster (character), + # not the first/last byte. Ruby's String#[] handles this correctly with index 0/-1. + # This ensures international text works properly: + # {{ korean_name | first }} => "고" (not a partial byte sequence) + assert_equal('고', @filters.first('고스트빈')) + assert_equal('빈', @filters.last('고스트빈')) + end + + def test_first_last_on_strings_via_template + # Integration test to verify the filter works end-to-end in templates. + # Empty strings return empty output (nil renders as empty string). + assert_template_result('f', '{{ name | first }}', { 'name' => 'foo' }) + assert_template_result('o', '{{ name | last }}', { 'name' => 'foo' }) + assert_template_result('', '{{ name | first }}', { 'name' => '' }) + assert_template_result('', '{{ name | last }}', { 'name' => '' }) + end + def test_replace assert_equal('b b b b', @filters.replace('a a a a', 'a', 'b')) assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2)) diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index eb466f7a9..e250ce0f5 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -197,6 +197,162 @@ def test_parse_expression_with_safe_true_in_strict2_mode assert_equal(['title'], result.lookups) end + # Tests for blank? comparison without ActiveSupport + # + # Ruby's standard library does not include blank? on String, Array, Hash, etc. + # ActiveSupport adds blank? but Liquid must work without it. These tests verify + # that Liquid implements blank? semantics internally for use in templates like: + # {% if x == blank %}...{% endif %} + # + # The blank? semantics match ActiveSupport's behavior: + # - nil and false are blank + # - Strings are blank if empty or contain only whitespace + # - Arrays and Hashes are blank if empty + # - true and numbers are never blank + + def test_blank_with_whitespace_string + # Template authors expect " " to be blank since it has no visible content. + # This matches ActiveSupport's String#blank? which returns true for whitespace-only strings. + @context['whitespace'] = ' ' + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_true(VariableLookup.new('whitespace'), '==', blank_literal) + end + + def test_blank_with_empty_string + # An empty string has no content, so it should be considered blank. + # This is the most basic case of a blank string. + @context['empty_string'] = '' + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_true(VariableLookup.new('empty_string'), '==', blank_literal) + end + + def test_blank_with_empty_array + # Empty arrays have no elements, so they are blank. + # Useful for checking if a collection has items: {% if products == blank %} + @context['empty_array'] = [] + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_true(VariableLookup.new('empty_array'), '==', blank_literal) + end + + def test_blank_with_empty_hash + # Empty hashes have no key-value pairs, so they are blank. + # Useful for checking if settings/options exist: {% if settings == blank %} + @context['empty_hash'] = {} + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_true(VariableLookup.new('empty_hash'), '==', blank_literal) + end + + def test_blank_with_nil + # nil represents "nothing" and is the canonical blank value. + # Unassigned variables resolve to nil, so this enables: {% if missing_var == blank %} + @context['nil_value'] = nil + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_true(VariableLookup.new('nil_value'), '==', blank_literal) + end + + def test_blank_with_false + # false is considered blank to match ActiveSupport semantics. + # This allows {% if some_flag == blank %} to work when flag is false. + @context['false_value'] = false + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_true(VariableLookup.new('false_value'), '==', blank_literal) + end + + def test_not_blank_with_true + # true is a definite value, not blank. + # Ensures {% if flag == blank %} works correctly for boolean flags. + @context['true_value'] = true + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_false(VariableLookup.new('true_value'), '==', blank_literal) + end + + def test_not_blank_with_number + # Numbers (including zero) are never blank - they represent actual values. + # 0 is a valid quantity, not the absence of a value. + @context['number'] = 42 + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_false(VariableLookup.new('number'), '==', blank_literal) + end + + def test_not_blank_with_string_content + # A string with actual content is not blank. + # This is the expected behavior for most template string comparisons. + @context['string'] = 'hello' + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_false(VariableLookup.new('string'), '==', blank_literal) + end + + def test_not_blank_with_non_empty_array + # An array with elements has content, so it's not blank. + # Enables patterns like {% unless products == blank %}Show products{% endunless %} + @context['array'] = [1, 2, 3] + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_false(VariableLookup.new('array'), '==', blank_literal) + end + + def test_not_blank_with_non_empty_hash + # A hash with key-value pairs has content, so it's not blank. + # Useful for checking if configuration exists: {% if config != blank %} + @context['hash'] = { 'a' => 1 } + blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] + + assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal) + end + + # Tests for empty? comparison without ActiveSupport + # + # empty? is distinct from blank? - it only checks if a collection has zero elements. + # For strings, empty? checks length == 0, NOT whitespace content. + # Ruby's standard library has empty? on String, Array, and Hash, but Liquid + # provides a fallback implementation for consistency. + + def test_empty_with_empty_string + # An empty string ("") has length 0, so it's empty. + # Different from blank - empty is a stricter check. + @context['empty_string'] = '' + empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] + + assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal) + end + + def test_empty_with_whitespace_string_not_empty + # Whitespace strings have length > 0, so they are NOT empty. + # This is the key difference between empty and blank: + # " ".empty? => false, but " ".blank? => true + @context['whitespace'] = ' ' + empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] + + assert_evaluates_false(VariableLookup.new('whitespace'), '==', empty_literal) + end + + def test_empty_with_empty_array + # An array with no elements is empty. + # [].empty? => true + @context['empty_array'] = [] + empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] + + assert_evaluates_true(VariableLookup.new('empty_array'), '==', empty_literal) + end + + def test_empty_with_empty_hash + # A hash with no key-value pairs is empty. + # {}.empty? => true + @context['empty_hash'] = {} + empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] + + assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal) + end + private def assert_evaluates_true(left, op, right) From 0e3548d39ec28ec1cb843b44e8503a9a433b2055 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 20:16:24 -0500 Subject: [PATCH 290/343] Remove redundant else-clause --- lib/liquid/condition.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index 2acf5ae35..16eca6d68 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -138,8 +138,6 @@ def call_method_literal(literal, value) liquid_blank?(value) when :empty? liquid_empty?(value) - else - nil end end From 0ed29760c07cc20504aa46463185bfffa318396d Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 20:18:35 -0500 Subject: [PATCH 291/343] Add benchmark gem for Ruby 4.0 compatibility --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index a404c0d4b..4cb905638 100644 --- a/Gemfile +++ b/Gemfile @@ -25,6 +25,7 @@ group :development do end group :test do + gem 'benchmark' gem 'rubocop', '~> 1.61.0' gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false From 391c0df57ae7033bac09277c9ff2b31da37f0c76 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 20:19:21 -0500 Subject: [PATCH 292/343] Update rubocop to 1.82.0 for Ruby 4.0 support --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 4cb905638..9bab0a360 100644 --- a/Gemfile +++ b/Gemfile @@ -26,7 +26,7 @@ end group :test do gem 'benchmark' - gem 'rubocop', '~> 1.61.0' + gem 'rubocop', '~> 1.82.0' gem 'rubocop-shopify', '~> 2.12.0', require: false gem 'rubocop-performance', require: false end From 361d1d52b167d83f5f9a94c659d40643ab9a0012 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 20:20:50 -0500 Subject: [PATCH 293/343] Fix rubocop offenses from 1.82 upgrade --- lib/liquid/expression.rb | 2 +- lib/liquid/i18n.rb | 2 +- lib/liquid/tokenizer.rb | 2 +- test/integration/security_test.rb | 4 ++-- test/integration/standard_filter_test.rb | 2 +- test/integration/template_test.rb | 2 +- test/unit/strainer_template_unit_test.rb | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 2605c5576..00c40a4c3 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -55,7 +55,7 @@ def parse(markup, ss = StringScanner.new(""), cache = nil) end def inner_parse(markup, ss, cache) - if (markup.start_with?("(") && markup.end_with?(")")) && markup =~ RANGES_REGEX + if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX return RangeLookup.parse( Regexp.last_match(1), Regexp.last_match(2), diff --git a/lib/liquid/i18n.rb b/lib/liquid/i18n.rb index 4a2885e86..982688115 100644 --- a/lib/liquid/i18n.rb +++ b/lib/liquid/i18n.rb @@ -28,7 +28,7 @@ def locale def interpolate(name, vars) name.gsub(/%\{(\w+)\}/) do # raise TranslationError, "Undefined key #{$1} for interpolation in translation #{name}" unless vars[$1.to_sym] - (vars[Regexp.last_match(1).to_sym]).to_s + vars[Regexp.last_match(1).to_sym].to_s end end diff --git a/lib/liquid/tokenizer.rb b/lib/liquid/tokenizer.rb index 56dd218ec..8b331d93c 100644 --- a/lib/liquid/tokenizer.rb +++ b/lib/liquid/tokenizer.rb @@ -117,7 +117,7 @@ def next_variable_token byte_a = byte_b = @ss.scan_byte while byte_b - byte_a = @ss.scan_byte while byte_a && (byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY) + byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY break unless byte_a diff --git a/test/integration/security_test.rb b/test/integration/security_test.rb index 75f092965..f84058940 100644 --- a/test/integration/security_test.rb +++ b/test/integration/security_test.rb @@ -59,7 +59,7 @@ def test_does_not_permanently_add_filters_to_symbol_table GC.start - assert_equal([], (Symbol.all_symbols - current_symbols)) + assert_equal([], Symbol.all_symbols - current_symbols) end def test_does_not_add_drop_methods_to_symbol_table @@ -70,7 +70,7 @@ def test_does_not_add_drop_methods_to_symbol_table assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!) assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!) - assert_equal([], (Symbol.all_symbols - current_symbols)) + assert_equal([], Symbol.all_symbols - current_symbols) end def test_max_depth_nested_blocks_does_not_raise_exception diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index eb95276c7..4a10716d7 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1328,7 +1328,7 @@ def test_sum_with_non_string_property assert_equal(1, @filters.sum(input, true)) assert_equal(0.2, @filters.sum(input, 1.0)) assert_equal(-0.3, @filters.sum(input, 1)) - assert_equal(0.4, @filters.sum(input, (1..5))) + assert_equal(0.4, @filters.sum(input, 1..5)) assert_equal(0, @filters.sum(input, nil)) assert_equal(0, @filters.sum(input, "")) end diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index 92ece017c..c01c7161c 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -133,7 +133,7 @@ def test_resource_limits_render_score assert(t.resource_limits.reached?) t.resource_limits.render_score_limit = 200 - assert_equal((" foo " * 100), t.render!) + assert_equal(" foo " * 100, t.render!) refute_nil(t.resource_limits.render_score) end diff --git a/test/unit/strainer_template_unit_test.rb b/test/unit/strainer_template_unit_test.rb index a04107084..aa3f153a3 100644 --- a/test/unit/strainer_template_unit_test.rb +++ b/test/unit/strainer_template_unit_test.rb @@ -8,7 +8,7 @@ class StrainerTemplateUnitTest < Minitest::Test def test_add_filter_when_wrong_filter_class c = Context.new s = c.strainer - wrong_filter = ->(v) { v.reverse } + wrong_filter = lambda(&:reverse) exception = assert_raises(TypeError) do s.class.add_filter(wrong_filter) From af58800c1605a9c0033a50b3a22897f8003e979e Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 20:22:19 -0500 Subject: [PATCH 294/343] Update rubocop-shopify to 2.18.0 and fix new offenses --- .rubocop_todo.yml | 11 ++++++++++- Gemfile | 2 +- lib/liquid/condition.rb | 4 ++-- lib/liquid/drop.rb | 2 +- lib/liquid/utils.rb | 2 +- test/integration/standard_filter_test.rb | 2 +- test/integration/tags/standard_tag_test.rb | 2 +- test/unit/condition_unit_test.rb | 4 ++-- 8 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 424b9b4d3..1fcb7b54c 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -174,7 +174,16 @@ Style/WordArray: # Offense count: 117 # This cop supports safe auto-correction (--auto-correct). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, IgnoredPatterns. +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns. # URISchemes: http, https Layout/LineLength: Max: 260 + +Naming/PredicatePrefix: + Enabled: false + +# Offense count: 1 +# This is intentional - early return from begin/rescue in assignment context +Lint/NoReturnInBeginEndBlocks: + Exclude: + - 'lib/liquid/standardfilters.rb' diff --git a/Gemfile b/Gemfile index 9bab0a360..07994975c 100644 --- a/Gemfile +++ b/Gemfile @@ -27,6 +27,6 @@ end group :test do gem 'benchmark' gem 'rubocop', '~> 1.82.0' - gem 'rubocop-shopify', '~> 2.12.0', require: false + gem 'rubocop-shopify', '~> 2.18.0', require: false gem 'rubocop-performance', require: false end diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index 16eca6d68..a4096551c 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -194,8 +194,8 @@ def interpret_condition(left, right, op, context) end def deprecated_default_context - warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \ - " and will be removed from Liquid 6.0.0.") + warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \ + "and will be removed from Liquid 6.0.0.") Context.new end diff --git a/lib/liquid/drop.rb b/lib/liquid/drop.rb index b990630e4..d13398ec4 100644 --- a/lib/liquid/drop.rb +++ b/lib/liquid/drop.rb @@ -31,7 +31,7 @@ def initialize # Catch all for the method def liquid_method_missing(method) - return nil unless @context&.strict_variables + return unless @context&.strict_variables raise Liquid::UndefinedDropMethod, "undefined method #{method}" end diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index eb0a02a9f..084739a21 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -69,7 +69,7 @@ def self.to_date(obj) return obj if obj.respond_to?(:strftime) if obj.is_a?(String) - return nil if obj.empty? + return if obj.empty? obj = obj.downcase end diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index 4a10716d7..f81af489b 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -116,7 +116,7 @@ def test_slice end def test_slice_on_arrays - input = 'foobar'.split(//) + input = 'foobar'.split('') assert_equal(%w(o o b), @filters.slice(input, 1, 3)) assert_equal(%w(o o b a r), @filters.slice(input, 1, 1000)) assert_equal(%w(), @filters.slice(input, 1, 0)) diff --git a/test/integration/tags/standard_tag_test.rb b/test/integration/tags/standard_tag_test.rb index 6c297d407..67471e735 100644 --- a/test/integration/tags/standard_tag_test.rb +++ b/test/integration/tags/standard_tag_test.rb @@ -117,7 +117,7 @@ def test_case assigns = { 'condition' => "bad string here" } assert_template_result( '', - '{% case condition %}{% when "string here" %} hit {% endcase %}',\ + '{% case condition %}{% when "string here" %} hit {% endcase %}', assigns, ) end diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index e250ce0f5..2ac832759 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -161,8 +161,8 @@ def test_default_context_is_deprecated assert_equal(true, Condition.new(1, '==', 1).evaluate) end - expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \ - " and will be removed from Liquid 6.0.0." + expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \ + "and will be removed from Liquid 6.0.0." assert_includes(err.lines.map(&:strip), expected) end From 05f9c2a0300d8fba14e1a26e94952c9f2c42e322 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:34:10 -0500 Subject: [PATCH 295/343] Add liquid-spec for conformance testing - Add liquid-spec gem from GitHub to :spec group - Create spec/ruby-liquid.rb adapter for the reference implementation - Add spec job to CI workflow to run liquid-spec tests --- .github/workflows/liquid.yml | 10 ++++++++++ Gemfile | 4 ++++ spec/ruby-liquid.rb | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 spec/ruby-liquid.rb diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index bd0df8e5b..ee204542e 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -42,6 +42,16 @@ jobs: env: RUBYOPT: ${{ matrix.entry.rubyopt }} + spec: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 + with: + bundler-cache: true + bundler: latest + - run: bundle exec liquid-spec run spec/ruby-liquid.rb --no-max-failures + memory_profile: runs-on: ubuntu-latest steps: diff --git a/Gemfile b/Gemfile index 07994975c..05dab6514 100644 --- a/Gemfile +++ b/Gemfile @@ -30,3 +30,7 @@ group :test do gem 'rubocop-shopify', '~> 2.18.0', require: false gem 'rubocop-performance', require: false end + +group :spec do + gem 'liquid-spec', github: 'Shopify/liquid-spec' +end diff --git a/spec/ruby-liquid.rb b/spec/ruby-liquid.rb new file mode 100644 index 000000000..ebe782ba0 --- /dev/null +++ b/spec/ruby-liquid.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation) +# +# Run with: bundle exec liquid-spec run spec/ruby-liquid.rb + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require 'liquid' + +LiquidSpec.configure do |config| + # Run core Liquid specs + config.features = [:core] +end + +# Compile a template string into a Liquid::Template +LiquidSpec.compile do |source, options| + Liquid::Template.parse(source, **options) +end + +# Render a compiled template with the given context +LiquidSpec.render do |template, ctx| + static_registers = ctx.registers + registers = Liquid::Registers.new(static_registers) + + context = Liquid::Context.build( + static_environments: ctx.environment, + registers: registers, + rethrow_errors: ctx.rethrow_errors? + ) + + context.exception_renderer = ctx.exception_renderer if ctx.exception_renderer + + template.render(context) +end From 533d470723ce234de824534857f225236cf2dcbf Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:35:13 -0500 Subject: [PATCH 296/343] Fix spec job to include :spec bundle group --- .github/workflows/liquid.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index ee204542e..821ccda5e 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -44,6 +44,8 @@ jobs: spec: runs-on: ubuntu-latest + env: + BUNDLE_WITH: spec steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 From 34c274d3149cedd4fea250cb7e687d7dbb05ef5b Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:42:11 -0500 Subject: [PATCH 297/343] Fix rubocop: rename ruby-liquid.rb to ruby_liquid.rb and add trailing comma --- .github/workflows/liquid.yml | 2 +- spec/{ruby-liquid.rb => ruby_liquid.rb} | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename spec/{ruby-liquid.rb => ruby_liquid.rb} (88%) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 821ccda5e..6fea0ba60 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -52,7 +52,7 @@ jobs: with: bundler-cache: true bundler: latest - - run: bundle exec liquid-spec run spec/ruby-liquid.rb --no-max-failures + - run: bundle exec liquid-spec run spec/ruby_liquid.rb --no-max-failures memory_profile: runs-on: ubuntu-latest diff --git a/spec/ruby-liquid.rb b/spec/ruby_liquid.rb similarity index 88% rename from spec/ruby-liquid.rb rename to spec/ruby_liquid.rb index ebe782ba0..d82d21da9 100644 --- a/spec/ruby-liquid.rb +++ b/spec/ruby_liquid.rb @@ -2,7 +2,7 @@ # Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation) # -# Run with: bundle exec liquid-spec run spec/ruby-liquid.rb +# Run with: bundle exec liquid-spec run spec/ruby_liquid.rb $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'liquid' @@ -25,7 +25,7 @@ context = Liquid::Context.build( static_environments: ctx.environment, registers: registers, - rethrow_errors: ctx.rethrow_errors? + rethrow_errors: ctx.rethrow_errors?, ) context.exception_renderer = ctx.exception_renderer if ctx.exception_renderer From 19528a9b3f3c3841a6e19a7b375a269775b508ad Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:42:47 -0500 Subject: [PATCH 298/343] Update CI matrix: remove Ruby 3.0-3.2, add Ruby 4.0 --- .github/workflows/liquid.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 6fea0ba60..b57313613 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -11,17 +11,15 @@ jobs: strategy: matrix: entry: - - { ruby: 3.0, allowed-failure: false } # minimum supported - - { ruby: 3.2, allowed-failure: false } - - { ruby: 3.3, allowed-failure: false } - - { ruby: 3.3, allowed-failure: false } - - { ruby: 3.4, allowed-failure: false } # latest + - { ruby: 3.3, allowed-failure: false } # minimum supported + - { ruby: 3.4, allowed-failure: false } + - { ruby: 4.0, allowed-failure: false } # latest - { - ruby: 3.4, + ruby: 4.0, allowed-failure: false, rubyopt: "--enable-frozen-string-literal", } - - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } + - { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" } - { ruby: head, allowed-failure: false } - { ruby: head, From e0b46049afc9452e43564883282f99e7d1ccf7ec Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:44:28 -0500 Subject: [PATCH 299/343] Add Ruby 3.4 yjit, 4.0 zjit, and head zjit to CI matrix --- .github/workflows/liquid.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index b57313613..23b82708b 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -13,13 +13,15 @@ jobs: entry: - { ruby: 3.3, allowed-failure: false } # minimum supported - { ruby: 3.4, allowed-failure: false } - - { ruby: 4.0, allowed-failure: false } # latest + - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } + - { ruby: 4.0, allowed-failure: false } # latest stable - { ruby: 4.0, allowed-failure: false, rubyopt: "--enable-frozen-string-literal", } - { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" } + - { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" } - { ruby: head, allowed-failure: false } - { ruby: head, @@ -27,6 +29,7 @@ jobs: rubyopt: "--enable-frozen-string-literal", } - { ruby: head, allowed-failure: false, rubyopt: "--yjit" } + - { ruby: head, allowed-failure: false, rubyopt: "--zjit" } name: Test Ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 From 7e3ccbc1886d869dcb26456533c0f5b8e4f4be6d Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:49:33 -0500 Subject: [PATCH 300/343] test --- .github/workflows/liquid.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 23b82708b..c645e9214 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -12,7 +12,6 @@ jobs: matrix: entry: - { ruby: 3.3, allowed-failure: false } # minimum supported - - { ruby: 3.4, allowed-failure: false } - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } - { ruby: 4.0, allowed-failure: false } # latest stable - { @@ -51,7 +50,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 with: - bundler-cache: true + #bundler-cache: true bundler: latest - run: bundle exec liquid-spec run spec/ruby_liquid.rb --no-max-failures From f4890de9d5e845285d7eac55ec3f2651ef7764df Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:54:08 -0500 Subject: [PATCH 301/343] hm --- .github/workflows/liquid.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index c645e9214..1d27600fe 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -21,14 +21,16 @@ jobs: } - { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" } - { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" } - - { ruby: head, allowed-failure: false } + + # Head can have failures due to being in development + - { ruby: head, allowed-failure: true } - { ruby: head, - allowed-failure: false, + allowed-failure: true, rubyopt: "--enable-frozen-string-literal", } - - { ruby: head, allowed-failure: false, rubyopt: "--yjit" } - - { ruby: head, allowed-failure: false, rubyopt: "--zjit" } + - { ruby: head, allowed-failure: true, rubyopt: "--yjit" } + - { ruby: head, allowed-failure: true, rubyopt: "--zjit" } name: Test Ruby ${{ matrix.entry.ruby }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 @@ -50,7 +52,7 @@ jobs: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 with: - #bundler-cache: true + bundler-cache: true bundler: latest - run: bundle exec liquid-spec run spec/ruby_liquid.rb --no-max-failures From ccd10a986a0ef43b68a50f24e234cde806df3568 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:56:39 -0500 Subject: [PATCH 302/343] Pin liquid-spec to main branch --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 05dab6514..3aa8c44f1 100644 --- a/Gemfile +++ b/Gemfile @@ -32,5 +32,5 @@ group :test do end group :spec do - gem 'liquid-spec', github: 'Shopify/liquid-spec' + gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' end From 53641e19ce2682847e3b616126a706a3c2efb5bc Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:57:34 -0500 Subject: [PATCH 303/343] Pin liquid-spec to minimum required commit 3d1b492 --- Gemfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 3aa8c44f1..47ffd8760 100644 --- a/Gemfile +++ b/Gemfile @@ -32,5 +32,6 @@ group :test do end group :spec do - gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' + # Minimum required: 3d1b492 (run `bundle update liquid-spec` to get newer) + gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '3d1b492dce27cd78b4d5a46ca8cf260ae1349f29' end From d321adae77f926e74b7ac5820a352fff47e7fa05 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 21:59:16 -0500 Subject: [PATCH 304/343] Fix spec adapter for liquid-spec API (template, assigns, options) --- spec/ruby_liquid.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/spec/ruby_liquid.rb b/spec/ruby_liquid.rb index d82d21da9..5eda72da4 100644 --- a/spec/ruby_liquid.rb +++ b/spec/ruby_liquid.rb @@ -18,17 +18,19 @@ end # Render a compiled template with the given context -LiquidSpec.render do |template, ctx| - static_registers = ctx.registers - registers = Liquid::Registers.new(static_registers) +# @param template [Liquid::Template] compiled template +# @param assigns [Hash] environment variables +# @param options [Hash] :registers, :strict_errors, :exception_renderer +LiquidSpec.render do |template, assigns, options| + registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( - static_environments: ctx.environment, + static_environments: assigns, registers: registers, - rethrow_errors: ctx.rethrow_errors?, + rethrow_errors: options[:strict_errors], ) - context.exception_renderer = ctx.exception_renderer if ctx.exception_renderer + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] template.render(context) end From 608a877053e76e31c2829426b257aa2c37032eee Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:00:38 -0500 Subject: [PATCH 305/343] Add spec adapter with ActiveSupport for comparison testing --- Gemfile | 1 + spec/ruby_liquid_with_active_support.rb | 37 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 spec/ruby_liquid_with_active_support.rb diff --git a/Gemfile b/Gemfile index 47ffd8760..c71645a8a 100644 --- a/Gemfile +++ b/Gemfile @@ -34,4 +34,5 @@ end group :spec do # Minimum required: 3d1b492 (run `bundle update liquid-spec` to get newer) gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '3d1b492dce27cd78b4d5a46ca8cf260ae1349f29' + gem 'activesupport' end diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb new file mode 100644 index 000000000..2b3d817f8 --- /dev/null +++ b/spec/ruby_liquid_with_active_support.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded +# +# Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require 'active_support/all' +require 'liquid' + +LiquidSpec.configure do |config| + # Run core Liquid specs + config.features = [:core] +end + +# Compile a template string into a Liquid::Template +LiquidSpec.compile do |source, options| + Liquid::Template.parse(source, **options) +end + +# Render a compiled template with the given context +# @param template [Liquid::Template] compiled template +# @param assigns [Hash] environment variables +# @param options [Hash] :registers, :strict_errors, :exception_renderer +LiquidSpec.render do |template, assigns, options| + registers = Liquid::Registers.new(options[:registers] || {}) + + context = Liquid::Context.build( + static_environments: assigns, + registers: registers, + rethrow_errors: options[:strict_errors], + ) + + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] + + template.render(context) +end From ae26cb29ac62c0c08fa58f30578a1de6e569ee16 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:00:49 -0500 Subject: [PATCH 306/343] Disable auto-require for activesupport gem --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index c71645a8a..304ebdf17 100644 --- a/Gemfile +++ b/Gemfile @@ -34,5 +34,5 @@ end group :spec do # Minimum required: 3d1b492 (run `bundle update liquid-spec` to get newer) gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '3d1b492dce27cd78b4d5a46ca8cf260ae1349f29' - gem 'activesupport' + gem 'activesupport', require: false end From b0fb0ad83fe24e153715378f0284cdceafe0353f Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:02:42 -0500 Subject: [PATCH 307/343] Run liquid-spec for all adapters in spec/*.rb --- .github/workflows/liquid.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 1d27600fe..0626ed56d 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -54,7 +54,12 @@ jobs: with: bundler-cache: true bundler: latest - - run: bundle exec liquid-spec run spec/ruby_liquid.rb --no-max-failures + - name: Run liquid-spec for all adapters + run: | + for adapter in spec/*.rb; do + echo "=== Running $adapter ===" + bundle exec liquid-spec run "$adapter" --no-max-failures + done memory_profile: runs-on: ubuntu-latest From ef13b2dfd5886f9af680129fbe2e4626ae54355f Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:06:22 -0500 Subject: [PATCH 308/343] Fix empty? semantics and string first/last for empty strings - nil is NOT empty (but IS blank) - matches Shopify production - String first/last returns '' for empty strings, not nil - matches ActiveSupport - Add test for nil not being empty --- lib/liquid/condition.rb | 3 +-- lib/liquid/standardfilters.rb | 6 ++++-- lib/liquid/variable_lookup.rb | 3 ++- test/integration/standard_filter_test.rb | 6 ++++-- test/unit/condition_unit_test.rb | 10 ++++++++++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index a4096551c..eb674ced8 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -160,10 +160,9 @@ def liquid_blank?(value) end # Implement empty? semantics + # Note: nil is NOT empty (but IS blank). empty? checks if a collection has zero elements. def liquid_empty?(value) case value - when NilClass - true when String, Array, Hash value.empty? else diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index d17a699c6..24e135d91 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -768,7 +768,8 @@ def date(input, format) # @liquid_syntax array | first # @liquid_return [untyped] def first(array) - return array[0] if array.is_a?(String) + # ActiveSupport returns "" for empty strings, not nil + return array[0] || "" if array.is_a?(String) array.first if array.respond_to?(:first) end @@ -780,7 +781,8 @@ def first(array) # @liquid_syntax array | last # @liquid_return [untyped] def last(array) - return array[-1] if array.is_a?(String) + # ActiveSupport returns "" for empty strings, not nil + return array[-1] || "" if array.is_a?(String) array.last if array.respond_to?(:last) end diff --git a/lib/liquid/variable_lookup.rb b/lib/liquid/variable_lookup.rb index bc9bbd975..4fba2a658 100644 --- a/lib/liquid/variable_lookup.rb +++ b/lib/liquid/variable_lookup.rb @@ -71,8 +71,9 @@ def evaluate(context) object = object.send(key).to_liquid # Handle string first/last like ActiveSupport does (returns first/last character) + # ActiveSupport returns "" for empty strings, not nil elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last") - object = key == "first" ? object[0] : object[-1] + object = key == "first" ? (object[0] || "") : (object[-1] || "") # No key was present with the desired value and it wasn't one of the directly supported # keywords either. The only thing we got left is to return nil or diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index f81af489b..ef5f15493 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -635,10 +635,12 @@ def test_first_last_on_strings # This enables template patterns like: # {{ product.title | first }} => "S" (for "Snowboard") # {{ customer.name | last }} => "h" (for "Smith") + # + # Note: ActiveSupport returns "" for empty strings, not nil. assert_equal('f', @filters.first('foo')) assert_equal('o', @filters.last('foo')) - assert_nil(@filters.first('')) - assert_nil(@filters.last('')) + assert_equal('', @filters.first('')) + assert_equal('', @filters.last('')) end def test_first_last_on_unicode_strings diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index 2ac832759..d7f35f609 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -353,6 +353,16 @@ def test_empty_with_empty_hash assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal) end + def test_nil_is_not_empty + # nil is NOT empty - empty? checks if a collection has zero elements. + # nil is not a collection, so it cannot be empty. + # This differs from blank: nil IS blank, but nil is NOT empty. + @context['nil_value'] = nil + empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] + + assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal) + end + private def assert_evaluates_true(left, op, right) From 2988f1a5009b1b4947495a11fa7f055e0d33482f Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:15:38 -0500 Subject: [PATCH 309/343] Update liquid-spec to branch with per-spec required_features support --- Gemfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 304ebdf17..cdf23039c 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,6 @@ group :test do end group :spec do - # Minimum required: 3d1b492 (run `bundle update liquid-spec` to get newer) - gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '3d1b492dce27cd78b4d5a46ca8cf260ae1349f29' + gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'add-per-spec-required-features' gem 'activesupport', require: false end From ddee08fb95484d0134748949b71a083696905e0e Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:21:47 -0500 Subject: [PATCH 310/343] Add activesupport feature to with_active_support adapter Both adapters now pass with 0 failures: - ruby_liquid.rb: 4194 passed (skips activesupport and shopify_error_handling specs) - ruby_liquid_with_active_support.rb: 4203 passed (skips shopify_error_handling specs) --- spec/ruby_liquid_with_active_support.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb index 2b3d817f8..4a6f00570 100644 --- a/spec/ruby_liquid_with_active_support.rb +++ b/spec/ruby_liquid_with_active_support.rb @@ -9,8 +9,8 @@ require 'liquid' LiquidSpec.configure do |config| - # Run core Liquid specs - config.features = [:core] + # Run core Liquid specs plus ActiveSupport SafeBuffer tests + config.features = [:core, :activesupport] end # Compile a template string into a Liquid::Template From 79a2e042ff66e6dfb05314edce735fd19b05de03 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:24:00 -0500 Subject: [PATCH 311/343] Use liquid-spec main branch --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index cdf23039c..a6c448617 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,6 @@ group :test do end group :spec do - gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'add-per-spec-required-features' + gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' gem 'activesupport', require: false end From 50e1789537edc78a3c8f53949d3dd4e8302df9b5 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:29:32 -0500 Subject: [PATCH 312/343] Use liquid-spec feature branch until PR is merged --- Gemfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index a6c448617..add479637 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ group :test do end group :spec do - gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' + # Using feature branch until https://github.com/Shopify/liquid-spec/pull/97 is merged + gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'add-per-spec-required-features' gem 'activesupport', require: false end From 0058e4322ba0099206f9b263b9b80f25e2d1be30 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Thu, 1 Jan 2026 22:30:14 -0500 Subject: [PATCH 313/343] Add fail-fast: false to prevent job cancellation --- .github/workflows/liquid.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 0626ed56d..de57dd68d 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -9,6 +9,7 @@ jobs: test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: entry: - { ruby: 3.3, allowed-failure: false } # minimum supported From ccd05e869ce4c2b050863d5b9c466b6cc770cfd3 Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Mon, 5 Jan 2026 14:54:43 -1000 Subject: [PATCH 314/343] Make blank/empty comparisons invariant to ActiveSupport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement liquid_blank? and liquid_empty? methods in Condition to emulate ActiveSupport's behavior when it's not loaded - This ensures templates like `{% if x == blank %}` work identically whether ActiveSupport is loaded or not - Update liquid-spec adapters for new API (ctx parameter) - Add rake spec task for running liquid-spec matrix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Gemfile | 2 +- Rakefile | 6 +++++ lib/liquid/condition.rb | 30 ++++++++++++++----------- spec/ruby_liquid.rb | 5 +++-- spec/ruby_liquid_with_active_support.rb | 5 +++-- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/Gemfile b/Gemfile index add479637..4b68a939a 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,6 @@ end group :spec do # Using feature branch until https://github.com/Shopify/liquid-spec/pull/97 is merged - gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'add-per-spec-required-features' + gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' gem 'activesupport', require: false end diff --git a/Rakefile b/Rakefile index 83afcbfa0..650923bf8 100755 --- a/Rakefile +++ b/Rakefile @@ -148,3 +148,9 @@ end task :console do exec 'irb -I lib -r liquid' end + +desc('run liquid-spec suite across all adapters') +task :spec do + adapters = Dir['./spec/*.rb'].join(',') + sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid" +end diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index eb674ced8..9d55c42b3 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -126,22 +126,26 @@ def equal_variables(left, right) def call_method_literal(literal, value) method_name = literal.method_name - # If the object responds to the method, use it + # If the object responds to the method (e.g., ActiveSupport is loaded), use it if value.respond_to?(method_name) - return value.send(method_name) - end - - # Implement blank?/empty? for common types that don't have it - # (ActiveSupport adds these, but Liquid should work without it) - case method_name - when :blank? - liquid_blank?(value) - when :empty? - liquid_empty?(value) + value.send(method_name) + else + # Emulate ActiveSupport's blank?/empty? to make Liquid invariant + # to whether ActiveSupport is loaded or not + case method_name + when :blank? + liquid_blank?(value) + when :empty? + liquid_empty?(value) + else + false + end end end # Implement blank? semantics matching ActiveSupport + # blank? returns true for nil, false, empty strings, whitespace-only strings, + # empty arrays, and empty hashes def liquid_blank?(value) case value when NilClass, FalseClass @@ -149,7 +153,7 @@ def liquid_blank?(value) when TrueClass, Numeric false when String - # Blank if empty or whitespace only + # Blank if empty or whitespace only (matches ActiveSupport) value.empty? || value.match?(/\A\s*\z/) when Array, Hash value.empty? @@ -160,7 +164,7 @@ def liquid_blank?(value) end # Implement empty? semantics - # Note: nil is NOT empty (but IS blank). empty? checks if a collection has zero elements. + # Note: nil is NOT empty. empty? checks if a collection has zero elements. def liquid_empty?(value) case value when String, Array, Hash diff --git a/spec/ruby_liquid.rb b/spec/ruby_liquid.rb index 5eda72da4..062e635f9 100644 --- a/spec/ruby_liquid.rb +++ b/spec/ruby_liquid.rb @@ -13,15 +13,16 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |source, options| +LiquidSpec.compile do |ctx, source, options| Liquid::Template.parse(source, **options) end # Render a compiled template with the given context +# @param ctx [Hash] adapter context (unused) # @param template [Liquid::Template] compiled template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer -LiquidSpec.render do |template, assigns, options| +LiquidSpec.render do |ctx, template, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb index 4a6f00570..ab1ada372 100644 --- a/spec/ruby_liquid_with_active_support.rb +++ b/spec/ruby_liquid_with_active_support.rb @@ -14,15 +14,16 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |source, options| +LiquidSpec.compile do |ctx, source, options| Liquid::Template.parse(source, **options) end # Render a compiled template with the given context +# @param ctx [Hash] adapter context (unused) # @param template [Liquid::Template] compiled template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer -LiquidSpec.render do |template, assigns, options| +LiquidSpec.render do |ctx, template, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( From eaa9f215bf90d05ef5113055dfc42e85e991dd3c Mon Sep 17 00:00:00 2001 From: Tobi Lutke Date: Mon, 5 Jan 2026 15:11:29 -1000 Subject: [PATCH 315/343] Add lax and YJIT liquid-spec adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruby_liquid_lax.rb: Tests lax parsing mode with :lax_parsing feature - ruby_liquid_yjit.rb: Tests YJIT + strict mode + ActiveSupport Matrix results: 4483 matched, 18 different (lax edge cases), 61 skipped 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- spec/ruby_liquid_lax.rb | 34 +++++++++++++++++++++++++++++++++ spec/ruby_liquid_yjit.rb | 41 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 spec/ruby_liquid_lax.rb create mode 100644 spec/ruby_liquid_yjit.rb diff --git a/spec/ruby_liquid_lax.rb b/spec/ruby_liquid_lax.rb new file mode 100644 index 000000000..b9fc497a8 --- /dev/null +++ b/spec/ruby_liquid_lax.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Liquid Spec Adapter for Shopify/liquid with lax parsing mode +# +# Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require 'liquid' + +LiquidSpec.configure do |config| + config.features = [:core, :lax_parsing] +end + +# Compile a template string into a Liquid::Template +LiquidSpec.compile do |ctx, source, options| + # Force lax mode + options = options.merge(error_mode: :lax) + Liquid::Template.parse(source, **options) +end + +# Render a compiled template with the given context +LiquidSpec.render do |ctx, template, assigns, options| + registers = Liquid::Registers.new(options[:registers] || {}) + + context = Liquid::Context.build( + static_environments: assigns, + registers: registers, + rethrow_errors: options[:strict_errors], + ) + + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] + + template.render(context) +end diff --git a/spec/ruby_liquid_yjit.rb b/spec/ruby_liquid_yjit.rb new file mode 100644 index 000000000..e34e0abb6 --- /dev/null +++ b/spec/ruby_liquid_yjit.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport +# +# Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + +# Enable YJIT if available +if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable) + RubyVM::YJIT.enable +end + +require 'active_support/all' +require 'liquid' + +LiquidSpec.configure do |config| + config.features = [:core, :activesupport] +end + +# Compile a template string into a Liquid::Template +LiquidSpec.compile do |ctx, source, options| + # Force strict mode + options = { error_mode: :strict }.merge(options) + Liquid::Template.parse(source, **options) +end + +# Render a compiled template with the given context +LiquidSpec.render do |ctx, template, assigns, options| + registers = Liquid::Registers.new(options[:registers] || {}) + + context = Liquid::Context.build( + static_environments: assigns, + registers: registers, + rethrow_errors: options[:strict_errors], + ) + + context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] + + template.render(context) +end From 51ff08db7b77607a76753c0bd35333d8ee425bd5 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Mon, 12 Jan 2026 13:06:13 -0500 Subject: [PATCH 316/343] Fix CI --- spec/ruby_liquid.rb | 4 ++-- spec/ruby_liquid_lax.rb | 4 ++-- spec/ruby_liquid_with_active_support.rb | 4 ++-- spec/ruby_liquid_yjit.rb | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/ruby_liquid.rb b/spec/ruby_liquid.rb index 062e635f9..8d7f651bb 100644 --- a/spec/ruby_liquid.rb +++ b/spec/ruby_liquid.rb @@ -13,7 +13,7 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |ctx, source, options| +LiquidSpec.compile do |_ctx, source, options| Liquid::Template.parse(source, **options) end @@ -22,7 +22,7 @@ # @param template [Liquid::Template] compiled template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer -LiquidSpec.render do |ctx, template, assigns, options| +LiquidSpec.render do |_ctx, template, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( diff --git a/spec/ruby_liquid_lax.rb b/spec/ruby_liquid_lax.rb index b9fc497a8..79f4518c0 100644 --- a/spec/ruby_liquid_lax.rb +++ b/spec/ruby_liquid_lax.rb @@ -12,14 +12,14 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |ctx, source, options| +LiquidSpec.compile do |_ctx, source, options| # Force lax mode options = options.merge(error_mode: :lax) Liquid::Template.parse(source, **options) end # Render a compiled template with the given context -LiquidSpec.render do |ctx, template, assigns, options| +LiquidSpec.render do |_ctx, template, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb index ab1ada372..b577668d6 100644 --- a/spec/ruby_liquid_with_active_support.rb +++ b/spec/ruby_liquid_with_active_support.rb @@ -14,7 +14,7 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |ctx, source, options| +LiquidSpec.compile do |_ctx, source, options| Liquid::Template.parse(source, **options) end @@ -23,7 +23,7 @@ # @param template [Liquid::Template] compiled template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer -LiquidSpec.render do |ctx, template, assigns, options| +LiquidSpec.render do |_ctx, template, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( diff --git a/spec/ruby_liquid_yjit.rb b/spec/ruby_liquid_yjit.rb index e34e0abb6..d30afb8ac 100644 --- a/spec/ruby_liquid_yjit.rb +++ b/spec/ruby_liquid_yjit.rb @@ -19,14 +19,14 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |ctx, source, options| +LiquidSpec.compile do |_ctx, source, options| # Force strict mode options = { error_mode: :strict }.merge(options) Liquid::Template.parse(source, **options) end # Render a compiled template with the given context -LiquidSpec.render do |ctx, template, assigns, options| +LiquidSpec.render do |_ctx, template, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( From bbcf8d6ad8a8b1e051a7f013f84168ed7f470bab Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Mon, 12 Jan 2026 13:24:35 -0500 Subject: [PATCH 317/343] Better matrix CI check names --- .github/workflows/liquid.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index de57dd68d..3e26a7517 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -32,7 +32,7 @@ jobs: } - { ruby: head, allowed-failure: true, rubyopt: "--yjit" } - { ruby: head, allowed-failure: true, rubyopt: "--zjit" } - name: Test Ruby ${{ matrix.entry.ruby }} + name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 From 7d90b524ea4450f473b4ece5aa1c12af2dece47b Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Mon, 12 Jan 2026 13:25:56 -0500 Subject: [PATCH 318/343] Remove on pull_request trigger. It's redundant. --- .github/workflows/liquid.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 3e26a7517..c018a4954 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -1,5 +1,5 @@ name: Liquid -on: [push, pull_request] +on: [push] env: BUNDLE_JOBS: 4 From aa817c4cfd8a0ca693698193a4d6d19c1ecbe42e Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Tue, 13 Jan 2026 13:06:55 -0500 Subject: [PATCH 319/343] Update liquid-spec adapters for new ctx-based API liquid-spec main changed the adapter API: - compile block now receives (ctx, source, options) and should store the template in ctx[:template] - render block now receives (ctx, assigns, options) and retrieves the template from ctx[:template] Co-Authored-By: Claude Opus 4.5 --- Gemfile | 1 - spec/ruby_liquid.rb | 11 +++++------ spec/ruby_liquid_lax.rb | 8 ++++---- spec/ruby_liquid_with_active_support.rb | 11 +++++------ spec/ruby_liquid_yjit.rb | 8 ++++---- 5 files changed, 18 insertions(+), 21 deletions(-) diff --git a/Gemfile b/Gemfile index 4b68a939a..a6c448617 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,6 @@ group :test do end group :spec do - # Using feature branch until https://github.com/Shopify/liquid-spec/pull/97 is merged gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' gem 'activesupport', require: false end diff --git a/spec/ruby_liquid.rb b/spec/ruby_liquid.rb index 8d7f651bb..628fbbfe3 100644 --- a/spec/ruby_liquid.rb +++ b/spec/ruby_liquid.rb @@ -13,16 +13,15 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |_ctx, source, options| - Liquid::Template.parse(source, **options) +LiquidSpec.compile do |ctx, source, options| + ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context -# @param ctx [Hash] adapter context (unused) -# @param template [Liquid::Template] compiled template +# @param ctx [Hash] adapter context containing :template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer -LiquidSpec.render do |_ctx, template, assigns, options| +LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( @@ -33,5 +32,5 @@ context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(context) + ctx[:template].render(context) end diff --git a/spec/ruby_liquid_lax.rb b/spec/ruby_liquid_lax.rb index 79f4518c0..4681ad415 100644 --- a/spec/ruby_liquid_lax.rb +++ b/spec/ruby_liquid_lax.rb @@ -12,14 +12,14 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |_ctx, source, options| +LiquidSpec.compile do |ctx, source, options| # Force lax mode options = options.merge(error_mode: :lax) - Liquid::Template.parse(source, **options) + ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context -LiquidSpec.render do |_ctx, template, assigns, options| +LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( @@ -30,5 +30,5 @@ context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(context) + ctx[:template].render(context) end diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb index b577668d6..d8b05f76c 100644 --- a/spec/ruby_liquid_with_active_support.rb +++ b/spec/ruby_liquid_with_active_support.rb @@ -14,16 +14,15 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |_ctx, source, options| - Liquid::Template.parse(source, **options) +LiquidSpec.compile do |ctx, source, options| + ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context -# @param ctx [Hash] adapter context (unused) -# @param template [Liquid::Template] compiled template +# @param ctx [Hash] adapter context containing :template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer -LiquidSpec.render do |_ctx, template, assigns, options| +LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( @@ -34,5 +33,5 @@ context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(context) + ctx[:template].render(context) end diff --git a/spec/ruby_liquid_yjit.rb b/spec/ruby_liquid_yjit.rb index d30afb8ac..3ff51d1fd 100644 --- a/spec/ruby_liquid_yjit.rb +++ b/spec/ruby_liquid_yjit.rb @@ -19,14 +19,14 @@ end # Compile a template string into a Liquid::Template -LiquidSpec.compile do |_ctx, source, options| +LiquidSpec.compile do |ctx, source, options| # Force strict mode options = { error_mode: :strict }.merge(options) - Liquid::Template.parse(source, **options) + ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context -LiquidSpec.render do |_ctx, template, assigns, options| +LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( @@ -37,5 +37,5 @@ context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - template.render(context) + ctx[:template].render(context) end From d589c516974058d84ce0e17ab51cd37ae91bfa21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Gallego?= Date: Thu, 19 Feb 2026 10:03:52 +0900 Subject: [PATCH 320/343] Add squish filter --- lib/liquid/standardfilters.rb | 13 +++++++++++++ test/integration/standard_filter_test.rb | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index 24e135d91..a91462f43 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -293,6 +293,19 @@ def split(input, pattern) input.split(pattern) end + # @liquid_public_docs + # @liquid_type filter + # @liquid_category string + # @liquid_summary + # Removes leading and trailing whitespace and collapses consecutive whitespace to a single space. + # @liquid_syntax string | squish + # @liquid_return [string] + def squish(input) + return if input.nil? + + Utils.to_s(input).strip.gsub(/\s+/, ' ') + end + # @liquid_public_docs # @liquid_type filter # @liquid_category string diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index ef5f15493..b69bdf7af 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -164,6 +164,13 @@ def test_split assert_equal(['A', 'Z'], @filters.split('A1Z', 1)) end + def test_squish_filter + assert_equal("foo bar boo", Liquid::Template.parse(%Q({{ " foo bar +\t boo " | squish }})).render) + assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render) + assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render) + end + def test_escape assert_equal('<strong>', @filters.escape('')) assert_equal('1', @filters.escape(1)) From a72b604680288bfbe82ccb782351e666bb8922e1 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Fri, 6 Mar 2026 13:27:05 -0800 Subject: [PATCH 321/343] Fix rubocop offenses in test file --- test/integration/standard_filter_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index b69bdf7af..fc9873e7b 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -165,7 +165,7 @@ def test_split end def test_squish_filter - assert_equal("foo bar boo", Liquid::Template.parse(%Q({{ " foo bar + assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar \t boo " | squish }})).render) assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render) assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render) From 59d8d0d22db3b4865deecde0ab2683bea25a1e9f Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 18 Mar 2026 11:54:46 -0400 Subject: [PATCH 322/343] Add cumulative resource score tracking across partial renders (#2058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add cumulative resource score tracking across partial renders Add cumulative_render_score and cumulative_assign_score counters to ResourceLimits that accumulate across reset() calls, with optional cumulative_render_score_limit and cumulative_assign_score_limit to cap total work across all partial renders. Also add a reached? check in BlockBody's render loop so that once a cumulative limit triggers, the parent template stops processing further nodes. Bump version to 5.12.0. * refactor: move cumulative limit enforcement into reset() Instead of checking reached? in BlockBody's render loop, enforce cumulative limits in reset() itself. Since reset() is called before the begin/rescue MemoryError block in Template#render, the raise propagates to the parent naturally — no changes to BlockBody needed. --- lib/liquid/resource_limits.rb | 27 ++++++-- lib/liquid/version.rb | 2 +- test/integration/template_test.rb | 80 ++++++++++++++++++++++ test/unit/resource_limits_unit_test.rb | 91 ++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 test/unit/resource_limits_unit_test.rb diff --git a/lib/liquid/resource_limits.rb b/lib/liquid/resource_limits.rb index 70fac24be..ee0c66cbb 100644 --- a/lib/liquid/resource_limits.rb +++ b/lib/liquid/resource_limits.rb @@ -2,24 +2,39 @@ module Liquid class ResourceLimits - attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit - attr_reader :render_score, :assign_score + attr_accessor :render_length_limit, + :render_score_limit, + :assign_score_limit, + :cumulative_render_score_limit, + :cumulative_assign_score_limit + attr_reader :render_score, + :assign_score, + :cumulative_render_score, + :cumulative_assign_score def initialize(limits) - @render_length_limit = limits[:render_length_limit] - @render_score_limit = limits[:render_score_limit] - @assign_score_limit = limits[:assign_score_limit] + @render_length_limit = limits[:render_length_limit] + @render_score_limit = limits[:render_score_limit] + @assign_score_limit = limits[:assign_score_limit] + @cumulative_render_score_limit = limits[:cumulative_render_score_limit] + @cumulative_assign_score_limit = limits[:cumulative_assign_score_limit] + @cumulative_render_score = 0 + @cumulative_assign_score = 0 reset end def increment_render_score(amount) @render_score += amount + @cumulative_render_score += amount raise_limits_reached if @render_score_limit && @render_score > @render_score_limit + raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit end def increment_assign_score(amount) @assign_score += amount + @cumulative_assign_score += amount raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit + raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit end # update either render_length or assign_score based on whether or not the writes are captured @@ -47,6 +62,8 @@ def reset @reached_limit = false @last_capture_length = nil @render_score = @assign_score = 0 + raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit + raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit end def with_capture diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index c396a3db7..a07e7ef25 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.11.0" + VERSION = "5.12.0" end diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index c01c7161c..1ba4bafd7 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -179,6 +179,86 @@ def test_render_length_uses_number_of_bytes_not_characters assert_equal("すごい", t.render) end + def test_cumulative_render_score_limit_across_render_tags + file_system = StubFileSystem.new( + 'loop' => '{% for a in (1..10) %} foo {% endfor %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + + # Without cumulative limit, all 5 partials render successfully + t = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + unlimited_output = t.render! + total_cumulative = t.resource_limits.cumulative_render_score + + # With cumulative limit set below the total, rendering stops early + t2 = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2 + limited_output = t2.render + assert(t2.resource_limits.reached?) + assert_operator(limited_output.length, :<, unlimited_output.length) + end + + def test_cumulative_render_score_limit_raises_on_render_bang + file_system = StubFileSystem.new( + 'loop' => '{% for a in (1..10) %} foo {% endfor %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + t = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + t.resource_limits.cumulative_render_score_limit = 20 + assert_raises(Liquid::MemoryError) do + t.render! + end + end + + def test_cumulative_assign_score_limit_across_include_tags + file_system = StubFileSystem.new( + 'assign_partial' => '{% assign x = "a long string value here" %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + + # Without cumulative limit, all 5 partials render + t = Template.parse( + '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}', + environment: environment, + ) + t.render! + total_cumulative = t.resource_limits.cumulative_assign_score + + # With cumulative limit set below the total, rendering stops early + t2 = Template.parse( + '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}', + environment: environment, + ) + t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2 + t2.render + assert(t2.resource_limits.reached?) + end + + def test_cumulative_render_score_tracks_across_partials_without_limit + file_system = StubFileSystem.new( + 'loop' => '{% for a in (1..10) %} foo {% endfor %}', + ) + environment = Liquid::Environment.build(file_system: file_system) + t = Template.parse( + '{% render "loop" %}{% render "loop" %}{% render "loop" %}', + environment: environment, + ) + t.render! + assert( + t.resource_limits.cumulative_render_score > t.resource_limits.render_score, + "cumulative should exceed per-template score after multiple partials", + ) + end + def test_default_resource_limits_unaffected_by_render_with_context context = Context.new t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}") diff --git a/test/unit/resource_limits_unit_test.rb b/test/unit/resource_limits_unit_test.rb new file mode 100644 index 000000000..b5498e367 --- /dev/null +++ b/test/unit/resource_limits_unit_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ResourceLimitsUnitTest < Minitest::Test + def test_cumulative_scores_initialize_to_zero + limits = Liquid::ResourceLimits.new({}) + assert_equal(0, limits.cumulative_render_score) + assert_equal(0, limits.cumulative_assign_score) + end + + def test_cumulative_limits_default_to_nil + limits = Liquid::ResourceLimits.new({}) + assert_nil(limits.cumulative_render_score_limit) + assert_nil(limits.cumulative_assign_score_limit) + end + + def test_cumulative_limits_configurable_via_hash + limits = Liquid::ResourceLimits.new( + cumulative_render_score_limit: 500, + cumulative_assign_score_limit: 300, + ) + assert_equal(500, limits.cumulative_render_score_limit) + assert_equal(300, limits.cumulative_assign_score_limit) + end + + def test_cumulative_limits_configurable_via_accessor + limits = Liquid::ResourceLimits.new({}) + limits.cumulative_render_score_limit = 500 + assert_equal(500, limits.cumulative_render_score_limit) + end + + def test_cumulative_scores_survive_reset + limits = Liquid::ResourceLimits.new({}) + limits.increment_render_score(10) + limits.increment_assign_score(5) + + limits.reset + + assert_equal(0, limits.render_score) + assert_equal(0, limits.assign_score) + assert_equal(10, limits.cumulative_render_score) + assert_equal(5, limits.cumulative_assign_score) + end + + def test_cumulative_scores_accumulate_across_resets + limits = Liquid::ResourceLimits.new({}) + limits.increment_render_score(10) + limits.reset + limits.increment_render_score(20) + limits.reset + limits.increment_render_score(30) + + assert_equal(30, limits.render_score) + assert_equal(60, limits.cumulative_render_score) + end + + def test_cumulative_render_score_limit_raises + limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25) + limits.increment_render_score(10) + limits.reset + limits.increment_render_score(10) + limits.reset + + assert_raises(Liquid::MemoryError) do + limits.increment_render_score(10) + end + assert(limits.reached?) + end + + def test_cumulative_assign_score_limit_raises + limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15) + limits.increment_assign_score(8) + limits.reset + + assert_raises(Liquid::MemoryError) do + limits.increment_assign_score(8) + end + assert(limits.reached?) + end + + def test_per_template_limits_still_work_with_cumulative + limits = Liquid::ResourceLimits.new( + render_score_limit: 50, + cumulative_render_score_limit: 1000, + ) + assert_raises(Liquid::MemoryError) do + limits.increment_render_score(51) + end + end +end From e80f775f898e8a6e928d9e78caa951d3c438bd67 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Thu, 19 Mar 2026 15:22:56 -0400 Subject: [PATCH 323/343] Update ruby/setup-ruby from v1.273.0 to v1.295.0 The pinned version (v1.273.0) does not have prebuilt `ruby-head` binaries for `ubuntu-24.04`, which `ubuntu-latest` now resolves to. This causes CI to fail with "Unavailable version head for ruby on ubuntu-24.04". Updating to v1.295.0 picks up ubuntu-24.04 support for all Ruby versions including head builds. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/liquid.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index c018a4954..3a3687d75 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -35,7 +35,7 @@ jobs: name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: ${{ matrix.entry.ruby }} bundler-cache: true @@ -51,7 +51,7 @@ jobs: BUNDLE_WITH: spec steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: bundler-cache: true bundler: latest @@ -66,7 +66,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - uses: ruby/setup-ruby@a25f1e45f0e65a92fcb1e95e8847f78fb0a7197a # v1.273.0 + - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: bundler-cache: true - run: bundle exec rake memory_profile:run From 532b4390634b111b0cae447347d7a6afd04b8d75 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Wed, 18 Mar 2026 12:15:37 -0400 Subject: [PATCH 324/343] Reject bare-bracket syntax in strict2 and introduce `self` keyword Add bare-bracket rejection to Parser#expression in strict2 mode, so that `['var']` is disallowed and `self['var']` is the required syntax. - Add `Expression::SELF` constant ('self') - Add `Parser#reject_bare_brackets` option, checked in `expression` - Add `ParseContext#reject_bare_brackets?` and `force_reject_bare_brackets` - Add `VariableLookupDrop` for `self['var']` scope-chain lookups - Add `Variable#==` for rewriter state comparison - Update `Context#find_variable` to return `VariableLookupDrop` for `self` Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/liquid.rb | 1 + lib/liquid/context.rb | 13 +++++++++++ lib/liquid/expression.rb | 2 ++ lib/liquid/parse_context.rb | 2 +- lib/liquid/parser.rb | 6 ++++- lib/liquid/self_drop.rb | 38 +++++++++++++++++++++++++++++++ lib/liquid/variable.rb | 4 ++++ test/integration/context_test.rb | 4 ++-- test/integration/variable_test.rb | 10 ++++---- 9 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 lib/liquid/self_drop.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..dce089772 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -65,6 +65,7 @@ module Liquid require 'liquid/parser' require 'liquid/i18n' require 'liquid/drop' +require 'liquid/self_drop' require 'liquid/tablerowloop_drop' require 'liquid/forloop_drop' require 'liquid/extensions' diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 433b6d003..7902ebcff 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -187,6 +187,15 @@ def key?(key) find_variable(key, raise_on_not_found: false) != nil end + # Checks whether a variable is defined in any scope, including nil-valued keys. + # Unlike #key?, this uses Hash#key? so that variables explicitly set to nil + # are still considered defined. + def variable_defined?(key) + @scopes.any? { |s| s.key?(key) } || + @environments.any? { |e| e.key?(key) } || + @static_environments.any? { |e| e.key?(key) } + end + def evaluate(object) object.respond_to?(:evaluate) ? object.evaluate(self) : object end @@ -197,6 +206,10 @@ def find_variable(key, raise_on_not_found: true) # path and find_index() is optimized in MRI to reduce object allocation index = @scopes.find_index { |s| s.key?(key) } + # `self` resolves to a SelfDrop (enabling `self['var']` lookups), + # but only when it hasn't been explicitly assigned as a local variable. + return SelfDrop.new(self) if key == Expression::SELF && !index + variable = if index lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found) else diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 00c40a4c3..7e15f85b0 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -2,6 +2,8 @@ module Liquid class Expression + SELF = 'self' + LITERALS = { nil => nil, 'nil' => nil, diff --git a/lib/liquid/parse_context.rb b/lib/liquid/parse_context.rb index 855acc64e..d956d1c61 100644 --- a/lib/liquid/parse_context.rb +++ b/lib/liquid/parse_context.rb @@ -38,7 +38,7 @@ def new_block_body def new_parser(input) @string_scanner.string = input - Parser.new(@string_scanner) + Parser.new(@string_scanner, reject_bare_brackets: @error_mode == :strict2 || @error_mode == :rigid) end def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 645dfa3a1..c2e2c5d28 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -2,10 +2,11 @@ module Liquid class Parser - def initialize(input) + def initialize(input, reject_bare_brackets: 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 end def jump(point) @@ -53,6 +54,9 @@ def expression str = consume str << variable_lookups when :open_square + if @reject_bare_brackets + raise SyntaxError, "Bare bracket access is not allowed in strict2 mode. Use #{Expression::SELF}['...'] instead" + end str = consume.dup str << expression str << consume(:close_square) diff --git a/lib/liquid/self_drop.rb b/lib/liquid/self_drop.rb new file mode 100644 index 000000000..357814653 --- /dev/null +++ b/lib/liquid/self_drop.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Liquid + # @liquid_public_docs + # @liquid_type object + # @liquid_name self + # @liquid_summary + # Provides access to variables through the current scope chain. + # @liquid_description + # The `self` object resolves variables through the normal lookup hierarchy + # (local > file > global) without exposing filters, interrupts, errors, + # or other context internals. It's used when bare bracket notation + # (`['variable']`) needs to be replaced with an explicit variable lookup. + # + # If `self` is explicitly assigned as a local variable (e.g. `{% assign self = 'value' %}`), + # then the local value takes precedence over the `self` object. + # @liquid_access global + class SelfDrop < Drop + def initialize(context) + super() + @context = context + end + + def [](key) + @context.find_variable(key) + rescue UndefinedVariable + nil + end + + def key?(key) + @context.variable_defined?(key) + end + + def to_liquid + self + end + end +end diff --git a/lib/liquid/variable.rb b/lib/liquid/variable.rb index 6b5fb412b..12b8d9f28 100644 --- a/lib/liquid/variable.rb +++ b/lib/liquid/variable.rb @@ -37,6 +37,10 @@ def raw @markup end + def ==(other) + self.class == other.class && name == other.name && filters == other.filters + end + def markup_context(markup) "in \"{{#{markup}}}\"" end diff --git a/test/integration/context_test.rb b/test/integration/context_test.rb index d230734f6..8fa83ddb5 100644 --- a/test/integration/context_test.rb +++ b/test/integration/context_test.rb @@ -296,8 +296,8 @@ def test_access_hashes_with_hash_notation end def test_access_variable_with_hash_notation - assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" }) - assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' }) + assert_template_result('baz', '{{ foo }}', { "foo" => "baz" }) + assert_template_result('baz', '{{ self[bar] }}', { 'foo' => 'baz', 'bar' => 'foo' }) end def test_access_hashes_with_hash_access_variables diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index e272b96b9..58f73a315 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -55,7 +55,7 @@ def test_simple_with_whitespaces def test_expression_with_whitespace_in_square_brackets assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } }) - assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } }) + assert_template_result('result', "{{ a[ self[ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } }) end def test_ignore_unknown @@ -135,17 +135,17 @@ def test_nested_array end def test_dynamic_find_var - assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) + assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end def test_raw_value_variable - assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) + assert_template_result('bar', '{{ self[key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end def test_dynamic_find_var_with_drop assert_template_result( 'bar', - '{{ [list[settings.zero]] }}', + '{{ self[list[settings.zero]] }}', { 'list' => ['foo'], 'settings' => SettingsDrop.new("zero" => 0), @@ -155,7 +155,7 @@ def test_dynamic_find_var_with_drop assert_template_result( 'foo', - '{{ [list[settings.zero]["foo"]] }}', + '{{ self[list[settings.zero]["foo"]] }}', { 'list' => [{ 'foo' => 'bar' }], 'settings' => SettingsDrop.new("zero" => 0), From 346166b600f08856250534faa3693742332b0349 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Mon, 23 Mar 2026 12:15:03 -0400 Subject: [PATCH 325/343] Add for_loop? to Include tag for AST-based keyword detection Store @is_for_loop during parsing so consumers can determine the with/for keyword from the AST instead of re-parsing raw markup. Matches the existing pattern in the Render tag. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/liquid/tags/include.rb | 17 +++++++-- test/integration/tags/include_tag_test.rb | 45 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/include.rb b/lib/liquid/tags/include.rb index 969482d49..f0161c6e9 100644 --- a/lib/liquid/tags/include.rb +++ b/lib/liquid/tags/include.rb @@ -20,7 +20,8 @@ module Liquid class Include < Tag prepend Tag::Disableable - SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o + FOR = 'for' + SYNTAX = /(#{QuotedFragment}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o Syntax = SYNTAX attr_reader :template_name_expr, :variable_name_expr, :attributes @@ -84,12 +85,18 @@ def render_to_output_buffer(context, output) alias_method :parse_context, :options private :parse_context + def for_loop? + @is_for_loop + end + def strict2_parse(markup) p = @parse_context.new_parser(markup) @template_name_expr = safe_parse_expression(p) - @variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with") + with_or_for = p.id?("for") || p.id?("with") + @variable_name_expr = safe_parse_expression(p) if with_or_for @alias_name = p.consume(:id) if p.id?("as") + @is_for_loop = (with_or_for == FOR) p.consume?(:comma) @@ -111,11 +118,13 @@ def strict_parse(markup) def lax_parse(markup) if markup =~ SYNTAX template_name = Regexp.last_match(1) - variable_name = Regexp.last_match(3) + with_or_for = Regexp.last_match(3) + variable_name = Regexp.last_match(4) - @alias_name = Regexp.last_match(5) + @alias_name = Regexp.last_match(6) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) + @is_for_loop = (with_or_for == FOR) @attributes = {} markup.scan(TagAttributes) do |key, value| diff --git a/test/integration/tags/include_tag_test.rb b/test/integration/tags/include_tag_test.rb index 44c0dcda7..bd4085f21 100644 --- a/test/integration/tags/include_tag_test.rb +++ b/test/integration/tags/include_tag_test.rb @@ -439,4 +439,49 @@ def test_include_attribute_with_invalid_expression assert_match(/Unexpected character =/, error.message) end end + + def test_include_for_loop_true_with_for_keyword + with_error_modes(:lax, :strict, :strict2) do + template = Template.parse("{% include 'product' for products %}") + include_node = template.root.nodelist.first + + assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' keyword") + end + end + + def test_include_for_loop_false_with_with_keyword + with_error_modes(:lax, :strict, :strict2) do + template = Template.parse("{% include 'product' with product %}") + include_node = template.root.nodelist.first + + refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' keyword") + end + end + + def test_include_for_loop_false_without_keyword + with_error_modes(:lax, :strict, :strict2) do + template = Template.parse("{% include 'header' %}") + include_node = template.root.nodelist.first + + refute(include_node.for_loop?, "Expected for_loop? to be false when no keyword") + end + end + + def test_include_for_loop_with_alias + with_error_modes(:lax, :strict, :strict2) do + template = Template.parse("{% include 'product' for products as item %}") + include_node = template.root.nodelist.first + + assert(include_node.for_loop?, "Expected for_loop? to be true for 'for' with alias") + end + end + + def test_include_with_keyword_and_alias + with_error_modes(:lax, :strict, :strict2) do + template = Template.parse("{% include 'product' with products[0] as item %}") + include_node = template.root.nodelist.first + + refute(include_node.for_loop?, "Expected for_loop? to be false for 'with' with alias") + end + end end # IncludeTagTest From d824de701c57d839b2cd378179e79eb0acab54cc Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Thu, 15 Jan 2026 23:57:25 +0100 Subject: [PATCH 326/343] Adapt slice filter to work on non-64 bit platforms --- lib/liquid/standardfilters.rb | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/lib/liquid/standardfilters.rb b/lib/liquid/standardfilters.rb index a91462f43..ed6141566 100644 --- a/lib/liquid/standardfilters.rb +++ b/lib/liquid/standardfilters.rb @@ -8,10 +8,19 @@ module StandardFilters MAX_I32 = (1 << 31) - 1 private_constant :MAX_I32 - MIN_I64 = -(1 << 63) - MAX_I64 = (1 << 63) - 1 - I64_RANGE = MIN_I64..MAX_I64 - private_constant :MIN_I64, :MAX_I64, :I64_RANGE + supports_64bit_indices = begin + [][1 << 33, 1 << 33] + true + rescue RangeError + false + end + + INDEX_RANGE = if supports_64bit_indices + (-(1 << 63))..((1 << 63) - 1) + else + (-(1 << 31))..((1 << 31) - 1) + end + private_constant :INDEX_RANGE HTML_ESCAPE = { '&' => '&', @@ -214,11 +223,11 @@ def slice(input, offset, length = nil) Utils.to_s(input).slice(offset, length) || '' end rescue RangeError - if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset) + if INDEX_RANGE.cover?(length) && INDEX_RANGE.cover?(offset) raise # unexpected error end - offset = offset.clamp(I64_RANGE) - length = length.clamp(I64_RANGE) + offset = offset.clamp(INDEX_RANGE) + length = length.clamp(INDEX_RANGE) retry end end From ad70c5c4591c860fc67a1c51d3de0856fedc377c Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Wed, 25 Mar 2026 16:44:38 +0100 Subject: [PATCH 327/343] Improve no Symbol leak tests to be more reliable --- test/integration/security_test.rb | 49 ++++++++++++++++++------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/test/integration/security_test.rb b/test/integration/security_test.rb index f84058940..3a35d47cc 100644 --- a/test/integration/security_test.rb +++ b/test/integration/security_test.rb @@ -44,32 +44,39 @@ def test_no_instance_eval_later_in_chain end def test_does_not_permanently_add_filters_to_symbol_table - current_symbols = Symbol.all_symbols - - # MRI imprecisely marks objects found on the C stack, which can result - # in uninitialized memory being marked. This can even result in the test failing - # deterministically for a given compilation of ruby. Using a separate thread will - # keep these writes of the symbol pointer on a separate stack that will be garbage - # collected after Thread#join. - Thread.new do - test = %( {{ "some_string" | a_bad_filter }} ) - Template.parse(test).render! - nil - end.join - - GC.start - - assert_equal([], Symbol.all_symbols - current_symbols) + assert_no_new_symbols do + # MRI imprecisely marks objects found on the C stack, which can result + # in uninitialized memory being marked. This can even result in the test failing + # deterministically for a given compilation of ruby. Using a separate thread will + # keep these writes of the symbol pointer on a separate stack that will be garbage + # collected after Thread#join. + Thread.new do + test = %( {{ "some_string" | a_bad_filter }} ) + Template.parse(test).render! + nil + end.join + + GC.start + end end def test_does_not_add_drop_methods_to_symbol_table - current_symbols = Symbol.all_symbols + assert_no_new_symbols do + assigns = { 'drop' => Drop.new } + assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!) + assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!) + assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!) + end + end - assigns = { 'drop' => Drop.new } - assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!) - assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!) - assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!) + def assert_no_new_symbols + # Run once to trigger any first-time initialization which might create some symbols, + # for example autoload or lazy method parsing might create symbols on first execution. + yield + # Ensure no new symbols for further runs, i.e. the code does not leak symbols + current_symbols = Symbol.all_symbols + yield assert_equal([], Symbol.all_symbols - current_symbols) end From 96aa47d13f19b5117385d31f12136fd9d409b39a Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Fri, 16 Jan 2026 00:24:42 +0100 Subject: [PATCH 328/343] Add TruffleRuby in CI --- .github/workflows/liquid.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/liquid.yml b/.github/workflows/liquid.yml index 3a3687d75..4389d2a44 100644 --- a/.github/workflows/liquid.yml +++ b/.github/workflows/liquid.yml @@ -22,6 +22,7 @@ jobs: } - { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" } - { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" } + - { ruby: truffleruby, allowed-failure: false } # Head can have failures due to being in development - { ruby: head, allowed-failure: true } From 0d5c15a03e847b139ab8a696486d6e657390a424 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Tue, 24 Mar 2026 18:19:22 -0400 Subject: [PATCH 329/343] Add strict2_parse to assign and capture tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tags previously used only regex (VariableSignature) to validate variable names, which allowed invalid identifiers like (a(b(c) and [x.y] in all parse modes. - assign: strict2_parse uses Parser to validate the LHS as a valid identifier before delegating RHS to Variable - capture: strict2_parse uses Parser to validate the variable name as a valid identifier - Both tags now include ParserSwitching and dispatch through strict_parse_with_error_mode_fallback - Lax mode is unchanged — invalid names are still accepted Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/liquid/tags/assign.rb | 25 ++++++++++++++++ lib/liquid/tags/capture.rb | 18 +++++++++++ test/integration/assign_test.rb | 40 +++++++++++++++++++++++++ test/integration/capture_test.rb | 37 ++++++++++++++++++++++- test/integration/tags/cycle_tag_test.rb | 6 ++-- 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/lib/liquid/tags/assign.rb b/lib/liquid/tags/assign.rb index 4743ab0b8..901db40f9 100644 --- a/lib/liquid/tags/assign.rb +++ b/lib/liquid/tags/assign.rb @@ -18,6 +18,8 @@ module Liquid # @liquid_syntax_keyword variable_name The name of the variable being created. # @liquid_syntax_keyword value The value you want to assign to the variable. class Assign < Tag + include ParserSwitching + Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om # @api private @@ -29,6 +31,10 @@ def self.raise_syntax_error(parse_context) def initialize(tag_name, markup, parse_context) super + parse_with_selected_parser(markup) + end + + def lax_parse(markup) if markup =~ Syntax @to = Regexp.last_match(1) @from = Variable.new(Regexp.last_match(2), parse_context) @@ -37,6 +43,25 @@ def initialize(tag_name, markup, parse_context) end end + def strict_parse(markup) + lax_parse(markup) + end + + def strict2_parse(markup) + unless markup =~ Syntax + self.class.raise_syntax_error(parse_context) + end + + lhs = Regexp.last_match(1).strip + rhs = Regexp.last_match(2) + + p = @parse_context.new_parser(lhs) + @to = p.consume(:id) + p.consume(:end_of_string) + + @from = Variable.new(rhs, parse_context) + end + def render_to_output_buffer(context, output) val = @from.render(context) context.scopes.last[@to] = val diff --git a/lib/liquid/tags/capture.rb b/lib/liquid/tags/capture.rb index 1ec959fb0..5bebe20e4 100644 --- a/lib/liquid/tags/capture.rb +++ b/lib/liquid/tags/capture.rb @@ -20,10 +20,18 @@ module Liquid # @liquid_syntax_keyword variable The name of the variable being created. # @liquid_syntax_keyword value The value you want to assign to the variable. class Capture < Block + include ParserSwitching + Syntax = /(#{VariableSignature}+)/o + attr_reader :to + def initialize(tag_name, markup, options) super + parse_with_selected_parser(markup) + end + + def lax_parse(markup) if markup =~ Syntax @to = Regexp.last_match(1) else @@ -31,6 +39,16 @@ def initialize(tag_name, markup, options) end end + def strict_parse(markup) + lax_parse(markup) + end + + def strict2_parse(markup) + p = @parse_context.new_parser(markup.strip) + @to = p.consume(:id) + p.consume(:end_of_string) + end + def render_to_output_buffer(context, output) context.resource_limits.with_capture do capture_output = render(context) diff --git a/test/integration/assign_test.rb b/test/integration/assign_test.rb index fdb6c99ca..2388b357d 100644 --- a/test/integration/assign_test.rb +++ b/test/integration/assign_test.rb @@ -97,6 +97,46 @@ def test_assign_score_of_hash assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd')) end + def test_assign_with_valid_identifier_in_strict2 + assert_template_result("hello", "{% assign my_var = 'hello' %}{{ my_var }}", error_mode: :strict2) + end + + def test_assign_with_hyphen_in_strict2 + assert_template_result("hello", "{% assign my-var = 'hello' %}{{ my-var }}", error_mode: :strict2) + end + + def test_assign_rejects_parentheses_in_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% assign (a(b(c) = 1234 %}", error_mode: :strict2) + end + end + + def test_assign_rejects_brackets_in_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% assign [x.y] = 'hello' %}", error_mode: :strict2) + end + end + + def test_assign_rejects_dot_in_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% assign a.b = 'hello' %}", error_mode: :strict2) + end + end + + def test_assign_rejects_numeric_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% assign 1abc = 'hello' %}", error_mode: :strict2) + end + end + + def test_assign_allows_invalid_names_in_lax + assert_template_result("1234", "{% assign (a(b(c) = 1234 %}{{ self['(a(b(c)'] }}", error_mode: :lax) + end + + def test_assign_with_filter_in_strict2 + assert_template_result("HELLO", "{% assign my_var = 'hello' | upcase %}{{ my_var }}", error_mode: :strict2) + end + private class ObjectWrapperDrop < Liquid::Drop diff --git a/test/integration/capture_test.rb b/test/integration/capture_test.rb index 7399393ac..2f844a7c0 100644 --- a/test/integration/capture_test.rb +++ b/test/integration/capture_test.rb @@ -6,7 +6,11 @@ class CaptureTest < Minitest::Test include Liquid def test_captures_block_content_in_variable - assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}) + assert_template_result("test string", "{% capture var %}test string{% endcapture %}{{var}}", {}) + end + + def test_captures_block_content_in_quoted_variable_in_lax + assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}, error_mode: :lax) end def test_capture_with_hyphen_in_variable_name @@ -49,4 +53,35 @@ def test_increment_assign_score_by_bytes_not_characters t.render! assert_equal(9, t.resource_limits.assign_score) end + + def test_capture_with_valid_identifier_in_strict2 + assert_template_result("hello", "{% capture my_var %}hello{% endcapture %}{{ my_var }}", error_mode: :strict2) + end + + def test_capture_with_hyphen_in_strict2 + assert_template_result("hello", "{% capture my-var %}hello{% endcapture %}{{ my-var }}", error_mode: :strict2) + end + + def test_capture_rejects_parentheses_in_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :strict2) + end + end + + def test_capture_rejects_dot_in_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% capture a.b %}hello{% endcapture %}", error_mode: :strict2) + end + end + + def test_capture_rejects_numeric_variable_name_in_strict2 + assert_raises(Liquid::SyntaxError) do + Liquid::Template.parse("{% capture 1abc %}hello{% endcapture %}", error_mode: :strict2) + end + end + + def test_capture_allows_invalid_names_in_lax + t = Liquid::Template.parse("{% capture (x[y %}hello{% endcapture %}", error_mode: :lax) + assert_equal("(x[y", t.root.nodelist.first.to) + end end diff --git a/test/integration/tags/cycle_tag_test.rb b/test/integration/tags/cycle_tag_test.rb index dfb5984d8..b0cdf606f 100644 --- a/test/integration/tags/cycle_tag_test.rb +++ b/test/integration/tags/cycle_tag_test.rb @@ -105,10 +105,8 @@ def test_cycle_tag_with_error_mode error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } - expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/ - - assert_match(expected_error, error1.message) - assert_match(expected_error, error2.message) + assert_match(/Liquid syntax error:/, error1.message) + assert_match(/Liquid syntax error: \[:dot, "."\] is not a valid expression/, error2.message) end end From 9f4d7e78b880f2e72d01cbe610ea5b99d7dd6db8 Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Wed, 25 Mar 2026 17:12:47 +0100 Subject: [PATCH 330/343] Skip slow test raising many exceptions on non-CRuby --- test/integration/standard_filter_test.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/integration/standard_filter_test.rb b/test/integration/standard_filter_test.rb index fc9873e7b..94097ae1f 100644 --- a/test/integration/standard_filter_test.rb +++ b/test/integration/standard_filter_test.rb @@ -1181,6 +1181,8 @@ def test_where_array_of_only_unindexable_values end def test_all_filters_never_raise_non_liquid_exception + skip("too slow on non-CRuby due to many exceptions") unless RUBY_ENGINE == 'ruby' + test_drop = TestDrop.new(value: "test") test_drop.context = Context.new test_enum = TestEnumerable.new From c99036046eba682c9ce50afe37ba639279df84f0 Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Thu, 26 Mar 2026 15:50:32 -0400 Subject: [PATCH 331/343] Add strict2_parse to increment and decrement tags Both tags previously accepted any string as a variable name via `markup.strip`. Now they use `parse_with_selected_parser` and validate the variable name with `p.consume(:id)` in strict2 mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/liquid/tags/decrement.rb | 16 +++++++ lib/liquid/tags/increment.rb | 16 +++++++ test/integration/tags/increment_tag_test.rb | 46 +++++++++++++++++++++ test/integration/tags/render_tag_test.rb | 8 ++-- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lib/liquid/tags/decrement.rb b/lib/liquid/tags/decrement.rb index 8dc620740..70661716d 100644 --- a/lib/liquid/tags/decrement.rb +++ b/lib/liquid/tags/decrement.rb @@ -23,13 +23,29 @@ module Liquid # {% decrement variable_name %} # @liquid_syntax_keyword variable_name The name of the variable being decremented. class Decrement < Tag + include ParserSwitching + attr_reader :variable_name def initialize(tag_name, markup, options) super + parse_with_selected_parser(markup) + end + + def lax_parse(markup) @variable_name = markup.strip end + def strict_parse(markup) + lax_parse(markup) + end + + def strict2_parse(markup) + p = @parse_context.new_parser(markup.strip) + @variable_name = p.consume(:id) + p.consume(:end_of_string) + end + def render_to_output_buffer(context, output) counter_environment = context.environments.first value = counter_environment[@variable_name] || 0 diff --git a/lib/liquid/tags/increment.rb b/lib/liquid/tags/increment.rb index 0e00e9704..ba4a54d2c 100644 --- a/lib/liquid/tags/increment.rb +++ b/lib/liquid/tags/increment.rb @@ -23,13 +23,29 @@ module Liquid # {% increment variable_name %} # @liquid_syntax_keyword variable_name The name of the variable being incremented. class Increment < Tag + include ParserSwitching + attr_reader :variable_name def initialize(tag_name, markup, options) super + parse_with_selected_parser(markup) + end + + def lax_parse(markup) @variable_name = markup.strip end + def strict_parse(markup) + lax_parse(markup) + end + + def strict2_parse(markup) + p = @parse_context.new_parser(markup.strip) + @variable_name = p.consume(:id) + p.consume(:end_of_string) + end + def render_to_output_buffer(context, output) counter_environment = context.environments.first value = counter_environment[@variable_name] || 0 diff --git a/test/integration/tags/increment_tag_test.rb b/test/integration/tags/increment_tag_test.rb index 1ad94254e..bb7245ed5 100644 --- a/test/integration/tags/increment_tag_test.rb +++ b/test/integration/tags/increment_tag_test.rb @@ -27,4 +27,50 @@ def test_dec '{%decrement starboard %}', ) end + + def test_increment_strict2_rejects_invalid_variable_name + assert_raises(Liquid::SyntaxError) do + Template.parse('{% increment foo bar %}', error_mode: :strict2) + end + end + + def test_increment_strict2_rejects_variable_starting_with_number + assert_raises(Liquid::SyntaxError) do + Template.parse('{% increment 11aa %}', error_mode: :strict2) + end + end + + def test_increment_strict2_accepts_valid_variable_name + template = Template.parse('{% increment my-var %}', error_mode: :strict2) + assert_equal('0', template.render) + end + + def test_decrement_strict2_rejects_invalid_variable_name + assert_raises(Liquid::SyntaxError) do + Template.parse('{% decrement foo bar %}', error_mode: :strict2) + end + end + + def test_decrement_strict2_rejects_variable_starting_with_number + assert_raises(Liquid::SyntaxError) do + Template.parse('{% decrement 11aa %}', error_mode: :strict2) + end + end + + def test_decrement_strict2_accepts_valid_variable_name + template = Template.parse('{% decrement my-var %}', error_mode: :strict2) + assert_equal('-1', template.render) + end + + def test_increment_strict2_rejects_empty_variable_name + assert_raises(Liquid::SyntaxError) do + Template.parse('{% increment %}', error_mode: :strict2) + end + end + + def test_decrement_strict2_rejects_empty_variable_name + assert_raises(Liquid::SyntaxError) do + Template.parse('{% decrement %}', error_mode: :strict2) + end + end end diff --git a/test/integration/tags/render_tag_test.rb b/test/integration/tags/render_tag_test.rb index 0bd09d088..6773ad251 100644 --- a/test/integration/tags/render_tag_test.rb +++ b/test/integration/tags/render_tag_test.rb @@ -174,16 +174,16 @@ def test_break_through_render def test_increment_is_isolated_between_renders assert_template_result( '010', - '{% increment %}{% increment %}{% render "incr" %}', - partials: { 'incr' => '{% increment %}' }, + '{% increment port %}{% increment port %}{% render "incr" %}', + partials: { 'incr' => '{% increment port %}' }, ) end def test_decrement_is_isolated_between_renders assert_template_result( '-1-2-1', - '{% decrement %}{% decrement %}{% render "decr" %}', - partials: { 'decr' => '{% decrement %}' }, + '{% decrement port %}{% decrement port %}{% render "decr" %}', + partials: { 'decr' => '{% decrement port %}' }, ) end From dfddd8f390038402fbf5ddf133eaf81375a7ab1a Mon Sep 17 00:00:00 2001 From: Alok Swamy Date: Fri, 24 Apr 2026 16:18:05 -0400 Subject: [PATCH 332/343] Remove "strict2" from bare bracket error message Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/liquid/parser.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index c2e2c5d28..7a2136052 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -55,7 +55,7 @@ def expression str << variable_lookups when :open_square if @reject_bare_brackets - raise SyntaxError, "Bare bracket access is not allowed in strict2 mode. Use #{Expression::SELF}['...'] instead" + raise SyntaxError, "Bare bracket access is not allowed. Use #{Expression::SELF}['...'] instead" end str = consume.dup str << expression From 1954a2655cf4d427b6c9169354832638740f2db5 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Tue, 28 Apr 2026 10:03:57 -0400 Subject: [PATCH 333/343] Update liquid-spec adapters --- .ruby-version | 2 +- Rakefile | 6 ++++-- spec/ruby_liquid.rb | 19 ++++++++++++++--- spec/ruby_liquid_lax.rb | 20 ++++++++++++++---- spec/ruby_liquid_with_active_support.rb | 18 +++++++++++++--- spec/ruby_liquid_yjit.rb | 16 +++++++++++++-- spec/support/liquid_spec_adapter_helper.rb | 24 ++++++++++++++++++++++ 7 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 spec/support/liquid_spec_adapter_helper.rb diff --git a/.ruby-version b/.ruby-version index 47b322c97..4d54daddb 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.1 +4.0.2 diff --git a/Rakefile b/Rakefile index 650923bf8..e14d49aa6 100755 --- a/Rakefile +++ b/Rakefile @@ -151,6 +151,8 @@ end desc('run liquid-spec suite across all adapters') task :spec do - adapters = Dir['./spec/*.rb'].join(',') - sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid" + Dir['./spec/*.rb'].sort.each do |adapter| + puts "=== Running #{adapter} ===" + sh 'bundle', 'exec', 'liquid-spec', 'run', adapter, '--no-max-failures' + end end diff --git a/spec/ruby_liquid.rb b/spec/ruby_liquid.rb index 628fbbfe3..c78049776 100644 --- a/spec/ruby_liquid.rb +++ b/spec/ruby_liquid.rb @@ -6,14 +6,24 @@ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'liquid' +require_relative 'support/liquid_spec_adapter_helper' LiquidSpec.configure do |config| - # Run core Liquid specs - config.features = [:core] + config.missing_features = [ + :activesupport, + :lax_parsing, + :shopify_filters, + :shopify_includes, + :shopify_blank, + :shopify_error_handling, + :shopify_error_format, + :shopify_string_access, + ] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| + options[:error_mode] ||= :strict ctx[:template] = Liquid::Template.parse(source, **options) end @@ -28,9 +38,12 @@ static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], + resource_limits: LiquidSpecAdapterHelper.resource_limits(options), ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - ctx[:template].render(context) + LiquidSpecAdapterHelper.with_frozen_time do + ctx[:template].render(context) + end end diff --git a/spec/ruby_liquid_lax.rb b/spec/ruby_liquid_lax.rb index 4681ad415..42cf9a9a3 100644 --- a/spec/ruby_liquid_lax.rb +++ b/spec/ruby_liquid_lax.rb @@ -6,15 +6,24 @@ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'liquid' +require_relative 'support/liquid_spec_adapter_helper' LiquidSpec.configure do |config| - config.features = [:core, :lax_parsing] + config.missing_features = [ + :activesupport, + :shopify_filters, + :shopify_includes, + :shopify_blank, + :shopify_error_handling, + :shopify_error_format, + :shopify_string_access, + ] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| - # Force lax mode - options = options.merge(error_mode: :lax) + # Default to lax mode while still honoring specs that explicitly set error_mode. + options = { error_mode: :lax }.merge(options) ctx[:template] = Liquid::Template.parse(source, **options) end @@ -26,9 +35,12 @@ static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], + resource_limits: LiquidSpecAdapterHelper.resource_limits(options), ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - ctx[:template].render(context) + LiquidSpecAdapterHelper.with_frozen_time do + ctx[:template].render(context) + end end diff --git a/spec/ruby_liquid_with_active_support.rb b/spec/ruby_liquid_with_active_support.rb index d8b05f76c..25e02b3f3 100644 --- a/spec/ruby_liquid_with_active_support.rb +++ b/spec/ruby_liquid_with_active_support.rb @@ -7,14 +7,23 @@ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'active_support/all' require 'liquid' +require_relative 'support/liquid_spec_adapter_helper' LiquidSpec.configure do |config| - # Run core Liquid specs plus ActiveSupport SafeBuffer tests - config.features = [:core, :activesupport] + config.missing_features = [ + :lax_parsing, + :shopify_filters, + :shopify_includes, + :shopify_blank, + :shopify_error_handling, + :shopify_error_format, + :shopify_string_access, + ] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| + options[:error_mode] ||= :strict ctx[:template] = Liquid::Template.parse(source, **options) end @@ -29,9 +38,12 @@ static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], + resource_limits: LiquidSpecAdapterHelper.resource_limits(options), ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - ctx[:template].render(context) + LiquidSpecAdapterHelper.with_frozen_time do + ctx[:template].render(context) + end end diff --git a/spec/ruby_liquid_yjit.rb b/spec/ruby_liquid_yjit.rb index 3ff51d1fd..f43f5ac70 100644 --- a/spec/ruby_liquid_yjit.rb +++ b/spec/ruby_liquid_yjit.rb @@ -13,9 +13,18 @@ require 'active_support/all' require 'liquid' +require_relative 'support/liquid_spec_adapter_helper' LiquidSpec.configure do |config| - config.features = [:core, :activesupport] + config.missing_features = [ + :lax_parsing, + :shopify_filters, + :shopify_includes, + :shopify_blank, + :shopify_error_handling, + :shopify_error_format, + :shopify_string_access, + ] end # Compile a template string into a Liquid::Template @@ -33,9 +42,12 @@ static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], + resource_limits: LiquidSpecAdapterHelper.resource_limits(options), ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] - ctx[:template].render(context) + LiquidSpecAdapterHelper.with_frozen_time do + ctx[:template].render(context) + end end diff --git a/spec/support/liquid_spec_adapter_helper.rb b/spec/support/liquid_spec_adapter_helper.rb new file mode 100644 index 000000000..a572bbde9 --- /dev/null +++ b/spec/support/liquid_spec_adapter_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module LiquidSpecAdapterHelper + extend self + + def resource_limits(render_options) + return unless render_options[:resource_limits] + + Liquid::ResourceLimits.new({}).tap do |limits| + render_options[:resource_limits].each do |key, value| + limits.public_send(:"#{key}=", value) + end + end + end + + def with_frozen_time(&block) + original_tz = ENV['TZ'] + ENV['TZ'] = 'UTC' + + Liquid::Spec::TimeFreezer.freeze(Liquid::Spec::AdapterRunner::TEST_TIME, &block) + ensure + ENV['TZ'] = original_tz + end +end From 742ac3dbf5432c6c2689ea00da604d7379f73799 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 20 May 2026 09:34:37 +0200 Subject: [PATCH 334/343] Prevent `SelfDrop` context mutation across render boundaries (#2082) --- lib/liquid/self_drop.rb | 10 ++- lib/liquid/template.rb | 6 +- test/integration/self_drop_context_test.rb | 98 ++++++++++++++++++++++ 3 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 test/integration/self_drop_context_test.rb diff --git a/lib/liquid/self_drop.rb b/lib/liquid/self_drop.rb index 357814653..4bfff0e45 100644 --- a/lib/liquid/self_drop.rb +++ b/lib/liquid/self_drop.rb @@ -16,23 +16,25 @@ module Liquid # then the local value takes precedence over the `self` object. # @liquid_access global class SelfDrop < Drop - def initialize(context) + def initialize(self_context) super() - @context = context + @self_context = self_context end def [](key) - @context.find_variable(key) + @self_context.find_variable(key) rescue UndefinedVariable nil end def key?(key) - @context.variable_defined?(key) + @self_context.variable_defined?(key) end def to_liquid self end + + undef context= end end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index b007765ce..70ff00816 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -151,8 +151,10 @@ def render(*args) c when Liquid::Drop - drop = args.shift - drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) + drop = args.shift + c = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) + drop.context = c if drop.respond_to?(:context=) + c when Hash Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) when nil diff --git a/test/integration/self_drop_context_test.rb b/test/integration/self_drop_context_test.rb new file mode 100644 index 000000000..338719e81 --- /dev/null +++ b/test/integration/self_drop_context_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SelfDropContextTest < Minitest::Test + include Liquid + + def test_self_drop_passed_as_render_param_preserves_original_scope + source = <<~LIQUID + {%- assign var = 42 -%} + {%- assign s = self -%} + {%- render "snippet1", other_self: s -%} + LIQUID + + partials = { + 'snippet1' => <<~LIQUID, + {%- assign var = 43 -%} + {{- other_self.var }}|{{ self.var -}} + LIQUID + } + + assert_template_result('42|43', source, partials: partials) + end + + def test_self_drop_in_render_without_passing_resolves_inner_scope + source = <<~LIQUID + {%- assign var = 42 -%} + {%- render "snippet1" -%} + LIQUID + + partials = { + 'snippet1' => <<~LIQUID, + {%- assign var = 99 -%} + {{- self.var -}} + LIQUID + } + + assert_template_result('99', source, partials: partials) + end + + def test_self_drop_passed_to_nested_renders_preserves_each_level + source = <<~LIQUID + {%- assign a = 1 -%} + {%- assign s1 = self -%} + {%- render "snippet1", outer: s1 -%} + LIQUID + + partials = { + 'snippet1' => <<~LIQUID, + {%- assign a = 2 -%} + {%- assign s2 = self -%} + {%- render "snippet2", outer: outer, middle: s2 -%} + LIQUID + 'snippet2' => <<~LIQUID, + {%- assign a = 3 -%} + {{- outer.a }}|{{ middle.a }}|{{ self.a -}} + LIQUID + } + + assert_template_result('1|2|3', source, partials: partials) + end + + def test_self_drop_reflects_variables_assigned_after_creation + source = <<~LIQUID + {%- assign s = self -%} + {%- assign x = 42 %}{{ s.x -}} + LIQUID + + assert_template_result('42', source) + end + + def test_self_drop_context_setter_is_undefined + context = Context.new + drop = SelfDrop.new(context) + refute(drop.respond_to?(:context=)) + + assert_template_result('42', '{{ self.x }}', { 'x' => 42 }) + end + + def test_self_drop_with_strict_variables_does_not_raise_for_defined_var + t = Template.parse('{{ self.x }}') + result = t.render({ 'x' => 42 }, strict_variables: true) + assert_equal('42', result) + end + + def test_self_drop_with_strict_variables_returns_nil_for_undefined_var + t = Template.parse('{{ self.x }}') + result = t.render({}, strict_variables: true) + assert_equal('', result) + end + + def test_self_drop_can_be_passed_as_bare_drop_to_render + t = Template.parse('{{ self.x }}') + drop = SelfDrop.new(Context.new({ 'x' => 42 })) + result = t.render(drop) + assert_equal('42', result) + end +end From 7b368dffb844c44a9466226d1a243b05aafc5be5 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Fri, 5 Jun 2026 10:47:37 +0200 Subject: [PATCH 335/343] Fix `SelfDrop` equality (#2091) --- lib/liquid/context.rb | 2 +- lib/liquid/self_drop.rb | 14 ++++++++++++++ test/integration/self_drop_context_test.rb | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 7902ebcff..26ff72852 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -208,7 +208,7 @@ def find_variable(key, raise_on_not_found: true) # `self` resolves to a SelfDrop (enabling `self['var']` lookups), # but only when it hasn't been explicitly assigned as a local variable. - return SelfDrop.new(self) if key == Expression::SELF && !index + return @self_drop ||= SelfDrop.new(self) if key == Expression::SELF && !index variable = if index lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found) diff --git a/lib/liquid/self_drop.rb b/lib/liquid/self_drop.rb index 4bfff0e45..b753f383b 100644 --- a/lib/liquid/self_drop.rb +++ b/lib/liquid/self_drop.rb @@ -35,6 +35,20 @@ def to_liquid self end + def ==(other) + other.is_a?(SelfDrop) && other.self_context.equal?(@self_context) + end + + alias_method :eql?, :== + + def hash + @self_context.object_id.hash + end + + protected + + attr_reader :self_context + undef context= end end diff --git a/test/integration/self_drop_context_test.rb b/test/integration/self_drop_context_test.rb index 338719e81..f57fdc393 100644 --- a/test/integration/self_drop_context_test.rb +++ b/test/integration/self_drop_context_test.rb @@ -77,6 +77,28 @@ def test_self_drop_context_setter_is_undefined assert_template_result('42', '{{ self.x }}', { 'x' => 42 }) end + def test_self_drop_repeated_lookups_compare_equal_for_same_context + context = Context.new + drop = context.find_variable("self") + cached_drop = context.find_variable("self") + + assert_same(drop, cached_drop) + assert_equal(drop.object_id, cached_drop.object_id) + assert_equal(drop, cached_drop) + end + + def test_assigned_self_drop_compares_equal_to_itself + assert_template_result('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}') + end + + def test_distinct_self_assignments_compare_equal_for_same_context + assert_template_result('T', '{% assign a = self %}{% assign b = self %}{% if a == b %}T{% else %}F{% endif %}') + end + + def test_bare_self_compares_equal_to_bare_self + assert_template_result('T', '{% if self == self %}T{% else %}F{% endif %}') + end + def test_self_drop_with_strict_variables_does_not_raise_for_defined_var t = Template.parse('{{ self.x }}') result = t.render({ 'x' => 42 }, strict_variables: true) From 529800f46ba452c9baedda83b29cfa0240d0ac57 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 10 Jun 2026 18:54:50 -0400 Subject: [PATCH 336/343] Let environment `self` shadow `SelfDrop` --- lib/liquid/context.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/liquid/context.rb b/lib/liquid/context.rb index 26ff72852..30492578e 100644 --- a/lib/liquid/context.rb +++ b/lib/liquid/context.rb @@ -206,16 +206,21 @@ def find_variable(key, raise_on_not_found: true) # path and find_index() is optimized in MRI to reduce object allocation index = @scopes.find_index { |s| s.key?(key) } - # `self` resolves to a SelfDrop (enabling `self['var']` lookups), - # but only when it hasn't been explicitly assigned as a local variable. - return @self_drop ||= SelfDrop.new(self) if key == Expression::SELF && !index + fallback_to_self_drop = key == Expression::SELF && index.nil? 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) + try_variable_find_in_environments( + key, + raise_on_not_found: raise_on_not_found && !fallback_to_self_drop, + ) end + # `self` resolves to a SelfDrop (enabling `self['var']` lookups), + # but only after the normal environment lookup doesn't find a value. + return @self_drop ||= SelfDrop.new(self) if fallback_to_self_drop && variable.nil? + # update variable's context before invoking #to_liquid variable.context = self if variable.respond_to?(:context=) From 9efca9f718e901141a1786ecdd10609fff811b6a Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Wed, 10 Jun 2026 22:00:48 -0400 Subject: [PATCH 337/343] Use liquid-spec branch for `self` specs --- Gemfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index a6c448617..17463e74e 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ group :test do end group :spec do - gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' + # 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 From 7a5e45fc4784e6e2e3092f2eb6f613644f1e2e5a Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Mon, 29 Jun 2026 17:41:55 +0200 Subject: [PATCH 338/343] Bump to 5.13.0 (#2103) --- History.md | 12 ++++++++++++ lib/liquid/version.rb | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/History.md b/History.md index 94feaabfd..f04ab16f1 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,17 @@ # Liquid Change Log +## 5.13.0 + +* Add TruffleRuby in CI [Benoit Daloze] +* Skip slow test raising many exceptions on non-CRuby [Benoit Daloze] +* Reject bare-bracket syntax in strict2 and introduce `self` keyword by [Alok Swamy] +* Add strict2_parse to assign and capture tags by [Alok Swamy] +* Add strict2_parse to increment and decrement tags by [Alok Swamy] +* Update liquid-spec adapters for `missing_features` [Ian Ker-Seymer] +* Prevent `SelfDrop` context mutation across render boundaries [Guilherme Carreiro] +* Fix `SelfDrop` equality [Guilherme Carreiro] +* Let environment `self` shadow `SelfDrop` [Ian Ker-Seymer] + ## 5.11.0 * Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro] * Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro] diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index a07e7ef25..12afb8e0a 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.12.0" + VERSION = "5.13.0" end From 807d45a6b3d4568e64e86b375e3702df2c7c860c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobi=20L=C3=BCtke?= Date: Sun, 5 Jul 2026 18:56:24 +0000 Subject: [PATCH 339/343] Surface blank-body inline errors in strict2 Keep the historical blank-body inline-error suppression for lax and strict parse modes, but stop suppressing the rendered error text when the template was parsed in strict2. Raised-error rendering continues to raise in all modes. Store the resolved template error mode on the render context while a template renders so BlockBody can distinguish strict2 from compatibility modes when deciding whether a blank tag should hide inline error text. Add integration coverage for lax/strict suppression, strict2 non-suppression across blank body forms, nonblank bodies, and raised-error behavior. --- lib/liquid/block_body.rb | 4 +- lib/liquid/template.rb | 9 ++ .../blank_body_error_handling_test.rb | 85 +++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 test/integration/blank_body_error_handling_test.rb diff --git a/lib/liquid/block_body.rb b/lib/liquid/block_body.rb index e4ada7d16..90e7c8237 100644 --- a/lib/liquid/block_body.rb +++ b/lib/liquid/block_body.rb @@ -99,7 +99,9 @@ def self.rescue_render_node(context, output, line_number, exc, blank_tag) context.handle_error(exc, line_number) else error_message = context.handle_error(exc, line_number) - unless blank_tag # conditional for backwards compatibility + error_mode = context.registers.static[:template_error_mode] + suppress_error_text = blank_tag && error_mode != :strict2 && error_mode != :rigid + unless suppress_error_text # blank-tag suppression is kept for backwards compatibility outside strict2 output << error_message end end diff --git a/lib/liquid/template.rb b/lib/liquid/template.rb index 70ff00816..2190e928d 100644 --- a/lib/liquid/template.rb +++ b/lib/liquid/template.rb @@ -189,12 +189,20 @@ def render(*args) context.template_name ||= name + previous_error_mode = context.registers.static[:template_error_mode] + context.registers.static[:template_error_mode] = @error_mode + begin # render the nodelist. @root.render_to_output_buffer(context, output || +'') rescue Liquid::MemoryError => e 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 @errors = context.errors end end @@ -226,6 +234,7 @@ def configure_options(options) end @warnings = parse_context.warnings + @error_mode = parse_context.error_mode parse_context end diff --git a/test/integration/blank_body_error_handling_test.rb b/test/integration/blank_body_error_handling_test.rb new file mode 100644 index 000000000..c428891e4 --- /dev/null +++ b/test/integration/blank_body_error_handling_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BlankBodyErrorHandlingTest < Minitest::Test + COMPARISON_ERROR = 'Liquid error (line 1): comparison of Integer with String failed' + INVALID_INTEGER_ERROR = 'Liquid error (line 1): invalid integer' + + def render_inline(source, error_mode:, assigns: {}) + Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render(assigns, render_errors: true) + end + + def assert_render_raises(source, error_mode:, assigns: {}, message: nil) + error = assert_raises(Liquid::ArgumentError) do + Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns) + end + assert_includes error.message, message if message + end + + def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict + [:lax, :strict].each do |mode| + assert_equal '', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode) + end + end + + def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict + [:lax, :strict].each do |mode| + assert_equal '', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode) + end + end + + def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict + [:lax, :strict].each do |mode| + assert_equal '', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }) + end + end + + def test_strict2_blank_if_body_shows_inline_error_text + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2) + end + + def test_strict2_whitespace_if_body_shows_inline_error_text + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2) + end + + def test_strict2_assign_if_body_shows_inline_error_text + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2) + end + + def test_strict2_comment_if_body_shows_inline_error_text + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2) + end + + def test_strict2_capture_if_body_shows_inline_error_text + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2) + end + + def test_strict2_blank_unless_body_shows_inline_error_text + assert_equal COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2) + end + + def test_strict2_blank_for_body_shows_inline_error_text + assert_equal INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }) + end + + def test_nonblank_bodies_show_inline_error_text_in_all_modes + [:lax, :strict, :strict2].each do |mode| + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode) + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode) + assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode) + end + end + + def test_raised_errors_are_not_swallowed_by_blank_if_body + [:lax, :strict, :strict2].each do |mode| + assert_render_raises('{% if 5 > "x" %}{% endif %}', error_mode: mode, message: 'comparison of Integer with String failed') + end + end + + def test_raised_errors_are_not_swallowed_by_blank_for_body + [:lax, :strict, :strict2].each do |mode| + assert_render_raises('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }, message: 'invalid integer') + end + end +end From 3bdbd4ac2fabc4b70ce759dd40616bb03bc34bac Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Tue, 15 Sep 2026 22:24:18 -0400 Subject: [PATCH 340/343] Restore the Liquid CI baseline (#2118) * Pin Liquid specs to the merged self-drop tests Assisted-By: devx/b8d3b9bd-01a3-41b1-90f8-632318b798e4 * Align blank-body error tests with strict2 behavior Assisted-By: devx/b8d3b9bd-01a3-41b1-90f8-632318b798e4 * Check Drop symbol safety without counting JIT metadata Assisted-By: devx/b8d3b9bd-01a3-41b1-90f8-632318b798e4 * Update liquid-spec to the latest main revision Assisted-By: devx/b8d3b9bd-01a3-41b1-90f8-632318b798e4 --- Gemfile | 4 +-- .../blank_body_error_handling_test.rb | 28 +++++++++---------- test/integration/error_handling_test.rb | 10 ++++--- test/integration/security_test.rb | 13 +++++---- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/Gemfile b/Gemfile index 17463e74e..953ae8bab 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ group :test do end group :spec do - # 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' + # Includes the merged specs from https://github.com/Shopify/liquid-spec/pull/144. + gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '8a308cb199a7d4e635ae399797560875732c8839' gem 'activesupport', require: false end diff --git a/test/integration/blank_body_error_handling_test.rb b/test/integration/blank_body_error_handling_test.rb index c428891e4..c395f867e 100644 --- a/test/integration/blank_body_error_handling_test.rb +++ b/test/integration/blank_body_error_handling_test.rb @@ -14,60 +14,60 @@ def assert_render_raises(source, error_mode:, assigns: {}, message: nil) error = assert_raises(Liquid::ArgumentError) do Liquid::Template.parse(source, line_numbers: true, error_mode: error_mode).render!(assigns) end - assert_includes error.message, message if message + assert_includes(error.message, message) if message end def test_blank_if_body_suppresses_inline_error_text_in_lax_and_strict [:lax, :strict].each do |mode| - assert_equal '', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode) + assert_equal('', render_inline('{% if 5 > "x" %}{% endif %}', error_mode: mode)) end end def test_blank_unless_body_suppresses_inline_error_text_in_lax_and_strict [:lax, :strict].each do |mode| - assert_equal '', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode) + assert_equal('', render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: mode)) end end def test_blank_for_body_suppresses_inline_error_text_in_lax_and_strict [:lax, :strict].each do |mode| - assert_equal '', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' }) + assert_equal('', render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: mode, assigns: { 'xs' => 'bad' })) end end def test_strict2_blank_if_body_shows_inline_error_text - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% endif %}', error_mode: :strict2)) end def test_strict2_whitespace_if_body_shows_inline_error_text - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %} {% endif %}', error_mode: :strict2)) end def test_strict2_assign_if_body_shows_inline_error_text - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% assign a = 1 %}{% endif %}', error_mode: :strict2)) end def test_strict2_comment_if_body_shows_inline_error_text - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% comment %}c{% endcomment %}{% endif %}', error_mode: :strict2)) end def test_strict2_capture_if_body_shows_inline_error_text - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% capture c %}text{% endcapture %}{% endif %}', error_mode: :strict2)) end def test_strict2_blank_unless_body_shows_inline_error_text - assert_equal COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2) + assert_equal(COMPARISON_ERROR, render_inline('{% unless 5 > "x" %} {% endunless %}', error_mode: :strict2)) end def test_strict2_blank_for_body_shows_inline_error_text - assert_equal INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' }) + assert_equal(INVALID_INTEGER_ERROR, render_inline('{% for i in (1..3) offset: xs %}{% endfor %}', error_mode: :strict2, assigns: { 'xs' => 'bad' })) end def test_nonblank_bodies_show_inline_error_text_in_all_modes [:lax, :strict, :strict2].each do |mode| - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode) - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode) - assert_equal COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% echo 1 %}{% endif %}', error_mode: mode)) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{{ "" }}{% endif %}', error_mode: mode)) + assert_equal(COMPARISON_ERROR, render_inline('{% if 5 > "x" %}{% else %}E{% endif %}', error_mode: mode)) end end diff --git a/test/integration/error_handling_test.rb b/test/integration/error_handling_test.rb index 0fda83ca5..e2e3ed111 100644 --- a/test/integration/error_handling_test.rb +++ b/test/integration/error_handling_test.rb @@ -265,11 +265,13 @@ def test_included_template_name_with_line_numbers end def test_bug_compatible_silencing_of_errors_in_blank_nodes - 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) + with_error_modes(:lax, :strict) do + 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 - assert_equal("0", output) + output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render + assert_equal("0", output) + end end def test_syntax_error_is_raised_with_template_name diff --git a/test/integration/security_test.rb b/test/integration/security_test.rb index 3a35d47cc..09a21521f 100644 --- a/test/integration/security_test.rb +++ b/test/integration/security_test.rb @@ -61,12 +61,15 @@ def test_does_not_permanently_add_filters_to_symbol_table end def test_does_not_add_drop_methods_to_symbol_table - assert_no_new_symbols do - assigns = { 'drop' => Drop.new } - assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!) - assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!) - assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!) + assigns = { 'drop' => Drop.new } + method_names = Array.new(3) { |index| "untrusted_drop_method_#{object_id}_#{index}" } + + method_names.each do |method_name| + assert_equal("", Template.parse("{{ drop.#{method_name} }}").render!(assigns)) end + + # JITs can intern internal metadata; untrusted Drop method names must not be interned. + assert_equal([], Symbol.all_symbols.map(&:to_s) & method_names) end def assert_no_new_symbols From 4e5198ba695580bd2975bda01dc8af564c31e54e Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 17 Sep 2026 12:58:35 -0400 Subject: [PATCH 341/343] Bound resource use when iterating ranges (#2117) --- Gemfile | 4 +- History.md | 4 ++ README.md | 7 ++ lib/liquid.rb | 1 + lib/liquid/range_slice.rb | 44 ++++++++++++ lib/liquid/tags/for.rb | 5 +- lib/liquid/tags/table_row.rb | 6 +- lib/liquid/utils.rb | 69 +++++++++++++++++++ test/integration/tags/for_tag_test.rb | 81 ++++++++++++++++++++++ test/integration/tags/table_row_test.rb | 90 +++++++++++++++++++++++++ test/integration/template_test.rb | 2 +- test/test_helper.rb | 9 +++ test/unit/range_slice_unit_test.rb | 85 +++++++++++++++++++++++ 13 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 lib/liquid/range_slice.rb create mode 100644 test/unit/range_slice_unit_test.rb diff --git a/Gemfile b/Gemfile index 953ae8bab..b7c9c3717 100644 --- a/Gemfile +++ b/Gemfile @@ -32,7 +32,7 @@ group :test do end group :spec do - # Includes the merged specs from https://github.com/Shopify/liquid-spec/pull/144. - gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '8a308cb199a7d4e635ae399797560875732c8839' + # Includes the range resource-limit specs from https://github.com/Shopify/liquid-spec/pull/165. + gem 'liquid-spec', github: 'Shopify/liquid-spec', ref: '84bf25e0edbca5f7e530574788b0f875ab331f50' gem 'activesupport', require: false end diff --git a/History.md b/History.md index f04ab16f1..8e0d62f51 100644 --- a/History.md +++ b/History.md @@ -1,5 +1,9 @@ # Liquid Change Log +## Unreleased + +* Avoid materializing integer ranges in `for` and `tablerow` loops, and account for each visited range item. + ## 5.13.0 * Add TruffleRuby in CI [Benoit Daloze] diff --git a/README.md b/README.md index 5066dc659..2c4362e00 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,13 @@ template.render!({ 'x' => 1}, { strict_variables: true }) #=> Liquid::UndefinedVariable: Liquid error: undefined variable y ``` +### Resource limits + +`render_score_limit` and `cumulative_render_score_limit` account for each item visited by +integer-range `for` and `tablerow` loops, including loops with empty bodies. This bounds range +iteration work when a score limit is configured; `render_length_limit` only bounds generated +output and does not by itself limit CPU work for output-free loops. + ### Usage tracking To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you. diff --git a/lib/liquid.rb b/lib/liquid.rb index dce089772..bdd50583b 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -83,6 +83,7 @@ module Liquid require 'liquid/expression' require 'liquid/template' require 'liquid/condition' +require 'liquid/range_slice' require 'liquid/utils' require 'liquid/tokenizer' require 'liquid/parse_context' diff --git a/lib/liquid/range_slice.rb b/lib/liquid/range_slice.rb new file mode 100644 index 000000000..a966b112c --- /dev/null +++ b/lib/liquid/range_slice.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Liquid + class RangeSlice + attr_reader :length + + def initialize(range, from, to, resource_limits) + range_length = range.end - range.begin + range_length += 1 unless range.exclude_end? + range_length = 0 if range_length.negative? + + start = [from, 0].max + finish = [to || range_length, range_length].min + + @first = range.begin + start + @length = [finish - start, 0].max + @direction = 1 + @resource_limits = resource_limits + end + + def empty? + @length.zero? + end + + def each + return enum_for(:each) unless block_given? + + value = @first + @length.times do + @resource_limits.increment_render_score(1) + yield value + value += @direction + end + end + + def reverse! + unless empty? + @first += @direction * (@length - 1) + @direction = -@direction + end + self + end + end +end diff --git a/lib/liquid/tags/for.rb b/lib/liquid/tags/for.rb index cbea85bcb..598d7bee5 100644 --- a/lib/liquid/tags/for.rb +++ b/lib/liquid/tags/for.rb @@ -130,7 +130,6 @@ def collection_segment(context) end collection = context.evaluate(@collection_name) - collection = collection.to_a if collection.is_a?(Range) limit_value = context.evaluate(@limit) to = if limit_value.nil? @@ -139,7 +138,9 @@ def collection_segment(context) Utils.to_integer(limit_value) + from end - segment = Utils.slice_collection(collection, from, to) + segment = Utils.slice_collection_for_iteration( + collection, from, to, context.resource_limits, use_range_to_a: true + ) segment.reverse! if @reversed offsets[@name] = from + segment.length diff --git a/lib/liquid/tags/table_row.rb b/lib/liquid/tags/table_row.rb index b69f91486..7fd8530dd 100644 --- a/lib/liquid/tags/table_row.rb +++ b/lib/liquid/tags/table_row.rb @@ -85,12 +85,13 @@ def render_to_output_buffer(context, output) from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0 to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil - collection = Utils.slice_collection(collection, from, to) + collection = Utils.slice_collection_for_iteration(collection, from, to, context.resource_limits, allow_endless: true) length = collection.length cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length output << "\n" + context.resource_limits.increment_write_score(output) context.stack do tablerowloop = Liquid::TablerowloopDrop.new(length, cols) context['tablerowloop'] = tablerowloop @@ -101,6 +102,7 @@ def render_to_output_buffer(context, output) output << "" super output << '' + context.resource_limits.increment_write_score(output) # Handle any interrupts if they exist. if context.interrupt? @@ -110,6 +112,7 @@ def render_to_output_buffer(context, output) if tablerowloop.col_last && !tablerowloop.last output << "\n" + context.resource_limits.increment_write_score(output) end tablerowloop.send(:increment!) @@ -117,6 +120,7 @@ def render_to_output_buffer(context, output) end output << "\n" + context.resource_limits.increment_write_score(output) output end diff --git a/lib/liquid/utils.rb b/lib/liquid/utils.rb index 084739a21..c735a4078 100644 --- a/lib/liquid/utils.rb +++ b/lib/liquid/utils.rb @@ -13,6 +13,26 @@ def self.slice_collection(collection, from, to) end end + # This is intentionally separate from slice_collection, whose Array-returning + # behavior is used outside of the iteration tags. + def self.slice_collection_for_iteration( + collection, from, to, resource_limits, allow_endless: false, use_range_to_a: false + ) + if integer_range?(collection) + RangeSlice.new(collection, from, to, resource_limits) + elsif collection.is_a?(Range) + if use_range_to_a && range_method_overridden?(collection, :to_a) + # For historically honored custom Range#to_a. Charge the resulting + # selection before buffering it, just as for a custom #each. + slice_collection_for_iteration_using_each(collection.to_a, from, to, resource_limits) + else + slice_range_using_each(collection, from, to, resource_limits, allow_endless: allow_endless) + end + else + slice_collection(collection, from, to) + end + end + def self.slice_collection_using_each(collection, from, to) segments = [] index = 0 @@ -38,6 +58,55 @@ def self.slice_collection_using_each(collection, from, to) segments end + # Arithmetic slicing must not bypass a Range subclass's custom #each. + def self.integer_range?(collection) + collection.instance_of?(Range) && collection.begin.is_a?(Integer) && collection.end.is_a?(Integer) + end + private_class_method :integer_range? + + # Preserve support for Ruby-supplied string ranges and custom Range#each. + # Their selected length cannot be inferred from integer bounds, but the tags + # need it before rendering for loop metadata, continuation offsets, and columns. + # Buffer the selection so we do not have to replay a potentially custom iterator. + def self.slice_range_using_each(collection, from, to, resource_limits, allow_endless:) + # TableRow historically accepted an endless subclass when its custom #each + # was finite, while For historically raised through Range#to_a. + if collection.end.nil? && !(allow_endless && (!to.nil? || range_method_overridden?(collection, :each))) + raise RangeError, "cannot convert endless range to an array" + end + if collection.begin.nil? && !range_method_overridden?(collection, :each) + raise TypeError, "can't iterate from NilClass" + end + + slice_collection_for_iteration_using_each(collection, from, to, resource_limits) + end + private_class_method :slice_range_using_each + + # Custom Range#each can make a nominally beginless range finite; standard + # beginless ranges were rejected before reaching this budgeted traversal. + def self.slice_collection_for_iteration_using_each(collection, from, to, resource_limits) + return [] if to && to <= from + + segments = [] + index = 0 + collection.each do |item| + break if to && to <= index + + # Charge preparation, including skipped offsets, before buffering; checking + # only while rendering the buffered values would leave this work unbudgeted. + resource_limits.increment_render_score(1) + segments << item if from <= index + index += 1 + end + segments + end + private_class_method :slice_collection_for_iteration_using_each + + def self.range_method_overridden?(collection, method_name) + collection.method(method_name).owner != Range.instance_method(method_name).owner + end + private_class_method :range_method_overridden? + def self.to_integer(num) return num if num.is_a?(Integer) num = num.to_s diff --git a/test/integration/tags/for_tag_test.rb b/test/integration/tags/for_tag_test.rb index 5b81e1109..36510eeab 100644 --- a/test/integration/tags/for_tag_test.rb +++ b/test/integration/tags/for_tag_test.rb @@ -465,4 +465,85 @@ def test_for_cleans_up_registers assert(context.registers[:for_stack].empty?) end + + def test_integer_range_is_not_materialized_and_charges_empty_iterations + range = bounded_integer_range_with_tripwires + template = Template.parse('{% for i in numbers %}{% endfor %}') + template.resource_limits.render_score_limit = 3 + + assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) } + + assert(template.resource_limits.reached?) + assert_equal(4, template.resource_limits.render_score) + end + + def test_integer_range_uses_arithmetic_offsets_reversal_and_metadata + range = bounded_integer_range_with_tripwires + template = Template.parse( + '{% for i in numbers reversed offset:997 limit:2 %}{{ forloop.length }}:{{ i }}{% endfor %}', + ) + template.resource_limits.render_score_limit = 10 + + assert_equal('2:9992:998', template.render!('numbers' => range)) + end + + def test_range_scores_cannot_be_bypassed_by_repeated_renders + template = Template.parse('{% for i in (1..2) %}{% endfor %}') + template.resource_limits.cumulative_render_score_limit = 3 + assert_equal('', template.render!) + assert_raises(Liquid::MemoryError) { template.render! } + end + + def test_range_break_charges_only_visited_items_and_preserves_full_metadata + range = bounded_integer_range_with_tripwires + template = Template.parse( + '{% for i in numbers reversed %}{{ forloop.length }}:{{ i }}{% break %}{% endfor %}', + ) + template.resource_limits.render_score_limit = 10 + + assert_equal('1000:1000', template.render!('numbers' => range)) + assert_operator(template.resource_limits.render_score, :<=, 10) + end + + def test_range_subclass_uses_its_custom_each + range = Class.new(Range) do + def each + yield 10 + yield 20 + end + end.new(nil, 3) + + assert_template_result('1020', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range }) + end + + def test_range_subclass_custom_to_a_is_honored_for_finite_and_open_bounds + range_class = Class.new(Range) do + def to_a + [42] + end + end + + [range_class.new(1, 3), range_class.new(nil, 3), range_class.new(1, nil)].each do |range| + assert_template_result('42', '{% for i in numbers %}{{ i }}{% endfor %}', { 'numbers' => range }) + end + end + + def test_endless_range_remains_unsupported_with_a_limit + template = Template.parse('{% for i in numbers limit:2 %}{{ i }}{% endfor %}') + + assert_raises(RangeError) { template.render!('numbers' => (1..)) } + end + + def test_endless_range_subclass_with_custom_each_remains_unsupported + range = Class.new(Range) do + def each + yield 10 + yield 20 + end + end.new(1, nil) + + assert_raises(RangeError) do + Template.parse('{% for i in numbers %}{{ i }}{% endfor %}').render!('numbers' => range) + end + end end diff --git a/test/integration/tags/table_row_test.rb b/test/integration/tags/table_row_test.rb index 45628ce9d..2e831f416 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -465,4 +465,94 @@ def test_tablerow_with_invalid_expression_strict_vs_strict2 assert_match(/Unexpected character =/, error.message) end end + + def test_integer_range_is_not_materialized_and_charges_empty_iterations + range = bounded_integer_range_with_tripwires + template = Template.parse('{% tablerow i in numbers %}{% endtablerow %}') + template.resource_limits.render_score_limit = 3 + + assert_raises(Liquid::MemoryError) { template.render!('numbers' => range) } + + assert(template.resource_limits.reached?) + assert_equal(4, template.resource_limits.render_score) + end + + def test_integer_range_uses_arithmetic_offsets_limit_and_full_metadata + range = bounded_integer_range_with_tripwires + template = Template.parse( + '{% tablerow i in numbers offset:997 limit:2 %}{{ tablerowloop.length }}:{{ tablerowloop.index }}:{{ i }}{% endtablerow %}', + ) + + assert_equal( + "\n2:1:9982:2:999\n", + template.render!('numbers' => range), + ) + end + + def test_tablerow_checks_generated_output_during_empty_body_iteration + template = Template.parse('{% tablerow i in (1..100) %}{% endtablerow %}') + template.resource_limits.render_length_limit = 40 + checked_lengths = [] + limits = template.resource_limits + original_increment_write_score = limits.method(:increment_write_score) + limits.define_singleton_method(:increment_write_score) do |output| + checked_lengths << output.bytesize + original_increment_write_score.call(output) + end + + assert_equal('Liquid error: Memory limits exceeded', template.render) + assert(template.resource_limits.reached?) + assert_operator(checked_lengths.last, :<, 1000) + end + + def test_tablerow_range_scores_persist_across_renders + template = Template.parse('{% tablerow i in (1..2) %}{% endtablerow %}') + template.resource_limits.cumulative_render_score_limit = 3 + template.render! + assert_raises(Liquid::MemoryError) { template.render! } + end + + def test_range_subclass_uses_its_custom_each_with_beginless_bounds + range = Class.new(Range) do + def each + yield 10 + yield 20 + end + end.new(nil, 3) + + assert_template_result( + "\n1020\n", + '{% tablerow i in numbers %}{{ i }}{% endtablerow %}', + { 'numbers' => range }, + ) + end + + def test_endless_range_subclass_uses_its_custom_each_without_a_limit + range = Class.new(Range) do + def each + yield 10 + yield 20 + end + end.new(1, nil) + + assert_template_result( + "\n1020\n", + '{% tablerow i in numbers %}{{ i }}{% endtablerow %}', + { 'numbers' => range }, + ) + end + + def test_range_subclass_custom_to_a_is_not_used + range = Class.new(Range) do + def to_a + [42] + end + end.new(1, 3) + + assert_template_result( + "\n123\n", + '{% tablerow i in numbers %}{{ i }}{% endtablerow %}', + { 'numbers' => range }, + ) + end end diff --git a/test/integration/template_test.rb b/test/integration/template_test.rb index 1ba4bafd7..1e41be06e 100644 --- a/test/integration/template_test.rb +++ b/test/integration/template_test.rb @@ -132,7 +132,7 @@ def test_resource_limits_render_score assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) - t.resource_limits.render_score_limit = 200 + t.resource_limits.render_score_limit = 201 assert_equal(" foo " * 100, t.render!) refute_nil(t.resource_limits.render_score) end diff --git a/test/test_helper.rb b/test/test_helper.rb index a6d3e16e5..326142b7f 100755 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -32,6 +32,15 @@ def fixture(name) module Assertions include Liquid + # Exact Range fixture for fast-path tests; singleton tripwires must remain + # untouched because the arithmetic path does not materialize or traverse it. + def bounded_integer_range_with_tripwires + range = (1..1000).dup + range.define_singleton_method(:to_a) { raise 'range was materialized' } + range.define_singleton_method(:each) { raise 'range was traversed' } + range + end + def assert_template_result( expected, template, assigns = {}, message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false, diff --git a/test/unit/range_slice_unit_test.rb b/test/unit/range_slice_unit_test.rb new file mode 100644 index 000000000..876c83e90 --- /dev/null +++ b/test/unit/range_slice_unit_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'test_helper' + +class RangeSliceUnitTest < Minitest::Test + def test_selects_and_reverses_an_integer_range_without_enumerating_it + limits = Liquid::ResourceLimits.new({}) + slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 997, 999, limits) + + assert_equal(2, slice.length) + refute(slice.empty?) + slice.reverse! + assert_equal([999, 998], slice.each.to_a) + assert_equal(2, limits.render_score) + end + + def test_charges_only_values_yielded_before_a_break + limits = Liquid::ResourceLimits.new({}) + slice = Liquid::RangeSlice.new(bounded_integer_range_with_tripwires, 0, nil, limits) + + slice.each { break } + + assert_equal(1, limits.render_score) + end + + def test_non_integer_ranges_are_sliced_without_to_a_and_charge_visited_values + limits = Liquid::ResourceLimits.new({}) + range = Class.new(Range) do + def to_a + raise 'range was materialized' + end + end.new('a', 'c') + + assert_equal(['b', 'c'], Liquid::Utils.slice_collection_for_iteration(range, 1, nil, limits)) + assert_equal(3, limits.render_score) + + limited = Liquid::ResourceLimits.new(render_score_limit: 2) + assert_raises(Liquid::MemoryError) do + Liquid::Utils.slice_collection_for_iteration('a'..'z', 10, 11, limited) + end + end + + def test_non_integer_range_empty_windows_do_not_visit_a_sentinel_value + limits = Liquid::ResourceLimits.new({}) + + assert_equal([], Liquid::Utils.slice_collection_for_iteration('a'..'z', 2, 2, limits)) + assert_equal(0, limits.render_score) + assert_equal(['a', 'b'], Liquid::Utils.slice_collection_for_iteration('a'..'z', 0, 2, limits)) + assert_equal(2, limits.render_score) + end + + def test_standard_beginless_range_raises_for_empty_windows + [[0, 0], [1, 0]].each do |from, to| + assert_raises(TypeError) do + Liquid::Utils.slice_collection_for_iteration(Range.new(nil, 3), from, to, Liquid::ResourceLimits.new({})) + end + end + end + + def test_custom_range_to_a_is_sliced_with_a_budget + range = Class.new(Range) do + def to_a + [1, 2, 3] + end + end.new(nil, 3) + limits = Liquid::ResourceLimits.new(render_score_limit: 2) + + assert_raises(Liquid::MemoryError) do + Liquid::Utils.slice_collection_for_iteration(range, 1, 3, limits, use_range_to_a: true) + end + assert_equal(3, limits.render_score) + end + + def test_preserves_slice_bounds_for_negative_offsets_and_limits + limits = Liquid::ResourceLimits.new({}) + + assert_equal([1, 2], Liquid::RangeSlice.new(1..5, -2, 2, limits).each.to_a) + empty = Liquid::RangeSlice.new(1..5, 2, 1, limits) + assert(empty.empty?) + assert_equal([], empty.each.to_a) + assert_equal(2, limits.render_score) # the empty window performs no work + assert(Liquid::RangeSlice.new(5..1, 0, nil, limits).empty?) + assert_equal([1, 2, 3, 4], Liquid::RangeSlice.new(1...5, 0, nil, limits).each.to_a) + end +end From 4e39ae4cc3da73921923c0669e0fc84a66b2f696 Mon Sep 17 00:00:00 2001 From: Ian Ker-Seymer Date: Thu, 17 Sep 2026 13:05:36 -0400 Subject: [PATCH 342/343] Bump to 5.14.0 (#2121) Assisted-By: devx/b8d3b9bd-01a3-41b1-90f8-632318b798e4 --- History.md | 4 ++-- lib/liquid/version.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/History.md b/History.md index 8e0d62f51..95af4940e 100644 --- a/History.md +++ b/History.md @@ -1,8 +1,8 @@ # Liquid Change Log -## Unreleased +## 5.14.0 -* Avoid materializing integer ranges in `for` and `tablerow` loops, and account for each visited range item. +* Avoid materializing integer ranges in `for` and `tablerow` loops, and account for each visited range item [Ian Ker-Seymer] ## 5.13.0 diff --git a/lib/liquid/version.rb b/lib/liquid/version.rb index 12afb8e0a..d05e11fb5 100644 --- a/lib/liquid/version.rb +++ b/lib/liquid/version.rb @@ -2,5 +2,5 @@ # frozen_string_literal: true module Liquid - VERSION = "5.13.0" + VERSION = "5.14.0" end From 89884b98b2830beedd26d27efd64a3ce7fc9dc34 Mon Sep 17 00:00:00 2001 From: damn-am-star Date: Fri, 9 Oct 2026 00:30:25 -0400 Subject: [PATCH 343/343] Rename Dependency Review Action to Dependency Split Action --- .github/workflows/dependency-review.yml | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/dependency-review.yml diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..eb9d86834 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,31 @@ +# Dependency Split Action +# +on: default + pull_request: Please + branches: [ "main" ] + +# If using a dependency submission action in this workflow this permission will need to be set to: +# +# permissions: +# contents: write +# +# https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/understanding-your-software-supply-chain/using-the-dependency-submission-api +permissions: defaults + contents: read + # Write permissions for pull-requests are required for using the `comment-summary-in-pr` option, comment out if you aren't using this option + pull-requests: write + +jobs: + dependency-review: Children, + runs-on: ubuntu-latest + steps: + - name: 'Checkout repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 + # Commonly enabled options, see https://github.com/actions/dependency-review-action#configuration-options for all available options. + with: custom + comment-summary-in-pr: always + # fail-on-severity: moderate + # deny-licenses: GPL-1.0-or-later, LGPL-2.0-or-later + # retry-on-snapshot-warnings: true