Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 14 additions & 29 deletions src/Drop.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Keepsuit\Liquid\Concerns\ContextAware;
use Keepsuit\Liquid\Contracts\IsContextAware;
use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException;
use Keepsuit\Liquid\Support\DropMemberType;
use Keepsuit\Liquid\Support\DropMetadata;
use Keepsuit\Liquid\Support\Str;

Expand Down Expand Up @@ -47,44 +48,28 @@ public function __toString(): string

public function __get(string $name): mixed
{
$invokableMethods = $this->getMetadata()->invokableMethods;
$cacheableMethods = $this->getMetadata()->cacheableMethods;

$possibleNames = array_unique([
$name,
Str::camel($name),
Str::snake($name),
]);

foreach ($possibleNames as $propertyName) {
if (in_array($propertyName, $this->getMetadata()->properties)) {
return $this->{$propertyName};
}
}
$metadata = $this->getMetadata();
$resolution = $metadata->resolveStaticMember($name);

foreach ($possibleNames as $methodName) {
if (! in_array($methodName, $invokableMethods)) {
continue;
if ($resolution !== null) {
if ($resolution->type === DropMemberType::Property) {
return $this->{$resolution->name};
}

$isCacheable = in_array($methodName, $cacheableMethods);

if ($isCacheable && isset($this->cache[$methodName])) {
return $this->cache[$methodName];
if ($resolution->cacheable && array_key_exists($resolution->name, $this->cache)) {
return $this->cache[$resolution->name];
}

if (method_exists($this, $methodName)) {
$result = $this->{$methodName}();
$result = $this->{$resolution->name}();

if ($isCacheable) {
$this->cache[$methodName] = $result;
}

return $result;
if ($resolution->cacheable) {
$this->cache[$resolution->name] = $result;
}

return $result;
}

foreach ($possibleNames as $methodName) {
foreach ($metadata->possibleNames($name) as $methodName) {
try {
return $this->liquidMethodMissing($methodName);
} catch (UndefinedDropMethodException) {
Expand Down
9 changes: 5 additions & 4 deletions src/Drops/SelfDrop.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Keepsuit\Liquid\Drops;

use Keepsuit\Liquid\Render\RenderContext;
use Keepsuit\Liquid\Support\MissingValue;

/**
* Proxy object that resolves property lookups through the current render context scope chain.
Expand All @@ -17,16 +18,16 @@ public function __construct(

public function __get(string $name): mixed
{
$variables = $this->context->findVariables($name);
$variable = $this->context->findVariable($name);

return $variables[0] ?? null;
return $variable instanceof MissingValue ? null : $variable;
}

public function __isset(string $name): bool
{
$variables = $this->context->findVariables($name);
$variable = $this->context->findVariable($name);

return $variables !== [];
return ! $variable instanceof MissingValue;
}

public function __toString(): string
Expand Down
49 changes: 26 additions & 23 deletions src/Nodes/BodyNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Keepsuit\Liquid\Nodes;

use Keepsuit\Liquid\Contracts\CanBeStreamed;
use Keepsuit\Liquid\Contracts\Disableable;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException;
use Keepsuit\Liquid\Exceptions\UndefinedFilterException;
Expand Down Expand Up @@ -52,12 +53,19 @@ public function render(RenderContext $context): string
$output = '';

foreach ($this->children as $node) {
// Text is the majority of children and cannot fail or interrupt.
if ($node instanceof Text) {
$output .= $node->value;

continue;
}

try {
if ($node instanceof Tag) {
if ($node instanceof Disableable && $node instanceof Tag) {
$node->ensureTagIsEnabled($context);
}

$output .= $this->renderChild($context, $node);
$output .= $node->render($context);
} catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) {
$context->handleError($exception, $node->lineNumber);
} catch (\Throwable $exception) {
Expand All @@ -84,12 +92,26 @@ public function stream(RenderContext $context): \Generator
$context->resourceLimits->incrementRenderScore(count($this->children));

foreach ($this->children as $node) {
// Text is the majority of children and cannot fail or interrupt.
if ($node instanceof Text) {
$context->resourceLimits->incrementWriteScore($node->value);
yield $node->value;

continue;
}

try {
if ($node instanceof Tag) {
if ($node instanceof Disableable && $node instanceof Tag) {
$node->ensureTagIsEnabled($context);
}

foreach ($this->streamChild($context, $node) as $output) {
if ($node instanceof CanBeStreamed) {
foreach ($node->stream($context) as $output) {
$context->resourceLimits->incrementWriteScore($output);
yield $output;
}
} else {
$output = $node->render($context);
$context->resourceLimits->incrementWriteScore($output);
yield $output;
}
Expand All @@ -107,25 +129,6 @@ public function stream(RenderContext $context): \Generator
}
}

protected function renderChild(RenderContext $context, Node $node): string
{
return $node->render($context);
}

/**
* @return \Generator<string>
*/
public function streamChild(RenderContext $context, Node $node): \Generator
{
if ($node instanceof CanBeStreamed) {
yield from $node->stream($context);

return;
}

yield $node->render($context);
}

public function blank(): bool
{
foreach ($this->children as $node) {
Expand Down
16 changes: 13 additions & 3 deletions src/Nodes/Variable.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,19 @@ public function evaluate(RenderContext $context): mixed
}

foreach ($this->filters as [$filterName, $filterArgs, $filterNamedArgs]) {
$filterArgs = $this->evaluateFilterExpressions($context, $filterArgs ?? []);
$filterNamedArgs = $this->evaluateFilterExpressions($context, $filterNamedArgs ?? []);
$output = $context->applyFilter($filterName, $output, [...$filterArgs, ...$filterNamedArgs]);
if ($filterArgs === [] && $filterNamedArgs === []) {
$output = $context->applyFilter($filterName, $output);

continue;
}

$filterArgs = $this->evaluateFilterExpressions($context, $filterArgs);

if ($filterNamedArgs !== []) {
$filterArgs = [...$filterArgs, ...$this->evaluateFilterExpressions($context, $filterNamedArgs)];
}

$output = $context->applyFilter($filterName, $output, $filterArgs);
}

return $output;
Expand Down
106 changes: 65 additions & 41 deletions src/Nodes/VariableLookup.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,11 @@ class VariableLookup implements CanBeEvaluated, HasParseTreeVisitorChildren

private const LOOKUP_REGEX = '{\.([\w\-]+)|\["([\w\-]+)"\]|\[\'([\w\-]+)\'\]|\[(\d+)\]}';

/**
* @var int[]
*/
public readonly array $lookupFilters;

public function __construct(
public readonly string $name,
/** @var string[] */
/** @var array<string|int|VariableLookup> */
public readonly array $lookups = [],
) {
$lookupFilters = [];
foreach ($this->lookups as $i => $lookup) {
if (in_array($lookup, self::FILTER_METHODS, true)) {
$lookupFilters[] = $i;
}
}
$this->lookupFilters = $lookupFilters;
}
) {}

/**
* Parses `a.b[0]["c"]` into a name plus its lookups.
Expand Down Expand Up @@ -88,49 +75,86 @@ public function parseTreeVisitorChildren(): array

public function evaluate(RenderContext $context): mixed
{
$name = $context->evaluate($this->name);
assert(is_string($name));
$variables = $context->findVariables($name);
$variable = $context->findVariable($this->name);

if ($variable instanceof MissingValue) {
return $this->undefined($context);
}

if ($this->lookups === []) {
if ($context->options->strictVariables && $variables === []) {
return new UndefinedVariable($this->toString());
}
return $variable;
}

$result = $this->walkLookups($context, $variable);

return $variables[0] ?? null;
if (! $result instanceof MissingValue) {
return $result;
}

foreach ($variables as $object) {
$object = $context->evaluate($object);
// The name resolved but the lookup chain broke on the innermost value: an
// outer scope may still hold one the chain resolves against.
foreach ($context->findVariables($this->name) as $candidate) {
// Skip the value already walked above: re-walking it would repeat any
// side effects the broken chain triggered on the way.
if ($candidate === $variable) {
continue;
}

$result = $this->walkLookups($context, $candidate);

if ($object instanceof \Generator) {
$object = iterator_to_array($object, preserve_keys: false);
if (! $result instanceof MissingValue) {
return $result;
}
}

return $this->undefined($context);
}

protected function undefined(RenderContext $context): ?UndefinedVariable
{
return $context->options->strictVariables ? new UndefinedVariable($this->toString()) : null;
}

/**
* Walks the lookup chain against $object, returning MissingValue if it breaks.
*/
protected function walkLookups(RenderContext $context, mixed $object): mixed
{
if ($object instanceof CanBeEvaluated) {
$object = $context->evaluate($object);
}

foreach ($this->lookups as $i => $lookup) {
$key = $context->evaluate($lookup) ?? '';
if ($object instanceof \Generator) {
$object = iterator_to_array($object, preserve_keys: false);
}

assert(is_string($key) || is_int($key));
foreach ($this->lookups as $lookup) {
$key = $lookup instanceof VariableLookup ? $context->evaluate($lookup) : $lookup;

$nextObject = $context->evaluate($context->internalContextLookup($object, $key));
if (! (is_string($key) || is_int($key))) {
return new MissingValue;
}

if ($nextObject instanceof MissingValue && is_iterable($object) && in_array($i, $this->lookupFilters, true)) {
$nextObject = $context->applyFilter($lookup, $object);
}
$nextObject = $context->internalContextLookup($object, $key);

if ($nextObject instanceof MissingValue) {
continue 2;
}
if ($nextObject instanceof CanBeEvaluated) {
$nextObject = $context->evaluate($nextObject);
}

$object = $nextObject;
if ($object instanceof IsContextAware) {
$object->setContext($context);
if ($nextObject instanceof MissingValue) {
if (is_iterable($object) && is_string($lookup) && in_array($lookup, self::FILTER_METHODS, true)) {
$nextObject = $context->applyFilter($lookup, $object);
} else {
return $nextObject;
}
}

return $object;
$object = $nextObject;
if ($object instanceof IsContextAware) {
$object->setContext($context);
}
}

return $context->options->strictVariables ? new UndefinedVariable($this->toString()) : null;
return $object;
}
}
16 changes: 13 additions & 3 deletions src/Parse/ExpressionParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function parseExpression(): mixed
protected function parseVariable(): VariableLookup
{
$name = $this->tokenStream->consume(TokenType::Identifier)->data;
$lookups = $this->parseVariableLookups();
$lookups = $this->parseVariableLookups($name);

return new VariableLookup(
name: $name,
Expand All @@ -63,7 +63,7 @@ protected function parseVariable(): VariableLookup
/**
* @throws SyntaxException
*/
protected function parseVariableLookups(): array
protected function parseVariableLookups(string $variableName): array
{
$lookups = [];

Expand All @@ -74,7 +74,17 @@ protected function parseVariableLookups(): array
continue;
}
if ($this->tokenStream->consumeOrFalse(TokenType::OpenSquare)) {
$lookups[] = $this->tokenStream->expression();
$expression = $this->tokenStream->expression();
$lookups[] = match (true) {
is_string($expression), is_int($expression), $expression instanceof VariableLookup => $expression,
default => throw new SyntaxException(sprintf('Invalid variable lookup: %s[%s]', $variableName, match (true) {
$expression instanceof Literal => $expression->value,
$expression instanceof RangeLookup => $expression->toString(),
is_bool($expression) => $expression ? 'true' : 'false',
$expression === null => 'nil',
default => (string) $expression
})),
};
$this->tokenStream->consume(TokenType::CloseSquare);

continue;
Expand Down
Loading