diff --git a/CHANGELOG.md b/CHANGELOG.md index 4726701..c2d9c52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,22 @@ # Python Liquid2 Change Log -## Version 0.2.1 (unreleased) +## Version 0.3.0 (unreleased) + +**Breaking changes** + +- Most built-in expression parsing functions/methods now expect the current `Environment` instance to be passed as the first argument. **Fixes** - Fixed `{% for %}` tag expressions with a comma between the iterable and `limit`, `offset` or `reversed`. Previously we were assuming a comma immediately following the iterable would mean we are iterating an array literal. We're also explicitly disallowing `limit`, `offset` and `reversed` arguments after an array literal. +**Features** + +- Added optional filter argument validation at template parse time. ([docs](https://jg-rp.github.io/python-liquid2/custom_filters/#filter-argument-validation)) +- Added lambda expressions as filter arguments. Both custom and built-in filters can accept arguments of the form ` => ` or `(, ) => `. +- Updated filters `map`, `where`, `sort`, `sort_natural`, `uniq`, `compact` and `sum` to accept lambda expression or string arguments. +- Added filters `reject`, `has`, `find` and `find_index`. + ## Version 0.2.0 **Features** diff --git a/docs/api/expression.md b/docs/api/expression.md new file mode 100644 index 0000000..d7e966f --- /dev/null +++ b/docs/api/expression.md @@ -0,0 +1 @@ +::: liquid2.expression.Expression diff --git a/docs/custom_filters.md b/docs/custom_filters.md index 2e2cd2c..269f43f 100644 --- a/docs/custom_filters.md +++ b/docs/custom_filters.md @@ -137,3 +137,51 @@ del env.filters["safe"] !!! tip You can add, remove and replace filters on `liquid2.DEFAULT_ENVIRONMENT` too. Convenience functions [`parse()`](api/convenience.md#liquid2.parse) and [`render()`](api/convenience.md#liquid2.render) use `DEFAULT_ENVIRONMENT` + +## Filter argument validation + +When implementing a filter as a class, you have the option of implementing a `validate()` method. If present, `validate` will be called when parsing the template, giving you the opportunity to raise an exception if the filter's arguments are not acceptable. + +Here's an example of the built-in [`map`](filter_reference.md#map) filter. It uses `validate` to check that if a lambda expression is passed as an argument, that expression is a path to a variable, not a logical expression. + +!!! tip + + See [map_filter.py](https://github.com/jg-rp/python-liquid2/tree/main/liquid2/builtin/filters/map_filter.py) for the full example. + +```python +class MapFilter: + """An implementation of the `map` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects exactly one argument, got {len(args)}", + token=token, + ) + + if not isinstance(args[0], PositionalArgument): + raise LiquidTypeError( + f"{name!r} takes no keyword arguments", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance(arg.expression, Path): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + +# ... +``` diff --git a/docs/filter_reference.md b/docs/filter_reference.md index e28d23f..18db17f 100644 --- a/docs/filter_reference.md +++ b/docs/filter_reference.md @@ -712,6 +712,133 @@ Return the input string with characters `&`, `<` and `>` converted to HTML-safe Have you read 'James & the Giant Peach'? ``` +## find + + + + +``` + | find: [, ] +``` + +Return the first item in the input array that contains a property, given as the first argument, equal to the value given as the second argument. If no such item exists, `null` is returned. + +In this example we select the first page in the "Programming" category. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign page = pages | find: 'category', 'Programming' %} +{{ page.title }} +``` + +```plain title="output" +Mastering JavaScript +``` + +### Lambda expressions + + + + +``` + | find: +``` + +We can pass a lambda expression as an argument to `find` to select the first item matching an arbitrary Boolean expression (one that evaluates to true or false). Using the same data as above, this example finds the first page with a "web development" tag. + +```liquid2 +{% assign page = pages | find: item => 'web development' in item.tags %} +{{ page.title }} +``` + +```plain title="output" +Mastering JavaScript +``` + +## find_index + +Return the index of the first item in the input array that contains a property, given as the first argument, equal to the value given as the second argument. If no such item exists, `null` is returned. + +In this example we find the index for the first page in the "Programming" category. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign index = pages | find_index: 'category', 'Programming' %} +{{ pages[index].title }} +``` + +```plain title="output" +Mastering JavaScript +``` + +### Lambda expressions + + + + +``` + | find_index: +``` + +We can pass a lambda expression as an argument to `find_index` to get the index for the first item matching an arbitrary Boolean expression (one that evaluates to true or false). Using the same data as above, this example finds the first page with a "web development" tag. + +```liquid2 +{% assign index = pages | find_index: item => 'web development' in item.tags %} +{{ page[index].title }} +``` + +```plain title="output" +Mastering JavaScript +``` + ## first @@ -762,6 +889,75 @@ Return the input down to the nearest whole number. Liquid tries to convert the i If the input can't be converted to a number, `0` is returned. +## has + + + + +``` + | has: [, ] +``` + +Return `true` if the input array contains an object with a property identified by the first argument that is equal to the object given as the second argument. `false` is returned if none of the items in the input array contain such a property/value. + +In this example we test to see if any pages are in the "Programming" category. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign has_programming_page = pages | has: 'category', 'Programming' %} +{{ has_programming_page }} +``` + +```plain title="output" +true +``` + +### Lambda expressions + + + + +``` + | has: +``` + +Use the same data as above, we can pass a lambda expression to `has` to test against an arbitrary Boolean expression (one that evaluates to true or false). + +This example test for a page with a category equal to "programming" or "Programming". + +```liquid2 +{% assign has_programming_page = pages | has: p => p.category == 'programming' or p.category == 'Programming' %} +{{ has_programming_page }} +``` + +```plain title="output" +true +``` + ## gettext @@ -898,10 +1094,10 @@ So much room for activities ! ## map - + ``` - | map: + | map: ``` Extract properties from an array of objects into a new array. @@ -936,12 +1132,55 @@ For example, if `pages` is an array of objects with a `category` property: - technology ``` +### Lambda expressions + + + + +You can use a lambda expression to select arbitrary nested properties and array items from a sequence of objects. + +For example, if `pages` is an array of objects with a `tags` property, which is an array of strings: + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign first_tags = pages | map: page => page.tags[0] -%} +{{ first_tags | json }} +``` + +```plain title="output" +["recipes", "Europe", "JavaScript"] +``` + ## minus -```` +``` | minus: ``` @@ -952,7 +1191,7 @@ Return the result of subtracting one number from another. If either the input or {{ "16" | minus: 4 }} {{ 183.357 | minus: 12.2 }} {{ "hello" | minus: 10 }} -```` +``` ```plain title="output" 2 @@ -1190,6 +1429,62 @@ concatenation. World! ``` +## reject + + + + +``` + | reject: [, ] +``` + +Return a copy of the input array including only those objects that have a property, named with the first argument, **that is not equal to** a value, given as the second argument. If a second argument is not given, only elements with the named property that are falsy will be included. + +```json title="data" +{ + "products": [ + { "title": "Vacuum", "type": "house", "available": true }, + { "title": "Spatula", "type": "kitchen", "available": false }, + { "title": "Television", "type": "lounge", "available": true }, + { "title": "Garlic press", "type": "kitchen", "available": true } + ] +} +``` + +```liquid2 +All products: +{% for product in products -%} +- {{ product.title }} +{% endfor %} + +{%- assign kitchen_products = products | reject: "type", "kitchen" -%} + +Non kitchen products: +{% for product in kitchen_products -%} +- {{ product.title }} +{% endfor %} + +{%- assign unavailable_products = products | reject: "available" -%} + +Unavailable products: +{% for product in unavailable_products -%} +- {{ product.title }} +{% endfor %} +``` + +```plain title="output" +All products: +- Vacuum +- Spatula +- Television +- Garlic press +Non kitchen products: +- Vacuum +- Television +Unavailable products: +- Spatula +``` + ## remove @@ -2101,3 +2396,50 @@ Available product: - Television - Garlic press ``` + +### Lambda expressions + + + + +``` + | where: +``` + +Use a lambda expression to select array items according to an arbitrary Boolean expression (one that evaluates to true or false). + +In this example we select pages that have a "coding" tag. + +```json title="data" +{ + "pages": [ + { + "id": 1, + "title": "Introduction to Cooking", + "category": "Cooking", + "tags": ["recipes", "beginner", "cooking techniques"] + }, + { + "id": 2, + "title": "Top 10 Travel Destinations in Europe", + "category": "Travel", + "tags": ["Europe", "destinations", "travel tips"] + }, + { + "id": 3, + "title": "Mastering JavaScript", + "category": "Programming", + "tags": ["JavaScript", "web development", "coding"] + } + ] +} +``` + +```liquid2 +{% assign coding_pages = pages | where: page => page.tags contains 'coding' %} +{{ coding_pages | map: page => page.title | json }} +``` + +```plain title="output" +["Mastering JavaScript"] +``` diff --git a/docs/migration.md b/docs/migration.md index 722c182..97c262a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -92,6 +92,16 @@ Hello, {{ you | capitalize }}! {% assign greeting = 'Hello, ${you | capitalize}!' %} ``` +### Lambda expression as filter arguments + + + +Many built-in filters that operate on arrays now support lambda expressions. For example, we can use the [`where`](filter_reference.md#where) filter to select values according to an arbitrary Boolean expression. + +```liquid2 +{% assign coding_pages = pages | where: page => page.tags contains 'coding' %} +``` + ### Logical `not` Logical expressions now support negation with the `not` operator and grouping terms with parentheses by default. Previously this was an opt-in feature. diff --git a/docs/tag_reference.md b/docs/tag_reference.md index 32fab3a..19eeeb9 100644 --- a/docs/tag_reference.md +++ b/docs/tag_reference.md @@ -115,7 +115,7 @@ Hello, {{ you }}! Values can be modified prior to output using filters. Filters are applied to an expression using the pipe symbol (`|`), followed by the filter's name and, possibly, some filter arguments. Filter arguments appear after a colon (`:`) and are separated by commas (`,`). -Multiple filters can be chained together, effectively piping the output of one filter into the input of another. +Multiple filters can be chained together, effectively piping the output of one filter into the input of another. See the [filter reference](filter_reference.md) for details of all built in filters. ```liquid2 {{ user_name | upcase }} diff --git a/liquid2/__about__.py b/liquid2/__about__.py index 3ced358..493f741 100644 --- a/liquid2/__about__.py +++ b/liquid2/__about__.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/liquid2/builtin/__init__.py b/liquid2/builtin/__init__.py index e19e111..fffb913 100644 --- a/liquid2/builtin/__init__.py +++ b/liquid2/builtin/__init__.py @@ -19,6 +19,7 @@ from .expressions import Identifier from .expressions import IntegerLiteral from .expressions import KeywordArgument +from .expressions import LambdaExpression from .expressions import Literal from .expressions import LogicalAndExpression from .expressions import LogicalNotExpression @@ -42,23 +43,22 @@ from .expressions import parse_primitive from .expressions import parse_string_or_identifier from .expressions import parse_string_or_path -from .filters.array import compact from .filters.array import concat from .filters.array import first from .filters.array import join from .filters.array import last -from .filters.array import map_ from .filters.array import reverse -from .filters.array import sort -from .filters.array import sort_natural -from .filters.array import sort_numeric -from .filters.array import sum_ -from .filters.array import uniq -from .filters.array import where from .filters.babel import Currency from .filters.babel import DateTime from .filters.babel import Number from .filters.babel import Unit +from .filters.filtering_filters import CompactFilter +from .filters.filtering_filters import RejectFilter +from .filters.filtering_filters import WhereFilter +from .filters.find_filters import FindFilter +from .filters.find_filters import FindIndexFilter +from .filters.find_filters import HasFilter +from .filters.map_filter import MapFilter from .filters.math import abs_ from .filters.math import at_least from .filters.math import at_most @@ -74,6 +74,9 @@ from .filters.misc import date from .filters.misc import default from .filters.misc import size +from .filters.sorting_filters import SortFilter +from .filters.sorting_filters import SortNaturalFilter +from .filters.sorting_filters import SortNumericFilter from .filters.string import append from .filters.string import capitalize from .filters.string import downcase @@ -100,12 +103,14 @@ from .filters.string import upcase from .filters.string import url_decode from .filters.string import url_encode +from .filters.sum_filter import SumFilter from .filters.translate import BaseTranslateFilter from .filters.translate import GetText from .filters.translate import NGetText from .filters.translate import NPGetText from .filters.translate import PGetText from .filters.translate import Translate +from .filters.uniq_filter import UniqFilter from .loaders.caching_file_system_loader import CachingFileSystemLoader from .loaders.choice_loader import CachingChoiceLoader from .loaders.choice_loader import ChoiceLoader @@ -145,6 +150,7 @@ __all__ = ( "abs_", + "LambdaExpression", "AssignTag", "at_least", "at_most", @@ -249,15 +255,19 @@ def register_default_tags_and_filters(env: Environment) -> None: # noqa: PLR091 env.filters["first"] = first env.filters["last"] = last env.filters["concat"] = concat - env.filters["map"] = map_ + env.filters["map"] = MapFilter() env.filters["reverse"] = reverse - env.filters["sort"] = sort - env.filters["sort_natural"] = sort_natural - env.filters["sort_numeric"] = sort_numeric - env.filters["sum"] = sum_ - env.filters["where"] = where - env.filters["uniq"] = uniq - env.filters["compact"] = compact + env.filters["sort"] = SortFilter() + env.filters["sort_natural"] = SortNaturalFilter() + env.filters["sort_numeric"] = SortNumericFilter() + env.filters["sum"] = SumFilter() + env.filters["where"] = WhereFilter() + env.filters["reject"] = RejectFilter() + env.filters["uniq"] = UniqFilter() + env.filters["compact"] = CompactFilter() + env.filters["find"] = FindFilter() + env.filters["find_index"] = FindIndexFilter() + env.filters["has"] = HasFilter() env.filters["abs"] = abs_ env.filters["at_least"] = at_least diff --git a/liquid2/builtin/expressions.py b/liquid2/builtin/expressions.py index 19ad64f..3ece928 100644 --- a/liquid2/builtin/expressions.py +++ b/liquid2/builtin/expressions.py @@ -10,6 +10,7 @@ from typing import Any from typing import Collection from typing import Generic +from typing import Iterable from typing import Iterator from typing import Mapping from typing import Sequence @@ -33,11 +34,13 @@ from liquid2 import is_token_type from liquid2.exceptions import LiquidSyntaxError from liquid2.exceptions import LiquidTypeError +from liquid2.exceptions import UnknownFilterError from liquid2.expression import Expression from liquid2.limits import to_int from liquid2.unescape import unescape if TYPE_CHECKING: + from liquid2 import Environment from liquid2 import OutputToken from liquid2 import PathT from liquid2 import RenderContext @@ -333,12 +336,12 @@ def children(self) -> list[Expression]: return self.items @staticmethod - def parse(stream: TokenStream, left: Expression) -> ArrayLiteral: + def parse(env: Environment, stream: TokenStream, left: Expression) -> ArrayLiteral: items: list[Expression] = [left] while stream.current().type_ == TokenType.COMMA: stream.next() # ignore comma try: - items.append(parse_primitive(stream.current())) + items.append(parse_primitive(env, stream.current())) stream.next() except LiquidSyntaxError: # Trailing commas are OK. @@ -349,7 +352,9 @@ def parse(stream: TokenStream, left: Expression) -> ArrayLiteral: class TemplateString(Expression): __slots__ = ("template",) - def __init__(self, token: TokenT, template: list[Token | OutputToken]): + def __init__( + self, env: Environment, token: TokenT, template: list[Token | OutputToken] + ): super().__init__(token) self.template: list[Expression] = [] @@ -366,7 +371,7 @@ def __init__(self, token: TokenT, template: list[Token | OutputToken]): ) elif is_output_token(_token): self.template.append( - FilteredExpression.parse(TokenStream(_token.expression)) + FilteredExpression.parse(env, TokenStream(_token.expression)) ) else: raise LiquidSyntaxError( @@ -407,6 +412,92 @@ def children(self) -> list[Expression]: return self.template +class LambdaExpression(Expression): + __slots__ = ("params", "expression") + + def __init__(self, token: TokenT, params: list[Identifier], expression: Expression): + super().__init__(token) + self.params = params + self.expression = expression + + def __str__(self) -> str: + if len(self.params) == 1: + return f"{self.params[0]} => {self.expression}" + return f"({', '.join(self.params)}) => {self.expression}" + + def __hash__(self) -> int: + return hash((tuple(self.params), hash(self.expression))) + + def __sizeof__(self) -> int: + return sys.getsizeof(self.expression) + + def evaluate(self, _context: RenderContext) -> object: + return self + + def children(self) -> list[Expression]: + return [self.expression] + + def scope(self) -> Iterable[Identifier]: + return self.params + + def map(self, context: RenderContext, it: Iterable[object]) -> Iterator[object]: + """Return an iterator mapping this expression to items in _it_.""" + scope: dict[str, object] = {} + + if len(self.params) == 1: + param = self.params[0] + with context.extend(scope): + for item in it: + scope[param] = item + yield self.expression.evaluate(context) + + else: + name_param, index_param = self.params[:2] + with context.extend(scope): + for index, item in enumerate(it): + scope[index_param] = index + scope[name_param] = item + yield self.expression.evaluate(context) + + @staticmethod + def parse(env: Environment, stream: TokenStream) -> LambdaExpression: + """Parse an arrow function from tokens in _stream_.""" + token = stream.next() + + if is_token_type(token, TokenType.WORD): + # A single param function without parens. + stream.expect(TokenType.ARROW) + stream.next() + expr = parse_boolean_primitive(env, stream) + stream.backup() + return LambdaExpression( + token, + [parse_identifier(token)], + expr, + ) + + assert token.type_ == TokenType.LPAREN + params: list[Identifier] = [] + + while stream.current().type_ != TokenType.RPAREN: + params.append(parse_identifier(stream.next())) + if stream.current().type_ == TokenType.COMMA: + stream.next() + + stream.expect(TokenType.RPAREN) + stream.next() + stream.expect(TokenType.ARROW) + stream.next() + expr = parse_boolean_primitive(env, stream) + stream.backup() + + return LambdaExpression( + token, + params, + expr, + ) + + RE_PROPERTY = re.compile(r"[\u0080-\uFFFFa-zA-Z_][\u0080-\uFFFFa-zA-Z0-9_-]*") Segments: TypeAlias = tuple[Union[str, int, "Segments"], ...] @@ -532,24 +623,26 @@ def children(self) -> list[Expression]: return children @staticmethod - def parse(stream: TokenStream) -> FilteredExpression | TernaryFilteredExpression: + def parse( + env: Environment, stream: TokenStream + ) -> FilteredExpression | TernaryFilteredExpression: """Return a new FilteredExpression parsed from _stream_.""" - left = parse_primitive(stream.next()) + left = parse_primitive(env, stream.next()) if stream.current().type_ == TokenType.COMMA: # Array literal syntax - left = ArrayLiteral.parse(stream, left) - filters = Filter.parse(stream, delim=(TokenType.PIPE,)) + left = ArrayLiteral.parse(env, stream, left) + filters = Filter.parse(env, stream, delim=(TokenType.PIPE,)) if is_token_type(stream.current(), TokenType.IF): return TernaryFilteredExpression.parse( - FilteredExpression(left.token, left, filters), stream + env, FilteredExpression(left.token, left, filters), stream ) stream.expect_eos() return FilteredExpression(left.token, left, filters) -def parse_primitive(token: TokenT) -> Expression: # noqa: PLR0911 +def parse_primitive(env: Environment, token: TokenT) -> Expression: # noqa: PLR0911 """Parse _token_ as a primitive expression.""" if is_token_type(token, TokenType.TRUE): return TrueLiteral(token=token) @@ -582,14 +675,16 @@ def parse_primitive(token: TokenT) -> Expression: # noqa: PLR0911 ) if is_template_string_token(token): - return TemplateString(token, token.template) + return TemplateString(env, token, token.template) if is_path_token(token): return Path(token, token.path) if is_range_token(token): return RangeLiteral( - token, parse_primitive(token.range_start), parse_primitive(token.range_stop) + token, + parse_primitive(env, token.range_start), + parse_primitive(env, token.range_stop), ) raise LiquidSyntaxError( @@ -684,26 +779,26 @@ def children(self) -> list[Expression]: @staticmethod def parse( - expr: FilteredExpression, stream: TokenStream + env: Environment, expr: FilteredExpression, stream: TokenStream ) -> TernaryFilteredExpression: """Return a new TernaryFilteredExpression parsed from tokens in _stream_.""" stream.expect(TokenType.IF) stream.next() # move past `if` - condition = BooleanExpression.parse(stream, inline=True) + condition = BooleanExpression.parse(env, stream, inline=True) alternative: Expression | None = None filters: list[Filter] | None = None tail_filters: list[Filter] | None = None if is_token_type(stream.current(), TokenType.ELSE): stream.next() # move past `else` - alternative = parse_primitive(stream.next()) + alternative = parse_primitive(env, stream.next()) if stream.current().type_ == TokenType.PIPE: - filters = Filter.parse(stream, delim=(TokenType.PIPE,)) + filters = Filter.parse(env, stream, delim=(TokenType.PIPE,)) if stream.current().type_ == TokenType.DOUBLE_PIPE: tail_filters = Filter.parse( - stream, delim=(TokenType.PIPE, TokenType.DOUBLE_PIPE) + env, stream, delim=(TokenType.PIPE, TokenType.DOUBLE_PIPE) ) stream.expect_eos() @@ -717,6 +812,7 @@ class Filter: def __init__( self, + env: Environment, token: TokenT, name: str, arguments: list[KeywordArgument | PositionalArgument], @@ -725,11 +821,25 @@ def __init__( self.name = name self.args = arguments + if env.validate_filter_arguments: + self.validate_filter_arguments(env) + def __str__(self) -> str: if self.args: return f"{self.name}: {''.join(str(arg) for arg in self.args)}" return self.name + def validate_filter_arguments(self, env: Environment) -> None: + try: + func = env.filters[self.name] + except KeyError as err: + raise UnknownFilterError( + f"unknown filter '{self.name}'", token=self.token + ) from err + + if hasattr(func, "validate"): + func.validate(env, self.token, self.name, self.args) + def evaluate(self, left: object, context: RenderContext) -> object: func = context.filter(self.name, token=self.token) positional_args, keyword_args = self.evaluate_args(context) @@ -786,6 +896,7 @@ def children(self) -> list[Expression]: @staticmethod def parse( # noqa: PLR0912 + env: Environment, stream: TokenStream, *, delim: tuple[TokenType, ...], @@ -812,10 +923,26 @@ def parse( # noqa: PLR0912 # A named or keyword argument stream.next() # skip = or : stream.next() - filter_arguments.append( - KeywordArgument( - token.value, parse_primitive(stream.current()) + + if stream.peek().type_ == TokenType.ARROW: + filter_arguments.append( + KeywordArgument( + token.value, + LambdaExpression.parse(env, stream), + ) + ) + else: + filter_arguments.append( + KeywordArgument( + token.value, + parse_primitive(env, stream.current()), + ) ) + elif stream.peek().type_ == TokenType.ARROW: + # A positional argument that is an arrow function with a + # single parameter. + filter_arguments.append( + PositionalArgument(LambdaExpression.parse(env, stream)) ) else: # A positional query that is a single word @@ -824,7 +951,9 @@ def parse( # noqa: PLR0912 ) elif is_template_string_token(token): filter_arguments.append( - PositionalArgument(TemplateString(token, token.template)) + PositionalArgument( + TemplateString(env, token, token.template) + ) ) elif is_path_token(token): filter_arguments.append( @@ -838,9 +967,16 @@ def parse( # noqa: PLR0912 TokenType.FALSE, TokenType.TRUE, TokenType.NULL, + TokenType.RANGE, ): filter_arguments.append( - PositionalArgument(parse_primitive(stream.current())) + PositionalArgument(parse_primitive(env, stream.current())) + ) + elif token.type_ == TokenType.LPAREN: + # A positional argument that is an arrow function with + # parameters surrounded by parentheses. + filter_arguments.append( + PositionalArgument(LambdaExpression.parse(env, stream)) ) elif token.type_ == TokenType.COMMA: # Leading, trailing and duplicate commas are OK @@ -850,7 +986,7 @@ def parse( # noqa: PLR0912 stream.next() - filters.append(Filter(filter_token, filter_name, filter_arguments)) + filters.append(Filter(env, filter_token, filter_name, filter_arguments)) return filters @@ -949,13 +1085,15 @@ async def evaluate_async(self, context: RenderContext) -> object: return is_truthy(await self.expression.evaluate_async(context)) @staticmethod - def parse(stream: TokenStream, *, inline: bool = False) -> BooleanExpression: + def parse( + env: Environment, stream: TokenStream, *, inline: bool = False + ) -> BooleanExpression: """Return a new BooleanExpression parsed from tokens in _stream_. 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(stream) + expr = parse_boolean_primitive(env, stream) if not inline: stream.expect_eos() return BooleanExpression(expr.token, expr) @@ -1004,7 +1142,7 @@ def children(self) -> list[Expression]: def parse_boolean_primitive( # noqa: PLR0912 - stream: TokenStream, precedence: int = PRECEDENCE_LOWEST + env: Environment, stream: TokenStream, precedence: int = PRECEDENCE_LOWEST ) -> Expression: """Parse a Boolean expression from tokens in _stream_.""" left: Expression @@ -1034,17 +1172,19 @@ def parse_boolean_primitive( # noqa: PLR0912 token, unescape(token.value.replace("\\'", "'"), token=token) ) elif is_template_string_token(token): - left = TemplateString(token, token.template) + left = TemplateString(env, token, token.template) elif is_path_token(token): left = Path(token, token.path) elif is_range_token(token): left = RangeLiteral( - token, parse_primitive(token.range_start), parse_primitive(token.range_stop) + token, + parse_primitive(env, token.range_start), + parse_primitive(env, token.range_stop), ) elif is_token_type(token, TokenType.NOT_WORD): - left = LogicalNotExpression.parse(stream) + left = LogicalNotExpression.parse(env, stream) elif is_token_type(token, TokenType.LPAREN): - left = parse_grouped_expression(stream) + left = parse_grouped_expression(env, stream) else: raise LiquidSyntaxError( f"expected a primitive expression, found {token.type_.name}", @@ -1062,12 +1202,14 @@ def parse_boolean_primitive( # noqa: PLR0912 if token.type_ not in BINARY_OPERATORS: return left - left = parse_infix_expression(stream, left) + left = parse_infix_expression(env, stream, left) return left -def parse_infix_expression(stream: TokenStream, left: Expression) -> Expression: # noqa: PLR0911 +def parse_infix_expression( + env: Environment, stream: TokenStream, left: Expression +) -> Expression: # noqa: PLR0911 """Return a logical, comparison, or membership expression parsed from _stream_.""" token = stream.next() assert token is not None @@ -1076,43 +1218,43 @@ def parse_infix_expression(stream: TokenStream, left: Expression) -> Expression: match token.type_: case TokenType.EQ: return EqExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.LT: return LtExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.GT: return GtExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.NE: return NeExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.LE: return LeExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.GE: return GeExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.CONTAINS: return ContainsExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.IN: return InExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.AND_WORD: return LogicalAndExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case TokenType.OR_WORD: return LogicalOrExpression( - token, left, parse_boolean_primitive(stream, precedence) + token, left, parse_boolean_primitive(env, stream, precedence) ) case _: raise LiquidSyntaxError( @@ -1121,9 +1263,9 @@ def parse_infix_expression(stream: TokenStream, left: Expression) -> Expression: ) -def parse_grouped_expression(stream: TokenStream) -> 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(stream) + expr = parse_boolean_primitive(env, stream) token = stream.next() while token.type_ != TokenType.RPAREN: @@ -1137,7 +1279,7 @@ def parse_grouped_expression(stream: TokenStream) -> Expression: token=token, ) - expr = parse_infix_expression(stream, expr) + expr = parse_infix_expression(env, stream, expr) if token.type_ != TokenType.RPAREN: raise LiquidSyntaxError("unbalanced parentheses", token=token) @@ -1162,8 +1304,8 @@ async def evaluate_async(self, context: RenderContext) -> object: return not is_truthy(await self.expression.evaluate_async(context)) @staticmethod - def parse(stream: TokenStream) -> Expression: - expr = parse_boolean_primitive(stream) + def parse(env: Environment, stream: TokenStream) -> Expression: + expr = parse_boolean_primitive(env, stream) return LogicalNotExpression(expr.token, expr) def children(self) -> list[Expression]: @@ -1593,14 +1735,14 @@ def children(self) -> list[Expression]: return children @staticmethod - def parse(stream: TokenStream) -> LoopExpression: + def parse(env: Environment, stream: TokenStream) -> LoopExpression: """Parse tokens from _stream_ as a for loop expression.""" token = stream.current() identifier = parse_identifier(token) stream.next() stream.expect(TokenType.IN) stream.next() # Move past 'in' - iterable = parse_primitive(stream.next()) + iterable = parse_primitive(env, stream.next()) # We're looking for a comma that isn't followed by a known keyword. # This means we have an array literal. @@ -1617,7 +1759,7 @@ def parse(stream: TokenStream) -> LoopExpression: ) ): # Array literal syntax - iterable = ArrayLiteral.parse(stream, iterable) + iterable = ArrayLiteral.parse(env, stream, iterable) # Arguments are not allowed to follow an array literal. stream.expect_eos() return LoopExpression( @@ -1645,11 +1787,11 @@ def parse(stream: TokenStream) -> LoopExpression: case "limit": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - limit = parse_primitive(stream.next()) + limit = parse_primitive(env, stream.next()) case "cols": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() - cols = parse_primitive(stream.next()) + cols = parse_primitive(env, stream.next()) case "offset": stream.expect_one_of(TokenType.COLON, TokenType.ASSIGN) stream.next() @@ -1660,7 +1802,7 @@ def parse(stream: TokenStream) -> LoopExpression: ): offset = StringLiteral(token=offset_token, value="continue") else: - offset = parse_primitive(offset_token) + offset = parse_primitive(env, offset_token) case _: raise LiquidSyntaxError( "expected 'reversed', 'offset' or 'limit', ", @@ -1773,7 +1915,9 @@ def parse_string_or_path(token: TokenT) -> StringLiteral | Path: ) -def parse_keyword_arguments(tokens: TokenStream) -> list[KeywordArgument]: +def parse_keyword_arguments( + env: Environment, tokens: TokenStream +) -> list[KeywordArgument]: """Parse _tokens_ into a list or keyword arguments. Argument keys and values can be separated by a colon (`:`) or an equals sign @@ -1794,7 +1938,7 @@ def parse_keyword_arguments(tokens: TokenStream) -> list[KeywordArgument]: if is_token_type(token, TokenType.WORD): tokens.expect_one_of(TokenType.COLON, TokenType.ASSIGN) tokens.next() # Move past ":" or "=" - value = parse_primitive(tokens.next()) + value = parse_primitive(env, tokens.next()) args.append(KeywordArgument(token.value, value)) else: raise LiquidSyntaxError( @@ -1806,6 +1950,7 @@ def parse_keyword_arguments(tokens: TokenStream) -> list[KeywordArgument]: def parse_positional_and_keyword_arguments( + env: Environment, tokens: TokenStream, ) -> tuple[list[PositionalArgument], list[KeywordArgument]]: """Parse _tokens_ into a lists of keyword and positional arguments. @@ -1832,16 +1977,16 @@ def parse_positional_and_keyword_arguments( ): # A keyword argument tokens.next() # Move past ":" or "=" - value = parse_primitive(tokens.next()) + value = parse_primitive(env, tokens.next()) kwargs.append(KeywordArgument(token.value, value)) else: # A primitive as a positional argument - args.append(PositionalArgument(parse_primitive(token))) + args.append(PositionalArgument(parse_primitive(env, token))) return args, kwargs -def parse_parameters(tokens: TokenStream) -> dict[str, Parameter]: +def parse_parameters(env: Environment, tokens: TokenStream) -> dict[str, Parameter]: """Parse _tokens_ as a list of arguments suitable for a macro definition.""" params: dict[str, Parameter] = {} @@ -1862,7 +2007,7 @@ def parse_parameters(tokens: TokenStream) -> dict[str, Parameter]: ): # A parameter with a default value tokens.next() # Move past ":" or "=" - value = parse_primitive(tokens.next()) + value = parse_primitive(env, tokens.next()) params[token.value] = Parameter(token, token.value, value) else: params[token.value] = Parameter(token, token.value, None) diff --git a/liquid2/builtin/filters/filtering_filters.py b/liquid2/builtin/filters/filtering_filters.py new file mode 100644 index 0000000..817ed10 --- /dev/null +++ b/liquid2/builtin/filters/filtering_filters.py @@ -0,0 +1,179 @@ +"""Implementations of `where`, `reject` and `compact` that accept lambda expressions.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.builtin.expressions import is_truthy +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the where filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +class _FilterFilter: + """Base class for filters that filter array-like objects.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) not in (1, 2): + raise LiquidTypeError( + f"{name!r} expects one or two arguments, got {len(args)}", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects one argument when given a lambda expressions", + token=args[1].token, + ) + + +class WhereFilter(_FilterFilter): + """An implementation of the `where` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + return [ + i + for i, r in zip(left, key.map(context, left), strict=True) + if not is_undefined(r) and is_truthy(r) + ] + + if value is not None and not is_undefined(value): + return [itm for itm in left if _getitem(itm, key) == value] + + return [itm for itm in left if _getitem(itm, key) not in (False, None)] + + +class RejectFilter(_FilterFilter): + """An implementation of the `reject` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + return [ + i + for i, r in zip(left, key.map(context, left), strict=True) + if is_undefined(r) or not is_truthy(r) + ] + + if value is not None and not is_undefined(value): + return [itm for itm in left if _getitem(itm, key) != value] + + return [itm for itm in left if _getitem(itm, key) in (False, None)] + + +class CompactFilter: + """An implementation of the `compact` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidTypeError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + return [ + i + for i, r in zip(left, key.map(context, left), strict=True) + if not is_undefined(r) and r is not None + ] + + if key is not None: + try: + return [itm for itm in left if itm[key] is not None] + except TypeError as err: + raise LiquidTypeError( + f"can't read property '{key}'", token=None + ) from err + + return [itm for itm in left if itm is not None] diff --git a/liquid2/builtin/filters/find_filters.py b/liquid2/builtin/filters/find_filters.py new file mode 100644 index 0000000..d813689 --- /dev/null +++ b/liquid2/builtin/filters/find_filters.py @@ -0,0 +1,156 @@ +"""Implementations of `find`, `find_index` and `has` accepting lambda expressions.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import PositionalArgument +from liquid2.builtin.expressions import is_truthy +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the `find` filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +class FindFilter: + """An implementation of the `find` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) not in (1, 2): + raise LiquidTypeError( + f"{name!r} expects one or two arguments, got {len(args)}", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects one argument when given a lambda expressions", + token=args[1].token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> object: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + for item, rv in zip(left, key.map(context, left), strict=True): + if not is_undefined(rv) and is_truthy(rv): + return item + + elif value is not None and not is_undefined(value): + for item in left: + if _getitem(item, key) == value: + return item + + else: + for item in left: + if item not in (False, None): + return item + + return None + + +class FindIndexFilter(FindFilter): + """An implementation of the `find_index` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> object: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + for i, pair in enumerate(zip(left, key.map(context, left), strict=True)): + item, rv = pair + if not is_undefined(rv) and is_truthy(rv): + return i + + elif value is not None and not is_undefined(value): + for i, item in enumerate(left): + if _getitem(item, key) == value: + return i + + else: + for i, item in enumerate(left): + if item not in (False, None): + return i + + return None + + +class HasFilter(FindFilter): + """An implementation of the `has` filter that accepts lambda expressions.""" + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression, + value: object = None, + *, + context: RenderContext, + ) -> bool: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + for rv in key.map(context, left): + if not is_undefined(rv) and is_truthy(rv): + return True + + elif value is not None and not is_undefined(value): + for item in left: + if _getitem(item, key) == value: + return True + + else: + return any(item not in (False, None) for item in left) + + return False diff --git a/liquid2/builtin/filters/map_filter.py b/liquid2/builtin/filters/map_filter.py new file mode 100644 index 0000000..de5d2b2 --- /dev/null +++ b/liquid2/builtin/filters/map_filter.py @@ -0,0 +1,107 @@ +"""An implementation of the `map` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Null +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +class _Null: + """A null without a token for use by the map filter.""" + + def __eq__(self, other: object) -> bool: + return other is None or isinstance(other, (_Null, Null)) + + def __str__(self) -> str: # pragma: no cover + return "" + + +_NULL = _Null() + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the map filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +class MapFilter: + """An implementation of the `map` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) != 1: + raise LiquidTypeError( + f"{name!r} expects exactly one argument, got {len(args)}", + token=token, + ) + + if not isinstance(args[0], PositionalArgument): + raise LiquidTypeError( + f"{name!r} takes no keyword arguments", + token=token, + ) + + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance(arg.expression, Path): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + first: str | LambdaExpression, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(first, LambdaExpression): + return [ + _NULL if is_undefined(item) else item + for item in first.map(context, left) + ] + + try: + return [_getitem(itm, str(first), default=_NULL) for itm in left] + except TypeError as err: + raise LiquidTypeError("can't map sequence", token=None) from err diff --git a/liquid2/builtin/filters/sorting_filters.py b/liquid2/builtin/filters/sorting_filters.py new file mode 100644 index 0000000..1d22015 --- /dev/null +++ b/liquid2/builtin/filters/sorting_filters.py @@ -0,0 +1,186 @@ +"""Implementations of `sort` and `sort_natural` filters accepting lambda expressions.""" + +from __future__ import annotations + +import math +import re +from decimal import Decimal +from functools import partial +from operator import getitem +from operator import itemgetter +from typing import TYPE_CHECKING +from typing import Any + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.limits import to_int +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + +# Send objects with missing keys to the end when sorting a list. +_MAX_CH = chr(0x10FFFF) + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the sort filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +def _lower(obj: Any) -> str: + """Helper for the sort filter.""" + try: + return str(obj).lower() + except AttributeError: + return "" + + +class SortFilter: + """An implementation of the `sort` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidTypeError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: object, + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[tuple[object, object]] = [] + for item, rv in zip(left, key.map(context, left), strict=True): + items.append((item, _MAX_CH if is_undefined(rv) else rv)) + return [item[0] for item in sorted(items, key=itemgetter(1))] + + if key: + key_func = partial(_getitem, key=str(key), default=_MAX_CH) + return sorted(left, key=key_func) + + try: + return sorted(left) + except TypeError as err: + raise LiquidTypeError("can't sort sequence", token=None) from err + + +class SortNaturalFilter(SortFilter): + """An implementation of the `sort` filter that accepts a lambda expression.""" + + def __call__( + self, + left: object, + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[tuple[object, object]] = [] + for item, rv in zip(left, key.map(context, left), strict=True): + items.append((item, _MAX_CH if is_undefined(rv) else str(rv).lower())) + return [item[0] for item in sorted(items, key=itemgetter(1))] + + if key: + item_getter = partial(_getitem, key=str(key), default=_MAX_CH) + return sorted(left, key=lambda obj: _lower(item_getter(obj))) + + return sorted(left, key=_lower) + + +RE_NUMERIC = re.compile(r"-?\d+") + + +class SortNumericFilter(SortFilter): + """An implementation `sort_numeric` that accepts a lambda expression.""" + + def __call__( + self, + left: object, + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + items: list[tuple[object, object]] = [] + for item, rv in zip(left, key.map(context, left), strict=True): + items.append((item, _MAX_CH if is_undefined(rv) else rv)) + return [item[0] for item in sorted(items, key=lambda i: _ints(i[1]))] + + if key: + _key = str(key) + return sorted(left, key=lambda item: _ints(_get_numeric_item(item, _key))) + return sorted(left, key=_ints) + + +def _get_numeric_item(sequence: Any, key: object, default: object = None) -> Any: + """Item getter for the `sort_numeric` filter.""" + try: + return getitem(sequence, key) + except (KeyError, IndexError, TypeError): + return default + + +def _ints(obj: object) -> tuple[int | float | Decimal, ...]: + """Key function for the `sort_numeric` filter.""" + if isinstance(obj, bool): + # isinstance(False, int) == True + return (math.inf,) + if isinstance(obj, (int, float, Decimal)): + return (obj,) + + ints = tuple(to_int(n) for n in RE_NUMERIC.findall(str(obj))) + + if not ints: + return (math.inf,) + return ints diff --git a/liquid2/builtin/filters/sum_filter.py b/liquid2/builtin/filters/sum_filter.py new file mode 100644 index 0000000..0c13888 --- /dev/null +++ b/liquid2/builtin/filters/sum_filter.py @@ -0,0 +1,96 @@ +"""An implementation of the `sum` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from decimal import Decimal +from operator import getitem +from typing import TYPE_CHECKING +from typing import Any +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import decimal_arg +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + + +def _getitem(obj: Any, key: object, default: object = None) -> Any: + """Helper for the sum filter. + + Same as obj[key], but returns a default value if key does not exist + in obj. + """ + try: + return getitem(obj, key) + except (KeyError, IndexError): + return default + except TypeError: + if not hasattr(obj, "__getitem__"): + raise + return default + + +class SumFilter: + """An implementation of the `sum` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidTypeError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> float | int: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + if isinstance(key, LambdaExpression): + rv = sum( + decimal_arg(item, 0) + for item in key.map(context, left) + if not is_undefined(item) + ) + elif key is not None and not is_undefined(key): + rv = sum(decimal_arg(_getitem(elem, key, 0), 0) for elem in left) + else: + rv = sum(decimal_arg(elem, 0) for elem in left) + + if isinstance(rv, Decimal): + return float(rv) + return rv diff --git a/liquid2/builtin/filters/uniq_filter.py b/liquid2/builtin/filters/uniq_filter.py new file mode 100644 index 0000000..a77376f --- /dev/null +++ b/liquid2/builtin/filters/uniq_filter.py @@ -0,0 +1,101 @@ +"""An implementation of the `uniq` filter that accepts lambda expressions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Iterable + +from liquid2.builtin import LambdaExpression +from liquid2.builtin import Path +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidTypeError +from liquid2.filter import sequence_arg +from liquid2.undefined import is_undefined + +if TYPE_CHECKING: + from liquid2 import Environment + from liquid2 import RenderContext + from liquid2 import TokenT + from liquid2.builtin import KeywordArgument + +MISSING = object() + + +class UniqFilter: + """An implementation of the `uniq` filter that accepts lambda expressions.""" + + with_context = True + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + args: list[KeywordArgument | PositionalArgument], + ) -> None: + """Raise a `LiquidTypeError` if _args_ are not valid.""" + if len(args) > 1: + raise LiquidTypeError( + f"{name!r} expects at most one argument, got {len(args)}", + token=token, + ) + + if len(args) == 1: + arg = args[0].value + + if isinstance(arg, LambdaExpression) and not isinstance( + arg.expression, Path + ): + raise LiquidTypeError( + f"{name!r} expects a path to a variable, " + f"got {arg.expression.__class__.__name__}", + token=arg.expression.token, + ) + + def __call__( + self, + left: Iterable[object], + key: str | LambdaExpression | None = None, + *, + context: RenderContext, + ) -> list[object]: + """Apply the filter and return the result.""" + left = sequence_arg(left) + + # Note that we're not using a dict or set for deduplication because we need + # to handle sequences containing unhashable objects, like dictionaries and + # lists. This is probably quite slow. + + if isinstance(key, LambdaExpression): + keys: list[object] = [] + items: list[object] = [] + + for item, rv in zip(left, key.map(context, left), strict=True): + current_key = MISSING if is_undefined(rv) else rv + if current_key not in keys: + keys.append(current_key) + items.append(item) + + return items + + if key is not None: + keys = [] + result = [] + for obj in left: + try: + item = obj[key] + except KeyError: + item = MISSING + except TypeError as err: + raise LiquidTypeError( + f"can't read property '{key}' of {obj}", + token=None, + ) from err + + if item not in keys: + keys.append(item) + result.append(obj) + + return result + + return [obj for i, obj in enumerate(left) if left.index(obj) == i] diff --git a/liquid2/builtin/output.py b/liquid2/builtin/output.py index cdc2621..a742277 100644 --- a/liquid2/builtin/output.py +++ b/liquid2/builtin/output.py @@ -74,5 +74,5 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=token) return self.node_class( - token, FilteredExpression.parse(TokenStream(token.expression)) + token, FilteredExpression.parse(self.env, TokenStream(token.expression)) ) diff --git a/liquid2/builtin/tags/assign_tag.py b/liquid2/builtin/tags/assign_tag.py index 4477fb4..92554b1 100644 --- a/liquid2/builtin/tags/assign_tag.py +++ b/liquid2/builtin/tags/assign_tag.py @@ -86,5 +86,5 @@ def parse(self, stream: TokenStream) -> Node: return self.node_class( token, name=name, - expression=FilteredExpression.parse(expr_stream), + expression=FilteredExpression.parse(self.env, expr_stream), ) diff --git a/liquid2/builtin/tags/case_tag.py b/liquid2/builtin/tags/case_tag.py index 59ab2d6..39d8a45 100644 --- a/liquid2/builtin/tags/case_tag.py +++ b/liquid2/builtin/tags/case_tag.py @@ -126,7 +126,7 @@ def parse(self, stream: TokenStream) -> Node: token = stream.current() assert isinstance(token, TagToken) expr_stream = stream.into_inner() - left = parse_primitive(expr_stream.next()) + left = parse_primitive(self.env, expr_stream.next()) expr_stream.expect_eos() # Check for content or markup between the _case_ tag and the first _when_ or @@ -194,10 +194,10 @@ def parse(self, stream: TokenStream) -> Node: ) def _parse_when_expression(self, stream: TokenStream) -> list[Expression]: - expressions: list[Expression] = [parse_primitive(stream.next())] + expressions: list[Expression] = [parse_primitive(self.env, stream.next())] while stream.current().type_ in (TokenType.COMMA, TokenType.OR_WORD): stream.next() - expressions.append(parse_primitive(stream.next())) + expressions.append(parse_primitive(self.env, stream.next())) stream.expect_eos() return expressions diff --git a/liquid2/builtin/tags/cycle_tag.py b/liquid2/builtin/tags/cycle_tag.py index e7105d5..d14596f 100644 --- a/liquid2/builtin/tags/cycle_tag.py +++ b/liquid2/builtin/tags/cycle_tag.py @@ -96,7 +96,7 @@ def parse(self, stream: TokenStream) -> Node: items: list[Expression] = [] # We must have at least one item - items.append(parse_primitive(expr_stream.next())) + items.append(parse_primitive(self.env, expr_stream.next())) while True: item_token = expr_stream.next() @@ -117,6 +117,6 @@ def parse(self, stream: TokenStream) -> Node: if item_token.type_ == TokenType.EOI: break - items.append(parse_primitive(item_token)) + items.append(parse_primitive(self.env, item_token)) return self.node_class(token, name, items) diff --git a/liquid2/builtin/tags/echo_tag.py b/liquid2/builtin/tags/echo_tag.py index f1c6537..e57965e 100644 --- a/liquid2/builtin/tags/echo_tag.py +++ b/liquid2/builtin/tags/echo_tag.py @@ -74,7 +74,7 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=token) expr_stream = TokenStream(token.expression) - expr = FilteredExpression.parse(expr_stream) + expr = FilteredExpression.parse(self.env, expr_stream) expr_stream.expect_eos() return self.node_class(token, expr) diff --git a/liquid2/builtin/tags/for_tag.py b/liquid2/builtin/tags/for_tag.py index e7a21b8..160febc 100644 --- a/liquid2/builtin/tags/for_tag.py +++ b/liquid2/builtin/tags/for_tag.py @@ -178,7 +178,7 @@ def parse(self, stream: TokenStream) -> Node: if not token.expression: raise LiquidSyntaxError("missing expression", token=token) - expression = LoopExpression.parse(TokenStream(token.expression)) + expression = LoopExpression.parse(self.env, TokenStream(token.expression)) parse_block = self.env.parser.parse_block diff --git a/liquid2/builtin/tags/if_tag.py b/liquid2/builtin/tags/if_tag.py index 20bac2e..7e9d5da 100644 --- a/liquid2/builtin/tags/if_tag.py +++ b/liquid2/builtin/tags/if_tag.py @@ -133,7 +133,7 @@ def parse(self, stream: TokenStream) -> Node: if not token.expression: raise LiquidSyntaxError("missing expression", token=token) - condition = parse_expression(TokenStream(token.expression)) + condition = parse_expression(self.env, TokenStream(token.expression)) block_token = stream.current() assert block_token is not None @@ -150,7 +150,7 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=alternative_token) alternative_expression = parse_expression( - TokenStream(alternative_token.expression) + self.env, TokenStream(alternative_token.expression) ) alternative_block = BlockNode( diff --git a/liquid2/builtin/tags/include_tag.py b/liquid2/builtin/tags/include_tag.py index 4349257..3331381 100644 --- a/liquid2/builtin/tags/include_tag.py +++ b/liquid2/builtin/tags/include_tag.py @@ -245,7 +245,7 @@ def parse(self, stream: TokenStream) -> Node: ): tokens.next() # Move past "for" loop = True - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) @@ -254,11 +254,11 @@ def parse(self, stream: TokenStream) -> Node: TokenType.COMMA, ): tokens.next() # Move past "with" - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) - args = parse_keyword_arguments(tokens) + args = parse_keyword_arguments(self.env, tokens) tokens.expect_eos() return self.node_class(token, name, loop=loop, var=var, alias=alias, args=args) diff --git a/liquid2/builtin/tags/macro_tag.py b/liquid2/builtin/tags/macro_tag.py index 436530e..8b6a7c3 100644 --- a/liquid2/builtin/tags/macro_tag.py +++ b/liquid2/builtin/tags/macro_tag.py @@ -114,7 +114,7 @@ def parse(self, stream: TokenStream) -> Node: tokens = TokenStream(token.expression) name = parse_string_or_identifier(tokens.next()) - args = parse_parameters(tokens) + args = parse_parameters(self.env, tokens) block = BlockNode( stream.current(), self.env.parser.parse_block(stream, ("endmacro",)) ) @@ -279,5 +279,5 @@ def parse(self, stream: TokenStream) -> Node: tokens = TokenStream(token.expression) name = parse_string_or_identifier(tokens.next()) - args, kwargs = parse_positional_and_keyword_arguments(tokens) + args, kwargs = parse_positional_and_keyword_arguments(self.env, tokens) return self.node_class(token, name, args, kwargs) diff --git a/liquid2/builtin/tags/render_tag.py b/liquid2/builtin/tags/render_tag.py index 0beaf6b..2e84375 100644 --- a/liquid2/builtin/tags/render_tag.py +++ b/liquid2/builtin/tags/render_tag.py @@ -74,8 +74,7 @@ def __str__(self) -> str: var += "," args = " " + ", ".join(str(arg) for arg in self.args) if self.args else "" return ( - f"{{%{self.token.wc[0]} render " - f"{self.name}{var}{args} {self.token.wc[1]}%}}" + f"{{%{self.token.wc[0]} render {self.name}{var}{args} {self.token.wc[1]}%}}" ) def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: @@ -299,7 +298,7 @@ def parse(self, stream: TokenStream) -> Node: ): tokens.next() # Move past "for" loop = True - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) @@ -308,11 +307,11 @@ def parse(self, stream: TokenStream) -> Node: TokenType.COMMA, ): tokens.next() # Move past "with" - var = parse_primitive(tokens.next()) + var = parse_primitive(self.env, tokens.next()) if tokens.current().type_ == TokenType.AS: tokens.next() # Move past "as" alias = parse_string_or_identifier(tokens.next()) - args = parse_keyword_arguments(tokens) + args = parse_keyword_arguments(self.env, tokens) tokens.expect_eos() return self.node_class(token, name, loop=loop, var=var, alias=alias, args=args) diff --git a/liquid2/builtin/tags/translate_tag.py b/liquid2/builtin/tags/translate_tag.py index bf2da5c..d542836 100644 --- a/liquid2/builtin/tags/translate_tag.py +++ b/liquid2/builtin/tags/translate_tag.py @@ -295,7 +295,9 @@ def parse(self, stream: TokenStream) -> TranslateNode: if token.expression: args = { arg.name: arg - for arg in parse_keyword_arguments(TokenStream(token.expression)) + for arg in parse_keyword_arguments( + self.env, TokenStream(token.expression) + ) } else: args = {} diff --git a/liquid2/builtin/tags/unless_tag.py b/liquid2/builtin/tags/unless_tag.py index 9b6ff92..355a20d 100644 --- a/liquid2/builtin/tags/unless_tag.py +++ b/liquid2/builtin/tags/unless_tag.py @@ -133,7 +133,7 @@ def parse(self, stream: TokenStream) -> Node: parse_block = self.env.parser.parse_block parse_expression = BooleanExpression.parse - condition = parse_expression(TokenStream(token.expression)) + condition = parse_expression(self.env, TokenStream(token.expression)) block_token = stream.current() assert block_token is not None @@ -153,7 +153,7 @@ def parse(self, stream: TokenStream) -> Node: raise LiquidSyntaxError("missing expression", token=alternative_token) alternative_expression = parse_expression( - TokenStream(alternative_token.expression) + self.env, TokenStream(alternative_token.expression) ) alternative_block = BlockNode( diff --git a/liquid2/builtin/tags/with_tag.py b/liquid2/builtin/tags/with_tag.py index 91ebb4a..50431e1 100644 --- a/liquid2/builtin/tags/with_tag.py +++ b/liquid2/builtin/tags/with_tag.py @@ -91,7 +91,7 @@ def parse(self, stream: TokenStream) -> Node: assert isinstance(token, TagToken) tokens = TokenStream(token.expression) - args = parse_keyword_arguments(tokens) + args = parse_keyword_arguments(self.env, tokens) block = BlockNode( stream.current(), self.env.parser.parse_block(stream, ("endwith",)) ) diff --git a/liquid2/environment.py b/liquid2/environment.py index 3a116c0..e3dafc2 100644 --- a/liquid2/environment.py +++ b/liquid2/environment.py @@ -48,6 +48,9 @@ class Environment: default_trim: The automatic whitespace stripping mode to use. This mode can then be overridden by template authors per Liquid tag using whitespace control symbols (`-`, `+`, `~`). + validate_filter_arguments: If `True`, class-based filters that define a + `validate()` method will have their arguments validated as each template is + parsed. """ context_depth_limit: ClassVar[int] = 30 @@ -85,11 +88,13 @@ def __init__( auto_escape: bool = False, undefined: Type[Undefined] = Undefined, default_trim: WhitespaceControl = WhitespaceControl.PLUS, + validate_filter_arguments: bool = True, ) -> None: self.loader = loader or DictLoader({}) self.globals = globals or {} self.auto_escape = auto_escape self.undefined = undefined + self.validate_filter_arguments = validate_filter_arguments self.default_trim: WhitespaceControl = ( WhitespaceControl.PLUS diff --git a/liquid2/expression.py b/liquid2/expression.py index 64b4c86..20bc8bb 100644 --- a/liquid2/expression.py +++ b/liquid2/expression.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from liquid2 import TokenT + from .builtin import Identifier from .context import RenderContext @@ -32,3 +33,10 @@ async def evaluate_async(self, context: RenderContext) -> object: @abstractmethod def children(self) -> Iterable[Expression]: """Return this expression's child expressions.""" + + def scope(self) -> Iterable[Identifier]: + """Return variables this expression adds the scope of any child expressions. + + Used by lambda expressions only. + """ + return [] diff --git a/liquid2/filter.py b/liquid2/filter.py index d625b86..4ede009 100644 --- a/liquid2/filter.py +++ b/liquid2/filter.py @@ -137,6 +137,22 @@ def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: return wrapper +def sequence_arg(val: object) -> Sequence[Any]: + """Return _val_ as an Sequence.""" + if is_undefined(val): + val.poke() + return [] + if isinstance(val, str): + return list(val) + if isinstance(val, Sequence): + return _flatten(val) + if isinstance(val, Mapping): + return [val] + if isinstance(val, Iterable): + return list(val) + return [val] + + def sequence_filter(_filter: Callable[..., Any]) -> Callable[..., Any]: """Coerce the left value to sequence. @@ -146,16 +162,7 @@ def sequence_filter(_filter: Callable[..., Any]) -> Callable[..., Any]: @wraps(_filter) def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: - if is_undefined(val): - val.poke() - val = [] - elif isinstance(val, str): - val = list(val) - elif isinstance(val, Sequence): - val = _flatten(val) - elif isinstance(val, Mapping) or not isinstance(val, Iterable): - val = [val] - return _filter(val, *args, **kwargs) + return _filter(sequence_arg(val), *args, **kwargs) return wrapper @@ -165,6 +172,8 @@ def math_filter(_filter: Callable[..., Any]) -> Callable[..., Any]: @wraps(_filter) def wrapper(val: object, *args: Any, **kwargs: Any) -> Any: + if is_undefined(val): + val.poke() val = num_arg(val, default=0) return _filter(val, *args, **kwargs) diff --git a/liquid2/lexer.py b/liquid2/lexer.py index db380ff..59eadcb 100644 --- a/liquid2/lexer.py +++ b/liquid2/lexer.py @@ -69,6 +69,7 @@ class Lexer: ESCAPES = frozenset(["b", "f", "n", "r", "t", "u", "/", "\\", "$"]) SYMBOLS: dict[str, str] = { + "ARROW": r"=>", "GE": r">=", "LE": r"<=", "EQ": r"==", @@ -139,6 +140,7 @@ class Lexer: "PIPE": TokenType.PIPE, "EXCLAIM": TokenType.EXCLAIM, "QUESTION": TokenType.QUESTION, + "ARROW": TokenType.ARROW, } MARKUP: dict[str, str] = { diff --git a/liquid2/shopify/tags/tablerow_tag.py b/liquid2/shopify/tags/tablerow_tag.py index 249523a..2f38723 100644 --- a/liquid2/shopify/tags/tablerow_tag.py +++ b/liquid2/shopify/tags/tablerow_tag.py @@ -201,7 +201,7 @@ def parse(self, stream: TokenStream) -> Node: if not token.expression: raise LiquidSyntaxError("missing expression", token=token) - expression = LoopExpression.parse(TokenStream(token.expression)) + expression = LoopExpression.parse(self.env, TokenStream(token.expression)) block_token = stream.current() assert block_token is not None block = BlockNode( diff --git a/liquid2/static_analysis.py b/liquid2/static_analysis.py index 24d5f1e..c50cccd 100644 --- a/liquid2/static_analysis.py +++ b/liquid2/static_analysis.py @@ -172,11 +172,7 @@ def _visit(node: Node, template_name: str, scope: _StaticScope) -> None: # Update variables from node.expressions() for expr in node.expressions(): - for var in _extract_variables(expr, template_name): - variables.add(var) - root = str(var.segments[0]) - if root not in scope: - globals.add(var) + _analyze_variables(expr, template_name, scope, globals, variables) # Update filters from expr for name, span in _extract_filters(expr, template_name): @@ -262,11 +258,7 @@ async def _visit(node: Node, template_name: str, scope: _StaticScope) -> None: # Update variables from node.expressions() for expr in node.expressions(): - for var in _extract_variables(expr, template_name): - variables.add(var) - root = str(var.segments[0]) - if root not in scope: - globals.add(var) + _analyze_variables(expr, template_name, scope, globals, variables) # Update filters from expr for name, span in _extract_filters(expr, template_name): @@ -343,17 +335,36 @@ def _extract_filters( yield from _extract_filters(expr, template_name) -def _extract_variables( - expression: Expression, template_name: str -) -> Iterable[Variable]: +def _analyze_variables( + expression: Expression, + template_name: str, + scope: _StaticScope, + globals: _VariableMap, + variables: _VariableMap, +) -> None: if isinstance(expression, Path): - yield Variable( + var = Variable( segments=_segments(expression, template_name), span=Span(template_name, expression.token.start, expression.token.stop), ) - for expr in expression.children(): - yield from _extract_variables(expr, template_name=template_name) + # NOTE: We're updating globals and variables here so we can manage scope while + # traversing the expression. This is for the benefit of lambda expressions that + # add names to the scope of their children. + variables.add(var) + + root = str(var.segments[0]) + if root not in scope: + globals.add(var) + + if child_scope := expression.scope(): + scope.push(set(child_scope)) + for expr in expression.children(): + _analyze_variables(expr, template_name, scope, globals, variables) + scope.pop() + else: + for expr in expression.children(): + _analyze_variables(expr, template_name, scope, globals, variables) def _segments(path: Path, template_name: str) -> Segments: diff --git a/liquid2/stream.py b/liquid2/stream.py index 1f28377..ebbcade 100644 --- a/liquid2/stream.py +++ b/liquid2/stream.py @@ -20,11 +20,12 @@ class TokenStream: """Step through a stream of tokens.""" + eoi = Token(type_=TokenType.EOI, value="", index=-1, source="") + def __init__(self, tokens: Sequence[TokenT]) -> None: self.tokens = tokens self.pos = 0 self.trim_carry = WhitespaceControl.DEFAULT - self.eoi = Token(type_=TokenType.EOI, value="", index=-1, source="") def current(self) -> TokenT: """Return the item at self[0] without advancing the iterator.""" @@ -49,6 +50,11 @@ def peek(self) -> TokenT: except IndexError: return self.eoi + def backup(self) -> None: + """Go back one token.""" + if self.pos != 0: + self.pos -= 1 + def expect(self, typ: TokenType) -> None: """Raise a _LiquidSyntaxError_ if the current token type doesn't match _typ_.""" token = self.current() diff --git a/liquid2/token.py b/liquid2/token.py index 927be26..ef25362 100644 --- a/liquid2/token.py +++ b/liquid2/token.py @@ -180,17 +180,22 @@ def __str__(self) -> str: def _expression_as_string(expression: list[TokenT]) -> str: buf: list[str] = [] + skip_next_space = False for token in expression: + if skip_next_space: + buf.append(str(token)) + skip_next_space = False + continue + if isinstance(token, Token): - if token.type_ == TokenType.SINGLE_QUOTE_STRING: - buf.append(f" '{token.value}'") - elif token.type_ == TokenType.DOUBLE_QUOTE_STRING: - buf.append(f' "{token.value}"') - elif token.type_ == TokenType.COMMA: - buf.append(",") # no leading space + if token.type_ in (TokenType.COMMA, TokenType.COLON, TokenType.RPAREN): + buf.append(str(token)) # no leading space else: - buf.append(f" {token.value}") + buf.append(f" {token}") + + if token.type_ == TokenType.LPAREN: + skip_next_space = True else: buf.append(f" {token}") @@ -215,6 +220,13 @@ class Token(TokenT): index: int source: str = field(repr=False) + def __str__(self) -> str: + if self.type_ == TokenType.SINGLE_QUOTE_STRING: + return f"'{self.value}'" + if self.type_ == TokenType.DOUBLE_QUOTE_STRING: + return f'"{self.value}"' + return self.value + @property def start(self) -> int: """Return the start position of this token.""" @@ -294,6 +306,9 @@ class RangeToken(TokenT): stop: int source: str = field(repr=False) + def __str__(self) -> str: + return f"({self.range_start}..{self.range_stop})" + @dataclass(kw_only=True, slots=True) class ErrorToken(TokenT): @@ -411,6 +426,7 @@ class TokenType(Enum): RANGE = auto() AND_WORD = auto() # and + ARROW = auto() # => AS = auto() ASSIGN = auto() # = COLON = auto() diff --git a/liquid2/undefined.py b/liquid2/undefined.py index 15fb641..8e1a533 100644 --- a/liquid2/undefined.py +++ b/liquid2/undefined.py @@ -109,6 +109,7 @@ class StrictUndefined(Undefined): allowed_properties = frozenset( [ "__repr__", + "__class__", "force_liquid_default", "name", "hint", diff --git a/mkdocs.yml b/mkdocs.yml index c1a82f3..3d0a628 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -62,6 +62,7 @@ nav: - Exceptions: "api/exceptions.md" - Builtin: "api/builtin.md" - AST: "api/ast.md" + - Expression: "api/expression.md" - Render context: "api/render_context.md" - Filter helpers: "api/filter.md" - Tag: "api/tag.md" diff --git a/tests/liquid2-compliance-test-suite/cts.json b/tests/liquid2-compliance-test-suite/cts.json index d3c27c9..34af6de 100644 --- a/tests/liquid2-compliance-test-suite/cts.json +++ b/tests/liquid2-compliance-test-suite/cts.json @@ -1207,6 +1207,48 @@ }, "result": "(title,foo)(name,a)(title,bar)(name,c)" }, + { + "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, compact, array of objects, lambda expression is not a path", + "template": "{% assign x = a | compact: i => i.title == 'foo' %}{% 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" + } + ] + }, + "invalid": true + }, { "name": "filters, concat, range literal concat filter left value", "template": "{{ (1..3) | concat: foo | join: '#' }}", @@ -1725,6 +1767,147 @@ "data": {}, "result": "" }, + { + "name": "filters, find, array of objects", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "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, array of objects, not found", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, + { + "name": "filters, find index, array of objects", + "template": "{% assign x = a | find_index: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "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, find index, array of objects, not found", + "template": "{% assign x = a | find_index: 'title', 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + }, { "name": "filters, first, range literal first filter left value", "template": "{{ (1..3) | first }}", @@ -1863,6 +2046,78 @@ "data": {}, "result": "0" }, + { + "name": "filters, has, array of objects", + "template": "{% assign x = a | has: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "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, has, array of objects, not found", + "template": "{% assign x = a | has: i => i.title == 42 %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + }, { "name": "filters, join, range literal join filter left value", "template": "{{ (1..3) | join: '#' }}", @@ -2178,6 +2433,126 @@ }, "result": "foo" }, + { + "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, map, array of objects, lambda expression is not a path", + "template": "{{ a | map: (i) => i.user.title == 'foo' | join: '#' }}", + "data": { + "a": [ + { + "user": { + "title": "foo" + } + }, + { + "user": { + "title": "bar" + } + }, + { + "user": { + "title": "baz" + } + } + ] + }, + "invalid": true + }, { "name": "filters, minus, integer value and integer arg", "template": "{{ 10 | minus: 2 }}", @@ -2437,16 +2812,106 @@ "invalid": true }, { - "name": "filters, prepend, undefined left value", - "template": "{{ nosuchthing | prepend: \"hi\" }}", - "data": {}, - "result": "hi" + "name": "filters, prepend, undefined left value", + "template": "{{ nosuchthing | prepend: \"hi\" }}", + "data": {}, + "result": "hi" + }, + { + "name": "filters, prepend, undefined argument", + "template": "{{ \"hi\" | prepend: nosuchthing }}", + "data": {}, + "result": "hi" + }, + { + "name": "filters, reject, array of objects, explicit null", + "template": "{% assign x = a | reject: 'title', null %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,)" + }, + { + "name": "filters, reject, array of objects, implicit null", + "template": "{% assign x = a | reject: 'title' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,)" + }, + { + "name": "filters, reject, array of objects, string match", + "template": "{% assign x = a | reject: 'title', 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "filters, reject, array of objects, missing key", + "template": "{% assign x = a | reject: '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)(title,bar)" }, { - "name": "filters, prepend, undefined argument", - "template": "{{ \"hi\" | prepend: nosuchthing }}", - "data": {}, - "result": "hi" + "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, remove, remove substrings", @@ -3244,6 +3709,54 @@ }, "invalid": true }, + { + "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 strings", "template": "{{ a | sort_natural | join: '#' }}", @@ -3379,6 +3892,92 @@ }, "result": "14{}" }, + { + "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, sort numeric, array of objects, lambda expression is not a path", + "template": "{% assign x = a | sort_numeric: i => i.x == y %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [ + { + "y": "-1", + "x": "10" + }, + { + "x": "2" + }, + { + "x": "3" + } + ] + }, + "invalid": true + }, { "name": "filters, split, split string", "template": "{{ \"Hi, how are you today?\" | split: \" \" | join: \"#\" }}", @@ -3779,6 +4378,42 @@ }, "invalid": true }, + { + "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, sum, hashes with lambda invalid argument", + "template": "{{ a | sum: i => i.k == 'foo' }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "invalid": true + }, { "name": "filters, times, int times int", "template": "{{ 5 | times: 2 }}", @@ -4107,6 +4742,48 @@ }, "result": "(title,foo)(name,a)(title,bar)(name,c)(heading,bar)(name,c)" }, + { + "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, uniq, array of objects, lambda expression is not a path", + "template": "{% assign x = a | uniq: i => i.title == 'foo' %}{% 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" + } + ] + }, + "invalid": true + }, { "name": "filters, upcase, make lower case", "template": "{{ \"hello\" | upcase }}", @@ -4277,6 +4954,24 @@ }, "invalid": true }, + { + "name": "filters, where, arrow function, two arguments", + "template": "{{ a | i => i.foo.bar, x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "invalid": true + }, { "name": "filters, where, left value is undefined", "template": "{{ nosuchthing | where: 'title' }}", @@ -4373,6 +5068,54 @@ }, "result": "(b,bar)" }, + { + "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,)" + }, { "name": "tags, assign, string literal", "template": "{% assign a = 'b' %}{{ a }}", diff --git a/tests/liquid2-compliance-test-suite/tests/filters/compact.json b/tests/liquid2-compliance-test-suite/tests/filters/compact.json index a05fd6a..1263349 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/compact.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/compact.json @@ -4,12 +4,7 @@ "name": "array with a nil", "template": "{{ a | compact | join: '#' }}", "data": { - "a": [ - "b", - "a", - null, - "A" - ] + "a": ["b", "a", null, "A"] }, "result": "b#a#A" }, @@ -61,6 +56,48 @@ ] }, "result": "(title,foo)(name,a)(title,bar)(name,c)" + }, + { + "name": "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": "array of objects, lambda expression is not a path", + "template": "{% assign x = a | compact: i => i.title == 'foo' %}{% 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" + } + ] + }, + "invalid": true } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/find.json b/tests/liquid2-compliance-test-suite/tests/filters/find.json new file mode 100644 index 0000000..8f1609d --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/find.json @@ -0,0 +1,73 @@ +{ + "tests": [ + { + "name": "array of objects", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "bar" + }, + { + "name": "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": "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": "array of objects, not found", + "template": "{% assign x = a | find: 'title', 'bar' %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/find_index.json b/tests/liquid2-compliance-test-suite/tests/filters/find_index.json new file mode 100644 index 0000000..5408b76 --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/find_index.json @@ -0,0 +1,76 @@ +{ + "tests": [ + { + "name": "array of objects", + "template": "{% assign x = a | find_index: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "1" + }, + { + "name": "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": "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": "array of objects, not found", + "template": "{% assign x = a | find_index: 'title', 42 %}{{ x.title if x else 'not found' }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "not found" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/has.json b/tests/liquid2-compliance-test-suite/tests/filters/has.json new file mode 100644 index 0000000..4ba01f2 --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/has.json @@ -0,0 +1,76 @@ +{ + "tests": [ + { + "name": "array of objects", + "template": "{% assign x = a | has: 'title', 'bar' %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "true" + }, + { + "name": "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": "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": "array of objects, not found", + "template": "{% assign x = a | has: i => i.title == 42 %}{{ x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "false" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/map.json b/tests/liquid2-compliance-test-suite/tests/filters/map.json index 05816b6..9782323 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/map.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/map.json @@ -106,6 +106,96 @@ } }, "result": "foo" + }, + { + "name": "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": "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": "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": "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": "array of objects, lambda expression is not a path", + "template": "{{ a | map: (i) => i.user.title == 'foo' | join: '#' }}", + "data": { + "a": [ + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "baz" } + } + ] + }, + "invalid": true } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/reject.json b/tests/liquid2-compliance-test-suite/tests/filters/reject.json new file mode 100644 index 0000000..2399a77 --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/reject.json @@ -0,0 +1,94 @@ +{ + "tests": [ + { + "name": "array of objects, explicit null", + "template": "{% assign x = a | reject: 'title', null %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,)" + }, + { + "name": "array of objects, implicit null", + "template": "{% assign x = a | reject: 'title' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "result": "(title,)" + }, + { + "name": "array of objects, string match", + "template": "{% assign x = a | reject: 'title', 'baz' %}{% for obj in x %}{% for i in obj %}({{ i[0] }},{{ i[1] }}){% endfor %}{% endfor %}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": "baz" + } + ] + }, + "result": "(title,foo)(title,bar)" + }, + { + "name": "array of objects, missing key", + "template": "{% assign x = a | reject: '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)(title,bar)" + }, + { + "name": "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)" + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sort.json b/tests/liquid2-compliance-test-suite/tests/filters/sort.json index ea63045..845bf1f 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/sort.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/sort.json @@ -103,6 +103,42 @@ "a": [[], {}, 1, "4"] }, "invalid": true + }, + { + "name": "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": "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)" } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json b/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json index 28ac892..21b2557 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/sort_natural.json @@ -4,13 +4,7 @@ "name": "array of strings", "template": "{{ a | sort_natural | join: '#' }}", "data": { - "a": [ - "b", - "a", - "C", - "B", - "A" - ] + "a": ["b", "a", "C", "B", "A"] }, "result": "a#A#b#B#C" }, @@ -18,14 +12,7 @@ "name": "array of strings with a nul", "template": "{% assign x = a | sort_natural %}{% for i in x %}{{ i }}{% unless forloop.last %}#{% endunless %}{% endfor %}", "data": { - "a": [ - "b", - "a", - null, - "C", - "B", - "A" - ] + "a": ["b", "a", null, "C", "B", "A"] }, "result": "a#A#b#B#C#" }, @@ -126,14 +113,46 @@ { "name": "incompatible types", "template": "{{ a | sort_natural }}", + "data": { + "a": [{}, 1, "4"] + }, + "result": "14{}" + }, + { + "name": "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": "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": [ - {}, - 1, - "4" + { + "user": { "title": "foo" } + }, + { + "user": { "title": "bar" } + }, + { + "user": { "title": "Baz" } + } ] }, - "result": "14{}" + "result": "(user.title,foo)(user.title,bar)(user.title,Baz)" } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json b/tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json new file mode 100644 index 0000000..e34769a --- /dev/null +++ b/tests/liquid2-compliance-test-suite/tests/filters/sort_numeric.json @@ -0,0 +1,20 @@ +{ + "tests": [ + { + "name": "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": "array of objects, lambda expression is not a path", + "template": "{% assign x = a | sort_numeric: i => i.x == y %}{% for item in x %}{% for pair in item %}{{ '(${pair[0]},${pair[1]})' }}{% endfor %}{% endfor %}", + "data": { + "a": [{ "y": "-1", "x": "10" }, { "x": "2" }, { "x": "3" }] + }, + "invalid": true + } + ] +} diff --git a/tests/liquid2-compliance-test-suite/tests/filters/sum.json b/tests/liquid2-compliance-test-suite/tests/filters/sum.json index bda6e26..9918aa9 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/sum.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/sum.json @@ -12,11 +12,7 @@ "name": "only zeros", "template": "{{ a | sum }}", "data": { - "a": [ - 0, - 0, - 0 - ] + "a": [0, 0, 0] }, "result": "0" }, @@ -24,11 +20,7 @@ "name": "ints", "template": "{{ a | sum }}", "data": { - "a": [ - 1, - 2, - 3 - ] + "a": [1, 2, 3] }, "result": "6" }, @@ -36,11 +28,7 @@ "name": "negative ints", "template": "{{ a | sum }}", "data": { - "a": [ - -1, - -2, - -3 - ] + "a": [-1, -2, -3] }, "result": "-6" }, @@ -48,11 +36,7 @@ "name": "negative strings", "template": "{{ a | sum }}", "data": { - "a": [ - "-1", - "-2", - "-3" - ] + "a": ["-1", "-2", "-3"] }, "result": "-6" }, @@ -60,11 +44,7 @@ "name": "positive and negative ints", "template": "{{ a | sum }}", "data": { - "a": [ - -2, - -3, - 10 - ] + "a": [-2, -3, 10] }, "result": "5" }, @@ -72,15 +52,7 @@ "name": "nested ints", "template": "{{ a | sum }}", "data": { - "a": [ - 1, - [ - 2, - [ - 3 - ] - ] - ] + "a": [1, [2, [3]]] }, "result": "6" }, @@ -159,11 +131,43 @@ { "name": "properties arguments with non-hash items", "template": "{{ a | sum: 'k' }}", + "data": { + "a": [1, 2, 3] + }, + "invalid": true + }, + { + "name": "hashes with lambda argument", + "template": "{{ a | sum: i => i.k }}", + "data": { + "a": [ + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } + ] + }, + "result": "6" + }, + { + "name": "hashes with lambda invalid argument", + "template": "{{ a | sum: i => i.k == 'foo' }}", "data": { "a": [ - 1, - 2, - 3 + { + "k": 1 + }, + { + "k": 2 + }, + { + "k": 3 + } ] }, "invalid": true diff --git a/tests/liquid2-compliance-test-suite/tests/filters/uniq.json b/tests/liquid2-compliance-test-suite/tests/filters/uniq.json index 358b72c..ef326a9 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/uniq.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/uniq.json @@ -4,12 +4,7 @@ "name": "array of strings", "template": "{{ a | uniq | join: '#' }}", "data": { - "a": [ - "a", - "b", - "b", - "a" - ] + "a": ["a", "b", "b", "a"] }, "result": "a#b" }, @@ -17,12 +12,7 @@ "name": "array of things", "template": "{{ a | uniq | join: '#' }}", "data": { - "a": [ - "a", - "b", - 1, - 1 - ] + "a": ["a", "b", 1, 1] }, "result": "a#b#1" }, @@ -38,13 +28,7 @@ "name": "unhashable items", "template": "{{ a | uniq | join: '#' }}", "data": { - "a": [ - "a", - "b", - [], - {}, - {} - ] + "a": ["a", "b", [], {}, {}] }, "result": "a#b#{}" }, @@ -117,6 +101,48 @@ ] }, "result": "(title,foo)(name,a)(title,bar)(name,c)(heading,bar)(name,c)" + }, + { + "name": "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": "array of objects, lambda expression is not a path", + "template": "{% assign x = a | uniq: i => i.title == 'foo' %}{% 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" + } + ] + }, + "invalid": true } ] } diff --git a/tests/liquid2-compliance-test-suite/tests/filters/where.json b/tests/liquid2-compliance-test-suite/tests/filters/where.json index 1f27931..097b5f8 100644 --- a/tests/liquid2-compliance-test-suite/tests/filters/where.json +++ b/tests/liquid2-compliance-test-suite/tests/filters/where.json @@ -98,6 +98,24 @@ }, "invalid": true }, + { + "name": "arrow function, two arguments", + "template": "{{ a | i => i.foo.bar, x }}", + "data": { + "a": [ + { + "title": "foo" + }, + { + "title": "bar" + }, + { + "title": null + } + ] + }, + "invalid": true + }, { "name": "left value is undefined", "template": "{{ nosuchthing | where: 'title' }}", @@ -193,6 +211,42 @@ ] }, "result": "(b,bar)" + }, + { + "name": "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": "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_lexer.py b/tests/test_lexer.py index 77f8982..1778216 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -90,12 +90,12 @@ class Case: Case( name="assign tag, filter", source="{% assign x = true | default: foo %}", - want="{% assign x = true | default : foo %}", + want="{% assign x = true | default: foo %}", ), Case( name="assign tag, filters", source="{% assign x = true | default: foo | upcase %}", - want="{% assign x = true | default : foo | upcase %}", + want="{% assign x = true | default: foo | upcase %}", ), Case( name="assign tag, condition", @@ -110,7 +110,7 @@ class Case: Case( name="assign tag, condition, tail filters", source="{% assign x = true if y || upcase | join : 'foo' %}", - want="{% assign x = true if y || upcase | join : 'foo' %}", + want="{% assign x = true if y || upcase | join: 'foo' %}", ), Case( name="assign tag, condition and alternative", @@ -202,6 +202,21 @@ class Case: source='{{ "${you}" }}', want='{{ "${you}" }}', ), + Case( + name="arrow expression", + source="{% assign x = a | map: i => i.foo.bar %}", + want="{% assign x = a | map: i => i.foo.bar %}", + ), + Case( + name="arrow expression, two arguments", + source="{% assign x = a | map: (item, index) => item.foo.bar %}", + want="{% assign x = a | map: (item, index) => item.foo.bar %}", + ), + Case( + name="range expression as filter argument", + source="{% assign x = a | foo: (1..4) %}", + want="{% assign x = a | foo: (1..4) %}", + ), ] diff --git a/tests/test_sort_numeric_filter.py b/tests/test_sort_numeric_filter.py index f460bcf..dd930c3 100644 --- a/tests/test_sort_numeric_filter.py +++ b/tests/test_sort_numeric_filter.py @@ -16,6 +16,8 @@ class Case(NamedTuple): expect: Any +# TODO: move these to the compliance test suite + TEST_CASES = [ Case( description="list of string ints", diff --git a/tests/test_static_analysis.py b/tests/test_static_analysis.py index f707ee8..ccb1378 100644 --- a/tests/test_static_analysis.py +++ b/tests/test_static_analysis.py @@ -744,8 +744,7 @@ def test_analyze_inheritance_chain() -> None: "{% block foo %}{% assign z = 7 %}{% endblock %}" ), "some": ( - "{% extends 'other' %}{{ y | append: x }}" - "{% block foo %}{% endblock %}" + "{% extends 'other' %}{{ y | append: x }}{% block foo %}{% endblock %}" ), } ) @@ -933,3 +932,47 @@ def test_analyze_array_literals(env: Environment) -> None: }, tags={"assign": [Span("", 0, 26)]}, ) + + +def test_analyze_lambda_expression(env: Environment) -> None: + source = "{% assign y = 42 %}{% assign x = a | where: i => i.foo.bar == y %}" + + _assert( + env.from_string(source), + locals={ + "y": [Variable(["y"], Span("", 10, 11))], + "x": [Variable(["x"], Span("", 29, 30))], + }, + globals={ + "a": [Variable(["a"], Span("", 33, 34))], + }, + variables={ + "a": [Variable(["a"], Span("", 33, 34))], + "i": [Variable(["i", "foo", "bar"], Span("", 49, 58))], + "y": [Variable(["y"], Span("", 62, 63))], + }, + tags={"assign": [Span("", 0, 19), Span("", 19, 66)]}, + filters={"where": [Span("", 37, 42)]}, + ) + + +def test_analyze_two_argument_lambda_expression(env: Environment) -> None: + source = "{% assign y = 42 %}{% assign x = a | where: (i, j) => i.foo.bar == j %}" + + _assert( + env.from_string(source), + locals={ + "y": [Variable(["y"], Span("", 10, 11))], + "x": [Variable(["x"], Span("", 29, 30))], + }, + globals={ + "a": [Variable(["a"], Span("", 33, 34))], + }, + variables={ + "a": [Variable(["a"], Span("", 33, 34))], + "i": [Variable(["i", "foo", "bar"], Span("", 54, 63))], + "j": [Variable(["j"], Span("", 67, 68))], + }, + tags={"assign": [Span("", 0, 19), Span("", 19, 71)]}, + filters={"where": [Span("", 37, 42)]}, + ) diff --git a/tests/test_template_str.py b/tests/test_template_str.py index a6ce881..d0dca54 100644 --- a/tests/test_template_str.py +++ b/tests/test_template_str.py @@ -356,10 +356,7 @@ def test_translate_str() -> None: source = "\n".join( [ "{% translate x:'foo', y:'bar' %}", - " Hello, {{ you }}" - "{% plural %}" - " Hello, {{ you }}s" - "{% endtranslate %}", + " Hello, {{ you }}{% plural %} Hello, {{ you }}s{% endtranslate %}", ] ) template = parse(source) @@ -370,10 +367,7 @@ def test_translate_str_wc() -> None: source = "\n".join( [ "{%- translate x:'foo', y:'bar' ~%}", - " Hello, {{ you ~}}" - "{%~ plural -%}" - " Hello, {{ you }}s" - "{%~ endtranslate +%}", + " Hello, {{ you ~}}{%~ plural -%} Hello, {{ you }}s{%~ endtranslate +%}", ] ) template = parse(source) @@ -442,3 +436,15 @@ def test_array_literal_str() -> None: source = "{% assign my_array = 1, 2, 3 %}" template = parse(source) assert str(template) == source + + +def test_lambda_expression_str() -> None: + source = "{% assign x = a | map: i => i.foo.bar %}" + template = parse(source) + assert str(template) == source + + +def test_two_argument_lambda_expression_str() -> None: + source = "{% assign x = a | where: (i, j) => i.foo.bar == j %}" + template = parse(source) + assert str(template) == source diff --git a/tests/test_undefined.py b/tests/test_undefined.py index 3c9e2de..0bba687 100644 --- a/tests/test_undefined.py +++ b/tests/test_undefined.py @@ -235,6 +235,12 @@ def test_strict_undefined_with_default() -> None: assert template.render() == "hello" +def test_where_filter_lambda_with_strict_undefined() -> None: + env = Environment(undefined=StrictUndefined) + template = env.from_string("{{ (1..3) | where: i => i.nosuchthing | join: '#' }}") + assert template.render() == "" + + def test_strict_undefined_magic() -> None: undefined = StrictUndefined("test", token=None) diff --git a/tests/test_validate_filters.py b/tests/test_validate_filters.py new file mode 100644 index 0000000..f1416a2 --- /dev/null +++ b/tests/test_validate_filters.py @@ -0,0 +1,53 @@ +import pytest + +from liquid2 import Environment +from liquid2 import TokenT +from liquid2.builtin import KeywordArgument +from liquid2.builtin import PositionalArgument +from liquid2.exceptions import LiquidSyntaxError +from liquid2.exceptions import UnknownFilterError + + +def test_unknown_filter_without_validation() -> None: + env = Environment(validate_filter_arguments=False) + # No exception at parse time + template = env.from_string("{{ x | nosuchthing }}") + + with pytest.raises(UnknownFilterError): + template.render(x="foo") + + +def test_unknown_filter_with_validation() -> None: + env = Environment(validate_filter_arguments=True) + # Exception is raised at parse time + with pytest.raises(UnknownFilterError): + env.from_string("{{ x | nosuchthing }}") + + +class MockInvalidFilter: + def __call__(self, _left: object) -> str: + return self.__class__.__name__ + + def validate( + self, + _env: Environment, + token: TokenT, + name: str, + _args: list[KeywordArgument | PositionalArgument], + ) -> None: + raise LiquidSyntaxError(f"{name!r} is invalid", token=token) + + +def test_invalid_filter_without_validation() -> None: + env = Environment(validate_filter_arguments=False) + env.filters["mock"] = MockInvalidFilter() + template = env.from_string("{{ x | mock }}") + assert template.render(x="foo") == "MockInvalidFilter" + + +def test_invalid_filter_with_validation() -> None: + env = Environment(validate_filter_arguments=True) + env.filters["mock"] = MockInvalidFilter() + + with pytest.raises(LiquidSyntaxError, match=r"'mock' is invalid"): + env.from_string("{{ x | mock }}")