From 45eff0a48f715448df5a503dfbeecc8cbc347c4a Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 14:46:40 +0200 Subject: [PATCH 1/9] Port Shopify Liquid v5.13 strict parser hardening for assignment tags Reject dotted and bracketed assignment targets in assign, capture, increment, and decrement at parse time. Previously these were silently accepted but produced unreachable state (e.g. {% assign foo.bar = "x" %} set a flat key "foo.bar" invisible to normal variable lookup). Co-Authored-By: Claude Sonnet 4.6 --- src/Tags/AssignTag.php | 2 +- src/Tags/CaptureTag.php | 3 ++- src/Tags/IncrementTag.php | 2 +- tests/Integration/Tags/AssignTagTest.php | 14 +++++++++++ tests/Integration/Tags/CaptureTagTest.php | 14 +++++++++++ tests/Integration/Tags/IncrementTagTest.php | 28 +++++++++++++++++++++ 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index 62a2e75..17f1ea5 100644 --- a/src/Tags/AssignTag.php +++ b/src/Tags/AssignTag.php @@ -34,7 +34,7 @@ public function parse(TagParseContext $context): static try { $to = $context->params->expression(); $this->to = match (true) { - $to instanceof VariableLookup, is_string($to) => (string) $to, + $to instanceof VariableLookup && $to->lookups === [] => $to->name, default => throw new SyntaxException(self::SYNTAX_ERROR), }; diff --git a/src/Tags/CaptureTag.php b/src/Tags/CaptureTag.php index 393214b..fe7d3bc 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -25,7 +25,8 @@ public function parse(TagParseContext $context): static $to = $context->params->expression(); $this->to = match (true) { - is_string($to), $to instanceof VariableLookup => (string) $to, + $to instanceof VariableLookup && $to->lookups === [] => $to->name, + is_string($to) => $to, default => throw new SyntaxException(self::SYNTAX_ERROR), }; diff --git a/src/Tags/IncrementTag.php b/src/Tags/IncrementTag.php index 34ce7af..2be34c8 100644 --- a/src/Tags/IncrementTag.php +++ b/src/Tags/IncrementTag.php @@ -21,7 +21,7 @@ public function parse(TagParseContext $context): static { $variableName = $context->params->expression(); $this->variableName = match (true) { - $variableName instanceof VariableLookup || is_string($variableName) => (string) $variableName, + $variableName instanceof VariableLookup && $variableName->lookups === [] => $variableName->name, default => throw new SyntaxException('Invalid variable name'), }; diff --git a/tests/Integration/Tags/AssignTagTest.php b/tests/Integration/Tags/AssignTagTest.php index b321dee..bde6c6f 100644 --- a/tests/Integration/Tags/AssignTagTest.php +++ b/tests/Integration/Tags/AssignTagTest.php @@ -84,6 +84,20 @@ expect($context->resourceLimits->getAssignScore())->toBe(5); }); +test('assign strict parsing rejects dotted target', function () { + assertMatchSyntaxError( + "Liquid syntax error (line 1): Syntax Error in 'assign' - Valid syntax: assign [var] = [source]", + '{% assign foo.bar = "x" %}', + ); +}); + +test('assign strict parsing rejects bracketed target', function () { + assertMatchSyntaxError( + "Liquid syntax error (line 1): Syntax Error in 'assign' - Valid syntax: assign [var] = [source]", + '{% assign foo[bar] = "x" %}', + ); +}); + test('assign score of int', function () { expect(assignScoreOf(123))->toBe(1); }); diff --git a/tests/Integration/Tags/CaptureTagTest.php b/tests/Integration/Tags/CaptureTagTest.php index a5afd46..8959170 100644 --- a/tests/Integration/Tags/CaptureTagTest.php +++ b/tests/Integration/Tags/CaptureTagTest.php @@ -44,6 +44,20 @@ assertTemplateResult('3-3', $source); }); +test('capture strict parsing rejects dotted target', function () { + assertMatchSyntaxError( + "Liquid syntax error (line 1): Syntax Error in 'capture' - Valid syntax: capture [var]", + '{% capture foo.bar %}x{% endcapture %}', + ); +}); + +test('capture strict parsing rejects bracketed target', function () { + assertMatchSyntaxError( + "Liquid syntax error (line 1): Syntax Error in 'capture' - Valid syntax: capture [var]", + '{% capture foo[bar] %}x{% endcapture %}', + ); +}); + test('increment assign score by bytes', function () { $context = new RenderContext; parseTemplate('{% capture foo %}すごい{% endcapture %}')->render($context); diff --git a/tests/Integration/Tags/IncrementTagTest.php b/tests/Integration/Tags/IncrementTagTest.php index 8342764..83e1103 100644 --- a/tests/Integration/Tags/IncrementTagTest.php +++ b/tests/Integration/Tags/IncrementTagTest.php @@ -32,3 +32,31 @@ LIQUID ); }); + +test('increment strict parsing rejects dotted target', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Invalid variable name', + '{% increment foo.bar %}', + ); +}); + +test('increment strict parsing rejects bracketed target', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Invalid variable name', + '{% increment foo[bar] %}', + ); +}); + +test('decrement strict parsing rejects dotted target', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Invalid variable name', + '{% decrement foo.bar %}', + ); +}); + +test('decrement strict parsing rejects bracketed target', function () { + assertMatchSyntaxError( + 'Liquid syntax error (line 1): Invalid variable name', + '{% decrement foo[bar] %}', + ); +}); From cd7f30a13383a8d83d8bbc48630eee2a34981acc Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 14:49:35 +0200 Subject: [PATCH 2/9] Add SelfDrop and self fallback variable for Shopify Liquid v5.13 compatibility SelfDrop is a proxy object that resolves property lookups through the current render context's scope chain. It does not implement IsContextAware, so a SelfDrop passed explicitly to a partial via `render` retains its original context rather than being rebound to the partial's isolated scope. RenderContext caches one SelfDrop per context instance so that repeated `self` lookups within the same context return the identical PHP object, making === equality work through the existing condition evaluation logic. The fallback is injected in findVariables() only when no value at all was found for key "self". An explicit `{% assign self = nil %}` leaves [null] in the variable list, so the SelfDrop fallback is correctly skipped. Co-Authored-By: Claude Sonnet 4.6 --- src/Drops/SelfDrop.php | 34 ++++++++++ src/Render/RenderContext.php | 15 +++++ tests/Integration/SelfDropTest.php | 102 +++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 src/Drops/SelfDrop.php create mode 100644 tests/Integration/SelfDropTest.php diff --git a/src/Drops/SelfDrop.php b/src/Drops/SelfDrop.php new file mode 100644 index 0000000..c0dab87 --- /dev/null +++ b/src/Drops/SelfDrop.php @@ -0,0 +1,34 @@ +context->findVariables($name); + + return $variables[0] ?? null; + } + + public function __isset(string $name): bool + { + return true; + } + + public function __toString(): string + { + return ''; + } +} diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index 6aed4f0..77e3057 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -9,6 +9,7 @@ use Keepsuit\Liquid\Contracts\LiquidErrorHandler; use Keepsuit\Liquid\Contracts\MapsToLiquid; use Keepsuit\Liquid\Drop; +use Keepsuit\Liquid\Drops\SelfDrop; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\ErrorHandlers\RethrowErrorHandler; use Keepsuit\Liquid\Exceptions\ArithmeticException; @@ -59,6 +60,8 @@ final class RenderContext */ protected array $interrupts = []; + private ?SelfDrop $selfDrop = null; + public function __construct( /** * Environment variables only available in the current context @@ -183,6 +186,13 @@ public function findVariables(string $key): array $variables = array_values(array_filter($variables, fn (mixed $value) => ! $value instanceof MissingValue)); + // Inject the implicit self drop only when no value (including explicit null) was found. + // An explicit `self = nil` leaves [null] in $variables, so the fallback is skipped, + // correctly distinguishing defined-null from undefined. + if ($variables === [] && $key === 'self') { + return [$this->getSelfDrop()]; + } + foreach ($variables as $variable) { if ($variable instanceof IsContextAware) { $variable->setContext($this); @@ -192,6 +202,11 @@ public function findVariables(string $key): array return $variables; } + public function getSelfDrop(): SelfDrop + { + return $this->selfDrop ??= new SelfDrop($this); + } + public function internalContextLookup(mixed $scope, int|string $key): mixed { try { diff --git a/tests/Integration/SelfDropTest.php b/tests/Integration/SelfDropTest.php new file mode 100644 index 0000000..b67b634 --- /dev/null +++ b/tests/Integration/SelfDropTest.php @@ -0,0 +1,102 @@ + 'foo', 'foo' => 'bar']); +}); + +// US 2: dot lookup +test('self dot lookup accesses variable by property name', function () { + assertTemplateResult('bar', '{{ self.foo }}', staticData: ['foo' => 'bar']); +}); + +// US 3: scope chain — local variable shadows broader +test('self lookup uses normal scope hierarchy', function () { + assertTemplateResult('local', '{% assign foo = "local" %}{{ self.foo }}'); +}); + +// US 4: explicit self assignment shadows implicit self drop +test('explicit self variable shadows fallback self drop', function () { + assertTemplateResult('override', '{% assign self = "override" %}{{ self }}'); + assertTemplateResult('', '{% assign self = "override" %}{{ self.foo }}'); +}); + +// US 5: dynamic key that is itself a variable +test('self bracket lookup with variable key', function () { + assertTemplateResult('Alice', '{{ self[key] }}', staticData: ['key' => 'name', 'name' => 'Alice']); +}); + +// US 6: nested expression +test('self bracket lookup can be composed in nested expression', function () { + assertTemplateResult('found', '{{ a[self["b"]] }}', staticData: ['b' => 'x', 'x' => 'found', 'a' => ['found' => 'found', 'x' => 'found']]); +}); + +// US 7 & 11: assigned self reflects later changes (no snapshot) +test('self assigned to variable reflects later assignments', function () { + assertTemplateResult('late', '{% assign s = self %}{% assign foo = "late" %}{{ s.foo }}'); +}); + +// US 8: self passed to partial keeps caller context +// data (not staticData) is local to the root context and not accessible in partials normally. +// A passed self retains the root context, so {{ self.outer }} resolves through root's data. +test('self passed to partial keeps caller context', function () { + assertTemplateResult('caller_value', '{% render "partial", self: self %}', data: ['outer' => 'caller_value'], partials: [ + 'partial' => '{{ self.outer }}', + ]); +}); + +// US 9: implicit self in partial is isolated to its own scope +// The partial's own implicit self cannot see root's local data. +test('implicit self in partial is isolated to partial scope', function () { + assertTemplateResult('', '{% render "partial" %}', data: ['outer' => 'caller_value'], partials: [ + 'partial' => '{{ self.outer }}', + ]); +}); + +// US 10: nested partials preserve each self context independently. +// Middle's implicit self sees middle's own assigns. +// Inner receives middle's implicit self, so it also sees middle's assigns. +// Neither sees root's local data (which is not accessible via implicit self in a partial). +test('nested partials preserve each self context independently', function () { + // Middle's implicit self sees its own assign; inner gets middle's self and sees middle_var. + assertTemplateResult('middle_value', '{% render "middle" %}', partials: [ + 'middle' => '{% assign middle_var = "middle_value" %}{% render "inner", self: self %}', + 'inner' => '{{ self.middle_var }}', + ]); + + // Root passes its own self to middle; middle's explicit self can see root's local data. + assertTemplateResult('root_value', '{% render "middle", self: self %}', data: ['root_var' => 'root_value'], partials: [ + 'middle' => '{{ self.root_var }}', + ]); +}); + +// US 12: repeated self lookups compare equal +test('repeated self lookups compare equal', function () { + assertTemplateResult('yes', '{% if self == self %}yes{% endif %}'); +}); + +// US 13: two variables assigned from same self context compare equal +test('two variables from same self context compare equal', function () { + assertTemplateResult('yes', '{% assign a = self %}{% assign b = self %}{% if a == b %}yes{% endif %}'); +}); + +// US 14: missing property renders blank under strict variables +test('self missing property renders blank under strict variables', function () { + $factory = new EnvironmentFactory; + $factory->setStrictVariables(true); + assertTemplateResult('', '{{ self.missing_key }}', factory: $factory); +}); + +// US 15: explicit null self is not confused with missing self +test('explicit null self does not trigger self drop fallback', function () { + assertTemplateResult('', '{% assign self = nil %}{{ self }}'); + // The assigned nil should be returned, not the SelfDrop + assertTemplateResult('yes', '{% assign self = nil %}{% if self == nil %}yes{% endif %}'); +}); + +// Regression: self.self does not infinitely recurse and renders blank +test('self dot self renders blank without infinite recursion', function () { + assertTemplateResult('', '{{ self.self }}'); +}); From 8049bc9fd5d66363e423993bc82426851851c1c6 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 14:49:57 +0200 Subject: [PATCH 3/9] Update README and CHANGELOG for Shopify Liquid v5.13 compatibility (v0.11.0) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e71753..42766b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to `liquid` will be documented in this file. +## v0.11.0 - 2026-07-10 + +### What's Changed + +* Add Shopify Liquid 5.13 compatibility by @cappuc + * Add `self` drop: templates can now use `{{ self.var }}`, `{{ self[key] }}`, and pass `self` across `render` boundaries. The implicit `self` in a partial is isolated to that partial's scope; an explicit `self: self` argument retains the caller's context. + * Parser hardening: `assign`, `capture`, `increment`, and `decrement` now reject dotted or bracketed targets (e.g. `{% assign foo.bar = "x" %}`) at parse time. + +**Full Changelog**: https://github.com/keepsuit/php-liquid/compare/v0.10.0...v0.11.0 + ## v0.10.0 - 2026-06-13 ### What's Changed diff --git a/README.md b/README.md index c3b2744..99f37af 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Liquid is a template engine with interesting advantages: | PHP Liquid | Shopify Liquid | |------------:|---------------:| +| v0.11 | v5.13 | | v0.10 | v5.12 | | v0.9 | v5.8 | | v0.8 | v5.7 | From 5cd65b11f7857f38d351e94b0863d7e61ad6bf58 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 15:03:16 +0200 Subject: [PATCH 4/9] Port missing Shopify v5.13 self drop tests Add four tests that were in Shopify's self_drop_context_test.rb but not previously ported: - Same-variable-name collision across render boundary: passed self holds caller's value while partial's implicit self holds its own (42|43) - Three-level nested render with the same variable shadowed at each level: outer.a|middle.a|self.a = 1|2|3 - Assigned self compares equal to itself (s == s) - Defined key on self resolves correctly under strict variables Co-Authored-By: Claude Sonnet 4.6 --- tests/Integration/SelfDropTest.php | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Integration/SelfDropTest.php b/tests/Integration/SelfDropTest.php index b67b634..7f7a3bb 100644 --- a/tests/Integration/SelfDropTest.php +++ b/tests/Integration/SelfDropTest.php @@ -77,6 +77,11 @@ assertTemplateResult('yes', '{% if self == self %}yes{% endif %}'); }); +// Upstream: test_assigned_self_drop_compares_equal_to_itself +test('assigned self drop compares equal to itself', function () { + assertTemplateResult('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}'); +}); + // US 13: two variables assigned from same self context compare equal test('two variables from same self context compare equal', function () { assertTemplateResult('yes', '{% assign a = self %}{% assign b = self %}{% if a == b %}yes{% endif %}'); @@ -89,6 +94,38 @@ assertTemplateResult('', '{{ self.missing_key }}', factory: $factory); }); +// Upstream: test_self_drop_with_strict_variables_does_not_raise_for_defined_var +test('self defined property resolves correctly under strict variables', function () { + $factory = new EnvironmentFactory; + $factory->setStrictVariables(true); + assertTemplateResult('42', '{{ self.x }}', staticData: ['x' => 42], factory: $factory); +}); + +// Upstream: test_self_drop_passed_as_render_param_preserves_original_scope +// Both the caller and partial use the same variable name; the passed self must hold +// the caller's value while the partial's implicit self holds the partial's own. +test('self passed as render param preserves original scope when variable name collides', function () { + assertTemplateResult('42|43', + '{%- assign var = 42 -%}{%- assign s = self -%}{%- render "snippet", other_self: s -%}', + partials: [ + 'snippet' => '{%- assign var = 43 -%}{{- other_self.var }}|{{ self.var -}}', + ] + ); +}); + +// Upstream: test_self_drop_passed_to_nested_renders_preserves_each_level +// Three levels all shadow the same variable `a`; each passed self must resolve +// to its own level's value independently. +test('self passed to nested renders preserves each level independently', function () { + assertTemplateResult('1|2|3', + '{%- assign a = 1 -%}{%- assign s1 = self -%}{%- render "snippet1", outer: s1 -%}', + partials: [ + 'snippet1' => '{%- assign a = 2 -%}{%- assign s2 = self -%}{%- render "snippet2", outer: outer, middle: s2 -%}', + 'snippet2' => '{%- assign a = 3 -%}{{- outer.a }}|{{ middle.a }}|{{ self.a -}}', + ] + ); +}); + // US 15: explicit null self is not confused with missing self test('explicit null self does not trigger self drop fallback', function () { assertTemplateResult('', '{% assign self = nil %}{{ self }}'); From 44bb920f879c553e73a710d558fedd7321d2f181 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 15:17:44 +0200 Subject: [PATCH 5/9] Tighten tag variable parsing - add shared simple-variable validation for assign, capture, and increment - reject invalid capture and expression syntax with updated tests --- src/Parse/TokenStream.php | 15 +++++++++++++++ src/Tags/AssignTag.php | 7 +------ src/Tags/CaptureTag.php | 9 +-------- src/Tags/IncrementTag.php | 8 +------- tests/Integration/ExpressionTest.php | 7 +++++++ tests/Integration/Tags/CaptureTagTest.php | 9 ++++++++- 6 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/Parse/TokenStream.php b/src/Parse/TokenStream.php index 936a7c3..61233e1 100644 --- a/src/Parse/TokenStream.php +++ b/src/Parse/TokenStream.php @@ -5,6 +5,7 @@ use Closure; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\Variable; +use Keepsuit\Liquid\Nodes\VariableLookup; /** * @phpstan-import-type Argument from ArgumentParser @@ -143,6 +144,20 @@ public function expression(): mixed return $this->expressionParser->parseExpression(); } + /** + * @throws SyntaxException + */ + public function simpleVariableName(string $error): string + { + $expression = $this->expression(); + + if ($expression instanceof VariableLookup && $expression->lookups === []) { + return $expression->name; + } + + throw new SyntaxException($error); + } + /** * @return Argument * diff --git a/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index 17f1ea5..6eed912 100644 --- a/src/Tags/AssignTag.php +++ b/src/Tags/AssignTag.php @@ -5,7 +5,6 @@ use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\Variable; -use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\ExpressionParser; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Parse\TokenType; @@ -32,11 +31,7 @@ public static function tagName(): string public function parse(TagParseContext $context): static { try { - $to = $context->params->expression(); - $this->to = match (true) { - $to instanceof VariableLookup && $to->lookups === [] => $to->name, - default => throw new SyntaxException(self::SYNTAX_ERROR), - }; + $this->to = $context->params->simpleVariableName(self::SYNTAX_ERROR); $context->params->consume(TokenType::Equals); diff --git a/src/Tags/CaptureTag.php b/src/Tags/CaptureTag.php index fe7d3bc..d7371e9 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -2,9 +2,7 @@ namespace Keepsuit\Liquid\Tags; -use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; -use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TagBlock; @@ -23,12 +21,7 @@ public function parse(TagParseContext $context): static $this->body = $context->body; - $to = $context->params->expression(); - $this->to = match (true) { - $to instanceof VariableLookup && $to->lookups === [] => $to->name, - is_string($to) => $to, - default => throw new SyntaxException(self::SYNTAX_ERROR), - }; + $this->to = $context->params->simpleVariableName(self::SYNTAX_ERROR); $context->params->assertEnd(); diff --git a/src/Tags/IncrementTag.php b/src/Tags/IncrementTag.php index 2be34c8..00db580 100644 --- a/src/Tags/IncrementTag.php +++ b/src/Tags/IncrementTag.php @@ -2,8 +2,6 @@ namespace Keepsuit\Liquid\Tags; -use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; @@ -19,11 +17,7 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $variableName = $context->params->expression(); - $this->variableName = match (true) { - $variableName instanceof VariableLookup && $variableName->lookups === [] => $variableName->name, - default => throw new SyntaxException('Invalid variable name'), - }; + $this->variableName = $context->params->simpleVariableName('Invalid variable name'); $context->params->assertEnd(); diff --git a/tests/Integration/ExpressionTest.php b/tests/Integration/ExpressionTest.php index cf7a1ba..ac4bff2 100644 --- a/tests/Integration/ExpressionTest.php +++ b/tests/Integration/ExpressionTest.php @@ -39,6 +39,13 @@ ); }); +test('bare bracket is not a valid expression', function () { + assertMatchSyntaxError( + "Liquid syntax error (line 1): `[` is not a valid expression", + '{{ [foo] }}', + ); +}); + function assertExpressionResult(mixed $expected, string $markup, ...$assigns): void { $liquid = "{% if expect == $markup %}pass{% else %}got {{ $markup }}{% endif %}"; diff --git a/tests/Integration/Tags/CaptureTagTest.php b/tests/Integration/Tags/CaptureTagTest.php index 8959170..1435ff7 100644 --- a/tests/Integration/Tags/CaptureTagTest.php +++ b/tests/Integration/Tags/CaptureTagTest.php @@ -3,7 +3,7 @@ use Keepsuit\Liquid\Render\RenderContext; test('capture block content in variable', function () { - assertTemplateResult('test string', "{% capture 'var' %}test string{% endcapture %}{{var}}"); + assertTemplateResult('test string', '{% capture var %}test string{% endcapture %}{{var}}'); }); test('capture with hyphen in variable name', function () { @@ -58,6 +58,13 @@ ); }); +test('capture strict parsing rejects quoted string target', function () { + assertMatchSyntaxError( + "Liquid syntax error (line 1): Syntax Error in 'capture' - Valid syntax: capture [var]", + "{% capture 'foo' %}x{% endcapture %}", + ); +}); + test('increment assign score by bytes', function () { $context = new RenderContext; parseTemplate('{% capture foo %}すごい{% endcapture %}')->render($context); From 31c4893a92db829f7e26958024a7fb35c8e7745e Mon Sep 17 00:00:00 2001 From: cappuc <4271608+cappuc@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:18:09 +0000 Subject: [PATCH 6/9] Fix styling --- tests/Integration/ExpressionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/ExpressionTest.php b/tests/Integration/ExpressionTest.php index ac4bff2..a9725bb 100644 --- a/tests/Integration/ExpressionTest.php +++ b/tests/Integration/ExpressionTest.php @@ -41,7 +41,7 @@ test('bare bracket is not a valid expression', function () { assertMatchSyntaxError( - "Liquid syntax error (line 1): `[` is not a valid expression", + 'Liquid syntax error (line 1): `[` is not a valid expression', '{{ [foo] }}', ); }); From 030632c68b01cc4e0e35a903ab9971ce44f1516d Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 15:49:16 +0200 Subject: [PATCH 7/9] Standardise assignment-target syntax errors via SyntaxException factories Add SyntaxException::expectedSimpleVariable() and tagSyntaxException() so the generic "expected a plain variable name" signal is raised in one place (TokenStream::simpleVariableName) and each tag enriches it with its own valid-syntax hint on catch, rather than each tag owning its full error string. - Remove SYNTAX_ERROR constants from AssignTag and CaptureTag - Narrow AssignTag's broad catch to just the simpleVariableName() call - Use static::tagName() in IncrementTag so DecrementTag inherits correctly - Chain the original SyntaxException as $previous for debuggability - Update all test expectations to the new message format Co-Authored-By: Claude Sonnet 4.6 --- src/Exceptions/SyntaxException.php | 15 +++++++++++++++ src/Parse/TokenStream.php | 4 ++-- src/Tags/AssignTag.php | 6 ++---- src/Tags/CaptureTag.php | 11 +++++++---- src/Tags/IncrementTag.php | 9 +++++++-- tests/Integration/Tags/AssignTagTest.php | 8 ++++---- tests/Integration/Tags/CaptureTagTest.php | 6 +++--- tests/Integration/Tags/IncrementTagTest.php | 8 ++++---- tests/Integration/Tags/StandardTagTest.php | 2 +- 9 files changed, 45 insertions(+), 24 deletions(-) diff --git a/src/Exceptions/SyntaxException.php b/src/Exceptions/SyntaxException.php index 5cdc7dc..db71a78 100644 --- a/src/Exceptions/SyntaxException.php +++ b/src/Exceptions/SyntaxException.php @@ -79,6 +79,21 @@ public static function unexpectedToken(Token $token): SyntaxException )); } + public static function expectedSimpleVariable(): SyntaxException + { + return new SyntaxException('Expected a simple variable name'); + } + + public static function tagSyntaxException(string $tagName, string $validSyntax, SyntaxException|string $original): SyntaxException + { + $originalMessage = $original instanceof SyntaxException ? $original->getMessage() : $original; + $previous = $original instanceof SyntaxException ? $original : null; + $exception = new SyntaxException($originalMessage.' - Valid syntax: '.$validSyntax, 0, E_ERROR, '', 0, $previous); + $exception->tagName = $tagName; + + return $exception; + } + protected function messagePrefix(): string { return 'Liquid syntax error'; diff --git a/src/Parse/TokenStream.php b/src/Parse/TokenStream.php index 61233e1..aaa1098 100644 --- a/src/Parse/TokenStream.php +++ b/src/Parse/TokenStream.php @@ -147,7 +147,7 @@ public function expression(): mixed /** * @throws SyntaxException */ - public function simpleVariableName(string $error): string + public function simpleVariableName(): string { $expression = $this->expression(); @@ -155,7 +155,7 @@ public function simpleVariableName(string $error): string return $expression->name; } - throw new SyntaxException($error); + throw SyntaxException::expectedSimpleVariable(); } /** diff --git a/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index 6eed912..2521b89 100644 --- a/src/Tags/AssignTag.php +++ b/src/Tags/AssignTag.php @@ -17,8 +17,6 @@ */ class AssignTag extends Tag implements HasParseTreeVisitorChildren { - protected const SYNTAX_ERROR = "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"; - protected string $to; protected Variable $from; @@ -31,7 +29,7 @@ public static function tagName(): string public function parse(TagParseContext $context): static { try { - $this->to = $context->params->simpleVariableName(self::SYNTAX_ERROR); + $this->to = $context->params->simpleVariableName(); $context->params->consume(TokenType::Equals); @@ -39,7 +37,7 @@ public function parse(TagParseContext $context): static $context->params->assertEnd(); } catch (SyntaxException $e) { - throw new SyntaxException(self::SYNTAX_ERROR); + throw SyntaxException::tagSyntaxException('assign', 'assign [var] = [source]', $e); } return $this; diff --git a/src/Tags/CaptureTag.php b/src/Tags/CaptureTag.php index d7371e9..04b54f6 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; @@ -9,8 +10,6 @@ class CaptureTag extends TagBlock { - protected const SYNTAX_ERROR = "Syntax Error in 'capture' - Valid syntax: capture [var]"; - protected string $to; protected BodyNode $body; @@ -21,9 +20,13 @@ public function parse(TagParseContext $context): static $this->body = $context->body; - $this->to = $context->params->simpleVariableName(self::SYNTAX_ERROR); + try { + $this->to = $context->params->simpleVariableName(); - $context->params->assertEnd(); + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException('capture', 'capture [var]', $e); + } return $this; } diff --git a/src/Tags/IncrementTag.php b/src/Tags/IncrementTag.php index 00db580..9c95d05 100644 --- a/src/Tags/IncrementTag.php +++ b/src/Tags/IncrementTag.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; @@ -17,9 +18,13 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $this->variableName = $context->params->simpleVariableName('Invalid variable name'); + try { + $this->variableName = $context->params->simpleVariableName(); - $context->params->assertEnd(); + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), sprintf('%s [var]', static::tagName()), $e); + } return $this; } diff --git a/tests/Integration/Tags/AssignTagTest.php b/tests/Integration/Tags/AssignTagTest.php index bde6c6f..bf75bc8 100644 --- a/tests/Integration/Tags/AssignTagTest.php +++ b/tests/Integration/Tags/AssignTagTest.php @@ -40,10 +40,10 @@ test('assign syntax error', function () { expect(fn () => renderTemplate('{% assign foo not values %}.')) - ->toThrow(SyntaxException::class, 'assign'); + ->toThrow(SyntaxException::class); expect(fn () => renderTemplate("{% assign foo = ('X' | downcase) %}")) - ->toThrow(SyntaxException::class, 'assign'); + ->toThrow(SyntaxException::class); }); test('expression with whitespace in square brackets', function () { @@ -86,14 +86,14 @@ test('assign strict parsing rejects dotted target', function () { assertMatchSyntaxError( - "Liquid syntax error (line 1): Syntax Error in 'assign' - Valid syntax: assign [var] = [source]", + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: assign [var] = [source]', '{% assign foo.bar = "x" %}', ); }); test('assign strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - "Liquid syntax error (line 1): Syntax Error in 'assign' - Valid syntax: assign [var] = [source]", + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: assign [var] = [source]', '{% assign foo[bar] = "x" %}', ); }); diff --git a/tests/Integration/Tags/CaptureTagTest.php b/tests/Integration/Tags/CaptureTagTest.php index 1435ff7..659a6fd 100644 --- a/tests/Integration/Tags/CaptureTagTest.php +++ b/tests/Integration/Tags/CaptureTagTest.php @@ -46,21 +46,21 @@ test('capture strict parsing rejects dotted target', function () { assertMatchSyntaxError( - "Liquid syntax error (line 1): Syntax Error in 'capture' - Valid syntax: capture [var]", + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', '{% capture foo.bar %}x{% endcapture %}', ); }); test('capture strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - "Liquid syntax error (line 1): Syntax Error in 'capture' - Valid syntax: capture [var]", + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', '{% capture foo[bar] %}x{% endcapture %}', ); }); test('capture strict parsing rejects quoted string target', function () { assertMatchSyntaxError( - "Liquid syntax error (line 1): Syntax Error in 'capture' - Valid syntax: capture [var]", + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', "{% capture 'foo' %}x{% endcapture %}", ); }); diff --git a/tests/Integration/Tags/IncrementTagTest.php b/tests/Integration/Tags/IncrementTagTest.php index 83e1103..b3b99f9 100644 --- a/tests/Integration/Tags/IncrementTagTest.php +++ b/tests/Integration/Tags/IncrementTagTest.php @@ -35,28 +35,28 @@ test('increment strict parsing rejects dotted target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Invalid variable name', + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: increment [var]', '{% increment foo.bar %}', ); }); test('increment strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Invalid variable name', + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: increment [var]', '{% increment foo[bar] %}', ); }); test('decrement strict parsing rejects dotted target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Invalid variable name', + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: decrement [var]', '{% decrement foo.bar %}', ); }); test('decrement strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Invalid variable name', + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: decrement [var]', '{% decrement foo[bar] %}', ); }); diff --git a/tests/Integration/Tags/StandardTagTest.php b/tests/Integration/Tags/StandardTagTest.php index 22c1dad..bb20302 100644 --- a/tests/Integration/Tags/StandardTagTest.php +++ b/tests/Integration/Tags/StandardTagTest.php @@ -75,7 +75,7 @@ test('capture detects bad syntax', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Syntax Error in \'capture\' - Valid syntax: capture [var]', + 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', '{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', staticData: ['var' => 'content'] ); From d1791cd272ac0e4cf8f289703b5da1ba7a04f165 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 16:33:02 +0200 Subject: [PATCH 8/9] Add Shopify Liquid 5.13 compatibility - Add `self` drop support for scoped lookups and render boundaries - Reject dotted and bracketed assign/capture/increment targets earlier - Update syntax errors and integration coverage --- CHANGELOG.md | 10 ------- src/Drops/SelfDrop.php | 4 ++- src/Exceptions/SyntaxException.php | 11 ++------ src/Parse/TokenStream.php | 11 +------- tests/Integration/SelfDropTest.php | 29 --------------------- tests/Integration/Tags/AssignTagTest.php | 4 +-- tests/Integration/Tags/CaptureTagTest.php | 6 ++--- tests/Integration/Tags/IncrementTagTest.php | 8 +++--- tests/Integration/Tags/StandardTagTest.php | 2 +- 9 files changed, 16 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42766b9..7e71753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,6 @@ All notable changes to `liquid` will be documented in this file. -## v0.11.0 - 2026-07-10 - -### What's Changed - -* Add Shopify Liquid 5.13 compatibility by @cappuc - * Add `self` drop: templates can now use `{{ self.var }}`, `{{ self[key] }}`, and pass `self` across `render` boundaries. The implicit `self` in a partial is isolated to that partial's scope; an explicit `self: self` argument retains the caller's context. - * Parser hardening: `assign`, `capture`, `increment`, and `decrement` now reject dotted or bracketed targets (e.g. `{% assign foo.bar = "x" %}`) at parse time. - -**Full Changelog**: https://github.com/keepsuit/php-liquid/compare/v0.10.0...v0.11.0 - ## v0.10.0 - 2026-06-13 ### What's Changed diff --git a/src/Drops/SelfDrop.php b/src/Drops/SelfDrop.php index c0dab87..52d938c 100644 --- a/src/Drops/SelfDrop.php +++ b/src/Drops/SelfDrop.php @@ -24,7 +24,9 @@ public function __get(string $name): mixed public function __isset(string $name): bool { - return true; + $variables = $this->context->findVariables($name); + + return $variables !== []; } public function __toString(): string diff --git a/src/Exceptions/SyntaxException.php b/src/Exceptions/SyntaxException.php index db71a78..cfa1d28 100644 --- a/src/Exceptions/SyntaxException.php +++ b/src/Exceptions/SyntaxException.php @@ -79,16 +79,9 @@ public static function unexpectedToken(Token $token): SyntaxException )); } - public static function expectedSimpleVariable(): SyntaxException + public static function tagSyntaxException(string $tagName, string $validSyntax, SyntaxException $original): SyntaxException { - return new SyntaxException('Expected a simple variable name'); - } - - public static function tagSyntaxException(string $tagName, string $validSyntax, SyntaxException|string $original): SyntaxException - { - $originalMessage = $original instanceof SyntaxException ? $original->getMessage() : $original; - $previous = $original instanceof SyntaxException ? $original : null; - $exception = new SyntaxException($originalMessage.' - Valid syntax: '.$validSyntax, 0, E_ERROR, '', 0, $previous); + $exception = new SyntaxException(sprintf('%s - Valid syntax: %s', $original->getMessage(), $validSyntax), previous: $original); $exception->tagName = $tagName; return $exception; diff --git a/src/Parse/TokenStream.php b/src/Parse/TokenStream.php index aaa1098..be0702b 100644 --- a/src/Parse/TokenStream.php +++ b/src/Parse/TokenStream.php @@ -5,7 +5,6 @@ use Closure; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\Variable; -use Keepsuit\Liquid\Nodes\VariableLookup; /** * @phpstan-import-type Argument from ArgumentParser @@ -93,8 +92,6 @@ public function consumeOrFalse(TokenType $type): Token|false /** * @throws SyntaxException - * - * @phpstan-impure */ public function id(string $identifier): Token { @@ -149,13 +146,7 @@ public function expression(): mixed */ public function simpleVariableName(): string { - $expression = $this->expression(); - - if ($expression instanceof VariableLookup && $expression->lookups === []) { - return $expression->name; - } - - throw SyntaxException::expectedSimpleVariable(); + return $this->consume(TokenType::Identifier)->data; } /** diff --git a/tests/Integration/SelfDropTest.php b/tests/Integration/SelfDropTest.php index 7f7a3bb..ef6fbff 100644 --- a/tests/Integration/SelfDropTest.php +++ b/tests/Integration/SelfDropTest.php @@ -2,63 +2,47 @@ use Keepsuit\Liquid\EnvironmentFactory; -// US 1: dynamic bracket lookup test('self bracket lookup resolves variable by name', function () { assertTemplateResult('bar', '{{ self[key] }}', staticData: ['key' => 'foo', 'foo' => 'bar']); }); -// US 2: dot lookup test('self dot lookup accesses variable by property name', function () { assertTemplateResult('bar', '{{ self.foo }}', staticData: ['foo' => 'bar']); }); -// US 3: scope chain — local variable shadows broader test('self lookup uses normal scope hierarchy', function () { assertTemplateResult('local', '{% assign foo = "local" %}{{ self.foo }}'); }); -// US 4: explicit self assignment shadows implicit self drop test('explicit self variable shadows fallback self drop', function () { assertTemplateResult('override', '{% assign self = "override" %}{{ self }}'); assertTemplateResult('', '{% assign self = "override" %}{{ self.foo }}'); }); -// US 5: dynamic key that is itself a variable test('self bracket lookup with variable key', function () { assertTemplateResult('Alice', '{{ self[key] }}', staticData: ['key' => 'name', 'name' => 'Alice']); }); -// US 6: nested expression test('self bracket lookup can be composed in nested expression', function () { assertTemplateResult('found', '{{ a[self["b"]] }}', staticData: ['b' => 'x', 'x' => 'found', 'a' => ['found' => 'found', 'x' => 'found']]); }); -// US 7 & 11: assigned self reflects later changes (no snapshot) test('self assigned to variable reflects later assignments', function () { assertTemplateResult('late', '{% assign s = self %}{% assign foo = "late" %}{{ s.foo }}'); }); -// US 8: self passed to partial keeps caller context -// data (not staticData) is local to the root context and not accessible in partials normally. -// A passed self retains the root context, so {{ self.outer }} resolves through root's data. test('self passed to partial keeps caller context', function () { assertTemplateResult('caller_value', '{% render "partial", self: self %}', data: ['outer' => 'caller_value'], partials: [ 'partial' => '{{ self.outer }}', ]); }); -// US 9: implicit self in partial is isolated to its own scope -// The partial's own implicit self cannot see root's local data. test('implicit self in partial is isolated to partial scope', function () { assertTemplateResult('', '{% render "partial" %}', data: ['outer' => 'caller_value'], partials: [ 'partial' => '{{ self.outer }}', ]); }); -// US 10: nested partials preserve each self context independently. -// Middle's implicit self sees middle's own assigns. -// Inner receives middle's implicit self, so it also sees middle's assigns. -// Neither sees root's local data (which is not accessible via implicit self in a partial). test('nested partials preserve each self context independently', function () { // Middle's implicit self sees its own assign; inner gets middle's self and sees middle_var. assertTemplateResult('middle_value', '{% render "middle" %}', partials: [ @@ -72,38 +56,30 @@ ]); }); -// US 12: repeated self lookups compare equal test('repeated self lookups compare equal', function () { assertTemplateResult('yes', '{% if self == self %}yes{% endif %}'); }); -// Upstream: test_assigned_self_drop_compares_equal_to_itself test('assigned self drop compares equal to itself', function () { assertTemplateResult('T', '{% assign s = self %}{% if s == s %}T{% else %}F{% endif %}'); }); -// US 13: two variables assigned from same self context compare equal test('two variables from same self context compare equal', function () { assertTemplateResult('yes', '{% assign a = self %}{% assign b = self %}{% if a == b %}yes{% endif %}'); }); -// US 14: missing property renders blank under strict variables test('self missing property renders blank under strict variables', function () { $factory = new EnvironmentFactory; $factory->setStrictVariables(true); assertTemplateResult('', '{{ self.missing_key }}', factory: $factory); }); -// Upstream: test_self_drop_with_strict_variables_does_not_raise_for_defined_var test('self defined property resolves correctly under strict variables', function () { $factory = new EnvironmentFactory; $factory->setStrictVariables(true); assertTemplateResult('42', '{{ self.x }}', staticData: ['x' => 42], factory: $factory); }); -// Upstream: test_self_drop_passed_as_render_param_preserves_original_scope -// Both the caller and partial use the same variable name; the passed self must hold -// the caller's value while the partial's implicit self holds the partial's own. test('self passed as render param preserves original scope when variable name collides', function () { assertTemplateResult('42|43', '{%- assign var = 42 -%}{%- assign s = self -%}{%- render "snippet", other_self: s -%}', @@ -113,9 +89,6 @@ ); }); -// Upstream: test_self_drop_passed_to_nested_renders_preserves_each_level -// Three levels all shadow the same variable `a`; each passed self must resolve -// to its own level's value independently. test('self passed to nested renders preserves each level independently', function () { assertTemplateResult('1|2|3', '{%- assign a = 1 -%}{%- assign s1 = self -%}{%- render "snippet1", outer: s1 -%}', @@ -126,14 +99,12 @@ ); }); -// US 15: explicit null self is not confused with missing self test('explicit null self does not trigger self drop fallback', function () { assertTemplateResult('', '{% assign self = nil %}{{ self }}'); // The assigned nil should be returned, not the SelfDrop assertTemplateResult('yes', '{% assign self = nil %}{% if self == nil %}yes{% endif %}'); }); -// Regression: self.self does not infinitely recurse and renders blank test('self dot self renders blank without infinite recursion', function () { assertTemplateResult('', '{{ self.self }}'); }); diff --git a/tests/Integration/Tags/AssignTagTest.php b/tests/Integration/Tags/AssignTagTest.php index bf75bc8..e7b67a1 100644 --- a/tests/Integration/Tags/AssignTagTest.php +++ b/tests/Integration/Tags/AssignTagTest.php @@ -86,14 +86,14 @@ test('assign strict parsing rejects dotted target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: assign [var] = [source]', + 'Liquid syntax error (line 1): Expected ==, got . - Valid syntax: assign [var] = [source]', '{% assign foo.bar = "x" %}', ); }); test('assign strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: assign [var] = [source]', + 'Liquid syntax error (line 1): Expected ==, got [ - Valid syntax: assign [var] = [source]', '{% assign foo[bar] = "x" %}', ); }); diff --git a/tests/Integration/Tags/CaptureTagTest.php b/tests/Integration/Tags/CaptureTagTest.php index 659a6fd..647463c 100644 --- a/tests/Integration/Tags/CaptureTagTest.php +++ b/tests/Integration/Tags/CaptureTagTest.php @@ -46,21 +46,21 @@ test('capture strict parsing rejects dotted target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', + 'Liquid syntax error (line 1): Unexpected token .: "." - Valid syntax: capture [var]', '{% capture foo.bar %}x{% endcapture %}', ); }); test('capture strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', + 'Liquid syntax error (line 1): Unexpected token [: "[" - Valid syntax: capture [var]', '{% capture foo[bar] %}x{% endcapture %}', ); }); test('capture strict parsing rejects quoted string target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', + 'Liquid syntax error (line 1): Expected Identifier, got String - Valid syntax: capture [var]', "{% capture 'foo' %}x{% endcapture %}", ); }); diff --git a/tests/Integration/Tags/IncrementTagTest.php b/tests/Integration/Tags/IncrementTagTest.php index b3b99f9..0b15d6b 100644 --- a/tests/Integration/Tags/IncrementTagTest.php +++ b/tests/Integration/Tags/IncrementTagTest.php @@ -35,28 +35,28 @@ test('increment strict parsing rejects dotted target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: increment [var]', + 'Liquid syntax error (line 1): Unexpected token .: "." - Valid syntax: increment [var]', '{% increment foo.bar %}', ); }); test('increment strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: increment [var]', + 'Liquid syntax error (line 1): Unexpected token [: "[" - Valid syntax: increment [var]', '{% increment foo[bar] %}', ); }); test('decrement strict parsing rejects dotted target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: decrement [var]', + 'Liquid syntax error (line 1): Unexpected token .: "." - Valid syntax: decrement [var]', '{% decrement foo.bar %}', ); }); test('decrement strict parsing rejects bracketed target', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: decrement [var]', + 'Liquid syntax error (line 1): Unexpected token [: "[" - Valid syntax: decrement [var]', '{% decrement foo[bar] %}', ); }); diff --git a/tests/Integration/Tags/StandardTagTest.php b/tests/Integration/Tags/StandardTagTest.php index bb20302..d617aac 100644 --- a/tests/Integration/Tags/StandardTagTest.php +++ b/tests/Integration/Tags/StandardTagTest.php @@ -75,7 +75,7 @@ test('capture detects bad syntax', function () { assertMatchSyntaxError( - 'Liquid syntax error (line 1): Expected a simple variable name - Valid syntax: capture [var]', + 'Liquid syntax error (line 1): Unexpected end of template - Valid syntax: capture [var]', '{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', staticData: ['var' => 'content'] ); From 9cba8381e91536e5b1b938a9f6cbb5c57e74c3e0 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 10 Jul 2026 17:20:36 +0200 Subject: [PATCH 9/9] Normalize tag syntax error messages - Wrap tag parsers with consistent syntax context - Update invalid-usage messages and tests to use angle-bracket syntax --- src/Tags/AssignTag.php | 2 +- src/Tags/BreakTag.php | 7 +- src/Tags/CaptureTag.php | 2 +- src/Tags/CaseTag.php | 19 +++-- src/Tags/ContinueTag.php | 7 +- src/Tags/CycleTag.php | 63 ++++++++------- src/Tags/DocTag.php | 20 +++-- src/Tags/EchoTag.php | 9 ++- src/Tags/ForTag.php | 18 +++-- src/Tags/IfChanged.php | 7 +- src/Tags/IfTag.php | 24 +++++- src/Tags/IncrementTag.php | 2 +- src/Tags/RawTag.php | 22 ++--- src/Tags/RenderTag.php | 90 +++++++++++---------- src/Tags/TableRowTag.php | 35 ++++---- tests/Integration/ParsingQuirksTest.php | 4 +- tests/Integration/Tags/AssignTagTest.php | 4 +- tests/Integration/Tags/CaptureTagTest.php | 6 +- tests/Integration/Tags/DocTagTest.php | 4 +- tests/Integration/Tags/IncrementTagTest.php | 8 +- tests/Integration/Tags/StandardTagTest.php | 10 +-- tests/Integration/Tags/TableRowTest.php | 4 +- 22 files changed, 225 insertions(+), 142 deletions(-) diff --git a/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index 2521b89..647e6cc 100644 --- a/src/Tags/AssignTag.php +++ b/src/Tags/AssignTag.php @@ -37,7 +37,7 @@ public function parse(TagParseContext $context): static $context->params->assertEnd(); } catch (SyntaxException $e) { - throw SyntaxException::tagSyntaxException('assign', 'assign [var] = [source]', $e); + throw SyntaxException::tagSyntaxException('assign', 'assign = ', $e); } return $this; diff --git a/src/Tags/BreakTag.php b/src/Tags/BreakTag.php index 41afd3d..09a57e6 100644 --- a/src/Tags/BreakTag.php +++ b/src/Tags/BreakTag.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Interrupts\BreakInterrupt; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; @@ -16,7 +17,11 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $context->params->assertEnd(); + try { + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'break', $e); + } return $this; } diff --git a/src/Tags/CaptureTag.php b/src/Tags/CaptureTag.php index 04b54f6..1e5fa30 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -25,7 +25,7 @@ public function parse(TagParseContext $context): static $context->params->assertEnd(); } catch (SyntaxException $e) { - throw SyntaxException::tagSyntaxException('capture', 'capture [var]', $e); + throw SyntaxException::tagSyntaxException('capture', 'capture ', $e); } return $this; diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index 66381f3..c9fde1e 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -32,11 +32,20 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - if ($context->tag === 'case') { - $this->left = $context->params->expression(); - $context->params->assertEnd(); - } else { - $this->conditions[] = $this->mapBodySectionToCondition($context); + try { + if ($context->tag === 'case') { + $this->left = $context->params->expression(); + $context->params->assertEnd(); + } else { + $this->conditions[] = $this->mapBodySectionToCondition($context); + } + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), match ($context->tag) { + 'case' => 'case ', + 'when' => 'when [, ...]', + 'else' => 'else', + default => '' + }, $e); } return $this; diff --git a/src/Tags/ContinueTag.php b/src/Tags/ContinueTag.php index 957084d..fd6cd1b 100644 --- a/src/Tags/ContinueTag.php +++ b/src/Tags/ContinueTag.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Interrupts\ContinueInterrupt; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; @@ -16,7 +17,11 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $context->params->assertEnd(); + try { + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'continue', $e); + } return $this; } diff --git a/src/Tags/CycleTag.php b/src/Tags/CycleTag.php index de809e5..a6226de 100644 --- a/src/Tags/CycleTag.php +++ b/src/Tags/CycleTag.php @@ -12,8 +12,6 @@ class CycleTag extends Tag implements HasParseTreeVisitorChildren { - protected const SYNTAX_ERROR = "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"; - /** * @var (string|int|float)[] */ @@ -28,40 +26,49 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $this->name = null; - $this->variables = []; + try { + $this->name = null; + $this->variables = []; + + if ($context->params->look(TokenType::Colon, 1)) { + $currentToken = $context->params->current(); - if ($context->params->look(TokenType::Colon, 1)) { - if (! in_array($context->params->current()?->type, [TokenType::String, TokenType::Number, TokenType::Identifier])) { - throw new SyntaxException(self::SYNTAX_ERROR); + $name = $context->params->expression(); + $this->name = match (true) { + is_string($name), is_numeric($name), $name instanceof VariableLookup => (string) $name, + $currentToken === null => throw SyntaxException::unexpectedEndOfTemplate(), + default => throw SyntaxException::unexpectedToken($currentToken), + }; + + $context->params->consume(TokenType::Colon); } - $name = $context->params->expression(); - $this->name = match (true) { - is_string($name), is_numeric($name), $name instanceof VariableLookup => (string) $name, - default => throw new SyntaxException(self::SYNTAX_ERROR), - }; + do { + $currentToken = $context->params->current(); - $context->params->consume(TokenType::Colon); - } + if (! $currentToken) { + throw SyntaxException::unexpectedEndOfTemplate(); + } - do { - if (! in_array($context->params->current()?->type, [TokenType::String, TokenType::Number])) { - throw new SyntaxException(self::SYNTAX_ERROR); - } + if (! in_array($currentToken->type, [TokenType::String, TokenType::Number])) { + throw SyntaxException::unexpectedToken($currentToken); + } - $variable = $context->params->expression(); - $this->variables[] = match (true) { - is_string($variable), is_numeric($variable) => $variable, - default => throw new SyntaxException(self::SYNTAX_ERROR), - }; - } while ($context->params->consumeOrFalse(TokenType::Comma)); + $variable = $context->params->expression(); + $this->variables[] = match (true) { + is_string($variable), is_numeric($variable) => $variable, + default => throw SyntaxException::unexpectedToken($currentToken) + }; + } while ($context->params->consumeOrFalse(TokenType::Comma)); - if ($this->name === null) { - $this->name = json_encode($this->variables, JSON_THROW_ON_ERROR); - } + if ($this->name === null) { + $this->name = json_encode($this->variables, JSON_THROW_ON_ERROR); + } - $context->params->assertEnd(); + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'cycle [:] [, ...]', $e); + } return $this; } diff --git a/src/Tags/DocTag.php b/src/Tags/DocTag.php index 817dcd9..c6d8297 100644 --- a/src/Tags/DocTag.php +++ b/src/Tags/DocTag.php @@ -25,17 +25,21 @@ public static function hasRawBody(): bool public function parse(TagParseContext $context): static { - $context->params->assertEnd(); + try { + $context->params->assertEnd(); - assert($context->body instanceof BodyNode); + assert($context->body instanceof BodyNode); - $body = $context->body->children()[0] ?? null; - $this->body = match (true) { - $body instanceof Raw => $body, - default => throw new SyntaxException('doc tag must have a single raw body'), - }; + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('must have a single raw body'), + }; - $this->ensureNoNestedDocTags(); + $this->ensureNoNestedDocTags(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'doc', $e); + } return $this; } diff --git a/src/Tags/EchoTag.php b/src/Tags/EchoTag.php index 1d510f4..2944ced 100644 --- a/src/Tags/EchoTag.php +++ b/src/Tags/EchoTag.php @@ -3,6 +3,7 @@ namespace Keepsuit\Liquid\Tags; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; @@ -14,9 +15,13 @@ class EchoTag extends Tag implements HasParseTreeVisitorChildren public function parse(TagParseContext $context): static { - $this->variable = $context->params->variable(); + try { + $this->variable = $context->params->variable(); - $context->params->assertEnd(); + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'echo ', $e); + } return $this; } diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index a1ed39b..855ca96 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -54,11 +54,19 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - match ($context->tag) { - 'for' => $this->parseForBlock($context), - 'else' => $this->parseElseBlock($context), - default => throw new SyntaxException('Invalid tag'), - }; + try { + match ($context->tag) { + 'for' => $this->parseForBlock($context), + 'else' => $this->parseElseBlock($context), + default => throw new SyntaxException('Invalid tag'), + }; + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), match ($context->tag) { + 'for' => 'for in [attributes...]', + 'else' => 'else', + default => '' + }, $e); + } return $this; } diff --git a/src/Tags/IfChanged.php b/src/Tags/IfChanged.php index 388a152..c90dbcd 100644 --- a/src/Tags/IfChanged.php +++ b/src/Tags/IfChanged.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; @@ -22,7 +23,11 @@ public function parse(TagParseContext $context): static $this->body = $context->body; - $context->params->assertEnd(); + try { + $context->params->assertEnd(); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'ifchanged', $e); + } return $this; } diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index c01245b..644ed79 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; +use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Parse\TokenType; use Keepsuit\Liquid\Render\RenderContext; @@ -22,7 +23,16 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $this->conditions[] = $this->mapBodySectionToCondition($context); + try { + $this->conditions[] = $this->mapBodySectionToCondition($context); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), match ($context->tag) { + 'if' => 'if ', + 'elsif' => 'elsif ', + 'else' => 'else', + default => '' + }, $e); + } return $this; } @@ -46,6 +56,9 @@ public function parseTreeVisitorChildren(): array return $this->conditions; } + /** + * @throws SyntaxException + */ protected function mapBodySectionToCondition(TagParseContext $bodySection): Condition { $condition = match ($bodySection->tag) { @@ -85,11 +98,17 @@ public function isSubTag(string $tagName): bool return in_array($tagName, ['else', 'elsif'], true); } + /** + * @throws SyntaxException + */ protected function parseCondition(TagParseContext $bodySection): Condition { return $this->parseBinaryComparison($bodySection); } + /** + * @throws SyntaxException + */ protected function parseBinaryComparison(TagParseContext $bodySection): Condition { $condition = $this->parseComparison($bodySection); @@ -104,6 +123,9 @@ protected function parseBinaryComparison(TagParseContext $bodySection): Conditio return $firstCondition; } + /** + * @throws SyntaxException + */ protected function parseComparison(TagParseContext $bodySection): Condition { $a = $bodySection->params->expression(); diff --git a/src/Tags/IncrementTag.php b/src/Tags/IncrementTag.php index 9c95d05..a70dd30 100644 --- a/src/Tags/IncrementTag.php +++ b/src/Tags/IncrementTag.php @@ -23,7 +23,7 @@ public function parse(TagParseContext $context): static $context->params->assertEnd(); } catch (SyntaxException $e) { - throw SyntaxException::tagSyntaxException(static::tagName(), sprintf('%s [var]', static::tagName()), $e); + throw SyntaxException::tagSyntaxException(static::tagName(), sprintf('%s ', static::tagName()), $e); } return $this; diff --git a/src/Tags/RawTag.php b/src/Tags/RawTag.php index d3eabe2..53ec180 100644 --- a/src/Tags/RawTag.php +++ b/src/Tags/RawTag.php @@ -25,15 +25,19 @@ public static function hasRawBody(): bool public function parse(TagParseContext $context): static { - $context->params->assertEnd(); - - assert($context->body instanceof BodyNode); - - $body = $context->body->children()[0] ?? null; - $this->body = match (true) { - $body instanceof Raw => $body, - default => throw new SyntaxException('raw tag must have a single raw body'), - }; + try { + $context->params->assertEnd(); + + assert($context->body instanceof BodyNode); + + $body = $context->body->children()[0] ?? null; + $this->body = match (true) { + $body instanceof Raw => $body, + default => throw new SyntaxException('must have a single raw body'), + }; + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'raw', $e); + } return $this; } diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index c9a5318..8f66954 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -41,59 +41,63 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $this->isForLoop = false; - $this->variableNameExpression = null; - $this->attributes = []; - - $context->getParseContext()->nested(function () use ($context) { - $templateNameExpression = $context->params->expression(); - $this->templateNameExpression = match (true) { - is_string($templateNameExpression) => $templateNameExpression, - $this->allowDynamicPartials() && $templateNameExpression instanceof VariableLookup => $templateNameExpression, - 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(); - } elseif ($context->params->idOrFalse('with')) { - $this->variableNameExpression = $context->params->expression(); - } + try { + $this->isForLoop = false; + $this->variableNameExpression = null; + $this->attributes = []; + + $context->getParseContext()->nested(function () use ($context) { + $templateNameExpression = $context->params->expression(); + $this->templateNameExpression = match (true) { + is_string($templateNameExpression) => $templateNameExpression, + $this->allowDynamicPartials() && $templateNameExpression instanceof VariableLookup => $templateNameExpression, + default => throw new SyntaxException('Template name must be a string'), + }; - $context->params->consumeOrFalse(TokenType::Comma); + $context->params->consumeOrFalse(TokenType::Comma); - if ($context->params->idOrFalse('as')) { - $aliasName = $context->params->expression(); - $this->aliasName = match (true) { - is_string($aliasName), $aliasName instanceof VariableLookup => (string) $aliasName, - default => throw new SyntaxException('Alias name must be a valid variable name'), - }; - } else { - $this->aliasName = null; - } + if ($context->params->idOrFalse('for')) { + $this->isForLoop = true; + $this->variableNameExpression = $context->params->expression(); + } elseif ($context->params->idOrFalse('with')) { + $this->variableNameExpression = $context->params->expression(); + } - 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'); + if ($context->params->idOrFalse('as')) { + $aliasName = $context->params->expression(); + $this->aliasName = match (true) { + is_string($aliasName), $aliasName instanceof VariableLookup => (string) $aliasName, + default => throw new SyntaxException('Alias name must be a valid variable name'), + }; + } else { + $this->aliasName = null; } - $context->params->consume(TokenType::Colon); - $attributeValue = $context->params->expression(); + while (! $context->params->isEnd()) { + $context->params->consumeOrFalse(TokenType::Comma); - $this->attributes[(string) $attributeName] = $attributeValue; - } + $attributeName = $context->params->expression(); + if (! (is_string($attributeName) || $attributeName instanceof VariableLookup)) { + throw new SyntaxException('Attribute name must be a valid variable name'); + } - $context->params->assertEnd(); + $context->params->consume(TokenType::Colon); + $attributeValue = $context->params->expression(); - if (is_string($this->templateNameExpression)) { - $context->getParseContext()->loadPartial($this->templateNameExpression); - } - }); + $this->attributes[(string) $attributeName] = $attributeValue; + } + + $context->params->assertEnd(); + + if (is_string($this->templateNameExpression)) { + $context->getParseContext()->loadPartial($this->templateNameExpression); + } + }); + } catch (SyntaxException $e) { + throw SyntaxException::tagSyntaxException(static::tagName(), 'render