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 | diff --git a/src/Drops/SelfDrop.php b/src/Drops/SelfDrop.php new file mode 100644 index 0000000..52d938c --- /dev/null +++ b/src/Drops/SelfDrop.php @@ -0,0 +1,36 @@ +context->findVariables($name); + + return $variables[0] ?? null; + } + + public function __isset(string $name): bool + { + $variables = $this->context->findVariables($name); + + return $variables !== []; + } + + public function __toString(): string + { + return ''; + } +} diff --git a/src/Exceptions/SyntaxException.php b/src/Exceptions/SyntaxException.php index 5cdc7dc..cfa1d28 100644 --- a/src/Exceptions/SyntaxException.php +++ b/src/Exceptions/SyntaxException.php @@ -79,6 +79,14 @@ public static function unexpectedToken(Token $token): SyntaxException )); } + public static function tagSyntaxException(string $tagName, string $validSyntax, SyntaxException $original): SyntaxException + { + $exception = new SyntaxException(sprintf('%s - Valid syntax: %s', $original->getMessage(), $validSyntax), previous: $original); + $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 936a7c3..be0702b 100644 --- a/src/Parse/TokenStream.php +++ b/src/Parse/TokenStream.php @@ -92,8 +92,6 @@ public function consumeOrFalse(TokenType $type): Token|false /** * @throws SyntaxException - * - * @phpstan-impure */ public function id(string $identifier): Token { @@ -143,6 +141,14 @@ public function expression(): mixed return $this->expressionParser->parseExpression(); } + /** + * @throws SyntaxException + */ + public function simpleVariableName(): string + { + return $this->consume(TokenType::Identifier)->data; + } + /** * @return Argument * 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/src/Tags/AssignTag.php b/src/Tags/AssignTag.php index 62a2e75..647e6cc 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; @@ -18,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; @@ -32,11 +29,7 @@ public static function tagName(): string public function parse(TagParseContext $context): static { try { - $to = $context->params->expression(); - $this->to = match (true) { - $to instanceof VariableLookup, is_string($to) => (string) $to, - default => throw new SyntaxException(self::SYNTAX_ERROR), - }; + $this->to = $context->params->simpleVariableName(); $context->params->consume(TokenType::Equals); @@ -44,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 = ', $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 393214b..1e5fa30 100644 --- a/src/Tags/CaptureTag.php +++ b/src/Tags/CaptureTag.php @@ -4,15 +4,12 @@ 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; class CaptureTag extends TagBlock { - protected const SYNTAX_ERROR = "Syntax Error in 'capture' - Valid syntax: capture [var]"; - protected string $to; protected BodyNode $body; @@ -23,13 +20,13 @@ public function parse(TagParseContext $context): static $this->body = $context->body; - $to = $context->params->expression(); - $this->to = match (true) { - is_string($to), $to instanceof VariableLookup => (string) $to, - default => throw new SyntaxException(self::SYNTAX_ERROR), - }; + try { + $this->to = $context->params->simpleVariableName(); - $context->params->assertEnd(); + $context->params->assertEnd(); + } catch (SyntaxException $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 34ce7af..a70dd30 100644 --- a/src/Tags/IncrementTag.php +++ b/src/Tags/IncrementTag.php @@ -3,7 +3,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,13 +18,13 @@ public static function tagName(): string public function parse(TagParseContext $context): static { - $variableName = $context->params->expression(); - $this->variableName = match (true) { - $variableName instanceof VariableLookup || is_string($variableName) => (string) $variableName, - default => throw new SyntaxException('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 ', 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