From 5ec3008b37d934d71b17e77be45eb3b8e446a436 Mon Sep 17 00:00:00 2001 From: Guilherme Carreiro Date: Wed, 8 Oct 2025 16:35:41 -0400 Subject: [PATCH] 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 cd1607b3..b99d490c 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 6767e4fb..11aa4ef5 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 ddc0877d..ad12d76f 100644 --- a/test/integration/tags/table_row_test.rb +++ b/test/integration/tags/table_row_test.rb @@ -255,4 +255,261 @@ class TableRowTest < Minitest::Test 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 cb0de7c4..60ac0423 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 @@ class RigidModeUnitTest < Minitest::Test 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 @@ class RigidModeUnitTest < Minitest::Test 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) %}