diff --git a/README.md b/README.md index 21b05d9..c3b2744 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Liquid is a template engine with interesting advantages: | PHP Liquid | Shopify Liquid | |------------:|---------------:| +| v0.10 | v5.12 | | v0.9 | v5.8 | | v0.8 | v5.7 | | v0.7 | v5.6 | @@ -55,7 +56,13 @@ $environment = \Keepsuit\Liquid\EnvironmentFactory::new() // set filesystem used to load templates ->setFilesystem(new \Keepsuit\Liquid\FileSystems\LocalFileSystem(__DIR__ . '/views')) // set the resource limits - ->setResourceLimits(new \Keepsuit\Liquid\ResourceLimits()) + ->setResourceLimits(new \Keepsuit\Liquid\Render\ResourceLimits( + renderLengthLimit: 100_000, + renderScoreLimit: 50_000, + assignScoreLimit: 5_000, + cumulativeRenderScoreLimit: 100_000, + cumulativeAssignScoreLimit: 10_000, + )) // register a custom extension ->addExtension(new CustomExtension()) // register a custom tag @@ -285,6 +292,18 @@ $environment = \Keepsuit\Liquid\EnvironmentFactory::new() $environment->addExtension(new CustomExtension()); ``` +## Resource limits + +`Keepsuit\Liquid\Render\ResourceLimits` supports both per-render limits and cumulative resource limits. + +- `renderLengthLimit`: limits the rendered output size for a render pass. +- `renderScoreLimit`: limits render work for a render pass. +- `assignScoreLimit`: limits assignment and capture work for a render pass. +- `cumulativeRenderScoreLimit`: limits total render work across a full render tree, including partial renders. +- `cumulativeAssignScoreLimit`: limits total assignment and capture work across a full render tree, including partial renders. + +Per-render counters can be reset between renders. Cumulative counters are intended for a full render lifecycle so repeated partial renders can share the same budget. + ## Custom tags and filters By default, only the standard liquid tags and filters are available. diff --git a/composer.json b/composer.json index 0ba8788..90ca7c8 100644 --- a/composer.json +++ b/composer.json @@ -16,13 +16,14 @@ ], "require": { "php": "^8.2", - "ext-mbstring": "*" + "ext-mbstring": "*", + "symfony/polyfill-php85": "^1.33" }, "require-dev": { "laravel/pint": "^1.2", "pestphp/pest": "^3.0 || ^4.0", "pestphp/pest-plugin-arch": "^3.0 || ^4.0", - "phpbench/phpbench": "dev-master", + "phpbench/phpbench": "^1.4", "phpstan/extension-installer": "^1.3", "phpstan/phpstan": "^2.0", "phpstan/phpstan-deprecation-rules": "^2.0", diff --git a/performance/Shopify/CustomFilters.php b/performance/Shopify/CustomFilters.php index 70eef4e..ac34fc4 100644 --- a/performance/Shopify/CustomFilters.php +++ b/performance/Shopify/CustomFilters.php @@ -75,8 +75,9 @@ public function highlightActiveTag(string|int|float $tag, string $cssClass = 'ac public function linkToAddTag(string|int|float $label, string $tag): string { - $currentTags = $this->context->get('current_tags') ?? []; - assert(is_array($currentTags)); + $currentTags = $this->context->get('current_tags'); + $currentTags = is_array($currentTags) ? $currentTags : []; + $currentTags = array_values(array_filter($currentTags, is_string(...))); $tags = array_unique([...$currentTags, $tag]); return sprintf( @@ -90,9 +91,10 @@ public function linkToAddTag(string|int|float $label, string $tag): string public function linkToRemoveTag(string|int|float $label, string $tag): string { - $currentTags = $this->context->get('current_tags') ?? []; - assert(is_array($currentTags)); - $tags = array_filter($currentTags, fn ($t) => $t !== $tag); + $currentTags = $this->context->get('current_tags'); + $currentTags = is_array($currentTags) ? $currentTags : []; + $currentTags = array_values(array_filter($currentTags, is_string(...))); + $tags = array_values(array_filter($currentTags, fn (string $currentTag) => $currentTag !== $tag)); return sprintf( '%s', diff --git a/performance/Shopify/Database.php b/performance/Shopify/Database.php index 864945c..cb12b54 100644 --- a/performance/Shopify/Database.php +++ b/performance/Shopify/Database.php @@ -18,24 +18,39 @@ public static function tables(): array } $database = (array) Yaml::parseFile(static::DATABASE_FILE_PATH); + $products = is_array($database['products'] ?? null) ? $database['products'] : []; + $collections = is_array($database['collections'] ?? null) ? $database['collections'] : []; + $blogs = is_array($database['blogs'] ?? null) ? $database['blogs'] : []; + $lineItems = is_array($database['line_items'] ?? null) ? $database['line_items'] : []; $database['products'] = array_map( - function (array $product) use ($database) { + function (array $product) use ($collections) { $collections = array_filter( - $database['collections'], - fn (array $collection) => Arr::first($collection['products'], fn (array $p) => $p['id'] === $product['id']) !== null + $collections, + fn (array $collection) => Arr::first( + is_array($collection['products'] ?? null) ? $collection['products'] : [], + fn (array $p) => $p['id'] === $product['id'] + ) !== null ); $product['collections'] = array_values($collections); return $product; }, - $database['products'] + $products ); $tables = []; foreach ($database as $key => $values) { - $tables[$key] = array_reduce($values, function (array $acc, array $item) { - if (isset($item['handle'])) { + if (! is_array($values)) { + continue; + } + + $tables[$key] = array_reduce($values, function (array $acc, mixed $item) { + if (! is_array($item)) { + return $acc; + } + + if (isset($item['handle']) && is_string($item['handle'])) { $acc[$item['handle']] = $item; } else { $acc[] = $item; @@ -45,17 +60,43 @@ function (array $product) use ($database) { }, []); } - $tables['collection'] = Arr::first($database['collections']); + $tables['collection'] = Arr::first($collections); $tables['product'] = Arr::first($database['products']); - $tables['blog'] = Arr::first($database['blogs']); + $tables['blog'] = Arr::first($blogs); $articles = $tables['blog']['articles'] ?? []; assert(is_array($articles)); $tables['article'] = Arr::first($articles); $tables['cart'] = [ - 'total_price' => array_reduce($tables['line_items'], fn (int $total, array $item) => $total + $item['line_price'] * $item['quantity'], 0), - 'item_count' => array_reduce($tables['line_items'], fn (int $total, array $item) => $total + $item['quantity'], 0), - 'items' => $database['line_items'], + 'total_price' => array_reduce($lineItems, function (int $total, mixed $item): int { + if (! is_array($item)) { + return $total; + } + + $linePrice = $item['line_price'] ?? 0; + $quantity = $item['quantity'] ?? 0; + if (! is_int($linePrice) && ! is_float($linePrice)) { + $linePrice = 0; + } + if (! is_int($quantity) && ! is_float($quantity)) { + $quantity = 0; + } + + return (int) ($total + $linePrice * $quantity); + }, 0), + 'item_count' => array_reduce($lineItems, function (int $total, mixed $item): int { + if (! is_array($item)) { + return $total; + } + + $quantity = $item['quantity'] ?? 0; + if (! is_int($quantity) && ! is_float($quantity)) { + $quantity = 0; + } + + return (int) ($total + $quantity); + }, 0), + 'items' => $lineItems, ]; return static::$tables = $tables; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 55f6a1d..fe7c22a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -10,21 +10,11 @@ parameters: count: 1 path: performance/Shopify/Database.php - - - message: "#^Parameter \\#1 \\$array of function array_filter expects array, mixed given\\.$#" - count: 1 - path: performance/Shopify/Database.php - - message: "#^Parameter \\#1 \\$callback of function array_map expects \\(callable\\(mixed\\)\\: mixed\\)\\|null, Closure\\(array\\)\\: non\\-empty\\-array given\\.$#" count: 1 path: performance/Shopify/Database.php - - - message: "#^Parameter \\#2 \\$array of function array_map expects array, mixed given\\.$#" - count: 1 - path: performance/Shopify/Database.php - - message: "#^Parameter \\#2 \\$callback of function array_filter expects \\(callable\\(mixed\\)\\: bool\\)\\|null, Closure\\(array\\)\\: bool given\\.$#" count: 1 diff --git a/src/Exceptions/SyntaxException.php b/src/Exceptions/SyntaxException.php index d497c9d..5cdc7dc 100644 --- a/src/Exceptions/SyntaxException.php +++ b/src/Exceptions/SyntaxException.php @@ -57,7 +57,7 @@ public static function unexpectedIdentifier(string $expected, string $given): Sy public static function invalidExpression(string $expression): SyntaxException { - return new SyntaxException(sprintf('%s is not a valid expression', $expression)); + return new SyntaxException(sprintf('`%s` is not a valid expression', $expression)); } public static function unexpectedCharacter(string $character): SyntaxException diff --git a/src/Filters/StandardFilters.php b/src/Filters/StandardFilters.php index c00d8e1..7b5cf14 100644 --- a/src/Filters/StandardFilters.php +++ b/src/Filters/StandardFilters.php @@ -551,6 +551,20 @@ public function rstrip(?string $input): string return rtrim($input ?? ''); } + /** + * Trims surrounding whitespace and collapses internal whitespace runs to single spaces. + */ + public function squish(?string $input): string + { + $input = trim($input ?? ''); + + if ($input === '') { + return ''; + } + + return preg_replace('/\s+/', ' ', $input) ?? $input; + } + /** * Strips all HTML tags from a string. */ diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index 3158df1..fe7c16e 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -115,7 +115,7 @@ protected function renderOutput(mixed $output): string } if (is_array($output)) { - return implode('', $output); + return implode('', array_map($this->renderOutput(...), $output)); } if (is_object($output) && method_exists($output, '__toString')) { diff --git a/src/Parse/ArgumentParser.php b/src/Parse/ArgumentParser.php index 580f1e0..7206d3a 100644 --- a/src/Parse/ArgumentParser.php +++ b/src/Parse/ArgumentParser.php @@ -2,6 +2,8 @@ namespace Keepsuit\Liquid\Parse; +use Keepsuit\Liquid\Exceptions\SyntaxException; + /** * @phpstan-import-type Expression from ExpressionParser * @@ -15,9 +17,15 @@ public function __construct( /** * @return Argument + * + * @throws SyntaxException */ public function parseArgument(): mixed { + if ($this->tokenStream->isEnd()) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + if ( $this->tokenStream->look(TokenType::Identifier) && $this->tokenStream->look(TokenType::Colon, 1) diff --git a/src/Parse/Lexer.php b/src/Parse/Lexer.php index 2b7b610..4e55645 100644 --- a/src/Parse/Lexer.php +++ b/src/Parse/Lexer.php @@ -172,7 +172,10 @@ protected function lexBlock(): void while (preg_match(LexerOptions::blockEndRegex(), $this->source, $matches, offset: $this->cursor) !== 1) { $this->lexExpression(); - $lastToken = $this->tokens[array_key_last($this->tokens)]; + $lastToken = array_last($this->tokens); + if ($lastToken === null) { + throw SyntaxException::unexpectedEndOfTemplate(); + } if ($tag === null && $lastToken->type === TokenType::Identifier) { $tag = $lastToken; @@ -188,7 +191,11 @@ protected function lexBlock(): void } // If the last token is a block start, we remove the node - $lastToken = $this->tokens[array_key_last($this->tokens)]; + $lastToken = array_last($this->tokens); + if ($lastToken === null) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + if ($lastToken->type === TokenType::BlockStart) { array_pop($this->tokens); } else { diff --git a/src/Parse/TokenStream.php b/src/Parse/TokenStream.php index 8b69ccd..936a7c3 100644 --- a/src/Parse/TokenStream.php +++ b/src/Parse/TokenStream.php @@ -145,6 +145,8 @@ public function expression(): mixed /** * @return Argument + * + * @throws SyntaxException */ public function argument(): mixed { diff --git a/src/Parse/VariableParser.php b/src/Parse/VariableParser.php index 2d8885d..3c3f2c3 100644 --- a/src/Parse/VariableParser.php +++ b/src/Parse/VariableParser.php @@ -11,6 +11,9 @@ public function __construct( protected TokenStream $tokenStream ) {} + /** + * @throws SyntaxException + */ public function parseVariable(): Variable { $currentToken = $this->tokenStream->current(); @@ -34,11 +37,18 @@ public function parseVariable(): Variable ))->setLineNumber($currentToken->lineNumber); } + /** + * @throws SyntaxException + */ protected function parseFilterArgs(): array { $filterArgs = [$this->tokenStream->argument()]; while ($this->tokenStream->consumeOrFalse(TokenType::Comma)) { + if ($this->tokenStream->isEnd() || $this->tokenStream->look(TokenType::VariableEnd)) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + $filterArgs[] = $this->tokenStream->argument(); } diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index ab8ce46..6aed4f0 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -286,7 +286,7 @@ public function setData(string $name, mixed $value): mixed public function setToActiveScope(string $key, mixed $value): array { - $index = array_key_last($this->scopes); + $index = count($this->scopes) - 1; return $this->scopes[$index] = [ ...$this->scopes[$index], diff --git a/src/Render/ResourceLimits.php b/src/Render/ResourceLimits.php index e6eb90e..1efc0ac 100644 --- a/src/Render/ResourceLimits.php +++ b/src/Render/ResourceLimits.php @@ -9,8 +9,12 @@ class ResourceLimits { protected int $renderScore = 0; + protected int $cumulativeRenderScore = 0; + protected int $assignScore = 0; + protected int $cumulativeAssignScore = 0; + protected ?int $lastCaptureLength = null; protected bool $reachedLimit = false; @@ -19,6 +23,8 @@ public function __construct( public readonly ?int $renderLengthLimit = null, public readonly ?int $renderScoreLimit = null, public readonly ?int $assignScoreLimit = null, + public readonly ?int $cumulativeRenderScoreLimit = null, + public readonly ?int $cumulativeAssignScoreLimit = null, ) {} public static function clone(ResourceLimits $limits): ResourceLimits @@ -27,6 +33,8 @@ public static function clone(ResourceLimits $limits): ResourceLimits renderLengthLimit: $limits->renderLengthLimit, renderScoreLimit: $limits->renderScoreLimit, assignScoreLimit: $limits->assignScoreLimit, + cumulativeRenderScoreLimit: $limits->cumulativeRenderScoreLimit, + cumulativeAssignScoreLimit: $limits->cumulativeAssignScoreLimit, ); } @@ -36,11 +44,16 @@ public static function clone(ResourceLimits $limits): ResourceLimits public function incrementRenderScore(int $amount = 1): ResourceLimits { $this->renderScore += $amount; + $this->cumulativeRenderScore += $amount; if ($this->renderScoreLimit != null && $this->renderScoreLimit < $this->renderScore) { $this->throwLimitReachedException(); } + if ($this->cumulativeRenderScoreLimit != null && $this->cumulativeRenderScoreLimit < $this->cumulativeRenderScore) { + $this->throwLimitReachedException(); + } + return $this; } @@ -50,11 +63,16 @@ public function incrementRenderScore(int $amount = 1): ResourceLimits public function incrementAssignScore(int $amount = 1): ResourceLimits { $this->assignScore += $amount; + $this->cumulativeAssignScore += $amount; if ($this->assignScoreLimit != null && $this->assignScoreLimit < $this->assignScore) { $this->throwLimitReachedException(); } + if ($this->cumulativeAssignScoreLimit != null && $this->cumulativeAssignScoreLimit < $this->cumulativeAssignScore) { + $this->throwLimitReachedException(); + } + return $this; } @@ -106,11 +124,21 @@ public function getAssignScore(): int return $this->assignScore; } + public function getCumulativeAssignScore(): int + { + return $this->cumulativeAssignScore; + } + public function getRenderScore(): int { return $this->renderScore; } + public function getCumulativeRenderScore(): int + { + return $this->cumulativeRenderScore; + } + public function withCapture(Closure $closure): mixed { $oldCaptureLength = $this->lastCaptureLength; diff --git a/src/Support/Arr.php b/src/Support/Arr.php index c57e0c2..64b283c 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -58,7 +58,10 @@ public static function set(array &$array, string|int $key, mixed $value): array $array = &$array[$key]; } - $array[array_shift($keys)] = $value; + $lastKey = array_shift($keys); + assert($lastKey !== null); + + $array[$lastKey] = $value; return $array; } diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index e23ccf4..66381f3 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -34,6 +34,7 @@ public function parse(TagParseContext $context): static { if ($context->tag === 'case') { $this->left = $context->params->expression(); + $context->params->assertEnd(); } else { $this->conditions[] = $this->mapBodySectionToCondition($context); } @@ -80,6 +81,9 @@ public function blank(): bool return true; } + /** + * @throws SyntaxException + */ protected function mapBodySectionToCondition(TagParseContext $bodySection): Condition { $condition = match ($bodySection->tag) { @@ -99,8 +103,15 @@ protected function mapBodySectionToCondition(TagParseContext $bodySection): Cond return $condition; } + /** + * @throws SyntaxException + */ protected function recordWhenCondition(TagParseContext $bodySection): Condition { + if ($bodySection->params->isEnd()) { + throw SyntaxException::unexpectedEndOfTemplate(); + } + $condition = new Condition($this->left, '==', $bodySection->params->expression()); if ($bodySection->params->idOrFalse('or') || $bodySection->params->consumeOrFalse(TokenType::Comma)) { @@ -112,6 +123,9 @@ protected function recordWhenCondition(TagParseContext $bodySection): Condition return $condition; } + /** + * @throws SyntaxException + */ protected function recordElseCondition(TagParseContext $bodySection): Condition { $bodySection->params->assertEnd(); diff --git a/src/Tags/CycleTag.php b/src/Tags/CycleTag.php index 021112a..de809e5 100644 --- a/src/Tags/CycleTag.php +++ b/src/Tags/CycleTag.php @@ -61,6 +61,8 @@ public function parse(TagParseContext $context): static $this->name = json_encode($this->variables, JSON_THROW_ON_ERROR); } + $context->params->assertEnd(); + return $this; } diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index 9ffa60e..c9a5318 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -43,6 +43,7 @@ public function parse(TagParseContext $context): static { $this->isForLoop = false; $this->variableNameExpression = null; + $this->attributes = []; $context->getParseContext()->nested(function () use ($context) { $templateNameExpression = $context->params->expression(); @@ -52,6 +53,8 @@ public function parse(TagParseContext $context): static default => throw new SyntaxException('Template name must be a string'), }; + $context->params->consumeOrFalse(TokenType::Comma); + if ($context->params->idOrFalse('for')) { $this->isForLoop = true; $this->variableNameExpression = $context->params->expression(); @@ -59,6 +62,8 @@ public function parse(TagParseContext $context): static $this->variableNameExpression = $context->params->expression(); } + $context->params->consumeOrFalse(TokenType::Comma); + if ($context->params->idOrFalse('as')) { $aliasName = $context->params->expression(); $this->aliasName = match (true) { @@ -69,7 +74,9 @@ public function parse(TagParseContext $context): static $this->aliasName = null; } - while ($context->params->consumeOrFalse(TokenType::Comma)) { + while (! $context->params->isEnd()) { + $context->params->consumeOrFalse(TokenType::Comma); + $attributeName = $context->params->expression(); if (! (is_string($attributeName) || $attributeName instanceof VariableLookup)) { throw new SyntaxException('Attribute name must be a valid variable name'); diff --git a/src/Tags/TableRowTag.php b/src/Tags/TableRowTag.php index ab8e1bb..0bf49e5 100644 --- a/src/Tags/TableRowTag.php +++ b/src/Tags/TableRowTag.php @@ -39,7 +39,13 @@ public function parse(TagParseContext $context): static $context->params->id('in'); $this->collectionName = $context->params->expression(); - while ($context->params->look(TokenType::Identifier)) { + while (true) { + $context->params->consumeOrFalse(TokenType::Comma); + + if ($context->params->isEnd()) { + break; + } + $attribute = $context->params->consume(TokenType::Identifier)->data; $context->params->consume(TokenType::Colon); $value = $context->params->expression(); diff --git a/tests/Integration/FilterTest.php b/tests/Integration/FilterTest.php index 91f9992..1e5eb8f 100644 --- a/tests/Integration/FilterTest.php +++ b/tests/Integration/FilterTest.php @@ -143,3 +143,16 @@ expect(parseTemplate("{{ 'img' | html_tag: data-src: 'src', data-widths: '100, 200' }}")->render($context)) ->toBe("data-src='src' data-widths='100, 200'"); }); + +test('filter strict parsing rejects trailing commas', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected end of template', + '{{ value | default: "fallback", }}', + ['value' => null], + ); + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected end of template', + '{{ value | substitute: first_name: "john", }}', + ['value' => 'hello %{first_name}'], + ); +}); diff --git a/tests/Integration/ParsingQuirksTest.php b/tests/Integration/ParsingQuirksTest.php index 45f50eb..f6d9b81 100644 --- a/tests/Integration/ParsingQuirksTest.php +++ b/tests/Integration/ParsingQuirksTest.php @@ -29,7 +29,7 @@ test('throw exception on empty filter', function () { assertTemplateResult('', '{{test}}'); assertMatchSyntaxError( - 'Liquid syntax error (line 1): | is not a valid expression', + 'Liquid syntax error (line 1): `|` is not a valid expression', '{{|test}}' ); assertMatchSyntaxError( diff --git a/tests/Integration/StandardFilterTest.php b/tests/Integration/StandardFilterTest.php index 8d43786..0fcafb8 100644 --- a/tests/Integration/StandardFilterTest.php +++ b/tests/Integration/StandardFilterTest.php @@ -483,6 +483,12 @@ assertTemplateResult(" \tab c", '{{ source | rstrip }}', staticData: ['source' => " \tab c \n \t"]); }); +test('squish', function () { + assertTemplateResult('foo bar boo', '{{ source | squish }}', staticData: ['source' => " foo bar\n\t boo "]); + assertTemplateResult('', '{{ source | squish }}', staticData: ['source' => null]); + assertTemplateResult('', '{{ source | squish }}', staticData: ['source' => ' ']); +}); + test('strip new lines', function () { assertTemplateResult('abc', '{{ source | strip_newlines }}', staticData: ['source' => "a\nb\nc"]); assertTemplateResult('abc', '{{ source | strip_newlines }}', staticData: ['source' => "a\r\nb\nc"]); diff --git a/tests/Integration/Tags/RenderTagTest.php b/tests/Integration/Tags/RenderTagTest.php index ffc93df..8edd309 100644 --- a/tests/Integration/Tags/RenderTagTest.php +++ b/tests/Integration/Tags/RenderTagTest.php @@ -47,6 +47,40 @@ ); }); +test('render accepts multiple named arguments without commas', function () { + assertTemplateResult( + '1 2', + '{% render "snippet" one: 1 two: 2 %}', + partials: ['snippet' => '{{ one }} {{ two }}'], + ); +}); + +test('render accepts optional commas around with alias and named arguments', function () { + assertTemplateResult( + 'Product: Draft 151cm override', + "{% render 'product', with products[0], as item, note: 'override' %}", + staticData: [ + 'products' => [['title' => 'Draft 151cm'], ['title' => 'Element 155cm']], + ], + partials: [ + 'product' => 'Product: {{ item.title }} {{ note }}', + ], + ); +}); + +test('render named arguments override with value', function () { + assertTemplateResult( + 'Element 155cm', + "{% render 'product' with products[0], product: products[1] %}", + staticData: [ + 'products' => [['title' => 'Draft 151cm'], ['title' => 'Element 155cm']], + ], + partials: [ + 'product' => '{{ product.title }}', + ], + ); +}); + test('render does not inherit parent scope variables', function () { assertTemplateResult( '', @@ -84,6 +118,16 @@ ->toThrow(SyntaxException::class); }); +test('render with filters on template name is invalid', function () { + expect(fn () => parseTemplate('{% render "snippet" | upcase %}')) + ->toThrow(SyntaxException::class); +}); + +test('render invalid trailing syntax fails during parse', function () { + expect(fn () => parseTemplate('{% render "snippet", one: 1, two %}')) + ->toThrow(SyntaxException::class); +}); + test('render tag caches second read of some partial', function () { $environment = EnvironmentFactory::new() ->setFilesystem($fileSystem = new StubFileSystem(['snippet' => 'echo'])) diff --git a/tests/Integration/Tags/StandardTagTest.php b/tests/Integration/Tags/StandardTagTest.php index b4cf542..22c1dad 100644 --- a/tests/Integration/Tags/StandardTagTest.php +++ b/tests/Integration/Tags/StandardTagTest.php @@ -247,6 +247,24 @@ ); }); +test('case strict parsing rejects trailing tokens', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected token Identifier: "extra"', + '{% case condition extra %}{% when 1 %} hit {% endcase %}', + staticData: ['condition' => 1], + ); + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected token Identifier: "extra"', + '{% case condition %}{% when 1 extra %} hit {% endcase %}', + staticData: ['condition' => 1], + ); + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected end of template', + '{% case condition %}{% when 1, %} hit {% endcase %}', + staticData: ['condition' => 1], + ); +}); + test('assign', function () { assertTemplateResult('variable', '{% assign a = "variable"%}{{a}}'); }); @@ -302,6 +320,13 @@ ); }); +test('cycle strict parsing rejects trailing tokens', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected token Identifier: "extra"', + '{% cycle "one", "two" extra %}', + ); +}); + test('size of array', function () { assertTemplateResult( 'array has 4 elements', diff --git a/tests/Integration/Tags/TableRowTest.php b/tests/Integration/Tags/TableRowTest.php index 1a5c209..d2dfdf0 100644 --- a/tests/Integration/Tags/TableRowTest.php +++ b/tests/Integration/Tags/TableRowTest.php @@ -136,6 +136,23 @@ '{% tablerow n in numbers cols:3 offset:1 limit:6%} {{n}} {% endtablerow %}', ['numbers' => [0, 1, 2, 3, 4, 5, 6, 7]], ); + + assertTemplateResult( + <<<'HTML' + + 1 + 2 + 3 + + + 4 + 5 + 6 + + HTML, + '{% tablerow n in numbers, cols:3, offset:1, limit:6 %} {{n}} {% endtablerow %}', + ['numbers' => [0, 1, 2, 3, 4, 5, 6, 7]], + ); }); test('blank string not iterable', function () { @@ -270,6 +287,19 @@ ); }); +test('tablerow strict parsing rejects malformed params', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Expected :, got Number', + '{% tablerow n in numbers cols 3 %}{% endtablerow %}', + ['numbers' => [1, 2, 3]], + ); + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Unexpected end of template', + '{% tablerow n in numbers cols: 3, limit %}{% endtablerow %}', + ['numbers' => [1, 2, 3]], + ); +}); + test('tablerow handles interrupts', function () { assertTemplateResult( "\n 1 \n", diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index 0b00b68..29b0620 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -7,6 +7,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\RenderContextOptions; use Keepsuit\Liquid\Render\ResourceLimits; +use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; test('assigns persist on same context between renders', function () { $template = parseTemplate("{{ foo }}{% assign foo = 'foo' %}{{ foo }}"); @@ -97,7 +98,43 @@ expect($context->resourceLimits) ->reached()->toBeFalse() ->getAssignScore()->toBeGreaterThan(0) - ->getRenderScore()->toBeGreaterThan(0); + ->getCumulativeAssignScore()->toBeGreaterThan(0) + ->getRenderScore()->toBeGreaterThan(0) + ->getCumulativeRenderScore()->toBeGreaterThan(0); +}); + +test('cumulative render score accumulates across repeated partial renders', function () { + $environment = EnvironmentFactory::new() + ->setFilesystem(new StubFileSystem(['snippet' => 'x'])) + ->build(); + + $template = $environment->parseString('{% render "snippet" %}'); + $context = $environment->newRenderContext(resourceLimits: new ResourceLimits(cumulativeRenderScoreLimit: 3)); + + expect($template->render($context))->toBe('x'); + expect($context->resourceLimits->getRenderScore())->toBeGreaterThan(0) + ->and($context->resourceLimits->getCumulativeRenderScore())->toBeGreaterThan(0); + + $context->resourceLimits->reset(); + + expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); +}); + +test('cumulative assign score accumulates across repeated partial renders', function () { + $environment = EnvironmentFactory::new() + ->setFilesystem(new StubFileSystem(['snippet' => '{% capture foo %}ab{% endcapture %}'])) + ->build(); + + $template = $environment->parseString('{% render "snippet" %}'); + $context = $environment->newRenderContext(resourceLimits: new ResourceLimits(cumulativeAssignScoreLimit: 3)); + + expect($template->render($context))->toBe(''); + expect($context->resourceLimits->getAssignScore())->toBe(2) + ->and($context->resourceLimits->getCumulativeAssignScore())->toBe(2); + + $context->resourceLimits->reset(); + + expect(fn () => $template->render($context))->toThrow(ResourceLimitException::class); }); test('render length persists between blocks', function () { diff --git a/tests/Unit/ResourceLimitsTest.php b/tests/Unit/ResourceLimitsTest.php new file mode 100644 index 0000000..f794d04 --- /dev/null +++ b/tests/Unit/ResourceLimitsTest.php @@ -0,0 +1,70 @@ +cumulativeRenderScoreLimit)->toBeNull() + ->and($limits->cumulativeAssignScoreLimit)->toBeNull() + ->and($limits->getCumulativeRenderScore())->toBe(0) + ->and($limits->getCumulativeAssignScore())->toBe(0); +}); + +test('resource limits constructor configures cumulative limits', function () { + $limits = new ResourceLimits( + cumulativeRenderScoreLimit: 10, + cumulativeAssignScoreLimit: 20, + ); + + expect($limits->cumulativeRenderScoreLimit)->toBe(10) + ->and($limits->cumulativeAssignScoreLimit)->toBe(20); +}); + +test('resource limits clone preserves cumulative limits', function () { + $limits = ResourceLimits::clone(new ResourceLimits( + renderLengthLimit: 1, + renderScoreLimit: 2, + assignScoreLimit: 3, + cumulativeRenderScoreLimit: 4, + cumulativeAssignScoreLimit: 5, + )); + + expect($limits->renderLengthLimit)->toBe(1) + ->and($limits->renderScoreLimit)->toBe(2) + ->and($limits->assignScoreLimit)->toBe(3) + ->and($limits->cumulativeRenderScoreLimit)->toBe(4) + ->and($limits->cumulativeAssignScoreLimit)->toBe(5); +}); + +test('resource limits reset leaves cumulative scores intact', function () { + $limits = new ResourceLimits; + $limits->incrementRenderScore(2); + $limits->incrementAssignScore(3); + + $limits->reset(); + + expect($limits->getRenderScore())->toBe(0) + ->and($limits->getAssignScore())->toBe(0) + ->and($limits->getCumulativeRenderScore())->toBe(2) + ->and($limits->getCumulativeAssignScore())->toBe(3); +}); + +test('resource limits cumulative render score limit', function () { + $limits = new ResourceLimits(cumulativeRenderScoreLimit: 3); + + $limits->incrementRenderScore(2); + + expect(fn () => $limits->incrementRenderScore(2))->toThrow(ResourceLimitException::class); + expect($limits->reached())->toBeTrue(); +}); + +test('resource limits cumulative assign score limit', function () { + $limits = new ResourceLimits(cumulativeAssignScoreLimit: 3); + + $limits->incrementAssignScore(2); + + expect(fn () => $limits->incrementAssignScore(2))->toThrow(ResourceLimitException::class); + expect($limits->reached())->toBeTrue(); +}); diff --git a/tests/Unit/TokenStreamTest.php b/tests/Unit/TokenStreamTest.php index 4e82d3f..42e432d 100644 --- a/tests/Unit/TokenStreamTest.php +++ b/tests/Unit/TokenStreamTest.php @@ -115,5 +115,5 @@ $tokenStream->consume(TokenType::VariableStart); expect(fn () => $tokenStream->expression()) - ->toThrow(SyntaxException::class, '== is not a valid expression'); + ->toThrow(SyntaxException::class, '`==` is not a valid expression'); });