Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
36 changes: 36 additions & 0 deletions src/Drops/SelfDrop.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Keepsuit\Liquid\Drops;

use Keepsuit\Liquid\Render\RenderContext;

/**
* Proxy object that resolves property lookups through the current render context scope chain.
* Intentionally does not implement IsContextAware: a SelfDrop passed across a render boundary
* must keep its original context rather than being rebound to the partial's context.
*/
final class SelfDrop
{
public function __construct(
private readonly RenderContext $context
) {}

public function __get(string $name): mixed
{
$variables = $this->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 '';
}
}
8 changes: 8 additions & 0 deletions src/Exceptions/SyntaxException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 8 additions & 2 deletions src/Parse/TokenStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ public function consumeOrFalse(TokenType $type): Token|false

/**
* @throws SyntaxException
*
* @phpstan-impure
*/
public function id(string $identifier): Token
{
Expand Down Expand Up @@ -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
*
Expand Down
15 changes: 15 additions & 0 deletions src/Render/RenderContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down
11 changes: 2 additions & 9 deletions src/Tags/AssignTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -32,19 +29,15 @@ 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);

$this->from = $context->params->variable();

$context->params->assertEnd();
} catch (SyntaxException $e) {
throw new SyntaxException(self::SYNTAX_ERROR);
throw SyntaxException::tagSyntaxException('assign', 'assign <var> = <source>', $e);
}

return $this;
Expand Down
7 changes: 6 additions & 1 deletion src/Tags/BreakTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
15 changes: 6 additions & 9 deletions src/Tags/CaptureTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <var>', $e);
}

return $this;
}
Expand Down
19 changes: 14 additions & 5 deletions src/Tags/CaseTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <expression>',
'when' => 'when <expression> [, <expression>...]',
'else' => 'else',
default => ''
}, $e);
}

return $this;
Expand Down
7 changes: 6 additions & 1 deletion src/Tags/ContinueTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
63 changes: 35 additions & 28 deletions src/Tags/CycleTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)[]
*/
Expand All @@ -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 [<name>:] <value>[, <value>...]', $e);
}

return $this;
}
Expand Down
20 changes: 12 additions & 8 deletions src/Tags/DocTag.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading