From 82f10b7ace7e3e39bf2b904428e495abff0c511e Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 3 Dec 2025 15:03:01 -0500 Subject: [PATCH 01/13] Make Parser.expression parse comparisons --- lib/liquid.rb | 1 + lib/liquid/binary_expression.rb | 46 +++++++++++++++++++++++++++++++++ lib/liquid/parser.rb | 17 ++++++++++++ test/unit/parser_unit_test.rb | 24 +++++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 lib/liquid/binary_expression.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index 4d0a71a64..d069f6ca0 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -62,6 +62,7 @@ module Liquid require 'liquid/tags' require "liquid/environment" require 'liquid/lexer' +require 'liquid/binary_expression' require 'liquid/parser' require 'liquid/i18n' require 'liquid/drop' diff --git a/lib/liquid/binary_expression.rb b/lib/liquid/binary_expression.rb new file mode 100644 index 000000000..944099f7f --- /dev/null +++ b/lib/liquid/binary_expression.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Liquid + class BinaryExpression + attr_reader :left, :operator, :right + + def initialize(left, operator, right) + @left = left + @operator = operator + @right = right + end + + def evaluate(context) + left_value = value(left, context) + right_value = value(@right, context) + + case operator + when '>' + left_value > right_value + when '>=' + left_value >= right_value + when '<' + left_value < right_value + when '<=' + left_value <= right_value + when '==' + left_value == right_value + when '!=', '<>' + left_value != right_value + when 'contains' + if left_value && right_value && left_value.respond_to?(:include?) + right_value = right_value.to_s if left_value.is_a?(String) + left_value.include?(right_value) + else + false + end + end + end + + private + + def value(expr, context) + Utils.to_liquid_value(context.evaluate(expr)) + end + end +end diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 7003af9be..66d7c4233 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -47,7 +47,24 @@ def look(type, ahead = 0) tok[0] == type end + # expression := comparison + # comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)* + # primary := string | number | variable_lookup | range | boolean def expression + comparison + end + + # comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)* + def comparison + expr = primary + while look(:comparison) + operator = consume + expr = BinaryExpression.new(expr, operator, primary) + end + expr + end + + def primary token = @tokens[@p] case token[0] when :id diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index cf0d8c7bb..58531b3c0 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -77,6 +77,30 @@ def test_expression assert_equal((0..5), p.expression) end + def test_comparison + p = new_parser("a > b") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('>', expr.operator) + assert(expr.left.is_a?(VariableLookup)) + assert_equal('a', expr.left.name) + assert(expr.right.is_a?(VariableLookup)) + assert_equal('b', expr.right.name) + + # BinaryExpression(>=) + # left: BinaryExpression(>) + # left: 10 + # right: 5 + # right: 4 + p = new_parser("10 > 5 >= 4") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('>=', expr.operator) + assert_equal(10, expr.left.left) + assert_equal(5, expr.left.right) + assert_equal(4, expr.right) + end + def test_number p = new_parser('-1 0 1 2.0') assert_equal(-1, p.number) From 1e70fed6389e2ef7e2640ce8713af3d04fcd6786 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 3 Dec 2025 15:03:42 -0500 Subject: [PATCH 02/13] Make Parser.expression parse equality expressions --- lib/liquid/lexer.rb | 6 +++--- lib/liquid/parser.rb | 14 ++++++++++++-- lib/liquid/tags/if.rb | 2 +- test/unit/lexer_unit_test.rb | 13 ++++++++++--- test/unit/parser_unit_test.rb | 25 +++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index f1740dbad..c0a6463bb 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -6,14 +6,14 @@ class Lexer CLOSE_SQUARE = [:close_square, "]"].freeze COLON = [:colon, ":"].freeze COMMA = [:comma, ","].freeze - COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze + COMPARISION_NOT_EQUAL = [:equality, "!="].freeze COMPARISON_CONTAINS = [:comparison, "contains"].freeze - COMPARISON_EQUAL = [:comparison, "=="].freeze + COMPARISON_EQUAL = [:equality, "=="].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 + COMPARISON_NOT_EQUAL_ALT = [:equality, "<>"].freeze DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 66d7c4233..8d5151e7d 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -47,11 +47,21 @@ def look(type, ahead = 0) tok[0] == type end - # expression := comparison + # expression := equality + # equality := comparison (("==" | "!=" | "<>") comparison)* # comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)* # primary := string | number | variable_lookup | range | boolean def expression - comparison + equality + end + + def equality + expr = comparison + while look(:equality) + operator = consume + expr = BinaryExpression.new(expr, operator, comparison) + end + expr end # comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)* diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 2081c788f..3471f6778 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -97,7 +97,7 @@ def parse_binary_comparisons(p) def parse_comparison(p) a = parse_expression(p) - if (op = p.consume?(:comparison)) + if (op = p.consume?(:comparison) || p.consume?(:equality)) b = parse_expression(p) Condition.new(a, op, b) else diff --git a/test/unit/lexer_unit_test.rb b/test/unit/lexer_unit_test.rb index 73eeb7398..494769b8b 100644 --- a/test/unit/lexer_unit_test.rb +++ b/test/unit/lexer_unit_test.rb @@ -26,10 +26,17 @@ def test_float ) end + def test_equality + assert_equal( + [[:equality, '=='], [:equality, '<>'], [:equality, '!='], [:end_of_string]], + tokenize('== <> != '), + ) + end + def test_comparison assert_equal( - [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], - tokenize('== <> contains '), + [[:comparison, '>'], [:comparison, '>='], [:comparison, '<'], [:comparison, '<='], [:comparison, 'contains'], [:end_of_string]], + tokenize('> >= < <= contains'), ) end @@ -81,7 +88,7 @@ def test_fancy_identifiers def test_whitespace assert_equal( - [[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]], + [[:id, 'five'], [:pipe, '|'], [:equality, '=='], [:end_of_string]], tokenize("five|\n\t =="), ) end diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index 58531b3c0..a0c86c081 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -77,6 +77,31 @@ def test_expression assert_equal((0..5), p.expression) end + def test_equality + p = new_parser("a == b") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('==', expr.operator) + assert_equal('a', expr.left.name) + assert_equal('b', expr.right.name) + + # BinaryExpression(==) + # left: BinaryExpression(<) + # left: 0 + # right: 5 + # right: BinaryExpression(>) + # left: 6 + # right: 1 + p = new_parser("0 < 5 == 6 > 1") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('==', expr.operator) + assert_equal(0, expr.left.left) + assert_equal(5, expr.left.right) + assert_equal(6, expr.right.left) + assert_equal(1, expr.right.right) + end + def test_comparison p = new_parser("a > b") expr = p.expression From 1d42d9cfe65e1ca755c0354bf02b63d4913f9c2f Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 3 Dec 2025 15:58:10 -0500 Subject: [PATCH 03/13] Use BinaryExpression instead of Condition for comparisons --- lib/liquid.rb | 1 + lib/liquid/binary_expression.rb | 72 +++++++++++---- lib/liquid/condition.rb | 10 --- lib/liquid/expression.rb | 4 +- lib/liquid/method_literal.rb | 16 ++++ lib/liquid/tags/if.rb | 8 +- test/integration/tags/if_else_tag_test.rb | 22 ----- test/unit/binary_expression_test.rb | 101 ++++++++++++++++++++++ test/unit/condition_unit_test.rb | 2 +- test/unit/parser_unit_test.rb | 46 +++++----- 10 files changed, 200 insertions(+), 82 deletions(-) create mode 100644 lib/liquid/method_literal.rb create mode 100644 test/unit/binary_expression_test.rb diff --git a/lib/liquid.rb b/lib/liquid.rb index d069f6ca0..0ffd07a1d 100644 --- a/lib/liquid.rb +++ b/lib/liquid.rb @@ -62,6 +62,7 @@ module Liquid require 'liquid/tags' require "liquid/environment" require 'liquid/lexer' +require 'liquid/method_literal' require 'liquid/binary_expression' require 'liquid/parser' require 'liquid/i18n' diff --git a/lib/liquid/binary_expression.rb b/lib/liquid/binary_expression.rb index 944099f7f..00e3873d2 100644 --- a/lib/liquid/binary_expression.rb +++ b/lib/liquid/binary_expression.rb @@ -2,39 +2,41 @@ module Liquid class BinaryExpression - attr_reader :left, :operator, :right + attr_reader :operator + attr_accessor :left_node, :right_node def initialize(left, operator, right) - @left = left + @left_node = left @operator = operator - @right = right + @right_node = right end def evaluate(context) - left_value = value(left, context) - right_value = value(@right, context) + left = value(left_node, context) + right = value(right_node, context) case operator when '>' - left_value > right_value + left > right if can_compare?(left, right) when '>=' - left_value >= right_value + left >= right if can_compare?(left, right) when '<' - left_value < right_value + left < right if can_compare?(left, right) when '<=' - left_value <= right_value + left <= right if can_compare?(left, right) when '==' - left_value == right_value + equal_variables(left, right) when '!=', '<>' - left_value != right_value + !equal_variables(left, right) when 'contains' - if left_value && right_value && left_value.respond_to?(:include?) - right_value = right_value.to_s if left_value.is_a?(String) - left_value.include?(right_value) - else - false - end + contains(left, right) end + rescue ::ArgumentError => e + raise Liquid::ArgumentError, e.message + end + + def to_s + "(#{left_node} #{operator} #{right_node})" end private @@ -42,5 +44,41 @@ def evaluate(context) def value(expr, context) Utils.to_liquid_value(context.evaluate(expr)) end + + def can_compare?(left, right) + left.respond_to?(operator) && right.respond_to?(operator) && !left.is_a?(Hash) && !right.is_a?(Hash) + end + + def contains(left, right) + if left && right && left.respond_to?(:include?) + right = right.to_s if left.is_a?(String) + left.include?(right) + else + false + end + rescue Encoding::CompatibilityError + # "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal + left.b.include?(right.b) + end + + def apply_method_literal(node, other) + other.send(node.method_name) if other.respond_to?(node.method_name) + end + + def equal_variables(left, right) + return apply_method_literal(left, right) if left.is_a?(MethodLiteral) + return apply_method_literal(right, left) if right.is_a?(MethodLiteral) + + left == right + end + + class ParseTreeVisitor < Liquid::ParseTreeVisitor + def children + [ + @node.left_node, + @node.right_node, + ] + end + end end end diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index 8c4afc980..bc3927295 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -29,16 +29,6 @@ class Condition # :nodoc: left.b.include?(right.b) end, } - - class MethodLiteral - attr_reader :method_name, :to_s - - def initialize(method_name, to_s) - @method_name = method_name - @to_s = to_s - end - end - @@method_literals = { 'blank' => MethodLiteral.new(:blank?, '').freeze, 'empty' => MethodLiteral.new(:empty?, '').freeze, diff --git a/lib/liquid/expression.rb b/lib/liquid/expression.rb index 1bf7edcf6..4f9754b9f 100644 --- a/lib/liquid/expression.rb +++ b/lib/liquid/expression.rb @@ -9,8 +9,8 @@ class Expression '' => nil, 'true' => true, 'false' => false, - 'blank' => '', - 'empty' => '', + 'blank' => MethodLiteral.new(:blank?, '').freeze, + 'empty' => MethodLiteral.new(:empty?, '').freeze, }.freeze DOT = ".".ord diff --git a/lib/liquid/method_literal.rb b/lib/liquid/method_literal.rb new file mode 100644 index 000000000..812c7199f --- /dev/null +++ b/lib/liquid/method_literal.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Liquid + class MethodLiteral + attr_reader :method_name, :to_s + + def initialize(method_name, to_s) + @method_name = method_name + @to_s = to_s + end + + def to_liquid + to_s + end + end +end diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 3471f6778..b07a91c73 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -96,13 +96,7 @@ def parse_binary_comparisons(p) end def parse_comparison(p) - a = parse_expression(p) - if (op = p.consume?(:comparison) || p.consume?(:equality)) - b = parse_expression(p) - Condition.new(a, op, b) - else - Condition.new(a) - end + Condition.new(p.expression) end class ParseTreeVisitor < Liquid::ParseTreeVisitor diff --git a/test/integration/tags/if_else_tag_test.rb b/test/integration/tags/if_else_tag_test.rb index 3f04f2b8d..b37e4e411 100644 --- a/test/integration/tags/if_else_tag_test.rb +++ b/test/integration/tags/if_else_tag_test.rb @@ -147,28 +147,6 @@ def test_syntax_error_no_expression assert_raises(SyntaxError) { assert_template_result('', '{% if %}') } end - def test_if_with_custom_condition - original_op = Condition.operators['contains'] - Condition.operators['contains'] = :[] - - assert_template_result('yes', %({% if 'bob' contains 'o' %}yes{% endif %})) - assert_template_result('no', %({% if 'bob' contains 'f' %}yes{% else %}no{% endif %})) - ensure - Condition.operators['contains'] = original_op - end - - 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 %}), - ) - ensure - Condition.operators['contains'] = original_op - end - def test_operators_are_whitelisted assert_raises(SyntaxError) do assert_template_result('', %({% if 1 or throw or or 1 %}yes{% endif %})) diff --git a/test/unit/binary_expression_test.rb b/test/unit/binary_expression_test.rb new file mode 100644 index 000000000..19fe33d7f --- /dev/null +++ b/test/unit/binary_expression_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BinaryExpressionTest < Minitest::Test + include Liquid + + def test_simple_comparison_evaluation + assert_eval(false, BinaryExpression.new(5, ">", 5)) + assert_eval(true, BinaryExpression.new(5, ">=", 5)) + assert_eval(false, BinaryExpression.new(5, "<", 5)) + assert_eval(true, BinaryExpression.new(5, "<=", 5)) + assert_eval(true, BinaryExpression.new("abcd", "contains", "a")) + end + + def test_complex_evaluation + # 1 > 2 == 2 > 3 + assert_eval(true, BinaryExpression.new( + BinaryExpression.new(1, '>', 2), + '==', + BinaryExpression.new(2, '>', 3), + )) + + # 1 > 2 != 2 > 3 + assert_eval(false, BinaryExpression.new( + BinaryExpression.new(1, '>', 2), + '!=', + BinaryExpression.new(2, '>', 3), + )) + + # a > 0 == b.prop > 0 + assert_eval( + true, + BinaryExpression.new( + BinaryExpression.new(var('a'), '>', 0), + '==', + BinaryExpression.new(var('b.prop'), '>', 0), + ), + { 'a' => 1, 'b' => { 'prop' => 2 } }, + ) + end + + def test_method_literal_equality + empty = MethodLiteral.new(:empty?, '') + + # a == empty, empty == a + assert_eval(false, BinaryExpression.new("123", "==", empty)) + assert_eval(true, BinaryExpression.new("", "==", empty)) + assert_eval(false, BinaryExpression.new(empty, "==", "123")) + assert_eval(true, BinaryExpression.new(empty, "==", "")) + + # a does not have .empty? + assert_eval(nil, BinaryExpression.new(1, "==", empty)) + assert_eval(nil, BinaryExpression.new(true, "==", empty)) + assert_eval(nil, BinaryExpression.new(false, "==", empty)) + assert_eval(nil, BinaryExpression.new(nil, "==", empty)) + + # a != empty + assert_eval(true, BinaryExpression.new("123", "!=", empty)) + assert_eval(false, BinaryExpression.new("", "!=", empty)) + assert_eval(true, BinaryExpression.new(empty, "!=", "123")) + assert_eval(false, BinaryExpression.new(empty, "!=", "")) + + # a does not have .empty? + assert_eval(true, BinaryExpression.new(1, "!=", empty)) + assert_eval(true, BinaryExpression.new(true, "!=", empty)) + assert_eval(true, BinaryExpression.new(false, "!=", empty)) + assert_eval(true, BinaryExpression.new(nil, "!=", empty)) + end + + def test_method_literal_comparison + empty = MethodLiteral.new(:empty?, '') + + ['>', '>='].each do |op| + assert_eval(nil, BinaryExpression.new("123", op, empty)) + assert_eval(nil, BinaryExpression.new("", op, empty)) + assert_eval(nil, BinaryExpression.new(empty, op, "123")) + assert_eval(nil, BinaryExpression.new(empty, op, "")) + end + + # Interesting case, contains on strings does include?(right.to_s) + assert_eval(true, BinaryExpression.new("123", "contains", empty)) + assert_eval(true, BinaryExpression.new("", "contains", empty)) + end + + def assert_eval(expected, expr, assigns = {}) + actual = expr.evaluate(context(assigns)) + message = "Expected '#{expr}' to evaluate to '#{expected}'" + return assert_nil(actual, message) if expected.nil? + + assert_equal(expected, actual, message) + end + + def var(markup) + Parser.new(markup).variable_lookup + end + + def context(assigns = {}) + Context.build(outer_scope: assigns) + end +end diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index 9123b5099..fee8f7263 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -183,7 +183,7 @@ def test_parse_expression_returns_method_literal_for_blank_and_empty parser = parse_context.new_parser('blank') result = Condition.parse_expression(parser) - assert_instance_of(Condition::MethodLiteral, result) + assert_instance_of(MethodLiteral, result) end private diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index a0c86c081..49ef252ad 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -82,24 +82,24 @@ def test_equality expr = p.expression assert(expr.is_a?(BinaryExpression)) assert_equal('==', expr.operator) - assert_equal('a', expr.left.name) - assert_equal('b', expr.right.name) + assert_equal('a', expr.left_node.name) + assert_equal('b', expr.right_node.name) # BinaryExpression(==) - # left: BinaryExpression(<) - # left: 0 - # right: 5 - # right: BinaryExpression(>) - # left: 6 - # right: 1 + # left_node: BinaryExpression(<) + # left_node: 0 + # right_node: 5 + # right_node: BinaryExpression(>) + # left_node: 6 + # right_node: 1 p = new_parser("0 < 5 == 6 > 1") expr = p.expression assert(expr.is_a?(BinaryExpression)) assert_equal('==', expr.operator) - assert_equal(0, expr.left.left) - assert_equal(5, expr.left.right) - assert_equal(6, expr.right.left) - assert_equal(1, expr.right.right) + assert_equal(0, expr.left_node.left_node) + assert_equal(5, expr.left_node.right_node) + assert_equal(6, expr.right_node.left_node) + assert_equal(1, expr.right_node.right_node) end def test_comparison @@ -107,23 +107,23 @@ def test_comparison expr = p.expression assert(expr.is_a?(BinaryExpression)) assert_equal('>', expr.operator) - assert(expr.left.is_a?(VariableLookup)) - assert_equal('a', expr.left.name) - assert(expr.right.is_a?(VariableLookup)) - assert_equal('b', expr.right.name) + assert(expr.left_node.is_a?(VariableLookup)) + assert_equal('a', expr.left_node.name) + assert(expr.right_node.is_a?(VariableLookup)) + assert_equal('b', expr.right_node.name) # BinaryExpression(>=) - # left: BinaryExpression(>) - # left: 10 - # right: 5 - # right: 4 + # left_node: BinaryExpression(>) + # left_node: 10 + # right_node: 5 + # right_node: 4 p = new_parser("10 > 5 >= 4") expr = p.expression assert(expr.is_a?(BinaryExpression)) assert_equal('>=', expr.operator) - assert_equal(10, expr.left.left) - assert_equal(5, expr.left.right) - assert_equal(4, expr.right) + assert_equal(10, expr.left_node.left_node) + assert_equal(5, expr.left_node.right_node) + assert_equal(4, expr.right_node) end def test_number From f49de89881600cd112dc173c6ca757a54298c5e7 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Wed, 3 Dec 2025 16:05:49 -0500 Subject: [PATCH 04/13] Add boolean expressions as variable unit tests --- test/integration/variable_test.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/integration/variable_test.rb b/test/integration/variable_test.rb index 38096e41d..b81a2106b 100644 --- a/test/integration/variable_test.rb +++ b/test/integration/variable_test.rb @@ -11,6 +11,24 @@ def test_simple_variable assert_template_result('worked wonderfully', "{{test}}", { 'test' => 'worked wonderfully' }) end + def test_equality + assert_template_result('true', "{{ 5 == 5 }}") + assert_template_result('false', "{{ 5 == 3 }}") + end + + def test_comparison + assert_template_result('true', "{{ 5 > 3 }}") + assert_template_result('false', "{{ 5 < 3 }}") + end + + def test_expression_piped_into_filter + assert_template_result('TRUE', "{{ 5 == 5 | upcase }}") + end + + def test_expression_used_as_filter_argument + assert_template_result('A: TRUE', "{{ 'a: $a' | replace: '$a', 5 == 5 | upcase }}") + end + def test_variable_render_calls_to_liquid assert_template_result('foobar', '{{ foo }}', { 'foo' => ThingWithToLiquid.new }) end From 585270e007f63f20449fe5ae9e2d87d4e851e5f1 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 14:52:41 -0500 Subject: [PATCH 05/13] Make Condition unit tests go through BinaryExpression Instead of having Condition parse the left op right, make BinaryExpression do it. Make sure all the tests pass as they used to in the process. --- lib/liquid/binary_expression.rb | 2 ++ test/unit/condition_unit_test.rb | 15 +++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/liquid/binary_expression.rb b/lib/liquid/binary_expression.rb index 00e3873d2..8ac743f87 100644 --- a/lib/liquid/binary_expression.rb +++ b/lib/liquid/binary_expression.rb @@ -30,6 +30,8 @@ def evaluate(context) !equal_variables(left, right) when 'contains' contains(left, right) + else + raise(Liquid::ArgumentError, "Unknown operator #{operator}") end rescue ::ArgumentError => e raise Liquid::ArgumentError, e.message diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index fee8f7263..09f654a69 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -170,18 +170,18 @@ def test_parse_expression environment = Environment.build parse_context = ParseContext.new(environment: environment) parser = parse_context.new_parser('product.title') - result = Condition.parse_expression(parser) + result = parser.expression assert_instance_of(VariableLookup, result) assert_equal('product', result.name) assert_equal(['title'], result.lookups) end - def test_parse_expression_returns_method_literal_for_blank_and_empty + def test_parser_expression_returns_method_literal_for_blank_and_empty environment = Environment.build parse_context = ParseContext.new(environment: environment) parser = parse_context.new_parser('blank') - result = Condition.parse_expression(parser) + result = parser.expression assert_instance_of(MethodLiteral, result) end @@ -189,22 +189,25 @@ def test_parse_expression_returns_method_literal_for_blank_and_empty private def assert_evaluates_true(left, op, right) + expr = BinaryExpression.new(left, op, right) assert( - Condition.new(left, op, right).evaluate(@context), + Condition.new(expr).evaluate(@context), "Evaluated false: #{left.inspect} #{op} #{right.inspect}", ) end def assert_evaluates_false(left, op, right) + expr = BinaryExpression.new(left, op, right) assert( - !Condition.new(left, op, right).evaluate(@context), + !Condition.new(expr).evaluate(@context), "Evaluated true: #{left.inspect} #{op} #{right.inspect}", ) end def assert_evaluates_argument_error(left, op, right) assert_raises(Liquid::ArgumentError) do - Condition.new(left, op, right).evaluate(@context) + expr = BinaryExpression.new(left, op, right) + Condition.new(expr).evaluate(@context) end end end # ConditionTest From c6e297d9dca1492e411b174a0b59685f03f0659d Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 14:53:37 -0500 Subject: [PATCH 06/13] Remove Condition.operators feature that let you define operators It was incompatible with the lexer anyway. Not used internally either. --- test/unit/condition_unit_test.rb | 9 --------- 1 file changed, 9 deletions(-) diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index 09f654a69..a706c7396 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -136,15 +136,6 @@ def test_and_condition assert_equal(false, condition.evaluate(Context.new)) end - def test_should_allow_custom_proc_operator - Condition.operators['starts_with'] = proc { |_cond, left, right| left =~ /^#{right}/ } - - assert_evaluates_true('bob', 'starts_with', 'b') - assert_evaluates_false('bob', 'starts_with', 'o') - ensure - Condition.operators.delete('starts_with') - end - def test_left_or_right_may_contain_operators @context = Liquid::Context.new @context['one'] = @context['another'] = "gnomeslab-and-or-liquid" From 1130dc7138a6b73e4802eb3721d9e2956eff6f30 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 15:12:25 -0500 Subject: [PATCH 07/13] Remove Condition.op, Condition.right - Remove comparison expression logic --- lib/liquid/condition.rb | 89 ++------------------------------ lib/liquid/tags/case.rb | 4 +- lib/liquid/tags/if.rb | 4 -- test/unit/condition_unit_test.rb | 47 +++++++++-------- 4 files changed, 33 insertions(+), 111 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index bc3927295..dbac9e29f 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -5,51 +5,15 @@ module Liquid # # Example: # - # c = Condition.new(1, '==', 1) + # c = Condition.new(expr) # c.evaluate #=> true # class Condition # :nodoc: - @@operators = { - '==' => ->(cond, left, right) { cond.send(:equal_variables, left, right) }, - '!=' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) }, - '<>' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) }, - '<' => :<, - '>' => :>, - '>=' => :>=, - '<=' => :<=, - 'contains' => lambda do |_cond, left, right| - if left && right && left.respond_to?(:include?) - right = right.to_s if left.is_a?(String) - left.include?(right) - else - false - end - rescue Encoding::CompatibilityError - # "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal - left.b.include?(right.b) - end, - } - @@method_literals = { - 'blank' => MethodLiteral.new(:blank?, '').freeze, - 'empty' => MethodLiteral.new(:empty?, '').freeze, - } - - def self.operators - @@operators - end - - def self.parse_expression(parser) - markup = parser.expression_string - @@method_literals[markup] || parser.unsafe_parse_expression(markup) - end - attr_reader :attachment, :child_condition - attr_accessor :left, :operator, :right + attr_accessor :left - def initialize(left = nil, operator = nil, right = nil) - @left = left - @operator = operator - @right = right + def initialize(left = nil) + @left = left @child_relation = nil @child_condition = nil @@ -59,7 +23,7 @@ def evaluate(context = deprecated_default_context) condition = self result = nil loop do - result = interpret_condition(condition.left, condition.right, condition.operator, context) + result = context.evaluate(condition.left) case condition.child_relation when :or @@ -102,48 +66,6 @@ def inspect private - 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 - end - - if right.is_a?(MethodLiteral) - if left.respond_to?(right.method_name) - return left.send(right.method_name) - else - return nil - end - end - - left == right - 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 - # return this as the result. - return context.evaluate(left) if op.nil? - - left = Liquid::Utils.to_liquid_value(context.evaluate(left)) - right = Liquid::Utils.to_liquid_value(context.evaluate(right)) - - operation = self.class.operators[op] || raise(Liquid::ArgumentError, "Unknown operator #{op}") - - if operation.respond_to?(:call) - operation.call(self, left, right) - elsif left.respond_to?(operation) && right.respond_to?(operation) && !left.is_a?(Hash) && !right.is_a?(Hash) - begin - left.send(operation, right) - rescue ::ArgumentError => e - raise Liquid::ArgumentError, e.message - end - end - 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.") @@ -154,7 +76,6 @@ class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ @node.left, - @node.right, @node.child_condition, @node.attachment ].compact diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 0f23ead1d..225c40922 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -99,8 +99,8 @@ def parse_when(markup, body) parser = @parse_context.new_parser(markup) loop do - expr = parser.expression - block = Condition.new(@left, '==', expr) + expr = BinaryExpression.new(@left, '==', parser.expression) + block = Condition.new(expr) block.attach(body) @blocks << block diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index b07a91c73..97d8ba61c 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -73,10 +73,6 @@ def push_block(tag, markup) block.attach(new_body) end - def parse_expression(parser) - Condition.parse_expression(parser) - end - def parse_markup(markup) p = @parse_context.new_parser(markup) condition = parse_binary_comparisons(p) diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index a706c7396..3416c8730 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -9,11 +9,6 @@ def setup @context = Liquid::Context.new end - def test_basic_condition - assert_equal(false, Condition.new(1, '==', 2).evaluate(Context.new)) - assert_equal(true, Condition.new(1, '==', 1).evaluate(Context.new)) - end - def test_default_operators_evalute_true assert_evaluates_true(1, '==', 1) assert_evaluates_true(1, '!=', 2) @@ -72,11 +67,11 @@ def test_comparation_of_int_and_str end def test_hash_compare_backwards_compatibility - assert_nil(Condition.new({}, '>', 2).evaluate(Context.new)) - assert_nil(Condition.new(2, '>', {}).evaluate(Context.new)) - assert_equal(false, Condition.new({}, '==', 2).evaluate(Context.new)) - assert_equal(true, Condition.new({ 'a' => 1 }, '==', 'a' => 1).evaluate(Context.new)) - assert_equal(true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate(Context.new)) + assert_evaluates_nil({}, '>', 2) + assert_evaluates_nil(2, '>', {}) + assert_evaluates_false({}, '==', 2) + assert_evaluates_true({ 'a' => 1 }, '==', 'a' => 1) + assert_evaluates_true({ 'a' => 2 }, 'contains', 'a') end def test_contains_works_on_arrays @@ -110,29 +105,30 @@ def test_contains_with_string_left_operand_coerces_right_operand_to_string end def test_or_condition - condition = Condition.new(1, '==', 2) - assert_equal(false, condition.evaluate(Context.new)) - - condition.or(Condition.new(2, '==', 1)) + false_expr = Parser.new('1 == 2').expression + true_expr = Parser.new('1 == 1').expression + condition = Condition.new(false_expr) assert_equal(false, condition.evaluate(Context.new)) - condition.or(Condition.new(1, '==', 1)) + condition.or(Condition.new(false_expr)) + assert_equal(false, condition.evaluate(Context.new)) + condition.or(Condition.new(true_expr)) assert_equal(true, condition.evaluate(Context.new)) end def test_and_condition - condition = Condition.new(1, '==', 1) + false_expr = Parser.new('1 == 2').expression + true_expr = Parser.new('1 == 1').expression + condition = Condition.new(true_expr) assert_equal(true, condition.evaluate(Context.new)) - condition.and(Condition.new(2, '==', 2)) - + condition.and(Condition.new(true_expr)) assert_equal(true, condition.evaluate(Context.new)) - condition.and(Condition.new(2, '==', 1)) - + condition.and(Condition.new(false_expr)) assert_equal(false, condition.evaluate(Context.new)) end @@ -149,7 +145,8 @@ def test_default_context_is_deprecated end _out, err = capture_io do - assert_equal(true, Condition.new(1, '==', 1).evaluate) + expr = Parser.new('1 == 1').expression + assert_equal(true, Condition.new(expr).evaluate) end expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated" \ @@ -179,6 +176,14 @@ def test_parser_expression_returns_method_literal_for_blank_and_empty private + def assert_evaluates_nil(left, op, right) + expr = BinaryExpression.new(left, op, right) + assert_nil( + Condition.new(expr).evaluate(@context), + "Evaluated not nil: #{left.inspect} #{op} #{right.inspect}", + ) + end + def assert_evaluates_true(left, op, right) expr = BinaryExpression.new(left, op, right) assert( From 7275dfe79655f84b489887e11332432f959d88d1 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 16:26:51 -0500 Subject: [PATCH 08/13] Add support for logical expressions --- lib/liquid/binary_expression.rb | 10 ++++++- lib/liquid/parser.rb | 22 ++++++++++++-- lib/liquid/tags/case.rb | 2 +- test/unit/binary_expression_test.rb | 45 +++++++++++++++++++++++++++++ test/unit/parser_unit_test.rb | 29 +++++++++++++++++++ 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/lib/liquid/binary_expression.rb b/lib/liquid/binary_expression.rb index 8ac743f87..71d1c4064 100644 --- a/lib/liquid/binary_expression.rb +++ b/lib/liquid/binary_expression.rb @@ -13,6 +13,14 @@ def initialize(left, operator, right) def evaluate(context) left = value(left_node, context) + + # logical relation short circuiting + if operator == 'and' + return left && value(right_node, context) + elsif operator == 'or' + return left || value(right_node, context) + end + right = value(right_node, context) case operator @@ -38,7 +46,7 @@ def evaluate(context) end def to_s - "(#{left_node} #{operator} #{right_node})" + "(#{left_node.inspect} #{operator} #{right_node.inspect})" end private diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 8d5151e7d..f715e6ed4 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -47,12 +47,30 @@ def look(type, ahead = 0) tok[0] == type end - # expression := equality + # expression := logical + # logical := equality (("and" | "or") equality)* # equality := comparison (("==" | "!=" | "<>") comparison)* # comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)* # primary := string | number | variable_lookup | range | boolean def expression - equality + logical + end + + # Logical relations in Liquid, unlike other languages, are right-to-left + # associative. This creates a right-leaning tree and is why the method + # looks a bit more complicated + # + # `a == b and b or c` is evaluated like (a and (b or c)) + def logical + expr = equality + while (operator = id?('and') || id?('or')) + if expr.is_a?(BinaryExpression) && (expr.operator == 'and' || expr.operator == 'or') + expr.right_node = BinaryExpression.new(expr.right_node, operator, equality) + else + expr = BinaryExpression.new(expr, operator, equality) + end + end + expr end def equality diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 225c40922..9288af3ad 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -99,7 +99,7 @@ def parse_when(markup, body) parser = @parse_context.new_parser(markup) loop do - expr = BinaryExpression.new(@left, '==', parser.expression) + expr = BinaryExpression.new(@left, '==', parser.equality) block = Condition.new(expr) block.attach(body) @blocks << block diff --git a/test/unit/binary_expression_test.rb b/test/unit/binary_expression_test.rb index 19fe33d7f..6b5bb2367 100644 --- a/test/unit/binary_expression_test.rb +++ b/test/unit/binary_expression_test.rb @@ -2,6 +2,25 @@ require 'test_helper' +class ExecutionSpy + attr_reader :called + attr_accessor :value + + def initialize(value) + @called = false + @value = value + end + + def to_liquid_value + @called = true + @value + end + + def reset + @called = false + end +end + class BinaryExpressionTest < Minitest::Test include Liquid @@ -13,6 +32,32 @@ def test_simple_comparison_evaluation assert_eval(true, BinaryExpression.new("abcd", "contains", "a")) end + def test_logical_expression_short_circuiting + spy = ExecutionSpy.new(true) + + # false or spy should try spy + assert_eval(true, BinaryExpression.new(false, 'or', spy)) + assert_equal(true, spy.called) + + spy.reset + + # true or spy should not call spy + assert_eval(true, BinaryExpression.new(true, 'or', spy)) + assert_equal(false, spy.called) + + spy.reset + + # true and spy should try spy + assert_eval(true, BinaryExpression.new(true, 'and', spy)) + assert_equal(true, spy.called) + + spy.reset + + # false and spy should not try spy + assert_eval(false, BinaryExpression.new(false, 'and', spy)) + assert_equal(false, spy.called) + end + def test_complex_evaluation # 1 > 2 == 2 > 3 assert_eval(true, BinaryExpression.new( diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index 49ef252ad..20e8692cf 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -77,6 +77,35 @@ def test_expression assert_equal((0..5), p.expression) end + def test_logical + p = new_parser("a and b") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('and', expr.operator) + assert_equal('a', expr.left_node.name) + assert_equal('b', expr.right_node.name) + + p = new_parser("a and b or c") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('and', expr.operator) + assert_equal('a', expr.left_node.name) + assert_equal('or', expr.right_node.operator) + assert_equal('b', expr.right_node.left_node.name) + assert_equal('c', expr.right_node.right_node.name) + + p = new_parser("a == b and c or d") + expr = p.expression + assert(expr.is_a?(BinaryExpression)) + assert_equal('and', expr.operator) + assert_equal('==', expr.left_node.operator) + assert_equal('a', expr.left_node.left_node.name) + assert_equal('b', expr.left_node.right_node.name) + assert_equal('or', expr.right_node.operator) + assert_equal('c', expr.right_node.left_node.name) + assert_equal('d', expr.right_node.right_node.name) + end + def test_equality p = new_parser("a == b") expr = p.expression From 0613db4f4ee016a93595c783926133d1627d0119 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 16:33:52 -0500 Subject: [PATCH 09/13] Remove Condition#{child_relation,and,or} - Remove Condition#child_relation - Remove Condition#and - Remove Condition#or - Simplify Condition#evaluate This logic was moved to the Parser & BinaryExpression --- lib/liquid/condition.rb | 33 ++------------------------------ lib/liquid/tags/if.rb | 17 +--------------- test/unit/condition_unit_test.rb | 32 ++++++++++++++++++++----------- 3 files changed, 24 insertions(+), 58 deletions(-) diff --git a/lib/liquid/condition.rb b/lib/liquid/condition.rb index dbac9e29f..01acbff91 100644 --- a/lib/liquid/condition.rb +++ b/lib/liquid/condition.rb @@ -9,43 +9,15 @@ module Liquid # c.evaluate #=> true # class Condition # :nodoc: - attr_reader :attachment, :child_condition + attr_reader :attachment attr_accessor :left def initialize(left = nil) @left = left - - @child_relation = nil - @child_condition = nil end def evaluate(context = deprecated_default_context) - condition = self - result = nil - loop do - result = context.evaluate(condition.left) - - case condition.child_relation - when :or - break if Liquid::Utils.to_liquid_value(result) - when :and - break unless Liquid::Utils.to_liquid_value(result) - else - break - end - condition = condition.child_condition - end - result - end - - def or(condition) - @child_relation = :or - @child_condition = condition - end - - def and(condition) - @child_relation = :and - @child_condition = condition + context.evaluate(left) end def attach(attachment) @@ -76,7 +48,6 @@ class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ @node.left, - @node.child_condition, @node.attachment ].compact end diff --git a/lib/liquid/tags/if.rb b/lib/liquid/tags/if.rb index 97d8ba61c..559dce08c 100644 --- a/lib/liquid/tags/if.rb +++ b/lib/liquid/tags/if.rb @@ -75,26 +75,11 @@ def push_block(tag, markup) def parse_markup(markup) p = @parse_context.new_parser(markup) - condition = parse_binary_comparisons(p) + condition = Condition.new(p.expression) p.consume(:end_of_string) condition end - def parse_binary_comparisons(p) - condition = parse_comparison(p) - first_condition = condition - while (op = p.id?('and') || p.id?('or')) - child_condition = parse_comparison(p) - condition.send(op, child_condition) - condition = child_condition - end - first_condition - end - - def parse_comparison(p) - Condition.new(p.expression) - end - class ParseTreeVisitor < Liquid::ParseTreeVisitor def children @node.blocks diff --git a/test/unit/condition_unit_test.rb b/test/unit/condition_unit_test.rb index 3416c8730..128d01469 100644 --- a/test/unit/condition_unit_test.rb +++ b/test/unit/condition_unit_test.rb @@ -105,31 +105,37 @@ def test_contains_with_string_left_operand_coerces_right_operand_to_string end def test_or_condition - false_expr = Parser.new('1 == 2').expression - true_expr = Parser.new('1 == 1').expression + false_expr = '1 == 2' + true_expr = '1 == 1' - condition = Condition.new(false_expr) + condition = Condition.new(expression(false_expr)) assert_equal(false, condition.evaluate(Context.new)) - condition.or(Condition.new(false_expr)) + condition = Condition.new(expression("#{false_expr} or #{false_expr}")) assert_equal(false, condition.evaluate(Context.new)) - condition.or(Condition.new(true_expr)) + condition = Condition.new(expression("#{false_expr} or #{true_expr}")) + assert_equal(true, condition.evaluate(Context.new)) + + condition = Condition.new(expression("#{true_expr} or #{false_expr}")) assert_equal(true, condition.evaluate(Context.new)) end def test_and_condition - false_expr = Parser.new('1 == 2').expression - true_expr = Parser.new('1 == 1').expression + false_expr = '1 == 2' + true_expr = '1 == 1' - condition = Condition.new(true_expr) + condition = Condition.new(expression(true_expr)) assert_equal(true, condition.evaluate(Context.new)) - condition.and(Condition.new(true_expr)) - assert_equal(true, condition.evaluate(Context.new)) + condition = Condition.new(expression("#{true_expr} and #{false_expr}")) + assert_equal(false, condition.evaluate(Context.new)) - condition.and(Condition.new(false_expr)) + condition = Condition.new(expression("#{false_expr} and #{true_expr}")) assert_equal(false, condition.evaluate(Context.new)) + + condition = Condition.new(expression("#{true_expr} and #{true_expr}")) + assert_equal(true, condition.evaluate(Context.new)) end def test_left_or_right_may_contain_operators @@ -206,4 +212,8 @@ def assert_evaluates_argument_error(left, op, right) Condition.new(expr).evaluate(@context) end end + + def expression(markup) + Parser.new(markup).expression + end end # ConditionTest From 34bba2b5ff33a254cc8cd8b63d87c92a5e6f5ccf Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 19:58:48 -0500 Subject: [PATCH 10/13] Prepare logical for grouping --- lib/liquid/lexer.rb | 22 ++++++++++++++-------- lib/liquid/parser.rb | 10 +++------- lib/liquid/tags/case.rb | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/lib/liquid/lexer.rb b/lib/liquid/lexer.rb index c0a6463bb..f6079f31a 100644 --- a/lib/liquid/lexer.rb +++ b/lib/liquid/lexer.rb @@ -6,22 +6,24 @@ class Lexer CLOSE_SQUARE = [:close_square, "]"].freeze COLON = [:colon, ":"].freeze COMMA = [:comma, ","].freeze - COMPARISION_NOT_EQUAL = [:equality, "!="].freeze COMPARISON_CONTAINS = [:comparison, "contains"].freeze - COMPARISON_EQUAL = [:equality, "=="].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 = [:equality, "<>"].freeze + EQUALITY_EQUAL_EQUAL = [:equality, "=="].freeze + EQUALITY_NOT_EQUAL = [:equality, "!="].freeze + EQUALITY_NOT_EQUAL_ALT = [:equality, "<>"].freeze 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+)?/ + IDENTIFIER = /[a-zA-Z_][\w-]*\??/ + LOGICAL_AND = [:logical, 'and'].freeze + LOGICAL_OR = [:logical, 'or'].freeze + NUMBER_LITERAL = /-?\d+(\.\d+)?/ OPEN_ROUND = [:open_round, "("].freeze OPEN_SQUARE = [:open_square, "["].freeze PIPE = [:pipe, "|"].freeze @@ -38,11 +40,11 @@ class Lexer TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table| table["=".ord] = [].tap do |sub_table| - sub_table["=".ord] = COMPARISON_EQUAL + sub_table["=".ord] = EQUALITY_EQUAL_EQUAL sub_table.freeze end table["!".ord] = [].tap do |sub_table| - sub_table["=".ord] = COMPARISION_NOT_EQUAL + sub_table["=".ord] = EQUALITY_NOT_EQUAL sub_table.freeze end table.freeze @@ -51,7 +53,7 @@ class Lexer 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 + sub_table[">".ord] = EQUALITY_NOT_EQUAL_ALT sub_table.freeze end table[">".ord] = [].tap do |sub_table| @@ -151,6 +153,10 @@ def tokenize(ss) # Special case for "contains" output << if type == :id && t == "contains" && output.last&.first != :dot COMPARISON_CONTAINS + elsif type == :id && t == "and" && output.last&.first != :dot + LOGICAL_AND + elsif type == :id && t == "or" && output.last&.first != :dot + LOGICAL_OR else [type, t] end diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index f715e6ed4..15a11bc30 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -62,14 +62,10 @@ def expression # # `a == b and b or c` is evaluated like (a and (b or c)) def logical + operator = nil expr = equality - while (operator = id?('and') || id?('or')) - if expr.is_a?(BinaryExpression) && (expr.operator == 'and' || expr.operator == 'or') - expr.right_node = BinaryExpression.new(expr.right_node, operator, equality) - else - expr = BinaryExpression.new(expr, operator, equality) - end - end + expr = BinaryExpression.new(expr, operator, equality) if (operator = consume?(:logical)) + expr.right_node = BinaryExpression.new(expr.right_node, operator, equality) while (operator = consume?(:logical)) expr end diff --git a/lib/liquid/tags/case.rb b/lib/liquid/tags/case.rb index 9288af3ad..4f4731ccf 100644 --- a/lib/liquid/tags/case.rb +++ b/lib/liquid/tags/case.rb @@ -104,7 +104,7 @@ def parse_when(markup, body) block.attach(body) @blocks << block - break unless parser.id?('or') || parser.consume?(:comma) + break unless parser.consume?(:logical) == 'or' || parser.consume?(:comma) end parser.consume(:end_of_string) From 3895bcae0ccb63df450be86e0f5d8a3dc303bb5d Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Fri, 5 Dec 2025 09:35:08 -0500 Subject: [PATCH 11/13] Add support for parenthesized expressions --- lib/liquid/parser.rb | 15 ++++++++++- test/integration/assign_test.rb | 15 ++++++++++- test/integration/parsing_quirks_test.rb | 9 +++---- test/unit/parser_unit_test.rb | 34 ++++++++++++++++++++----- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index 15a11bc30..e01117a29 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -100,7 +100,7 @@ def primary when :number number when :open_round - range_lookup + grouping_or_range_lookup else raise SyntaxError, "#{token} is not a valid expression" end @@ -131,6 +131,19 @@ def unnamed_variable_lookup VariableLookup.new(name, lookups, command_flags) end + # Parenthesized expressions are recursive + def grouping_or_range_lookup + consume(:open_round) + expr = expression + if consume?(:dotdot) + RangeLookup.create(expr, expression) + else + expr + end + ensure + consume(:close_round) + end + def range_lookup consume(:open_round) first = expression diff --git a/test/integration/assign_test.rb b/test/integration/assign_test.rb index 69163ab96..b3a04252e 100644 --- a/test/integration/assign_test.rb +++ b/test/integration/assign_test.rb @@ -35,13 +35,26 @@ def test_assign_with_filter ) end + def test_assign_boolean_expression_assignment + assert_template_result( + 'it rendered', + <<~LIQUID, + {%- assign should_render = a == 0 or (b == 1 and c == 2) -%} + {%- if should_render -%} + it rendered + {%- endif -%} + LIQUID + { 'b' => 1, 'c' => 2 }, + ) + end + def test_assign_syntax_error assert_match_syntax_error(/assign/, '{% assign foo not values %}.') end def test_assign_throws_on_unsupported_syntax assert_match_syntax_error( - "Expected dotdot but found pipe", + "Expected close_round but found pipe", "{% assign foo = ('X' | downcase) %}", ) end diff --git a/test/integration/parsing_quirks_test.rb b/test/integration/parsing_quirks_test.rb index 75954f93f..7927444ef 100644 --- a/test/integration/parsing_quirks_test.rb +++ b/test/integration/parsing_quirks_test.rb @@ -35,11 +35,10 @@ def test_error_on_empty_filter assert_raises(Liquid::SyntaxError) { Template.parse("{{test |a|b|}}") } end - def test_meaningless_parens_error - assert_raises(SyntaxError) do - markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" - Template.parse("{% if #{markup} %} YES {% endif %}") - end + def test_supported_parens + markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" + out = Template.parse("{% if #{markup} %} YES {% endif %}").render({ 'b' => 'bar', 'c' => 'baz' }) + assert_equal(' YES ', out) end def test_unexpected_characters_syntax_error diff --git a/test/unit/parser_unit_test.rb b/test/unit/parser_unit_test.rb index 20e8692cf..bc2b5ea55 100644 --- a/test/unit/parser_unit_test.rb +++ b/test/unit/parser_unit_test.rb @@ -98,12 +98,12 @@ def test_logical expr = p.expression assert(expr.is_a?(BinaryExpression)) assert_equal('and', expr.operator) - assert_equal('==', expr.left_node.operator) - assert_equal('a', expr.left_node.left_node.name) - assert_equal('b', expr.left_node.right_node.name) - assert_equal('or', expr.right_node.operator) - assert_equal('c', expr.right_node.left_node.name) - assert_equal('d', expr.right_node.right_node.name) + assert_equal('==', expr.left_node.operator) + assert_equal('a', expr.left_node.left_node.name) + assert_equal('b', expr.left_node.right_node.name) + assert_equal('or', expr.right_node.operator) + assert_equal('c', expr.right_node.left_node.name) + assert_equal('d', expr.right_node.right_node.name) end def test_equality @@ -197,6 +197,28 @@ def test_ranges assert_equal('(hi[5].wat..old)', p.expression_string) end + def test_groupings_aka_parenthesized_expressions + # without the parens, this would be evaled as a and (b or c) + p = new_parser("(a and b) or c") + expr = p.expression + assert_equal('or', expr.operator) + assert_equal('and', expr.left_node.operator) + assert_equal('a', expr.left_node.left_node.name) + assert_equal('b', expr.left_node.right_node.name) + assert_equal('c', expr.right_node.name) + end + + def test_groupings_can_be_used_to_hijack_operation_priority + # without parens would be parsed as `a and (b == c)` + p = new_parser("(a and b) == c") + expr = p.expression + assert_equal('==', expr.operator) + assert_equal('and', expr.left_node.operator) + assert_equal('a', expr.left_node.left_node.name) + assert_equal('b', expr.left_node.right_node.name) + assert_equal('c', expr.right_node.name) + end + def test_argument_string p = new_parser("filter: hi.there[5], keyarg: 7") assert_equal('filter', p.consume(:id)) From fd3bd8176bd79dba744867d38518556edf695cc6 Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Thu, 4 Dec 2025 15:15:50 -0500 Subject: [PATCH 12/13] Update History.md --- History.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/History.md b/History.md index 4337aeb98..8ebe43962 100644 --- a/History.md +++ b/History.md @@ -3,21 +3,22 @@ ## 6.0.0 ### Features -* (TODO) Add support for boolean expressions everywhere +* Add support for boolean expressions everywhere * As variable output `{{ a or b }}` * As filter argument `{{ collection | where: 'prop', a or b }}` * As tag argument `{% render 'snip', enabled: a or b %}` * As conditional tag argument `{% if cond %}` (extending previous behaviour) -* (TODO) Add support for subexpression prioritization and associativity +* Add support for subexpression prioritization and associativity * In ascending order of priority: * Logical: `and`, `or` (right to left) * Equality: `==`, `!=`, `<>` (left to right) * Comparison: `>`, `>=`, `<`, `<=`, `contains` (left to right) + * Groupings: `( expr )` - For example, this is now supported * `{{ a > b == c < d or e == f }}` which is equivalent to * `{{ ((a > b) == (c < d)) or (e == f) }}` -- (TODO) Add support for parenthesized expressions - * e.g. `(a or b) and c` +- Add support for parenthesized expressions + * e.g. `(a or b) == c` ### Architectural changes * `parse_expression` and `safe_parse_expression` have been removed from `Tag` and `ParseContext` @@ -32,10 +33,17 @@ * `:lax` and `lax_parse` is no longer supported * `:strict` and `strict_parse` is no longer supported * `strict2_parse` is renamed to `parse_markup` -* The `warnings` system has been removed. -* `Parser#expression` is renamed to `Parser#expression_string` -* `safe_parse_expression` methods are replaced by `Parser#expression` -* `parse_expression` methods are replaced by `Parser#unsafe_parse_expression` +* Expressions + * The `warnings` system has been removed. + * `Parser#expression` is renamed to `Parser#expression_string` + * `safe_parse_expression` methods are replaced by `Parser#expression` + * `parse_expression` methods are replaced by `Parser#unsafe_parse_expression` +* `Condition` + * `new(expr)` no longer accepts an `op` or `right`. Logic moved to BinaryExpression. + * `Condition#or` and `Condition#and` were replaced by `BinaryExpression`. + * `Condition#child_relation` replaced by `BinaryExpression`. + * `Condition.operations` was removed. + * `Condtion::MethodLiteral` was moved to the `Liquid` namespace ### Migrating from `^5.11.0` - In custom tags that include `ParserSwitching`, rename `strict2_parse` to `parse_markup` From a33443caaf6b864baa546392b0e49649a224e6fc Mon Sep 17 00:00:00 2001 From: "Charles-P. Clermont" Date: Fri, 5 Dec 2025 09:52:47 -0500 Subject: [PATCH 13/13] Annotate Parser with grammar rules --- lib/liquid/parser.rb | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/liquid/parser.rb b/lib/liquid/parser.rb index e01117a29..df4d6a277 100644 --- a/lib/liquid/parser.rb +++ b/lib/liquid/parser.rb @@ -13,6 +13,8 @@ def jump(point) @p = point end + # Consumes a token of specific type. + # Throws SyntaxError if token doesn't match type expectation. def consume(type = nil) token = @tokens[@p] if type && token[0] != type @@ -41,6 +43,7 @@ def id?(str) token[1] end + # Peeks the ahead token, returning true if matching expectation def look(type, ahead = 0) tok = @tokens[@p + ahead] return false unless tok @@ -51,7 +54,7 @@ def look(type, ahead = 0) # logical := equality (("and" | "or") equality)* # equality := comparison (("==" | "!=" | "<>") comparison)* # comparison := primary ((">=" | ">" | "<" | "<=" | ... ) primary)* - # primary := string | number | variable_lookup | range | boolean + # primary := string | number | variable_lookup | range | boolean | grouping def expression logical end @@ -60,7 +63,8 @@ def expression # associative. This creates a right-leaning tree and is why the method # looks a bit more complicated # - # `a == b and b or c` is evaluated like (a and (b or c)) + # `a and b or c` is evaluated like (a and (b or c)) + # logical := equality (("and" | "or") equality)* def logical operator = nil expr = equality @@ -69,6 +73,7 @@ def logical expr end + # equality := comparison (("==" | "!=" | "<>") comparison)* def equality expr = comparison while look(:equality) @@ -88,11 +93,12 @@ def comparison expr end + # primary := string | number | variable_lookup | range | boolean | grouping def primary token = @tokens[@p] case token[0] when :id - variable_lookup + variable_lookup_or_literal when :open_square unnamed_variable_lookup when :string @@ -115,7 +121,18 @@ def string consume(:string)[1..-2] end + # variable_lookup := id (lookup)* + # lookup := indexed_lookup | dot_lookup + # indexed_lookup := "[" expression "]" + # dot_lookup := "." id def variable_lookup + name = consume(:id) + lookups, command_flags = variable_lookups + VariableLookup.new(name, lookups, command_flags) + end + + # a variable_lookup without lookups could be a literal + def variable_lookup_or_literal name = consume(:id) lookups, command_flags = variable_lookups if Expression::LITERALS.key?(name) && lookups.empty? @@ -125,6 +142,7 @@ def variable_lookup end end + # unnamed_variable_lookup := indexed_lookup (lookup)* def unnamed_variable_lookup name = indexed_lookup lookups, command_flags = variable_lookups @@ -132,6 +150,7 @@ def unnamed_variable_lookup end # Parenthesized expressions are recursive + # grouping := "(" expression ")" def grouping_or_range_lookup consume(:open_round) expr = expression @@ -144,6 +163,7 @@ def grouping_or_range_lookup consume(:close_round) end + # range_lookup := "(" expression ".." expression ")" def range_lookup consume(:open_round) first = expression