mirror of
https://github.com/Shopify/liquid.git
synced 2026-09-15 08:50:45 -07:00
[WIP] Support for nested boolean expressions in parentheses
- Added unit tests for range syntax - Added logical expression unit tests - parser respects parentheses during expression traversal
This commit is contained in:
+21
-7
@@ -60,12 +60,7 @@ module Liquid
|
||||
when :string, :number
|
||||
consume
|
||||
when :open_round
|
||||
consume
|
||||
first = expression
|
||||
consume(:dotdot)
|
||||
last = expression
|
||||
consume(:close_round)
|
||||
"(#{first}..#{last})"
|
||||
consume_round_parentheses(token)
|
||||
else
|
||||
raise SyntaxError, "#{token} is not a valid expression"
|
||||
end
|
||||
@@ -79,13 +74,32 @@ module Liquid
|
||||
operator = consume(:boolean_operator)
|
||||
left = expr
|
||||
right = expression
|
||||
|
||||
if look(:close_round)
|
||||
"(#{left} #{operator} #{right})"
|
||||
else
|
||||
"#{left} #{operator} #{right}"
|
||||
end
|
||||
else
|
||||
expr
|
||||
end
|
||||
end
|
||||
|
||||
def consume_round_parentheses(token)
|
||||
consume
|
||||
first = expression
|
||||
dotdot_token = consume?(:dotdot)
|
||||
if dotdot_token
|
||||
last = expression
|
||||
consume(:close_round)
|
||||
"(#{first}..#{last})"
|
||||
elsif look(:close_round)
|
||||
consume(:close_round)
|
||||
first
|
||||
else
|
||||
raise SyntaxError, "#{token} is not a valid expression"
|
||||
end
|
||||
end
|
||||
|
||||
def argument
|
||||
str = +""
|
||||
# might be a keyword argument (identifier: expression)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
require 'test_boolean_helper'
|
||||
|
||||
class LogicalExpressionTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def setup
|
||||
@ss = StringScanner.new("")
|
||||
@cache = {}
|
||||
end
|
||||
|
||||
def test_logical_detection
|
||||
assert(Expression::LogicalExpression.logical?("foo and bar"))
|
||||
assert(Expression::LogicalExpression.logical?("foo or bar"))
|
||||
assert(Expression::LogicalExpression.logical?("true and false"))
|
||||
assert(Expression::LogicalExpression.logical?("1 or 0"))
|
||||
|
||||
refute(Expression::LogicalExpression.logical?("foo"))
|
||||
refute(Expression::LogicalExpression.logical?("1 == 1"))
|
||||
refute(Expression::LogicalExpression.logical?("a contains b"))
|
||||
refute(Expression::LogicalExpression.logical?("not foo"))
|
||||
end
|
||||
|
||||
def test_parenthesized_logical_detection
|
||||
assert(Expression::LogicalExpression.logical?("a and (b or c)"))
|
||||
assert(Expression::LogicalExpression.logical?("(a or b) and c"))
|
||||
end
|
||||
|
||||
def test_boolean_operator_detection
|
||||
assert(Expression::LogicalExpression.boolean_operator?("and"))
|
||||
assert(Expression::LogicalExpression.boolean_operator?("or"))
|
||||
|
||||
refute(Expression::LogicalExpression.boolean_operator?("not"))
|
||||
refute(Expression::LogicalExpression.boolean_operator?("=="))
|
||||
refute(Expression::LogicalExpression.boolean_operator?("contains"))
|
||||
refute(Expression::LogicalExpression.boolean_operator?("foo"))
|
||||
end
|
||||
|
||||
def test_basic_parsing
|
||||
result = Expression::LogicalExpression.parse("true and false", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
|
||||
result = Expression::LogicalExpression.parse("a or b", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
end
|
||||
|
||||
def test_parsing_with_different_expressions
|
||||
# Test with simple variable expressions
|
||||
result = Expression::LogicalExpression.parse("var1 and var2", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
|
||||
# Test with comparison expressions
|
||||
result = Expression::LogicalExpression.parse("a == 1 and b != 2", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
end
|
||||
|
||||
def test_parsing_complex_expressions
|
||||
# Test with nested logical expressions
|
||||
result = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
|
||||
result = Expression::LogicalExpression.parse("a or b and c", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
end
|
||||
|
||||
def test_parsing_parenthesized_expressions
|
||||
result = Expression::LogicalExpression.parse("(a and b) or c", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
|
||||
result = Expression::LogicalExpression.parse("a and (b or c)", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
|
||||
# Test with complex expressions
|
||||
result = Expression::LogicalExpression.parse("(a or b) and (c or d)", @ss, @cache)
|
||||
assert_instance_of(Condition, result)
|
||||
end
|
||||
|
||||
def test_evaluation_of_parsed_expressions
|
||||
context = Liquid::Context.new(
|
||||
"a" => true,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
"d" => false,
|
||||
)
|
||||
|
||||
# Test simple logical expressions
|
||||
expr = Expression::LogicalExpression.parse("a and c", @ss, @cache)
|
||||
assert_equal(true, expr.evaluate(context))
|
||||
|
||||
expr = Expression::LogicalExpression.parse("a and b", @ss, @cache)
|
||||
assert_equal(false, expr.evaluate(context))
|
||||
|
||||
expr = Expression::LogicalExpression.parse("b or c", @ss, @cache)
|
||||
assert_equal(true, expr.evaluate(context))
|
||||
|
||||
expr = Expression::LogicalExpression.parse("b or d", @ss, @cache)
|
||||
assert_equal(false, expr.evaluate(context))
|
||||
end
|
||||
|
||||
def test_evaluation_of_complex_expressions
|
||||
context = Liquid::Context.new(
|
||||
"a" => true,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
"d" => false,
|
||||
)
|
||||
|
||||
# Test complex logical expressions
|
||||
expr = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
|
||||
assert_equal(true, expr.evaluate(context))
|
||||
end
|
||||
|
||||
def test_evaluation_of_parenthesized_expressions
|
||||
context = Liquid::Context.new(
|
||||
"a" => true,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
"d" => false,
|
||||
)
|
||||
|
||||
expr = Expression::LogicalExpression.parse("a and (b or d)", @ss, @cache)
|
||||
assert_equal(false, expr.evaluate(context))
|
||||
|
||||
expr = Expression::LogicalExpression.parse("(a or b) and (c or d)", @ss, @cache)
|
||||
assert_equal(true, expr.evaluate(context))
|
||||
|
||||
expr = Expression::LogicalExpression.parse("(a or b) and (b or d)", @ss, @cache)
|
||||
assert_equal(false, expr.evaluate(context))
|
||||
end
|
||||
|
||||
def test_precedence_rules
|
||||
context = Liquid::Context.new(
|
||||
"a" => true,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
)
|
||||
|
||||
# Test precedence rules (AND has higher precedence than OR)
|
||||
# This should be interpreted as: a and (b or c)
|
||||
expr1 = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
|
||||
assert_equal(true, expr1.evaluate(context))
|
||||
|
||||
# Change context to make the expressions evaluate differently
|
||||
context = Liquid::Context.new(
|
||||
"a" => false,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
)
|
||||
|
||||
# With these values, "a and (b or c)" would be false
|
||||
expr1 = Expression::LogicalExpression.parse("a and b or c", @ss, @cache)
|
||||
assert_equal(false, expr1.evaluate(context))
|
||||
end
|
||||
|
||||
def test_precedence_with_parentheses
|
||||
context = Liquid::Context.new(
|
||||
"a" => true,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
)
|
||||
|
||||
# This should be interpreted as: (a and b) or c
|
||||
expr2 = Expression::LogicalExpression.parse("(a and b) or c", @ss, @cache)
|
||||
assert_equal(true, expr2.evaluate(context))
|
||||
|
||||
# Change context to make the expressions evaluate differently
|
||||
context = Liquid::Context.new(
|
||||
"a" => false,
|
||||
"b" => false,
|
||||
"c" => true,
|
||||
)
|
||||
|
||||
# But "(a and b) or c" would be true
|
||||
expr2 = Expression::LogicalExpression.parse("(a and b) or c", @ss, @cache)
|
||||
assert_equal(true, expr2.evaluate(context))
|
||||
end
|
||||
|
||||
def test_integration_with_if_tag
|
||||
# Test that our expressions work properly in actual templates
|
||||
assert_template_result("true", "{% if true and true %}true{% else %}false{% endif %}")
|
||||
assert_template_result("false", "{% if true and false %}true{% else %}false{% endif %}")
|
||||
assert_template_result("true", "{% if false or true %}true{% else %}false{% endif %}")
|
||||
assert_template_result("false", "{% if false or false %}true{% else %}false{% endif %}")
|
||||
end
|
||||
|
||||
def test_integration_with_parenthesized_if_tag
|
||||
# Test with parenthesized expressions
|
||||
assert_template_result("true", "{% if (true and false) or true %}true{% else %}false{% endif %}")
|
||||
assert_template_result("false", "{% if true and (false or false) %}true{% else %}false{% endif %}")
|
||||
assert_template_result("true", "{% if true and (false or true) %}true{% else %}false{% endif %}")
|
||||
end
|
||||
|
||||
def test_integration_with_variables
|
||||
# Test with variables
|
||||
template = "{% if a and b %}true{% else %}false{% endif %}"
|
||||
assert_template_result("true", template, { "a" => true, "b" => true })
|
||||
assert_template_result("false", template, { "a" => true, "b" => false })
|
||||
|
||||
template = "{% if a or b %}true{% else %}false{% endif %}"
|
||||
assert_template_result("true", template, { "a" => true, "b" => false })
|
||||
assert_template_result("false", template, { "a" => false, "b" => false })
|
||||
end
|
||||
|
||||
def test_integration_with_parenthesized_variables
|
||||
# Test with parenthesized expressions
|
||||
template = "{% if (a and b) or c %}true{% else %}false{% endif %}"
|
||||
assert_template_result("true", template, { "a" => true, "b" => true, "c" => false })
|
||||
assert_template_result("true", template, { "a" => false, "b" => false, "c" => true })
|
||||
assert_template_result("false", template, { "a" => false, "b" => false, "c" => false })
|
||||
|
||||
template = "{% if a and (b or c) %}true{% else %}false{% endif %}"
|
||||
assert_template_result("true", template, { "a" => true, "b" => true, "c" => false })
|
||||
assert_template_result("true", template, { "a" => true, "b" => false, "c" => true })
|
||||
assert_template_result("false", template, { "a" => true, "b" => false, "c" => false })
|
||||
assert_template_result("false", template, { "a" => false, "b" => true, "c" => true })
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class RangeUnitTest < Minitest::Test
|
||||
include Liquid
|
||||
|
||||
def test_basic_range_creation
|
||||
assert_template_result("1 2 3 4 5", "{% for i in (1..5) %}{{ i }} {% endfor %}")
|
||||
end
|
||||
|
||||
def test_range_with_variables
|
||||
assert_template_result("3 4 5", "{% assign start = 3 %}{% for i in (start..5) %}{{ i }} {% endfor %}")
|
||||
assert_template_result("1 2 3", "{% assign end = 3 %}{% for i in (1..end) %}{{ i }} {% endfor %}")
|
||||
assert_template_result("2 3 4", "{% assign start = 2 %}{% assign end = 4 %}{% for i in (start..end) %}{{ i }} {% endfor %}")
|
||||
end
|
||||
|
||||
def test_range_with_whitespace
|
||||
assert_template_result("1 2 3", "{% for i in ( 1 .. 3 ) %}{{ i }} {% endfor %}")
|
||||
assert_template_result("1 2 3", "{% for i in (1 .. 3) %}{{ i }} {% endfor %}")
|
||||
end
|
||||
|
||||
def test_range_with_expressions
|
||||
assert_template_result("3 4 5", "{% assign x = 1 %}{% assign start = x | plus: 2 %}{% for i in (start..5) %}{{ i }} {% endfor %}")
|
||||
assert_template_result("1 2 3", "{% assign x = 2 %}{% assign end = x | plus: 1 %}{% for i in (1..end) %}{{ i }} {% endfor %}")
|
||||
end
|
||||
|
||||
def test_range_with_literals_in_iteration
|
||||
assert_template_result("1 2 3 4 5", "{% for i in (1..5) %}{{ i }} {% endfor %}")
|
||||
end
|
||||
|
||||
def test_range_size_and_first_last
|
||||
assert_template_result("5", "{{ (1..5) | size }}")
|
||||
assert_template_result("1", "{{ (1..5) | first }}")
|
||||
assert_template_result("5", "{{ (1..5) | last }}")
|
||||
end
|
||||
|
||||
def test_empty_ranges
|
||||
assert_template_result("", "{% for i in (5..1) %}{{ i }}{% endfor %}")
|
||||
end
|
||||
|
||||
def test_ranges_in_conditionals
|
||||
assert_template_result("yes", "{% if 3 >= (1..5) %}no{% else %}yes{% endif %}")
|
||||
assert_template_result("yes", "{% if (1..5) contains 3 %}yes{% else %}no{% endif %}")
|
||||
assert_template_result("no", "{% if (1..5) contains 6 %}yes{% else %}no{% endif %}")
|
||||
end
|
||||
|
||||
def test_range_with_negative_numbers
|
||||
assert_template_result("-3 -2 -1 0", "{% for i in (-3..0) %}{{ i }} {% endfor %}")
|
||||
end
|
||||
|
||||
def test_range_with_floats
|
||||
# Liquid doesn't support float ranges, should either error or not iterate
|
||||
template = "{% for i in (1.5..3.5) %}{{ i }} {% endfor %}"
|
||||
# Floats are rounded down to the nearest integer
|
||||
assert_template_result("1 2 3", template)
|
||||
end
|
||||
|
||||
# def test_ranges_with_calculated_endpoints
|
||||
# assert_template_result(
|
||||
# "3 4 5",
|
||||
# "{% assign start = 1 %}{% assign end = 7 %}{% for i in (start | plus: 2 .. end | minus: 2) %}{{ i }} {% endfor %}",
|
||||
# )
|
||||
# end
|
||||
|
||||
def test_malformed_ranges
|
||||
# Missing start value
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (..5) %}{{ i }}{% endfor %}") }
|
||||
# Missing end value
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (1..) %}{{ i }}{% endfor %}") }
|
||||
# Missing both values
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (..) %}{{ i }}{% endfor %}") }
|
||||
# Wrong syntax (no parentheses)
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in 1..5 %}{{ i }}{% endfor %}") }
|
||||
# Unbalanced parentheses
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (1..5 %}{{ i }}{% endfor %}") }
|
||||
# Invalid characters in range
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% for i in (#..@) %}{{ i }}{% endfor %}") }
|
||||
# Invalid range
|
||||
assert_raises(Liquid::SyntaxError) { Liquid::Template.parse("{% assign start = 1 %}{% assign end = 7 %}{% for i in (start | plus: 2 .. end | minus: 2) %}{{ i }} {% endfor %}") }
|
||||
end
|
||||
|
||||
def test_ranges_with_strings_and_variables
|
||||
assert_template_result(
|
||||
"3 4 5",
|
||||
"{% assign range = (3..5) %}{% for i in range %}{{ i }} {% endfor %}",
|
||||
)
|
||||
assert_template_result(
|
||||
"4 5 6",
|
||||
"{% assign start = 4 %}{% assign range = (start..6) %}{% for i in range %}{{ i }} {% endfor %}",
|
||||
)
|
||||
end
|
||||
|
||||
def test_ranges_with_limit_and_offset
|
||||
assert_template_result(
|
||||
"2 3",
|
||||
"{% for i in (1..5) limit:2 offset:1 %}{{ i }} {% endfor %}",
|
||||
)
|
||||
assert_template_result(
|
||||
"3 4 5",
|
||||
"{% for i in (1..5) offset:2 %}{{ i }} {% endfor %}",
|
||||
)
|
||||
assert_template_result(
|
||||
"1 2",
|
||||
"{% for i in (1..5) limit:2 %}{{ i }} {% endfor %}",
|
||||
)
|
||||
end
|
||||
|
||||
def test_reversed_ranges
|
||||
assert_template_result(
|
||||
"5 4 3 2 1",
|
||||
"{% for i in (1..5) reversed %}{{ i }} {% endfor %}",
|
||||
)
|
||||
end
|
||||
|
||||
def test_variable_ranges_with_reversed
|
||||
assert_template_result(
|
||||
"4 3 2 1",
|
||||
"{% assign num = 4 %}{% for i in (1..num) reversed %}{{ i }} {% endfor %}",
|
||||
)
|
||||
end
|
||||
|
||||
def test_assigned_ranges_with_reversed
|
||||
assert_template_result(
|
||||
"5 4 3 2 1",
|
||||
"{% assign range = (1..5) %}{% for i in range reversed %}{{ i }} {% endfor %}",
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_template_result(expected, template, assigns = {})
|
||||
assert_equal(expected, Liquid::Template.parse(template).render!(assigns).strip)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user