From 0e51a0bb37c01fdd17963274ac6159bf80cf6336 Mon Sep 17 00:00:00 2001 From: James Prior Date: Sun, 9 Mar 2025 08:26:49 +0000 Subject: [PATCH 1/2] Last value Boolean operator semantics --- liquid/builtin/expressions/logical.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/liquid/builtin/expressions/logical.py b/liquid/builtin/expressions/logical.py index e223fcbe..efa08a12 100644 --- a/liquid/builtin/expressions/logical.py +++ b/liquid/builtin/expressions/logical.py @@ -210,14 +210,12 @@ def __str__(self) -> str: return f"{self.left} and {self.right}" def evaluate(self, context: RenderContext) -> object: - return is_truthy(self.left.evaluate(context)) and is_truthy( - self.right.evaluate(context) - ) + left = self.left.evaluate(context) + return self.right.evaluate(context) if is_truthy(left) else left 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) - ) + left = await self.left.evaluate_async(context) + return await self.right.evaluate_async(context) if is_truthy(left) else left def children(self) -> list[Expression]: return [self.left, self.right] @@ -235,14 +233,12 @@ 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) - ) + left = await self.left.evaluate_async(context) + return left if is_truthy(left) else await self.right.evaluate_async(context) def children(self) -> list[Expression]: return [self.left, self.right] From 158a6aeb30fd615c56a73428075810c520b6052d Mon Sep 17 00:00:00 2001 From: James Prior Date: Sun, 9 Mar 2025 18:11:18 +0000 Subject: [PATCH 2/2] Alias `parse_boolean_primitive` as `parse_primary` --- liquid/builtin/expressions/__init__.py | 2 + liquid/builtin/expressions/arguments.py | 14 +++--- liquid/builtin/expressions/filtered.py | 14 +++--- liquid/builtin/expressions/logical.py | 25 ++++------- liquid/builtin/expressions/loop.py | 10 ++--- liquid/builtin/expressions/primary.py | 10 +++++ liquid/builtin/tags/case_tag.py | 3 +- liquid/builtin/tags/cycle_tag.py | 4 +- liquid/builtin/tags/render_tag.py | 2 +- liquid/golden/case_tag.py | 11 +++++ liquid/golden/output_statement.py | 60 +++++++++++++++++++++++++ liquid/golden/plus_filter.py | 12 +++++ 12 files changed, 127 insertions(+), 40 deletions(-) create mode 100644 liquid/builtin/expressions/primary.py diff --git a/liquid/builtin/expressions/__init__.py b/liquid/builtin/expressions/__init__.py index f7ddda6e..c22b309e 100644 --- a/liquid/builtin/expressions/__init__.py +++ b/liquid/builtin/expressions/__init__.py @@ -10,6 +10,7 @@ from .path import Location from .path import Path from .path import Segments +from .primary import parse_primary from .primitive import Identifier from .primitive import Literal from .primitive import Nil @@ -32,6 +33,7 @@ "Nil", "parse_arguments", "parse_identifier", + "parse_primary", "parse_primitive", "parse_string_or_path", "Path", diff --git a/liquid/builtin/expressions/arguments.py b/liquid/builtin/expressions/arguments.py index 83aa84b7..8eb2a71c 100644 --- a/liquid/builtin/expressions/arguments.py +++ b/liquid/builtin/expressions/arguments.py @@ -12,7 +12,7 @@ from liquid.token import TOKEN_EOF from liquid.token import TOKEN_WORD -from .primitive import parse_primitive +from .primary import parse_primary if TYPE_CHECKING: from liquid import Environment @@ -65,7 +65,7 @@ def parse(env: Environment, tokens: TokenStream) -> list[KeywordArgument]: if token.kind == TOKEN_WORD: tokens.eat_one_of(*argument_separators) - value = parse_primitive(env, tokens) + value = parse_primary(env, tokens) args.append(KeywordArgument(token, token.value, value)) if env.mode == Mode.STRICT and tokens.current.kind == TOKEN_WORD: raise LiquidSyntaxError( @@ -114,7 +114,7 @@ def parse(env: Environment, tokens: TokenStream) -> list[PositionalArgument]: if tokens.current.kind == TOKEN_EOF: break - args.append(PositionalArgument(parse_primitive(env, tokens))) + args.append(PositionalArgument(parse_primary(env, tokens))) return args @@ -154,7 +154,7 @@ def parse(env: Environment, tokens: TokenStream) -> dict[str, Parameter]: if tokens.current.kind in argument_separators: # A parameter with a default value next(tokens) # Move past ":" or "=" - value = parse_primitive(env, tokens) + value = parse_primary(env, tokens) params[token.value] = Parameter(token, token.value, value) else: params[token.value] = Parameter(token, token.value, None) @@ -191,13 +191,13 @@ def parse_arguments( if tokens.peek.kind in argument_separators: name_token = next(tokens) next(tokens) # = or : - value = parse_primitive(env, tokens) + value = parse_primary(env, tokens) kwargs.append(KeywordArgument(name_token, token.value, value)) else: - args.append(PositionalArgument(parse_primitive(env, tokens))) + args.append(PositionalArgument(parse_primary(env, tokens))) else: # A primitive as a positional argument - args.append(PositionalArgument(parse_primitive(env, tokens))) + args.append(PositionalArgument(parse_primary(env, tokens))) if tokens.current.kind != TOKEN_COMMA: break diff --git a/liquid/builtin/expressions/filtered.py b/liquid/builtin/expressions/filtered.py index c3b54d68..6f6398d3 100644 --- a/liquid/builtin/expressions/filtered.py +++ b/liquid/builtin/expressions/filtered.py @@ -34,7 +34,7 @@ from .arguments import KeywordArgument from .arguments import PositionalArgument from .logical import BooleanExpression -from .primitive import parse_primitive +from .primary import parse_primary if TYPE_CHECKING: from liquid import Environment @@ -107,7 +107,7 @@ def parse( env: Environment, tokens: TokenStream ) -> Union[FilteredExpression, TernaryFilteredExpression]: """Parse a filtered expression from _tokens_.""" - left = parse_primitive(env, tokens) + left = parse_primary(env, tokens) filters = Filter.parse(env, tokens, delim=(TOKEN_PIPE,)) if tokens.current.kind == TOKEN_IF: @@ -221,7 +221,7 @@ def parse( if tokens.current.kind == TOKEN_ELSE: next(tokens) # else - alternative = parse_primitive(env, tokens) + alternative = parse_primary(env, tokens) if tokens.current.kind == TOKEN_PIPE: filters = Filter.parse(env, tokens, delim=(TOKEN_PIPE,)) @@ -348,12 +348,10 @@ def parse( next(tokens) # word next(tokens) # : or = args.append( - KeywordArgument( - tok, tok.value, parse_primitive(env, tokens) - ) + KeywordArgument(tok, tok.value, parse_primary(env, tokens)) ) else: - args.append(PositionalArgument(parse_primitive(env, tokens))) + args.append(PositionalArgument(parse_primary(env, tokens))) if tokens.current.kind in FILTER_TOKENS: raise LiquidSyntaxError( @@ -362,7 +360,7 @@ def parse( token=tokens.current, ) elif tok.kind in FILTER_TOKENS: - args.append(PositionalArgument(parse_primitive(env, tokens))) + args.append(PositionalArgument(parse_primary(env, tokens))) # There should be a comma between filter tokens. if tokens.current.kind in FILTER_TOKENS: diff --git a/liquid/builtin/expressions/logical.py b/liquid/builtin/expressions/logical.py index efa08a12..e64497ef 100644 --- a/liquid/builtin/expressions/logical.py +++ b/liquid/builtin/expressions/logical.py @@ -432,31 +432,24 @@ def parse_boolean_primitive( # noqa: PLR0912 kind = token.kind if kind == TOKEN_TRUE: - left = TrueLiteral(token) - next(tokens) + left = TrueLiteral(next(tokens)) elif kind == TOKEN_FALSE: - left = FalseLiteral(token) - next(tokens) + left = FalseLiteral(next(tokens)) + elif kind in (TOKEN_NIL, TOKEN_NULL): - left = Nil(token) - next(tokens) + left = Nil(next(tokens)) elif kind == TOKEN_INTEGER: - left = IntegerLiteral(token, to_int(token.value)) - next(tokens) + left = IntegerLiteral(next(tokens), to_int(token.value)) elif kind == TOKEN_FLOAT: - left = FloatLiteral(token, float(token.value)) - next(tokens) + left = FloatLiteral(next(tokens), float(token.value)) elif kind == TOKEN_STRING: - left = StringLiteral(token, token.value) - next(tokens) + left = StringLiteral(next(tokens), token.value) elif kind == TOKEN_RANGE_LITERAL: left = RangeLiteral.parse(env, tokens) elif kind == TOKEN_BLANK: - left = Blank(token) - next(tokens) + left = Blank(next(tokens)) elif kind == TOKEN_EMPTY: - left = Empty(token) - next(tokens) + left = Empty(next(tokens)) elif kind in (TOKEN_WORD, TOKEN_IDENTSTRING, TOKEN_LBRACKET): left = Path.parse(env, tokens) elif kind == TOKEN_LPAREN: diff --git a/liquid/builtin/expressions/loop.py b/liquid/builtin/expressions/loop.py index cfb49bbd..690bcc87 100644 --- a/liquid/builtin/expressions/loop.py +++ b/liquid/builtin/expressions/loop.py @@ -26,9 +26,9 @@ from liquid.token import TOKEN_OFFSET from liquid.token import TOKEN_REVERSED +from .primary import parse_primary from .primitive import StringLiteral from .primitive import parse_identifier -from .primitive import parse_primitive if TYPE_CHECKING: from liquid import Environment @@ -216,7 +216,7 @@ def parse(env: Environment, tokens: TokenStream) -> LoopExpression: token = tokens.current identifier = parse_identifier(env, tokens) tokens.eat(TOKEN_IN) - iterable = parse_primitive(env, tokens) + iterable = parse_primary(env, tokens) reversed_ = False offset: Expression | None = None @@ -237,7 +237,7 @@ def parse(env: Environment, tokens: TokenStream) -> LoopExpression: if kind == TOKEN_LIMIT: tokens.eat_one_of(*argument_separators) - limit = parse_primitive(env, tokens) + limit = parse_primary(env, tokens) elif kind == TOKEN_REVERSED: reversed_ = True elif kind == TOKEN_OFFSET: @@ -247,10 +247,10 @@ def parse(env: Environment, tokens: TokenStream) -> LoopExpression: next(tokens) offset = StringLiteral(token=offset_token, value="continue") else: - offset = parse_primitive(env, tokens) + offset = parse_primary(env, tokens) elif kind == TOKEN_COLS: tokens.eat_one_of(*argument_separators) - cols = parse_primitive(env, tokens) + cols = parse_primary(env, tokens) elif kind == TOKEN_COMMA: if env.mode == Mode.STRICT and tokens.peek.kind == TOKEN_COMMA: raise LiquidSyntaxError( diff --git a/liquid/builtin/expressions/primary.py b/liquid/builtin/expressions/primary.py new file mode 100644 index 00000000..4f5a3afd --- /dev/null +++ b/liquid/builtin/expressions/primary.py @@ -0,0 +1,10 @@ +"""Built-in primary expressions. + +A primary expression is any in line logical expression or primitive. +""" + +from .logical import parse_boolean_primitive + +parse_primary = parse_boolean_primitive + +__all__ = ("parse_primary",) diff --git a/liquid/builtin/tags/case_tag.py b/liquid/builtin/tags/case_tag.py index 8594c259..b857ae72 100644 --- a/liquid/builtin/tags/case_tag.py +++ b/liquid/builtin/tags/case_tag.py @@ -10,6 +10,7 @@ from liquid.ast import BlockNode from liquid.ast import Node +from liquid.builtin.expressions import parse_primary from liquid.builtin.expressions import parse_primitive from liquid.builtin.expressions.logical import _eq from liquid.exceptions import LiquidSyntaxError @@ -140,7 +141,7 @@ def parse(self, stream: TokenStream) -> Node: """Parse tokens from _stream_ into an AST node.""" token = stream.eat(TOKEN_TAG) tokens = stream.into_inner(tag=token) - left = parse_primitive(self.env, tokens) + left = parse_primary(self.env, tokens) tokens.expect_eos() # Eat whitespace or junk between `case` and when/else/endcase diff --git a/liquid/builtin/tags/cycle_tag.py b/liquid/builtin/tags/cycle_tag.py index 16bad30b..a5964744 100644 --- a/liquid/builtin/tags/cycle_tag.py +++ b/liquid/builtin/tags/cycle_tag.py @@ -10,7 +10,7 @@ from liquid.ast import Node from liquid.builtin.expressions import PositionalArgument -from liquid.builtin.expressions import parse_primitive +from liquid.builtin.expressions import parse_primary from liquid.stringify import to_liquid_string from liquid.tag import Tag from liquid.token import TOKEN_COLON @@ -120,7 +120,7 @@ def parse(self, stream: TokenStream) -> CycleNode: group_name: Optional[Expression] = None if tokens.peek.kind == TOKEN_COLON: - group_name = parse_primitive(self.env, tokens) + group_name = parse_primary(self.env, tokens) tokens.eat(TOKEN_COLON) args = PositionalArgument.parse(self.env, tokens) diff --git a/liquid/builtin/tags/render_tag.py b/liquid/builtin/tags/render_tag.py index 28121a09..811c3a5c 100644 --- a/liquid/builtin/tags/render_tag.py +++ b/liquid/builtin/tags/render_tag.py @@ -289,7 +289,7 @@ def parse(self, stream: TokenStream) -> Node: # This is the name of the template to be included. tokens.expect(TOKEN_STRING) name = parse_primitive(self.env, tokens) - assert isinstance(name, StringLiteral) + assert isinstance(name, StringLiteral) # TODO: better error alias: Optional[Identifier] = None var: Optional[Path] = None diff --git a/liquid/golden/case_tag.py b/liquid/golden/case_tag.py index dc5a01a4..dd26aac5 100644 --- a/liquid/golden/case_tag.py +++ b/liquid/golden/case_tag.py @@ -131,6 +131,17 @@ expect="bar", globals={"title": "Hello"}, ), + Case( + description="or separated when expression, duplicated", + template=( + r"{% case title %}" + r"{% when 'foo' %}foo" + r"{% when title or 'Hello' %}bar" + r"{% endcase %}" + ), + expect="barbar", + globals={"title": "Hello"}, + ), Case( description="mix or and comma separated when expression", template=( diff --git a/liquid/golden/output_statement.py b/liquid/golden/output_statement.py index 0def5957..7dd9db4a 100644 --- a/liquid/golden/output_statement.py +++ b/liquid/golden/output_statement.py @@ -292,4 +292,64 @@ expect="", globals={"a": [1, 2, 3]}, ), + Case( + description="logical or, left is nil", + template="{{ a or b }}", + expect="foo", + globals={"a": None, "b": "foo"}, + ), + Case( + description="logical or, left is a string", + template="{{ a or b }}", + expect="foo", + globals={"a": "foo", "b": "bar"}, + ), + Case( + description="logical or, left is an empty string", + template="{{ a or b }}", + expect="", + globals={"a": "", "b": "bar"}, + ), + Case( + description="logical or, left and right are nil", + template="{{ a or b }}", + expect="", + globals={"a": None, "b": None}, + ), + Case( + description="logical and, left is nil", + template="{{ a and b }}", + expect="", + globals={"a": None, "b": "foo"}, + ), + Case( + description="logical and, left is a string", + template="{{ a and b }}", + expect="bar", + globals={"a": "foo", "b": "bar"}, + ), + Case( + description="logical and, left is an empty string", + template="{{ a and b }}", + expect="bar", + globals={"a": "", "b": "bar"}, + ), + Case( + description="logical and, left and right are nil", + template="{{ a and b }}", + expect="", + globals={"a": None, "b": None}, + ), + Case( + description="comparison expression", + template="{{ a < 5 }}", + expect="true", + globals={"a": 2}, + ), + Case( + description="comparison and logical expression", + template="{{ a < 5 and false }}", + expect="false", + globals={"a": 2}, + ), ] diff --git a/liquid/golden/plus_filter.py b/liquid/golden/plus_filter.py index bee04287..767a4c4d 100644 --- a/liquid/golden/plus_filter.py +++ b/liquid/golden/plus_filter.py @@ -60,4 +60,16 @@ template=r"{{ 10 | plus: -2 }}", expect="8", ), + Case( + description="argument is a logical expression", + template=r"{{ 10 | plus: a or 1 }}", + expect="11", + globals={"a": None}, + ), + Case( + description="argument is a logical expression", + template=r"{{ a or 10 | plus: b or 1 }}", + expect="47", + globals={"a": 42, "b": 5}, + ), ]