From d73952c12eaca44974ffa5e7e2997f5947b5dd6f Mon Sep 17 00:00:00 2001 From: James Prior Date: Tue, 20 May 2025 19:35:54 +0100 Subject: [PATCH] Implement short circuit evaluation and arithmetic operators --- liquid2/builtin/__init__.py | 2 + liquid2/builtin/expressions.py | 556 ++++++++---- liquid2/builtin/tags/include_tag.py | 6 +- liquid2/builtin/tags/render_tag.py | 6 +- liquid2/environment.py | 4 + liquid2/filter.py | 2 +- liquid2/lexer.py | 14 + liquid2/token.py | 7 + liquid2/undefined.py | 6 + tests/liquid2-compliance-test-suite/cts.json | 815 +++++++++++++++++- .../liquid2-compliance-test-suite/schema.json | 2 +- .../tests/arithmetic.json | 295 +++++++ .../tests/compound.json | 69 ++ .../tests/identifiers.json | 8 - .../tests/lambda.json | 449 ++++++++++ tests/test_compliance.py | 12 +- tests/test_liquid_syntax_errors.py | 16 +- 17 files changed, 2066 insertions(+), 203 deletions(-) create mode 100644 tests/liquid2-compliance-test-suite/tests/arithmetic.json create mode 100644 tests/liquid2-compliance-test-suite/tests/compound.json create mode 100644 tests/liquid2-compliance-test-suite/tests/lambda.json diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index fffb913..48b2571 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -40,6 +40,7 @@ from .expressions import parse_keyword_arguments from .expressions import parse_parameters from .expressions import parse_positional_and_keyword_arguments +from .expressions import parse_primary from .expressions import parse_primitive from .expressions import parse_string_or_identifier from .expressions import parse_string_or_path @@ -211,6 +212,7 @@ "parse_identifier", "parse_keyword_arguments", "parse_positional_and_keyword_arguments", + "parse_primary", "parse_primitive", "parse_string_or_identifier", "Path", diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 3ece928..1f77b61 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -5,6 +5,7 @@ import re import sys from decimal import Decimal +from decimal import InvalidOperation from itertools import islice from typing import TYPE_CHECKING from typing import Any @@ -341,8 +342,7 @@ def parse(env: Environment, stream: TokenStream, left: Expression) -> ArrayLiter while stream.current().type_ == TokenType.COMMA: stream.next() # ignore comma try: - items.append(parse_primitive(env, stream.current())) - stream.next() + items.append(parse_primary(env, stream)) except LiquidSyntaxError: # Trailing commas are OK. break @@ -468,7 +468,7 @@ def parse(env: Environment, stream: TokenStream) -> LambdaExpression: # A single param function without parens. stream.expect(TokenType.ARROW) stream.next() - expr = parse_boolean_primitive(env, stream) + expr = parse_primary(env, stream) stream.backup() return LambdaExpression( token, @@ -488,7 +488,7 @@ def parse(env: Environment, stream: TokenStream) -> LambdaExpression: stream.next() stream.expect(TokenType.ARROW) stream.next() - expr = parse_boolean_primitive(env, stream) + expr = parse_primary(env, stream) stream.backup() return LambdaExpression( @@ -627,7 +627,7 @@ def parse( env: Environment, stream: TokenStream ) -> FilteredExpression | TernaryFilteredExpression: """Return a new FilteredExpression parsed from _stream_.""" - left = parse_primitive(env, stream.next()) + left = parse_primary(env, stream) if stream.current().type_ == TokenType.COMMA: # Array literal syntax left = ArrayLiteral.parse(env, stream, left) @@ -791,7 +791,7 @@ def parse( if is_token_type(stream.current(), TokenType.ELSE): stream.next() # move past `else` - alternative = parse_primitive(env, stream.next()) + alternative = parse_primary(env, stream) if stream.current().type_ == TokenType.PIPE: filters = Filter.parse(env, stream, delim=(TokenType.PIPE,)) @@ -935,9 +935,10 @@ def parse( # noqa: PLR0912 filter_arguments.append( KeywordArgument( token.value, - parse_primitive(env, stream.current()), + parse_primary(env, stream), ) ) + continue elif stream.peek().type_ == TokenType.ARROW: # A positional argument that is an arrow function with a # single parameter. @@ -970,8 +971,9 @@ def parse( # noqa: PLR0912 TokenType.RANGE, ): filter_arguments.append( - PositionalArgument(parse_primitive(env, stream.current())) + PositionalArgument(parse_primary(env, stream)) ) + continue elif token.type_ == TokenType.LPAREN: # A positional argument that is an arrow function with # parameters surrounded by parentheses. @@ -1052,6 +1054,7 @@ def __init__(self, token: TokenT, expression: Expression) -> None: def __str__(self) -> str: def _str(expression: Expression, parent_precedence: int) -> str: + # TODO: update to support arithmetic operators. if isinstance(expression, LogicalAndExpression): precedence = PRECEDENCE_LOGICAL_AND op = "and" @@ -1093,7 +1096,7 @@ def parse( If _inline_ is `False`, we expect the stream to be empty after parsing a Boolean expression and will raise a syntax error if it's not. """ - expr = parse_boolean_primitive(env, stream) + expr = parse_primary(env, stream) if not inline: stream.expect_eos() return BooleanExpression(expr.token, expr) @@ -1108,7 +1111,10 @@ def children(self) -> list[Expression]: PRECEDENCE_LOGICAL_AND = 4 PRECEDENCE_RELATIONAL = 5 PRECEDENCE_MEMBERSHIP = 6 -PRECEDENCE_PREFIX = 7 +PRECEDENCE_ADD_SUB = 8 +PRECEDENCE_MUL_DIV = 9 +PRECEDENCE_PREFIX = 10 +PRECEDENCE_POW = 11 PRECEDENCES = { TokenType.EQ: PRECEDENCE_RELATIONAL, @@ -1123,6 +1129,13 @@ def children(self) -> list[Expression]: TokenType.OR_WORD: PRECEDENCE_LOGICAL_OR, TokenType.NOT_WORD: PRECEDENCE_PREFIX, TokenType.RPAREN: PRECEDENCE_LOWEST, + TokenType.PLUS: PRECEDENCE_ADD_SUB, + TokenType.MINUS: PRECEDENCE_ADD_SUB, + TokenType.TIMES: PRECEDENCE_MUL_DIV, + TokenType.DIVIDE: PRECEDENCE_MUL_DIV, + TokenType.FLOOR_DIV: PRECEDENCE_MUL_DIV, + TokenType.MODULO: PRECEDENCE_MUL_DIV, + TokenType.POW: PRECEDENCE_POW, } BINARY_OPERATORS = frozenset( @@ -1137,14 +1150,45 @@ def children(self) -> list[Expression]: TokenType.IN, TokenType.AND_WORD, TokenType.OR_WORD, + TokenType.PLUS, + TokenType.MINUS, + TokenType.TIMES, + TokenType.DIVIDE, + TokenType.FLOOR_DIV, + TokenType.MODULO, + TokenType.POW, + ] +) + +ARITHMETIC_OPERATORS = frozenset( + [ + TokenType.PLUS, + TokenType.MINUS, + TokenType.TIMES, + TokenType.DIVIDE, + TokenType.FLOOR_DIV, + TokenType.MODULO, + TokenType.POW, ] ) -def parse_boolean_primitive( # noqa: PLR0912 - env: Environment, stream: TokenStream, precedence: int = PRECEDENCE_LOWEST +def parse_boolean_primitive( + env: Environment, + stream: TokenStream, +) -> Expression: + """Parse a compound expression from tokens in _stream_.""" + return parse_primary(env, stream) + + +def parse_primary( # noqa: PLR0912 + env: Environment, + stream: TokenStream, + precedence: int = PRECEDENCE_LOWEST, + *, + infix: bool = True, ) -> Expression: - """Parse a Boolean expression from tokens in _stream_.""" + """Parse a compound expression from tokens in _stream_.""" left: Expression token = stream.next() @@ -1176,21 +1220,40 @@ def parse_boolean_primitive( # noqa: PLR0912 elif is_path_token(token): left = Path(token, token.path) elif is_range_token(token): + # XXX: Due to our dubious early scanning/parsing of range expressions, + # we don't get the chance to parse range operands as compound expressions. left = RangeLiteral( token, parse_primitive(env, token.range_start), parse_primitive(env, token.range_stop), ) elif is_token_type(token, TokenType.NOT_WORD): - left = LogicalNotExpression.parse(env, stream) + left = LogicalNotExpression( + token, parse_primary(env, stream, precedence=PRECEDENCE_PREFIX) + ) + elif is_token_type(token, TokenType.PLUS): + if not env.arithmetic_operators: + raise LiquidSyntaxError("unexpected operator +", token=token) + left = PositiveExpression( + token, parse_primary(env, stream, precedence=PRECEDENCE_PREFIX) + ) + elif is_token_type(token, TokenType.MINUS): + if not env.arithmetic_operators: + raise LiquidSyntaxError("unexpected operator -", token=token) + left = NegativeExpression( + token, parse_primary(env, stream, precedence=PRECEDENCE_PREFIX) + ) elif is_token_type(token, TokenType.LPAREN): left = parse_grouped_expression(env, stream) else: raise LiquidSyntaxError( - f"expected a primitive expression, found {token.type_.name}", - token=stream.current(), + f"unexpected {token.type_.name}", + token=token, ) + if not infix: + return left + while True: token = stream.current() if ( @@ -1212,50 +1275,48 @@ def parse_infix_expression( ) -> Expression: # noqa: PLR0911 """Return a logical, comparison, or membership expression parsed from _stream_.""" token = stream.next() - assert token is not None + + if not env.arithmetic_operators and token.type_ in ARITHMETIC_OPERATORS: + raise LiquidSyntaxError( + f"unexpected operator {token.__class__.__name__}", token=token + ) + precedence = PRECEDENCES.get(token.type_, PRECEDENCE_LOWEST) + right = parse_primary(env, stream, precedence) match token.type_: case TokenType.EQ: - return EqExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return EqExpression(token, left, right) case TokenType.LT: - return LtExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return LtExpression(token, left, right) case TokenType.GT: - return GtExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return GtExpression(token, left, right) case TokenType.NE: - return NeExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return NeExpression(token, left, right) case TokenType.LE: - return LeExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return LeExpression(token, left, right) case TokenType.GE: - return GeExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return GeExpression(token, left, right) case TokenType.CONTAINS: - return ContainsExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return ContainsExpression(token, left, right) case TokenType.IN: - return InExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return InExpression(token, left, right) case TokenType.AND_WORD: - return LogicalAndExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return LogicalAndExpression(token, left, right) case TokenType.OR_WORD: - return LogicalOrExpression( - token, left, parse_boolean_primitive(env, stream, precedence) - ) + return LogicalOrExpression(token, left, right) + case TokenType.PLUS: + return PlusExpression(token, left, right) + case TokenType.MINUS: + return MinusExpression(token, left, right) + case TokenType.TIMES: + return TimesExpression(token, left, right) + case TokenType.DIVIDE: + return DivideExpression(token, left, right) + case TokenType.MODULO: + return ModuloExpression(token, left, right) + case TokenType.POW: + return PowExpression(token, left, right) case _: raise LiquidSyntaxError( f"expected an infix expression, found {token.__class__.__name__}", @@ -1265,7 +1326,7 @@ def parse_infix_expression( def parse_grouped_expression(env: Environment, stream: TokenStream) -> Expression: """Parse an expression from tokens in _stream_ until the next right parenthesis.""" - expr = parse_boolean_primitive(env, stream) + expr = parse_primary(env, stream) token = stream.next() while token.type_ != TokenType.RPAREN: @@ -1305,14 +1366,14 @@ async def evaluate_async(self, context: RenderContext) -> object: @staticmethod def parse(env: Environment, stream: TokenStream) -> Expression: - expr = parse_boolean_primitive(env, stream) + expr = parse_primary(env, stream, precedence=PRECEDENCE_PREFIX) return LogicalNotExpression(expr.token, expr) def children(self) -> list[Expression]: return [self.expression] -class LogicalAndExpression(Expression): +class _BinaryExpression(Expression): __slots__ = ("left", "right") def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: @@ -1320,55 +1381,62 @@ def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: self.left = left self.right = right - def __str__(self) -> str: - return f"{self.left} and {self.right}" + def children(self) -> list[Expression]: + return [self.left, self.right] - def evaluate(self, context: RenderContext) -> object: - return is_truthy(self.left.evaluate(context)) and is_truthy( - self.right.evaluate(context) + +class _ArithmeticExpression(_BinaryExpression): + __slots__ = () + + def inner_evaluate( + self, context: RenderContext + ) -> tuple[int | Decimal, int | Decimal]: + return ( + _decimal_operand(self.left.evaluate(context)), + _decimal_operand(self.right.evaluate(context)), ) - async def evaluate_async(self, context: RenderContext) -> object: - return is_truthy(await self.left.evaluate_async(context)) and is_truthy( - await self.right.evaluate_async(context) + async def inner_evaluate_async( + self, context: RenderContext + ) -> tuple[int | Decimal, int | Decimal]: + return ( + _decimal_operand(await self.left.evaluate_async(context)), + _decimal_operand(await self.right.evaluate_async(context)), ) - def children(self) -> list[Expression]: - return [self.left, self.right] +class LogicalAndExpression(_BinaryExpression): + __slots__ = () -class LogicalOrExpression(Expression): - __slots__ = ("left", "right") + def __str__(self) -> str: + return f"{self.left} and {self.right}" - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right + def evaluate(self, context: RenderContext) -> object: + left = self.left.evaluate(context) + return self.right.evaluate(context) if is_truthy(left) else left + + async def evaluate_async(self, context: RenderContext) -> object: + left = await self.left.evaluate_async(context) + return await self.right.evaluate_async(context) if is_truthy(left) else left + + +class LogicalOrExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} or {self.right}" def evaluate(self, context: RenderContext) -> object: - return is_truthy(self.left.evaluate(context)) or is_truthy( - self.right.evaluate(context) - ) + left = self.left.evaluate(context) + return left if is_truthy(left) else self.right.evaluate(context) async def evaluate_async(self, context: RenderContext) -> object: - return is_truthy(await self.left.evaluate_async(context)) or is_truthy( - await self.right.evaluate_async(context) - ) - - def children(self) -> list[Expression]: - return [self.left, self.right] - + left = await self.left.evaluate_async(context) + return left if is_truthy(left) else await self.right.evaluate_async(context) -class EqExpression(Expression): - __slots__ = ("left", "right") - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class EqExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} == {self.right}" @@ -1382,17 +1450,9 @@ async def evaluate_async(self, context: RenderContext) -> object: await self.right.evaluate_async(context), ) - def children(self) -> list[Expression]: - return [self.left, self.right] - -class NeExpression(Expression): - __slots__ = ("left", "right") - - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class NeExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} != {self.right}" @@ -1406,17 +1466,9 @@ async def evaluate_async(self, context: RenderContext) -> object: await self.right.evaluate_async(context), ) - def children(self) -> list[Expression]: - return [self.left, self.right] - - -class LeExpression(Expression): - __slots__ = ("left", "right") - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class LeExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} <= {self.right}" @@ -1431,17 +1483,9 @@ async def evaluate_async(self, context: RenderContext) -> object: right = await self.right.evaluate_async(context) return _eq(left, right) or _lt(self.token, left, right) - def children(self) -> list[Expression]: - return [self.left, self.right] - -class GeExpression(Expression): - __slots__ = ("left", "right") - - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class GeExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} >= {self.right}" @@ -1456,17 +1500,9 @@ async def evaluate_async(self, context: RenderContext) -> object: right = await self.right.evaluate_async(context) return _eq(left, right) or _lt(self.token, right, left) - def children(self) -> list[Expression]: - return [self.left, self.right] - - -class LtExpression(Expression): - __slots__ = ("left", "right") - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class LtExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} < {self.right}" @@ -1483,17 +1519,9 @@ async def evaluate_async(self, context: RenderContext) -> object: await self.right.evaluate_async(context), ) - def children(self) -> list[Expression]: - return [self.left, self.right] - -class GtExpression(Expression): - __slots__ = ("left", "right") - - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class GtExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} > {self.right}" @@ -1510,17 +1538,9 @@ async def evaluate_async(self, context: RenderContext) -> object: await self.left.evaluate_async(context), ) - def children(self) -> list[Expression]: - return [self.left, self.right] - - -class ContainsExpression(Expression): - __slots__ = ("left", "right") - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class ContainsExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} contains {self.right}" @@ -1537,17 +1557,9 @@ async def evaluate_async(self, context: RenderContext) -> object: await self.right.evaluate_async(context), ) - def children(self) -> list[Expression]: - return [self.left, self.right] - -class InExpression(Expression): - __slots__ = ("left", "right") - - def __init__(self, token: TokenT, left: Expression, right: Expression) -> None: - super().__init__(token=token) - self.left = left - self.right = right +class InExpression(_BinaryExpression): + __slots__ = () def __str__(self) -> str: return f"{self.left} in {self.right}" @@ -1564,8 +1576,199 @@ async def evaluate_async(self, context: RenderContext) -> object: await self.left.evaluate_async(context), ) + +class PlusExpression(_ArithmeticExpression): + __slots__ = () + + def __str__(self) -> str: + return f"{self.left} + {self.right}" + + def evaluate(self, context: RenderContext) -> object: + left, right = self.inner_evaluate(context) + if isinstance(left, int) and isinstance(right, int): + return left + right + return float(left + right) + + async def evaluate_async(self, context: RenderContext) -> object: + left, right = await self.inner_evaluate_async(context) + if isinstance(left, int) and isinstance(right, int): + return left + right + return float(left + right) + + +class MinusExpression(_ArithmeticExpression): + __slots__ = () + + def __str__(self) -> str: + return f"{self.left} - {self.right}" + + def evaluate(self, context: RenderContext) -> object: + left, right = self.inner_evaluate(context) + if isinstance(left, int) and isinstance(right, int): + return left - right + return float(left - right) + + async def evaluate_async(self, context: RenderContext) -> object: + left, right = await self.inner_evaluate_async(context) + if isinstance(left, int) and isinstance(right, int): + return left - right + return float(left - right) + + +class TimesExpression(_ArithmeticExpression): + __slots__ = () + + def __str__(self) -> str: + return f"{self.left} * {self.right}" + + def evaluate(self, context: RenderContext) -> object: + left, right = self.inner_evaluate(context) + if isinstance(left, int) and isinstance(right, int): + return left * right + return float(left * right) + + async def evaluate_async(self, context: RenderContext) -> object: + left, right = await self.inner_evaluate_async(context) + if isinstance(left, int) and isinstance(right, int): + return left * right + return float(left * right) + + +class DivideExpression(_ArithmeticExpression): + __slots__ = () + + def __str__(self) -> str: + return f"{self.left} / {self.right}" + + def evaluate(self, context: RenderContext) -> object: + left, right = self.inner_evaluate(context) + try: + if isinstance(left, int) and isinstance(right, int): + return left // right + return float(left / right) + except ZeroDivisionError as err: + raise LiquidTypeError(str(err), token=self.token) from err + + async def evaluate_async(self, context: RenderContext) -> object: + left, right = await self.inner_evaluate_async(context) + try: + if isinstance(left, int) and isinstance(right, int): + return left // right + return float(left / right) + except ZeroDivisionError as err: + raise LiquidTypeError(str(err), token=self.token) from err + + +class ModuloExpression(_ArithmeticExpression): + __slots__ = () + + def __str__(self) -> str: + return f"{self.left} % {self.right}" + + def evaluate(self, context: RenderContext) -> object: + left, right = self.inner_evaluate(context) + try: + if isinstance(left, int) and isinstance(right, int): + return left % right + return float(left % right) + except ZeroDivisionError as err: + raise LiquidTypeError(str(err), token=self.token) from err + + async def evaluate_async(self, context: RenderContext) -> object: + left, right = await self.inner_evaluate_async(context) + try: + if isinstance(left, int) and isinstance(right, int): + return left % right + return float(left % right) + except ZeroDivisionError as err: + raise LiquidTypeError(str(err), token=self.token) from err + + +class PowExpression(_ArithmeticExpression): + __slots__ = () + + def __str__(self) -> str: + return f"{self.left} ** {self.right}" + + def evaluate(self, context: RenderContext) -> object: + left, right = self.inner_evaluate(context) + if isinstance(left, int) and isinstance(right, int): + return left**right + return float(left**right) + + async def evaluate_async(self, context: RenderContext) -> object: + left, right = await self.inner_evaluate_async(context) + if isinstance(left, int) and isinstance(right, int): + return left**right + return float(left**right) + + +class NegativeExpression(Expression): + __slots__ = ("right",) + + NaN = Decimal("NaN") + + def __init__(self, token: TokenT, right: Expression): + super().__init__(token) + self.right = right + + def __str__(self) -> str: + return f"-{self.right}" + + def evaluate(self, context: RenderContext) -> object: + right = self.right.evaluate(context) + value = _decimal_operand(right, default=self.NaN) + return ( + value.__neg__() + if value is not self.NaN + else context.env.undefined(f"-{_to_liquid_string(right)}", token=self.token) + ) + + async def evaluate_async(self, context: RenderContext) -> object: + right = await self.right.evaluate_async(context) + value = _decimal_operand(right, default=self.NaN) + return ( + value.__neg__() + if value is not self.NaN + else context.env.undefined(f"-{_to_liquid_string(right)}", token=self.token) + ) + def children(self) -> list[Expression]: - return [self.left, self.right] + return [self.right] + + +class PositiveExpression(Expression): + __slots__ = ("right",) + + NaN = Decimal("NaN") + + def __init__(self, token: TokenT, right: Expression): + super().__init__(token) + self.right = right + + def __str__(self) -> str: + return f"+{self.right}" + + def evaluate(self, context: RenderContext) -> object: + right = self.right.evaluate(context) + value = _decimal_operand(right, default=self.NaN) + return ( + value.__pos__() + if value is not self.NaN + else context.env.undefined(f"+{_to_liquid_string(right)}", token=self.token) + ) + + async def evaluate_async(self, context: RenderContext) -> object: + right = await self.right.evaluate_async(context) + value = _decimal_operand(right, default=self.NaN) + return ( + value.__pos__() + if value is not self.NaN + else context.env.undefined(f"+{_to_liquid_string(right)}", token=self.token) + ) + + def children(self) -> list[Expression]: + return [self.right] class LoopExpression(Expression): @@ -1742,7 +1945,7 @@ def parse(env: Environment, stream: TokenStream) -> LoopExpression: stream.next() stream.expect(TokenType.IN) stream.next() # Move past 'in' - iterable = parse_primitive(env, stream.next()) + iterable = parse_primary(env, stream) # We're looking for a comma that isn't followed by a known keyword. # This means we have an array literal. @@ -1787,22 +1990,24 @@ def parse(env: Environment, stream: TokenStream) -> LoopExpression: case "limit": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - limit = parse_primitive(env, stream.next()) + limit = parse_primary(env, stream) case "cols": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - cols = parse_primitive(env, stream.next()) + cols = parse_primary(env, stream) case "offset": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - offset_token = stream.next() + offset_token = stream.current() if ( is_token_type(offset_token, TokenType.WORD) and offset_token.value == "continue" ): - offset = StringLiteral(token=offset_token, value="continue") + offset = StringLiteral( + token=stream.next(), value="continue" + ) else: - offset = parse_primitive(env, offset_token) + offset = parse_primary(env, stream) case _: raise LiquidSyntaxError( "expected 'reversed', 'offset' or 'limit', ", @@ -1938,7 +2143,7 @@ def parse_keyword_arguments( if is_token_type(token, TokenType.WORD): tokens.expect_one_of(TokenType.COLON, TokenType.ASSIGN) tokens.next() # Move past ":" or "=" - value = parse_primitive(env, tokens.next()) + value = parse_primary(env, tokens) args.append(KeywordArgument(token.value, value)) else: raise LiquidSyntaxError( @@ -1977,11 +2182,12 @@ def parse_positional_and_keyword_arguments( ): # A keyword argument tokens.next() # Move past ":" or "=" - value = parse_primitive(env, tokens.next()) + value = parse_primary(env, tokens) kwargs.append(KeywordArgument(token.value, value)) else: # A primitive as a positional argument - args.append(PositionalArgument(parse_primitive(env, token))) + tokens.backup() + args.append(PositionalArgument(parse_primary(env, tokens))) return args, kwargs @@ -2007,7 +2213,7 @@ def parse_parameters(env: Environment, tokens: TokenStream) -> dict[str, Paramet ): # A parameter with a default value tokens.next() # Move past ":" or "=" - value = parse_primitive(env, tokens.next()) + value = parse_primary(env, tokens) params[token.value] = Parameter(token, token.value, value) else: params[token.value] = Parameter(token, token.value, None) @@ -2114,3 +2320,27 @@ def _to_liquid_string(val: Any, *, auto_escape: bool = False) -> str: assert isinstance(val, str) return val + + +def _decimal_operand(val: Any, default: int | Decimal = 0) -> int | Decimal: + if isinstance(val, bool): + return default + + if isinstance(val, int): + return val + + if isinstance(val, float): + return Decimal(str(val)) + + if isinstance(val, str): + try: + return to_int(val) + except ValueError: + pass + + try: + return Decimal(val) + except (ValueError, InvalidOperation): + return default + + return default diff --git a/liquid2/builtin/tags/include_tag.py b/liquid2/builtin/tags/include_tag.py index 3331381..68708fa 100644 --- a/liquid2/builtin/tags/include_tag.py +++ b/liquid2/builtin/tags/include_tag.py @@ -18,7 +18,7 @@ from liquid2.builtin import Identifier from liquid2.builtin import Literal from liquid2.builtin import parse_keyword_arguments -from liquid2.builtin import parse_primitive +from liquid2.builtin import parse_primary from liquid2.builtin import parse_string_or_identifier from liquid2.builtin import parse_string_or_path from liquid2.exceptions import LiquidSyntaxError @@ -245,7 +245,7 @@ def parse(self, stream: TokenStream) -> Node: ): tokens.next() # Move past "for" loop = True - var = parse_primitive(self.env, tokens.next()) + var = parse_primary(self.env, tokens) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) @@ -254,7 +254,7 @@ def parse(self, stream: TokenStream) -> Node: TokenType.COMMA, ): tokens.next() # Move past "with" - var = parse_primitive(self.env, tokens.next()) + var = parse_primary(self.env, tokens) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) diff --git a/liquid2/builtin/tags/render_tag.py b/liquid2/builtin/tags/render_tag.py index 2e84375..5e222fe 100644 --- a/liquid2/builtin/tags/render_tag.py +++ b/liquid2/builtin/tags/render_tag.py @@ -19,7 +19,7 @@ from liquid2.builtin import Literal from liquid2.builtin import StringLiteral from liquid2.builtin import parse_keyword_arguments -from liquid2.builtin import parse_primitive +from liquid2.builtin import parse_primary from liquid2.builtin import parse_string_or_identifier from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import TemplateNotFoundError @@ -298,7 +298,7 @@ def parse(self, stream: TokenStream) -> Node: ): tokens.next() # Move past "for" loop = True - var = parse_primitive(self.env, tokens.next()) + var = parse_primary(self.env, tokens) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) @@ -307,7 +307,7 @@ def parse(self, stream: TokenStream) -> Node: TokenType.COMMA, ): tokens.next() # Move past "with" - var = parse_primitive(self.env, tokens.next()) + var = parse_primary(self.env, tokens) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) diff --git a/liquid2/environment.py b/liquid2/environment.py index a479f08..4d89985 100644 --- a/liquid2/environment.py +++ b/liquid2/environment.py @@ -78,6 +78,10 @@ class Environment: """If True, array indexes can be separated by dots without enclosing square brackets. The default is `False`.""" + arithmetic_operators: bool = False + """When `True`, infix operators `+`, `-`, `*`, `/`, `**` and `%`, and prefix + operators `+` and `-` will be enabled. Defaults to `False`.""" + lexer_class = Lexer """The lexer class to use when scanning template source text.""" diff --git a/liquid2/filter.py b/liquid2/filter.py index 4ede009..806936a 100644 --- a/liquid2/filter.py +++ b/liquid2/filter.py @@ -84,7 +84,7 @@ def num_arg(val: Any, default: float | int | None = None) -> float | int: def decimal_arg(val: Any, default: int | Decimal | None = None) -> int | Decimal: - """Return _val_ as an int or decimal, or _default_ is casting fails.""" + """Return _val_ as an int or decimal, or _default_ if casting fails.""" if isinstance(val, int): return val if isinstance(val, float): diff --git a/liquid2/lexer.py b/liquid2/lexer.py index a0d33b5..4df43b6 100644 --- a/liquid2/lexer.py +++ b/liquid2/lexer.py @@ -91,6 +91,13 @@ class Lexer: "LBRACKET": r"\[", "EXCLAIM": r"!", "QUESTION": r"\?", + "PLUS": r"\+(?![\}%]|$)", + "MINUS": r"\-(?![\}%]|$)", + "POW": r"\*\*", + "FLOORDIV": r"//", + "TIMES": r"\*", + "DIVIDE": r"/", + "MODULO": r"%(?![\}%]|$)", } NUMBERS: dict[str, str] = { @@ -142,6 +149,13 @@ class Lexer: "EXCLAIM": TokenType.EXCLAIM, "QUESTION": TokenType.QUESTION, "ARROW": TokenType.ARROW, + "PLUS": TokenType.PLUS, + "MINUS": TokenType.MINUS, + "TIMES": TokenType.TIMES, + "DIVIDE": TokenType.DIVIDE, + "MODULO": TokenType.MODULO, + "FLOORDIV": TokenType.FLOOR_DIV, + "POW": TokenType.POW, } MARKUP: dict[str, str] = { diff --git a/liquid2/token.py b/liquid2/token.py index ef25362..d69d2cb 100644 --- a/liquid2/token.py +++ b/liquid2/token.py @@ -432,6 +432,7 @@ class TokenType(Enum): COLON = auto() COMMA = auto() CONTAINS = auto() + DIVIDE = auto() DOT = auto() DOUBLE_DOT = auto() DOUBLE_PIPE = auto() @@ -442,6 +443,7 @@ class TokenType(Enum): EXCLAIM = auto() # '!', not used in any default expression FALSE = auto() FLOAT = auto() + FLOOR_DIV = auto() FOR = auto() GE = auto() GT = auto() @@ -451,16 +453,21 @@ class TokenType(Enum): LE = auto() LPAREN = auto() LT = auto() + MINUS = auto() + MODULO = auto() NE = auto() NOT_WORD = auto() NULL = auto() OR_WORD = auto() # or PIPE = auto() + PLUS = auto() + POW = auto() QUESTION = auto() # '?', not used in any default expression REQUIRED = auto() RPAREN = auto() SINGLE_QUOTE_STRING = auto() SINGLE_QUOTE_TEMPLATE_STRING = auto() + TIMES = auto() TRUE = auto() WITH = auto() WORD = auto() diff --git a/liquid2/undefined.py b/liquid2/undefined.py index f10ef6d..68ef8ab 100644 --- a/liquid2/undefined.py +++ b/liquid2/undefined.py @@ -75,6 +75,12 @@ def __reversed__(self) -> Iterable[Any]: def __liquid__(self) -> object: return None + def __pos__(self) -> object: + return self + + def __neg__(self) -> object: + return self + def poke(self) -> bool: """Prod the type, giving it the opportunity to raise an exception.""" return True diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index 6cd46dc..bf50f2f 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -1,6 +1,299 @@ { "description": "Liquid2 compliance test suite", "tests": [ + { + "name": "arithmetic, divide, integers", + "template": "{{ 10 / 2 }}", + "result": "5" + }, + { + "name": "arithmetic, divide, integer and float", + "template": "{{ 10 / 2.0 }}", + "result": "5.0" + }, + { + "name": "arithmetic, divide, integer floor division", + "template": "{{ 9 / 2 }}", + "result": "4" + }, + { + "name": "arithmetic, divide, float and integer", + "template": "{{ 9.0 / 2 }}", + "result": "4.5" + }, + { + "name": "arithmetic, divide, integer and float floor division", + "template": "{{ 20 / 7.0 }}", + "result": "2.857142857142857" + }, + { + "name": "arithmetic, divide, integer as strings", + "template": "{{ \"10\" / \"2\" }}", + "result": "5" + }, + { + "name": "arithmetic, divide, non numeric string left", + "template": "{{ \"foo\" / \"2\" }}", + "result": "0" + }, + { + "name": "arithmetic, divide, non numeric string right", + "template": "{{ \"10\" / \"foo\" }}", + "invalid": true + }, + { + "name": "arithmetic, divide, undefined left", + "template": "{{ nosuchthing / 2 }}", + "result": "0" + }, + { + "name": "arithmetic, divide, undefined right", + "template": "{{ 10 / nosuchthing }}", + "invalid": true + }, + { + "name": "arithmetic, divide, by zero", + "template": "{{ 10 / 0 }}", + "invalid": true + }, + { + "name": "arithmetic, minus, integers", + "template": "{{ 10 - 2 }}", + "result": "8" + }, + { + "name": "arithmetic, minus, integer and float", + "template": "{{ 10 - 2.0 }}", + "result": "8.0" + }, + { + "name": "arithmetic, minus, floats", + "template": "{{ 10.1 - 2.2 }}", + "result": "7.9" + }, + { + "name": "arithmetic, minus, floats as stings", + "template": "{{ \"10.1\" - \"2.2\" }}", + "result": "7.9" + }, + { + "name": "arithmetic, minus, non numeric string left", + "template": "{{ \"foo\" - \"2.0\" }}", + "result": "-2.0" + }, + { + "name": "arithmetic, minus, non numeric string right", + "template": "{{ \"10\" - \"foo\" }}", + "result": "10" + }, + { + "name": "arithmetic, minus, undefined left", + "template": "{{ nosuchthing - 2 }}", + "result": "-2" + }, + { + "name": "arithmetic, minus, undefined right", + "template": "{{ 10 - nosuchthing }}", + "result": "10" + }, + { + "name": "arithmetic, modulo, integers", + "template": "{{ 10 % 3 }}", + "result": "1" + }, + { + "name": "arithmetic, modulo, integer and float", + "template": "{{ 10 % 3.0 }}", + "result": "1.0" + }, + { + "name": "arithmetic, modulo, float and integer", + "template": "{{ 10.0 % 3 }}", + "result": "1.0" + }, + { + "name": "arithmetic, modulo, floats", + "template": "{{ 10.1 % 7.0 }}", + "result": "3.1" + }, + { + "name": "arithmetic, modulo, floats as strings", + "template": "{{ \"10.1\" % \"7.0\" }}", + "result": "3.1" + }, + { + "name": "arithmetic, modulo, non numeric string left", + "template": "{{ \"foo\" % \"7.0\" }}", + "result": "0.0" + }, + { + "name": "arithmetic, modulo, non numeric string right", + "template": "{{ 10 % \"foo\" }}", + "invalid": true + }, + { + "name": "arithmetic, modulo, undefined left", + "template": "{{ nosuchthing % 2 }}", + "result": "0" + }, + { + "name": "arithmetic, modulo, undefined right", + "template": "{{ 10 % nosuchhting }}", + "invalid": true + }, + { + "name": "arithmetic, plus, integers", + "template": "{{ 10 + 2 }}", + "result": "12" + }, + { + "name": "arithmetic, plus, integer and float", + "template": "{{ 10 + 2.0 }}", + "result": "12.0" + }, + { + "name": "arithmetic, plus, floats", + "template": "{{ 10.1 + 2.2 }}", + "result": "12.3" + }, + { + "name": "arithmetic, plus, floats as stings", + "template": "{{ \"10.1\" + \"2.2\" }}", + "result": "12.3" + }, + { + "name": "arithmetic, plus, non numeric string left", + "template": "{{ \"foo\" + \"2.0\" }}", + "result": "2.0" + }, + { + "name": "arithmetic, plus, non numeric string right", + "template": "{{ \"10\" + \"foo\" }}", + "result": "10" + }, + { + "name": "arithmetic, plus, undefined left", + "template": "{{ nosuchthing + 2 }}", + "result": "2" + }, + { + "name": "arithmetic, plus, undefined right", + "template": "{{ 10 + nosuchthing }}", + "result": "10" + }, + { + "name": "arithmetic, times, integers", + "template": "{{ 10 * 2 }}", + "result": "20" + }, + { + "name": "arithmetic, times, integer and float", + "template": "{{ 10 * 2.0 }}", + "result": "20.0" + }, + { + "name": "arithmetic, times, floats", + "template": "{{ 5 * 2.1 }}", + "result": "10.5" + }, + { + "name": "arithmetic, times, floats as stings", + "template": "{{ \"5\" * \"2.1\" }}", + "result": "10.5" + }, + { + "name": "arithmetic, times, non numeric string left", + "template": "{{ \"foo\" * \"2.0\" }}", + "result": "0.0" + }, + { + "name": "arithmetic, times, non numeric string right", + "template": "{{ \"10\" * \"foo\" }}", + "result": "0" + }, + { + "name": "arithmetic, times, undefined left", + "template": "{{ nosuchthing * 2 }}", + "result": "0" + }, + { + "name": "arithmetic, times, undefined right", + "template": "{{ 10 * nosuchthing }}", + "result": "0" + }, + { + "name": "arithmetic, pow, integers", + "template": "{{ 2 ** 3 }}", + "result": "8" + }, + { + "name": "arithmetic, pow, floats", + "template": "{{ 2.0 ** 3.0 }}", + "result": "8.0" + }, + { + "name": "arithmetic, pow, float and int", + "template": "{{ 2.0 ** 3 }}", + "result": "8.0" + }, + { + "name": "arithmetic, pow, int and float", + "template": "{{ 2 ** 3.0 }}", + "result": "8.0" + }, + { + "name": "arithmetic, times has higher precedence than plus", + "template": "{{ 2 + 3 * 4 }}", + "result": "14" + }, + { + "name": "arithmetic, group terms so plus is evaluated before times", + "template": "{{ (2 + 3) * 4 }}", + "result": "20" + }, + { + "name": "arithmetic, divide has higher precedence than minus", + "template": "{{ 4 - 3 / 2.0 }}", + "result": "2.5" + }, + { + "name": "arithmetic, group terms so minus is evaluated before divide", + "template": "{{ (4 - 3) / 2.0 }}", + "result": "0.5" + }, + { + "name": "arithmetic, pow has higher priority than times", + "template": "{{ 2 * 2**3 }}", + "result": "16" + }, + { + "name": "arithmetic, group terms to times is evaluated before pow", + "template": "{{ (2 * 2)**3 }}", + "result": "64" + }, + { + "name": "arithmetic, negate", + "template": "{{ -(1+2) }}", + "result": "-3" + }, + { + "name": "arithmetic, unary minus takes priority over infix plus", + "template": "{{ -1+2 }}", + "result": "1" + }, + { + "name": "arithmetic, unary plus, int as string", + "template": "{{ +a - 3 }}", + "data": { + "a": 42 + }, + "result": "39" + }, + { + "name": "arithmetic, unary plus, undefined", + "template": "{{ +a - 3 }}", + "result": "-3" + }, { "name": "comment, comment", "template": "Hello, {# this is a comment #} World!", @@ -25,6 +318,71 @@ "data": {}, "result": "Hello,World!" }, + { + "name": "compound, logical and, last value, truthy left", + "template": "{{ true and 42 }}", + "result": "42" + }, + { + "name": "compound, logical and, last value, falsy left", + "template": "{{ false and 42 }}", + "result": "false" + }, + { + "name": "compound, logical or, last value, truthy left", + "template": "{{ 99 or 42 }}", + "result": "99" + }, + { + "name": "compound, logical or, last value, falsy left", + "template": "{{ false or 42 }}", + "result": "42" + }, + { + "name": "compound, logical and, falsy left, or truthy", + "template": "{{ false and 42 or 99 }}", + "result": "99" + }, + { + "name": "compound, arithmetic and relational, truthy", + "template": "{{ 1 + 2 <= 3 }}", + "result": "true" + }, + { + "name": "compound, arithmetic and relational, falsy", + "template": "{{ 1 + 2 > 3 }}", + "result": "false" + }, + { + "name": "compound, if tag, arithmetic and relational", + "template": "{% if 1 + 2 <= 3 %}true{% endif %}", + "result": "true" + }, + { + "name": "compound, arithmetic, relational and logical, truthy", + "template": "{{ 1 + 2 > 3 or 4 < 5 }}", + "result": "true" + }, + { + "name": "compound, arithmetic, plus false", + "template": "{{ 1 + (2 > 3) }}", + "result": "1" + }, + { + "name": "compound, arithmetic, plus true", + "template": "{{ 1 + (2 < 3) }}", + "result": "1" + }, + { + "name": "compound, arithmetic operators bind more tightly than relational operators", + "template": "{{ 1 + 2 == 3 }}", + "result": "true" + }, + { + "name": "compound, not binds more tightly than or", + "template": "{{ not false or true }}", + "result": "true" + }, { "name": "identifiers, ascii lowercase", "template": "{% assign foo = 'hello' %}{{ foo }} {{ bar }}", @@ -123,18 +481,6 @@ }, "result": "123" }, - { - "name": "identifiers, leading hyphen in for loop target", - "template": "{% for x in -foo %}{{ x }}{% endfor %}", - "data": { - "-foo": [ - 1, - 2, - 3 - ] - }, - "invalid": true - }, { "name": "identifiers, hyphen in for loop variable", "template": "{% for x-y in foo %}{{ x-y }}{% endfor %}", @@ -255,6 +601,451 @@ }, "invalid": true }, + { + "name": "lambda, filters, compact, array of objects, lambda expression", + "template": "{% assign x = a | compact: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": null, + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "lambda, filters, find, array of objects, lambda expression", + "template": "{% assign x = a | find: i => i.title == 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "lambda, filters, find, array of objects, lambda expression, not found", + "template": "{% assign x = a | find: i => i.title == '42' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "lambda, filters, find index, array of objects, lambda expression", + "template": "{% assign x = a | find_index: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "lambda, filters, find index, array of objects, lambda expression, not found", + "template": "{% assign x = a | find_index: i => i.title == 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "lambda, filters, has, array of objects, lambda expression", + "template": "{% assign x = a | has: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "lambda, filters, has, array of objects, lambda expression, not found", + "template": "{% assign x = a | has: i => i.title == '42' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + }, + { + "name": "lambda, filters, map, array of objects, lambda expression", + "template": "{{ a | map: i => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "lambda, filters, map, array of objects, lambda expression, parentheses", + "template": "{{ a | map: (i) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "lambda, filters, map, array of objects, lambda expression, two params", + "template": "{{ a | map: (i, j) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "lambda, filters, map, array of objects, lambda expression, map to index", + "template": "{{ a | map: (i, j) => j | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "0#1#2" + }, + { + "name": "lambda, filters, reject, array of objects, lambda expression", + "template": "{% assign x = a | reject: i => i.title == 'bar' or i.title == 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "heading": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(heading,foo)" + }, + { + "name": "lambda, filters, sort, array of objects, lambda expression", + "template": "{% assign x = a | sort: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,Baz)(user.title,bar)(user.title,foo)" + }, + { + "name": "lambda, filters, sort, array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" + }, + { + "name": "lambda, filters, sort natural, array of objects, lambda expression", + "template": "{% assign x = a | sort_natural: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,bar)(user.title,Baz)(user.title,foo)" + }, + { + "name": "lambda, filters, sort natural, array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort_natural: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" + }, + { + "name": "lambda, filters, sort numeric, array of objects, lambda expression argument", + "template": "{% assign x = a | sort_numeric: i => i.x %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [ + { + "y": "-1", + "x": "10" + }, + { + "x": "2" + }, + { + "x": "3" + } + ] + }, + "result": "(x,2)(x,3)(y,-1)(x,10)" + }, + { + "name": "lambda, filters, sum, hashes with lambda argument", + "template": "{{ a | sum: i => i.k }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "result": "6" + }, + { + "name": "lambda, filters, uniq, array of objects, lambda expression", + "template": "{% assign x = a | uniq: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": "foo", + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "lambda, filters, where, array of hashes, lambda expression", + "template": "{% assign x = a | where: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": null + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)" + }, + { + "name": "lambda, filters, where, array of hashes, lambda expression, two arguments", + "template": "{% assign x = a | where: (item, index) => index > 0 %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": null + } + } + ] + }, + "result": "(user.title,bar)(user.title,)" + }, { "name": "output, string literal", "template": "{{ 'a' }}", diff --git a/tests/liquid2-compliance-test-suite/schema.json b/tests/liquid2-compliance-test-suite/schema.json index 9d91081..bc53c6d 100644 --- a/tests/liquid2-compliance-test-suite/schema.json +++ b/tests/liquid2-compliance-test-suite/schema.json @@ -39,7 +39,7 @@ "required": ["name", "template"], "oneOf": [ { - "required": ["data", "result"], + "required": ["result"], "properties": { "invalid": false } diff --git a/tests/liquid2-compliance-test-suite/tests/arithmetic.json b/tests/liquid2-compliance-test-suite/tests/arithmetic.json new file mode 100644 index 0000000..69db1ec --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/arithmetic.json @@ -0,0 +1,295 @@ +{ + "tests": [ + { + "name": "divide, integers", + "template": "{{ 10 / 2 }}", + "result": "5" + }, + { + "name": "divide, integer and float", + "template": "{{ 10 / 2.0 }}", + "result": "5.0" + }, + { + "name": "divide, integer floor division", + "template": "{{ 9 / 2 }}", + "result": "4" + }, + { + "name": "divide, float and integer", + "template": "{{ 9.0 / 2 }}", + "result": "4.5" + }, + { + "name": "divide, integer and float floor division", + "template": "{{ 20 / 7.0 }}", + "result": "2.857142857142857" + }, + { + "name": "divide, integer as strings", + "template": "{{ \"10\" / \"2\" }}", + "result": "5" + }, + { + "name": "divide, non numeric string left", + "template": "{{ \"foo\" / \"2\" }}", + "result": "0" + }, + { + "name": "divide, non numeric string right", + "template": "{{ \"10\" / \"foo\" }}", + "invalid": true + }, + { + "name": "divide, undefined left", + "template": "{{ nosuchthing / 2 }}", + "result": "0" + }, + { + "name": "divide, undefined right", + "template": "{{ 10 / nosuchthing }}", + "invalid": true + }, + { + "name": "divide, by zero", + "template": "{{ 10 / 0 }}", + "invalid": true + }, + { + "name": "minus, integers", + "template": "{{ 10 - 2 }}", + "result": "8" + }, + { + "name": "minus, integer and float", + "template": "{{ 10 - 2.0 }}", + "result": "8.0" + }, + { + "name": "minus, floats", + "template": "{{ 10.1 - 2.2 }}", + "result": "7.9" + }, + { + "name": "minus, floats as stings", + "template": "{{ \"10.1\" - \"2.2\" }}", + "result": "7.9" + }, + { + "name": "minus, non numeric string left", + "template": "{{ \"foo\" - \"2.0\" }}", + "result": "-2.0" + }, + { + "name": "minus, non numeric string right", + "template": "{{ \"10\" - \"foo\" }}", + "result": "10" + }, + { + "name": "minus, undefined left", + "template": "{{ nosuchthing - 2 }}", + "result": "-2" + }, + { + "name": "minus, undefined right", + "template": "{{ 10 - nosuchthing }}", + "result": "10" + }, + { + "name": "modulo, integers", + "template": "{{ 10 % 3 }}", + "result": "1" + }, + { + "name": "modulo, integer and float", + "template": "{{ 10 % 3.0 }}", + "result": "1.0" + }, + { + "name": "modulo, float and integer", + "template": "{{ 10.0 % 3 }}", + "result": "1.0" + }, + { + "name": "modulo, floats", + "template": "{{ 10.1 % 7.0 }}", + "result": "3.1" + }, + { + "name": "modulo, floats as strings", + "template": "{{ \"10.1\" % \"7.0\" }}", + "result": "3.1" + }, + { + "name": "modulo, non numeric string left", + "template": "{{ \"foo\" % \"7.0\" }}", + "result": "0.0" + }, + { + "name": "modulo, non numeric string right", + "template": "{{ 10 % \"foo\" }}", + "invalid": true + }, + { + "name": "modulo, undefined left", + "template": "{{ nosuchthing % 2 }}", + "result": "0" + }, + { + "name": "modulo, undefined right", + "template": "{{ 10 % nosuchhting }}", + "invalid": true + }, + { + "name": "plus, integers", + "template": "{{ 10 + 2 }}", + "result": "12" + }, + { + "name": "plus, integer and float", + "template": "{{ 10 + 2.0 }}", + "result": "12.0" + }, + { + "name": "plus, floats", + "template": "{{ 10.1 + 2.2 }}", + "result": "12.3" + }, + { + "name": "plus, floats as stings", + "template": "{{ \"10.1\" + \"2.2\" }}", + "result": "12.3" + }, + { + "name": "plus, non numeric string left", + "template": "{{ \"foo\" + \"2.0\" }}", + "result": "2.0" + }, + { + "name": "plus, non numeric string right", + "template": "{{ \"10\" + \"foo\" }}", + "result": "10" + }, + { + "name": "plus, undefined left", + "template": "{{ nosuchthing + 2 }}", + "result": "2" + }, + { + "name": "plus, undefined right", + "template": "{{ 10 + nosuchthing }}", + "result": "10" + }, + { + "name": "times, integers", + "template": "{{ 10 * 2 }}", + "result": "20" + }, + { + "name": "times, integer and float", + "template": "{{ 10 * 2.0 }}", + "result": "20.0" + }, + { + "name": "times, floats", + "template": "{{ 5 * 2.1 }}", + "result": "10.5" + }, + { + "name": "times, floats as stings", + "template": "{{ \"5\" * \"2.1\" }}", + "result": "10.5" + }, + { + "name": "times, non numeric string left", + "template": "{{ \"foo\" * \"2.0\" }}", + "result": "0.0" + }, + { + "name": "times, non numeric string right", + "template": "{{ \"10\" * \"foo\" }}", + "result": "0" + }, + { + "name": "times, undefined left", + "template": "{{ nosuchthing * 2 }}", + "result": "0" + }, + { + "name": "times, undefined right", + "template": "{{ 10 * nosuchthing }}", + "result": "0" + }, + { + "name": "pow, integers", + "template": "{{ 2 ** 3 }}", + "result": "8" + }, + { + "name": "pow, floats", + "template": "{{ 2.0 ** 3.0 }}", + "result": "8.0" + }, + { + "name": "pow, float and int", + "template": "{{ 2.0 ** 3 }}", + "result": "8.0" + }, + { + "name": "pow, int and float", + "template": "{{ 2 ** 3.0 }}", + "result": "8.0" + }, + { + "name": "times has higher precedence than plus", + "template": "{{ 2 + 3 * 4 }}", + "result": "14" + }, + { + "name": "group terms so plus is evaluated before times", + "template": "{{ (2 + 3) * 4 }}", + "result": "20" + }, + { + "name": "divide has higher precedence than minus", + "template": "{{ 4 - 3 / 2.0 }}", + "result": "2.5" + }, + { + "name": "group terms so minus is evaluated before divide", + "template": "{{ (4 - 3) / 2.0 }}", + "result": "0.5" + }, + { + "name": "pow has higher priority than times", + "template": "{{ 2 * 2**3 }}", + "result": "16" + }, + { + "name": "group terms to times is evaluated before pow", + "template": "{{ (2 * 2)**3 }}", + "result": "64" + }, + { + "name": "negate", + "template": "{{ -(1+2) }}", + "result": "-3" + }, + { + "name": "unary minus takes priority over infix plus", + "template": "{{ -1+2 }}", + "result": "1" + }, + { + "name": "unary plus, int as string", + "template": "{{ +a - 3 }}", + "data": { "a": 42 }, + "result": "39" + }, + { + "name": "unary plus, undefined", + "template": "{{ +a - 3 }}", + "result": "-3" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/compound.json b/tests/liquid2-compliance-test-suite/tests/compound.json new file mode 100644 index 0000000..a495d5d --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/compound.json @@ -0,0 +1,69 @@ +{ + "tests": [ + { + "name": "logical and, last value, truthy left", + "template": "{{ true and 42 }}", + "result": "42" + }, + { + "name": "logical and, last value, falsy left", + "template": "{{ false and 42 }}", + "result": "false" + }, + { + "name": "logical or, last value, truthy left", + "template": "{{ 99 or 42 }}", + "result": "99" + }, + { + "name": "logical or, last value, falsy left", + "template": "{{ false or 42 }}", + "result": "42" + }, + { + "name": "logical and, falsy left, or truthy", + "template": "{{ false and 42 or 99 }}", + "result": "99" + }, + { + "name": "arithmetic and relational, truthy", + "template": "{{ 1 + 2 <= 3 }}", + "result": "true" + }, + { + "name": "arithmetic and relational, falsy", + "template": "{{ 1 + 2 > 3 }}", + "result": "false" + }, + { + "name": "if tag, arithmetic and relational", + "template": "{% if 1 + 2 <= 3 %}true{% endif %}", + "result": "true" + }, + { + "name": "arithmetic, relational and logical, truthy", + "template": "{{ 1 + 2 > 3 or 4 < 5 }}", + "result": "true" + }, + { + "name": "arithmetic, plus false", + "template": "{{ 1 + (2 > 3) }}", + "result": "1" + }, + { + "name": "arithmetic, plus true", + "template": "{{ 1 + (2 < 3) }}", + "result": "1" + }, + { + "name": "arithmetic operators bind more tightly than relational operators", + "template": "{{ 1 + 2 == 3 }}", + "result": "true" + }, + { + "name": "not binds more tightly than or", + "template": "{{ not false or true }}", + "result": "true" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/identifiers.json b/tests/liquid2-compliance-test-suite/tests/identifiers.json index 6e5fd9c..186a066 100644 --- a/tests/liquid2-compliance-test-suite/tests/identifiers.json +++ b/tests/liquid2-compliance-test-suite/tests/identifiers.json @@ -94,14 +94,6 @@ }, "result": "123" }, - { - "name": "leading hyphen in for loop target", - "template": "{% for x in -foo %}{{ x }}{% endfor %}", - "data": { - "-foo": [1, 2, 3] - }, - "invalid": true - }, { "name": "hyphen in for loop variable", "template": "{% for x-y in foo %}{{ x-y }}{% endfor %}", diff --git a/tests/liquid2-compliance-test-suite/tests/lambda.json b/tests/liquid2-compliance-test-suite/tests/lambda.json new file mode 100644 index 0000000..bc74e30 --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/lambda.json @@ -0,0 +1,449 @@ +{ + "tests": [ + { + "name": "filters, compact, array of objects, lambda expression", + "template": "{% assign x = a | compact: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": null, + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "filters, find, array of objects, lambda expression", + "template": "{% assign x = a | find: i => i.title == 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "filters, find, array of objects, lambda expression, not found", + "template": "{% assign x = a | find: i => i.title == '42' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "filters, find index, array of objects, lambda expression", + "template": "{% assign x = a | find_index: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "filters, find index, array of objects, lambda expression, not found", + "template": "{% assign x = a | find_index: i => i.title == 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "filters, has, array of objects, lambda expression", + "template": "{% assign x = a | has: i => i.title == 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "filters, has, array of objects, lambda expression, not found", + "template": "{% assign x = a | has: i => i.title == '42' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + }, + { + "name": "filters, map, array of objects, lambda expression", + "template": "{{ a | map: i => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "filters, map, array of objects, lambda expression, parentheses", + "template": "{{ a | map: (i) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "filters, map, array of objects, lambda expression, two params", + "template": "{{ a | map: (i, j) => i.user.title | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "foo#bar#baz" + }, + { + "name": "filters, map, array of objects, lambda expression, map to index", + "template": "{{ a | map: (i, j) => j | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "result": "0#1#2" + }, + { + "name": "filters, reject, array of objects, lambda expression", + "template": "{% assign x = a | reject: i => i.title == 'bar' or i.title == 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "heading": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(heading,foo)" + }, + { + "name": "filters, sort, array of objects, lambda expression", + "template": "{% assign x = a | sort: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,Baz)(user.title,bar)(user.title,foo)" + }, + { + "name": "filters, sort, array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" + }, + { + "name": "filters, sort natural, array of objects, lambda expression", + "template": "{% assign x = a | sort_natural: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,bar)(user.title,Baz)(user.title,foo)" + }, + { + "name": "filters, sort natural, array of objects, lambda expression, all missing", + "template": "{% assign x = a | sort_natural: i => i.user.foo %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "Baz" + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" + }, + { + "name": "filters, sort numeric, array of objects, lambda expression argument", + "template": "{% assign x = a | sort_numeric: i => i.x %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [ + { + "y": "-1", + "x": "10" + }, + { + "x": "2" + }, + { + "x": "3" + } + ] + }, + "result": "(x,2)(x,3)(y,-1)(x,10)" + }, + { + "name": "filters, sum, hashes with lambda argument", + "template": "{{ a | sum: i => i.k }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "result": "6" + }, + { + "name": "filters, uniq, array of objects, lambda expression", + "template": "{% assign x = a | uniq: i => i.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo", + "name": "a" + }, + { + "title": "foo", + "name": "b" + }, + { + "title": "bar", + "name": "c" + } + ] + }, + "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "filters, where, array of hashes, lambda expression", + "template": "{% assign x = a | where: i => i.user.title %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": null + } + } + ] + }, + "result": "(user.title,foo)(user.title,bar)" + }, + { + "name": "filters, where, array of hashes, lambda expression, two arguments", + "template": "{% assign x = a | where: (item, index) => index > 0 %}{% for obj in x %}{% for i in obj %}({{ i[0] }}.title,{{ i[1].title }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": null + } + } + ] + }, + "result": "(user.title,bar)(user.title,)" + } + ] +} diff --git a/tests/test_compliance.py b/tests/test_compliance.py index 9b1bc5c..5817176 100644 --- a/tests/test_compliance.py +++ b/tests/test_compliance.py @@ -30,6 +30,10 @@ class Case: FILENAME = "tests/liquid2-compliance-test-suite/cts.json" +class MockEnvironment(Environment): + arithmetic_operators = True + + def cases() -> list[Case]: with open(FILENAME, encoding="utf8") as fd: data = json.load(fd) @@ -46,13 +50,13 @@ def invalid_cases() -> list[Case]: @pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name")) def test_compliance(case: Case) -> None: - env = Environment(loader=DictLoader(case.templates or {})) + env = MockEnvironment(loader=DictLoader(case.templates or {})) assert env.from_string(case.template).render(**case.data) == case.result @pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name")) def test_compliance_async(case: Case) -> None: - env = Environment(loader=DictLoader(case.templates or {})) + env = MockEnvironment(loader=DictLoader(case.templates or {})) template = env.from_string(case.template) async def coro() -> str: @@ -63,14 +67,14 @@ async def coro() -> str: @pytest.mark.parametrize("case", invalid_cases(), ids=operator.attrgetter("name")) def test_invalid_compliance(case: Case) -> None: - env = Environment(loader=DictLoader(case.templates or {})) + env = MockEnvironment(loader=DictLoader(case.templates or {})) with pytest.raises(LiquidError): env.from_string(case.template).render(**case.data) @pytest.mark.parametrize("case", invalid_cases(), ids=operator.attrgetter("name")) def test_invalid_compliance_async(case: Case) -> None: - env = Environment(loader=DictLoader(case.templates or {})) + env = MockEnvironment(loader=DictLoader(case.templates or {})) async def coro() -> str: template = env.from_string(case.template) diff --git a/tests/test_liquid_syntax_errors.py b/tests/test_liquid_syntax_errors.py index 0344d66..9c403cc 100644 --- a/tests/test_liquid_syntax_errors.py +++ b/tests/test_liquid_syntax_errors.py @@ -67,7 +67,7 @@ class Case(NamedTuple): Case( description="missing range or identifier in forloop", template="{% for x in %}{{ x }}foo{% endfor %}", - expect_msg="expected a primitive expression, found EOI", + expect_msg="unexpected EOI", ), Case( description="float with trailing dot in range literal", @@ -76,8 +76,8 @@ class Case(NamedTuple): ), Case( description="chained identifier for loop variable", - template="{% for x.y in (2...4) %}{{ x }}{% endfor %}", - expect_msg="unexpected '.'", + template="{% for x.y in (2..4) %}{{ x }}{% endfor %}", + expect_msg="expected an identifier, found PATH", ), Case( description="missing equal in assignment tag", @@ -92,12 +92,12 @@ class Case(NamedTuple): Case( description="minus string", template="{{ -'foo' }}", - expect_msg="unexpected '-'", + expect_msg="unexpected operator -", ), Case( description="unknown prefix operator", template="{{ +5 }}", - expect_msg=r"unexpected '\+'", + expect_msg="unexpected operator +", ), Case( description="float literal without a leading zero", @@ -204,8 +204,8 @@ class Case(NamedTuple): ), Case( description="unexpected identifier character", - template=r"{% assign foo+bar = 'hello there'%}{{ foo+bar }}", - expect_msg=r"unexpected '\+'", + template=r"{% assign foo&bar = 'hello there'%}{{ foo&bar }}", + expect_msg=r"unexpected '&'", ), Case( description="unexpected assign path", @@ -225,7 +225,7 @@ class Case(NamedTuple): Case( description="consecutive commas in positional argument list", template=r"{% call macro a,, b %}", - expect_msg="expected a primitive expression, found COMMA", + expect_msg="unexpected COMMA", ), Case( description="template string, unbalanced quotes",