diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 95fa025..55f6a1d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -30,11 +30,6 @@ parameters: count: 1 path: performance/Shopify/Database.php - - - message: "#^Strict comparison using \\!\\=\\= between null and null will always evaluate to false\\.$#" - count: 1 - path: src/Profiler/Profiler.php - - message: "#^Parameter \\#3 \\.\\.\\.\\$values of function sprintf expects bool\\|float\\|int\\|string\\|null, mixed given\\.$#" count: 1 diff --git a/src/Attributes/Hidden.php b/src/Attributes/Hidden.php index a7b6641..5b93e5a 100644 --- a/src/Attributes/Hidden.php +++ b/src/Attributes/Hidden.php @@ -5,8 +5,9 @@ use Attribute; /** - * This attribute can be used to mark a drop method as hidden, - * so it won't be exposed to the liquid context. + * This attribute can be used to hide: + * - a drop method, so it won't be exposed to the liquid context. + * - a FiltersProvider method, so it won't be registered as a filter. */ #[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)] class Hidden {} diff --git a/src/Concerns/ContextAware.php b/src/Concerns/ContextAware.php index b828916..e9afbe6 100644 --- a/src/Concerns/ContextAware.php +++ b/src/Concerns/ContextAware.php @@ -2,12 +2,14 @@ namespace Keepsuit\Liquid\Concerns; +use Keepsuit\Liquid\Attributes\Hidden; use Keepsuit\Liquid\Render\RenderContext; trait ContextAware { protected RenderContext $context; + #[Hidden] public function setContext(RenderContext $context): void { $this->context = $context; diff --git a/src/Contracts/LiquidExtension.php b/src/Contracts/LiquidExtension.php new file mode 100644 index 0000000..a3f9e8b --- /dev/null +++ b/src/Contracts/LiquidExtension.php @@ -0,0 +1,29 @@ +> + */ + public function getTags(): array; + + /** + * @return array> + */ + public function getFiltersProviders(): array; + + /** + * @return array + */ + public function getRegisters(): array; + + /** + * @return array + */ + public function getNodeVisitors(): array; +} diff --git a/src/Contracts/NodeVisitor.php b/src/Contracts/NodeVisitor.php new file mode 100644 index 0000000..9c1a0cf --- /dev/null +++ b/src/Contracts/NodeVisitor.php @@ -0,0 +1,12 @@ +, LiquidExtension> + */ + protected array $extensions = []; + public function __construct( ?TagRegistry $tagRegistry = null, ?FilterRegistry $filterRegistry = null, @@ -37,20 +45,27 @@ public function __construct( ?LiquidErrorHandler $errorHandler = null, ?ResourceLimits $defaultResourceLimits = null, ?RenderContextOptions $defaultRenderContextOptions = null, - public readonly bool $profile = false, + /** @var LiquidExtension[] $extensions */ + array $extensions = [], ) { - $this->tagRegistry = $tagRegistry ?? TagRegistry::default(); - $this->filterRegistry = $filterRegistry ?? FilterRegistry::default(); + $this->tagRegistry = $tagRegistry ?? new TagRegistry; + $this->filterRegistry = $filterRegistry ?? new FilterRegistry; $this->fileSystem = $fileSystem ?? new BlankFileSystem; $this->errorHandler = $errorHandler ?? new DefaultErrorHandler; $this->defaultResourceLimits = $defaultResourceLimits ?? new ResourceLimits; $this->defaultRenderContextOptions = $defaultRenderContextOptions ?? new RenderContextOptions; + + foreach ($extensions as $extension) { + $this->addExtension($extension); + } } public static function default(): Environment { if (! isset(self::$defaultEnvironment)) { - self::$defaultEnvironment = new self; + self::$defaultEnvironment = new Environment( + extensions: [new StandardExtension] + ); } return self::$defaultEnvironment; @@ -91,7 +106,6 @@ public function newRenderContext( data: $data, staticData: $staticData, registers: $registers, - profile: $this->profile, options: $options ?? $this->defaultRenderContextOptions, resourceLimits: $resourceLimits, environment: $this @@ -115,4 +129,67 @@ public function parseTemplate(string $templateName): Template return Template::parse($this->newParseContext(), $source, $templateName); } + + public function addExtension(LiquidExtension $extension): static + { + $this->extensions[$extension::class] = $extension; + + foreach ($extension->getTags() as $tag) { + $this->tagRegistry->register($tag); + } + + foreach ($extension->getFiltersProviders() as $filtersProvider) { + $this->filterRegistry->register($filtersProvider); + } + + return $this; + } + + /** + * @param class-string $extensionClass + */ + public function removeExtension(string $extensionClass): static + { + $extension = $this->extensions[$extensionClass] ?? null; + + if ($extension === null) { + return $this; + } + + unset($this->extensions[$extensionClass]); + + foreach ($extension->getTags() as $tag) { + $this->tagRegistry->delete($tag::tagName()); + } + + foreach ($extension->getFiltersProviders() as $filtersProvider) { + $this->filterRegistry->delete($filtersProvider); + } + + return $this; + } + + /** + * @return array + */ + public function getExtensions(): array + { + return array_values($this->extensions); + } + + public function getNodeVisitors(): array + { + return Arr::flatten(Arr::map( + $this->getExtensions(), + fn (LiquidExtension $extension) => $extension->getNodeVisitors() + )); + } + + public function getRegisters(): array + { + return array_merge(...Arr::map( + $this->getExtensions(), + fn (LiquidExtension $extension) => $extension->getRegisters() + )); + } } diff --git a/src/EnvironmentFactory.php b/src/EnvironmentFactory.php index fff1627..f38dfd3 100644 --- a/src/EnvironmentFactory.php +++ b/src/EnvironmentFactory.php @@ -3,8 +3,10 @@ namespace Keepsuit\Liquid; use Keepsuit\Liquid\Contracts\LiquidErrorHandler; +use Keepsuit\Liquid\Contracts\LiquidExtension; use Keepsuit\Liquid\Contracts\LiquidFileSystem; use Keepsuit\Liquid\ErrorHandlers\DefaultErrorHandler; +use Keepsuit\Liquid\Extensions\StandardExtension; use Keepsuit\Liquid\FileSystems\BlankFileSystem; use Keepsuit\Liquid\Filters\FiltersProvider; use Keepsuit\Liquid\Render\RenderContextOptions; @@ -26,16 +28,21 @@ final class EnvironmentFactory protected RenderContextOptions $defaultRenderContextOptions; - protected bool $profile = false; + /** + * @var array, LiquidExtension> + */ + protected array $extensions = []; public function __construct() { - $this->tagRegistry = TagRegistry::default(); - $this->filterRegistry = FilterRegistry::default(); + $this->tagRegistry = new TagRegistry; + $this->filterRegistry = new FilterRegistry; $this->fileSystem = new BlankFileSystem; $this->errorHandler = new DefaultErrorHandler; $this->resourceLimits = new ResourceLimits; $this->defaultRenderContextOptions = new RenderContextOptions; + + $this->addExtension(new StandardExtension); } public static function new(): EnvironmentFactory @@ -78,13 +85,6 @@ public function setResourceLimits(ResourceLimits $resourceLimits): EnvironmentFa return $this; } - public function setProfile(bool $profile = true): EnvironmentFactory - { - $this->profile = $profile; - - return $this; - } - public function setRethrowErrors(bool $rethrowErrors = true): EnvironmentFactory { $this->defaultRenderContextOptions = new RenderContextOptions( @@ -138,6 +138,13 @@ public function registerFilters(string $filtersProvider): EnvironmentFactory return $this; } + public function addExtension(LiquidExtension $extension): EnvironmentFactory + { + $this->extensions[$extension::class] = $extension; + + return $this; + } + public function build(): Environment { return new Environment( @@ -146,7 +153,7 @@ public function build(): Environment fileSystem: $this->fileSystem, defaultResourceLimits: $this->resourceLimits, defaultRenderContextOptions: $this->defaultRenderContextOptions, - profile: $this->profile, + extensions: array_values($this->extensions), ); } } diff --git a/src/Extensions/Extension.php b/src/Extensions/Extension.php new file mode 100644 index 0000000..ccea4c8 --- /dev/null +++ b/src/Extensions/Extension.php @@ -0,0 +1,28 @@ +tags, variables: $this->variables)]; + } + + public function getRegisters(): array + { + return [ + 'profiler' => $this->profiler, + ]; + } +} diff --git a/src/Extensions/StandardExtension.php b/src/Extensions/StandardExtension.php new file mode 100644 index 0000000..f5c6245 --- /dev/null +++ b/src/Extensions/StandardExtension.php @@ -0,0 +1,38 @@ + $children + * @param array $children */ public function setChildren(array $children): BodyNode { @@ -74,6 +74,11 @@ public function render(RenderContext $context): string return $output; } + /** + * @return \Generator + * + * @throws LiquidException + */ public function stream(RenderContext $context): \Generator { $context->resourceLimits->incrementRenderScore(count($this->children)); @@ -104,14 +109,6 @@ public function stream(RenderContext $context): \Generator protected function renderChild(RenderContext $context, Node $node): string { - if ($context->getProfiler() !== null) { - return $context->getProfiler()->profileNode( - node: $node, - context: $context, - templateName: $context->getTemplateName(), - ); - } - return $node->render($context); } @@ -120,12 +117,6 @@ protected function renderChild(RenderContext $context, Node $node): string */ public function streamChild(RenderContext $context, Node $node): \Generator { - if ($context->getProfiler() !== null) { - yield $this->renderChild($context, $node); - - return; - } - if ($node instanceof CanBeStreamed) { yield from $node->stream($context); @@ -156,9 +147,4 @@ public function removeBlankStrings(): void $this->children = array_filter($this->children, fn (Node $node) => ! ($node instanceof Text)); } - - public function parseTreeVisitorChildren(): array - { - return $this->children; - } } diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index d8b2efc..c5ae4b9 100644 --- a/src/Nodes/Document.php +++ b/src/Nodes/Document.php @@ -2,15 +2,14 @@ namespace Keepsuit\Liquid\Nodes; -use Keepsuit\Liquid\Contracts\CanBeRendered; use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Render\RenderContext; -class Document implements CanBeRendered, CanBeStreamed +class Document extends Node implements CanBeStreamed { public function __construct( - protected BodyNode $body, + public readonly BodyNode $body, ) {} /** @@ -18,14 +17,6 @@ public function __construct( */ public function render(RenderContext $context): string { - if ($context->getProfiler() !== null) { - return $context->getProfiler()->profile( - node: $this->body, - context: $context, - templateName: $context->getTemplateName() - ); - } - return $this->body->render($context); } @@ -34,12 +25,6 @@ public function render(RenderContext $context): string */ public function stream(RenderContext $context): \Generator { - if ($context->getProfiler() !== null) { - yield $this->render($context); - - return; - } - yield from $this->body->stream($context); } @@ -48,6 +33,6 @@ public function stream(RenderContext $context): \Generator */ public function children(): array { - return $this->body->children(); + return [$this->body]; } } diff --git a/src/Nodes/Node.php b/src/Nodes/Node.php index 342c962..31d0a4f 100644 --- a/src/Nodes/Node.php +++ b/src/Nodes/Node.php @@ -32,4 +32,9 @@ public function children(): array { return []; } + + public function debugLabel(): ?string + { + return null; + } } diff --git a/src/Nodes/Range.php b/src/Nodes/Range.php index c0405c5..83d4d7e 100644 --- a/src/Nodes/Range.php +++ b/src/Nodes/Range.php @@ -2,10 +2,9 @@ namespace Keepsuit\Liquid\Nodes; -use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Render\RenderContext; -class Range extends Node implements HasParseTreeVisitorChildren +class Range extends Node { public function __construct( public readonly int $start, @@ -22,8 +21,8 @@ public function toArray(): array return range($this->start, $this->end); } - public function parseTreeVisitorChildren(): array + public function debugLabel(): string { - return [$this->start, $this->end]; + return sprintf('(%s..%s)', $this->start, $this->end); } } diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index f7f273d..3158df1 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -132,4 +132,17 @@ protected static function evaluateFilterExpressions(RenderContext $context, arra $filterArgs ); } + + public function debugLabel(): ?string + { + return match (true) { + $this->name instanceof VariableLookup => $this->name->name, + $this->name instanceof RangeLookup => $this->name->toString(), + $this->name instanceof Literal => $this->name->value, + is_string($this->name) => $this->name, + is_bool($this->name) => $this->name ? 'true' : 'false', + is_numeric($this->name) => (string) $this->name, + default => null, + }; + } } diff --git a/src/Parse/NodeTraverser.php b/src/Parse/NodeTraverser.php new file mode 100644 index 0000000..1af6652 --- /dev/null +++ b/src/Parse/NodeTraverser.php @@ -0,0 +1,47 @@ + */ + protected array $visitors = [] + ) {} + + public function addVisitor(NodeVisitor $visitor): static + { + $this->visitors[] = $visitor; + + return $this; + } + + /** + * @template TNode of Node + * + * @param TNode $node + * @return TNode + */ + public function traverse(Node $node): Node + { + foreach ($this->visitors as $visitor) { + $this->applyVisitor($visitor, $node); + } + + return $node; + } + + private function applyVisitor(NodeVisitor $visitor, Node $node): void + { + $visitor->enterNode($node); + + foreach ($node->children() as $child) { + $this->applyVisitor($visitor, $child); + } + + $visitor->leaveNode($node); + } +} diff --git a/src/Parse/ParseContext.php b/src/Parse/ParseContext.php index fdf6c49..a1f4756 100644 --- a/src/Parse/ParseContext.php +++ b/src/Parse/ParseContext.php @@ -4,17 +4,13 @@ use Closure; use Keepsuit\Liquid\Environment; -use Keepsuit\Liquid\Exceptions\ArithmeticException; -use Keepsuit\Liquid\Exceptions\InternalException; use Keepsuit\Liquid\Exceptions\LiquidException; -use Keepsuit\Liquid\Exceptions\ResourceLimitException; use Keepsuit\Liquid\Exceptions\StackLevelException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Support\OutputsBag; use Keepsuit\Liquid\Support\PartialsCache; use Keepsuit\Liquid\Template; -use Throwable; class ParseContext { @@ -61,7 +57,7 @@ public function tokenize(string $markup): TokenStream return $this->lexer->tokenize($markup); } - public function parse(TokenStream $tokenStream): BodyNode + public function parse(TokenStream $tokenStream): Document { return $this->parser->parse($tokenStream); } @@ -125,21 +121,4 @@ public function nested(Closure $callback) $this->depth -= 1; } } - - /** - * @throws LiquidException - */ - public function handleError(Throwable $error): void - { - $error = match (true) { - $error instanceof ResourceLimitException => throw $error, - $error instanceof \ArithmeticError => new ArithmeticException($error), - $error instanceof LiquidException => $error, - default => new InternalException($error), - }; - - $error->lineNumber = $error->lineNumber ?? $this->lineNumber; - - throw $error; - } } diff --git a/src/Parse/ParseTreeVisitor.php b/src/Parse/ParseTreeVisitor.php index 6d3747a..04232a8 100644 --- a/src/Parse/ParseTreeVisitor.php +++ b/src/Parse/ParseTreeVisitor.php @@ -4,7 +4,6 @@ use Closure; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; -use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Node; use Keepsuit\Liquid\Template; @@ -49,10 +48,6 @@ protected function children(): array return $this->node->children(); } - if ($this->node instanceof Document) { - return $this->node->children(); - } - if ($this->node instanceof Template) { return $this->node->root->children(); } diff --git a/src/Parse/Parser.php b/src/Parse/Parser.php index 827118a..a85027a 100644 --- a/src/Parse/Parser.php +++ b/src/Parse/Parser.php @@ -2,8 +2,10 @@ namespace Keepsuit\Liquid\Parse; +use Keepsuit\Liquid\Exceptions\StandardException; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Raw; use Keepsuit\Liquid\Nodes\Text; use Keepsuit\Liquid\Nodes\Variable; @@ -25,19 +27,23 @@ public function __construct( /** * @throws SyntaxException + * @throws StandardException */ - public function parse(TokenStream $tokenStream): BodyNode + public function parse(TokenStream $tokenStream): Document { $this->tokenStream = $tokenStream; $this->blockScopes = []; - return $this->subparse(); + $body = $this->subparse(); + $document = new Document($body); + + return $this->newNodeTraverser()->traverse($document); } /** * @throws SyntaxException */ - public function subparse(): BodyNode + protected function subparse(): BodyNode { if ($this->currentToken() === null) { return new BodyNode([]); @@ -183,4 +189,9 @@ public function currentToken(): ?Token { return $this->tokenStream->current(); } + + protected function newNodeTraverser(): NodeTraverser + { + return new NodeTraverser(visitors: $this->parseContext->environment->getNodeVisitors()); + } } diff --git a/src/Profiler/Profile.php b/src/Profiler/Profile.php new file mode 100644 index 0000000..3c72bc7 --- /dev/null +++ b/src/Profiler/Profile.php @@ -0,0 +1,137 @@ +name = $name ?? $type->value; + + $this->enter(); + } + + protected function enter(): void + { + $this->start = ProfileSnapshot::record(); + + $this->end = null; + $this->selfDuration = null; + } + + public function leave(): void + { + $this->end = ProfileSnapshot::record(); + } + + public function getStartTime(): float + { + return $this->start->time; + } + + public function getEndTime(): float + { + $this->ensureProfileIsClosed(); + + return $this->end->time; + } + + public function getDuration(): float + { + return $this->getEndTime() - $this->getStartTime(); + } + + public function getSelfDuration(): float + { + $this->ensureProfileIsClosed(); + + if ($this->selfDuration !== null) { + return $this->selfDuration; + } + + $totalChildrenTime = 0; + foreach ($this->children as $child) { + $totalChildrenTime += $child->getDuration(); + } + $this->selfDuration = $this->getDuration() - $totalChildrenTime; + + return $this->selfDuration; + } + + public function getMemoryUsage(): int + { + $this->ensureProfileIsClosed(); + + return $this->end->memory - $this->start->memory; + } + + public function getPeakMemoryUsage(): int + { + $this->ensureProfileIsClosed(); + + return $this->end->peakMemory - $this->start->peakMemory; + } + + /** + * @phpstan-assert ProfileSnapshot $this->end + * + * @throws StandardException + */ + protected function ensureProfileIsClosed(): void + { + if ($this->end === null) { + throw new StandardException('Profile has not been closed'); + } + } + + public function addChild(Profile $profile): static + { + if ($this->end !== null) { + throw new StandardException('Cannot add children to a closed profile'); + } + + $this->children[] = $profile; + + return $this; + } + + /** + * @return Profile[] + */ + public function getChildren(): array + { + return $this->children; + } + + public function serialize(): array + { + return [ + 'type' => $this->type->value, + 'name' => $this->name, + 'start' => $this->getStartTime(), + 'end' => $this->getEndTime(), + 'duration' => $this->getDuration(), + 'memory_usage' => $this->getMemoryUsage(), + 'peak_memory_usage' => $this->getPeakMemoryUsage(), + 'children' => Arr::map($this->children, fn (Profile $profile) => $profile->serialize()), + ]; + } +} diff --git a/src/Profiler/ProfileSnapshot.php b/src/Profiler/ProfileSnapshot.php new file mode 100644 index 0000000..736d8c6 --- /dev/null +++ b/src/Profiler/ProfileSnapshot.php @@ -0,0 +1,21 @@ + + */ + protected array $rootProfiles = []; - protected ?Timing $currentTiming = null; + /** + * @var array + */ + protected array $activeProfiles = []; - public function profile(Node $node, RenderContext $context, ?string $templateName): string + public function enter(Profile $profile): void { - if ($this->currentTiming != null) { - return $node->render($context); + if (count($this->activeProfiles) === 0) { + $this->rootProfiles[] = $profile; + } else { + $this->activeProfiles[0]->addChild($profile); } - try { - $this->currentRootTiming = null; - - return $this->profileNode($node, $context, $templateName); - } finally { - $this->currentTiming = null; - - if ($this->currentRootTiming !== null) { - $this->rootTimings[] = $this->currentRootTiming; - $this->totalTime += $this->currentRootTiming->getTotalTime(); - } - } + array_unshift($this->activeProfiles, $profile); } - public function profileNode(Node $node, RenderContext $context, ?string $templateName): string + public function leave(): Profile { - if ($node instanceof Text) { - return $node->render($context); - } + $activeProfile = array_shift($this->activeProfiles); - $timing = new Timing( - $node, - templateName: $templateName, - ); + if (! $activeProfile instanceof Profile) { + throw new StandardException('There is no active profile to leave'); + } - $this->currentRootTiming ??= $timing; + $activeProfile->leave(); - $parentTiming = $this->currentTiming; - $this->currentTiming = $timing; + return $activeProfile; + } - $output = $timing->measure(fn () => $node->render($context)); + /** + * @return Profile[] + */ + public function getProfiles(): array + { + return $this->rootProfiles; + } - $parentTiming?->addChild($timing); - $this->currentTiming = $parentTiming; + public function reset(): void + { + $this->rootProfiles = []; + $this->activeProfiles = []; + } - return $output; + public function serialize(): array + { + return Arr::map($this->rootProfiles, fn (Profile $profile) => $profile->serialize()); } - public function getTotalTime(): int + public function getDuration(): float { - return $this->totalTime; + return array_sum(Arr::map($this->rootProfiles, fn (Profile $profile) => $profile->getDuration())); } - public function getTiming(): ?Timing + public function getStartTime(): float { - return $this->currentRootTiming; + if ($this->rootProfiles === []) { + return 0; + } + + return $this->rootProfiles[0]->getStartTime(); } - /** - * @return array - */ - public function getAllTimings(): array + public function getEndTime(): float { - return $this->rootTimings; + if ($this->rootProfiles === []) { + return 0; + } + + return $this->rootProfiles[count($this->rootProfiles) - 1]->getEndTime(); } } diff --git a/src/Profiler/ProfilerDisplayEndNode.php b/src/Profiler/ProfilerDisplayEndNode.php new file mode 100644 index 0000000..2d92ebc --- /dev/null +++ b/src/Profiler/ProfilerDisplayEndNode.php @@ -0,0 +1,27 @@ +endProfile($context); + + return ''; + } + + protected function endProfile(RenderContext $context): ?Profile + { + $profiler = $context->getRegister('profiler'); + + if (! $profiler instanceof Profiler) { + return null; + } + + return $profiler->leave(); + } +} diff --git a/src/Profiler/ProfilerDisplayStartNode.php b/src/Profiler/ProfilerDisplayStartNode.php new file mode 100644 index 0000000..71695f6 --- /dev/null +++ b/src/Profiler/ProfilerDisplayStartNode.php @@ -0,0 +1,39 @@ +startProfile($context); + + return ''; + } + + protected function startProfile(RenderContext $context): ?Profile + { + $profiler = $context->getRegister('profiler'); + + if (! $profiler instanceof Profiler) { + return null; + } + + $name = match ($this->type) { + ProfileType::Template => $context->getTemplateName(), + default => $this->name, + }; + + $profiler->enter($profile = new Profile($this->type, name: $name)); + + return $profile; + } +} diff --git a/src/Profiler/ProfilerNodeVisitor.php b/src/Profiler/ProfilerNodeVisitor.php new file mode 100644 index 0000000..644036e --- /dev/null +++ b/src/Profiler/ProfilerNodeVisitor.php @@ -0,0 +1,54 @@ +body->children(); + $node->body->setChildren([ + new ProfilerDisplayStartNode(type: ProfileType::Template), + ...$children, + new ProfilerDisplayEndNode, + ]); + + return; + } + + if ($node instanceof BodyNode) { + $children = Arr::map($node->children(), function (Node $child) { + return match (true) { + $this->tags && $child instanceof Tag => new BodyNode([ + new ProfilerDisplayStartNode(type: ProfileType::Tag, name: $child->debugLabel()), + $child, + new ProfilerDisplayEndNode, + ]), + $this->variables && $child instanceof Variable => new BodyNode([ + new ProfilerDisplayStartNode(type: ProfileType::Variable, name: $child->debugLabel()), + $child, + new ProfilerDisplayEndNode, + ]), + default => $child, + }; + }); + $node->setChildren($children); + } + } +} diff --git a/src/Profiler/Timing.php b/src/Profiler/Timing.php deleted file mode 100644 index 4e70610..0000000 --- a/src/Profiler/Timing.php +++ /dev/null @@ -1,93 +0,0 @@ - - */ - protected array $children = []; - - public function __construct( - public readonly Node $node, - public readonly ?string $templateName = null, - ) { - $this->lineNumber = $node->lineNumber(); - } - - public function getTotalTime(): int - { - if ($this->totalTime === null) { - throw new \RuntimeException('Timing::getTotalTime() called before timing was complete'); - } - - return $this->totalTime; - } - - public function getSelfTime(): int - { - if ($this->selfTime !== null) { - return $this->selfTime; - } - - $totalChildrenTime = 0; - foreach ($this->children as $child) { - $totalChildrenTime += $child->getTotalTime(); - } - $this->selfTime = $this->totalTime - $totalChildrenTime; - - return $this->selfTime; - } - - /** - * @return array - */ - public function getChildren(): array - { - return $this->children; - } - - /** - * @param \Closure(): string $renderFunction - */ - public function measure(\Closure $renderFunction): string - { - if ($this->startTime !== null) { - throw new \RuntimeException('Timing::measure() called while already measuring'); - } - - $this->startTime = $this->time(); - - try { - $output = $renderFunction(); - } finally { - $this->totalTime = $this->time() - $this->startTime; - } - - return $output; - } - - public function addChild(Timing $timing): void - { - $this->children[] = $timing; - } - - protected function time(): int - { - $time = hrtime(true); - assert(is_int($time)); - - return $time; - } -} diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index 7a8003d..7b81b68 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -21,7 +21,6 @@ use Keepsuit\Liquid\Interrupts\Interrupt; use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Profiler\Profiler; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Support\MissingValue; use Keepsuit\Liquid\Support\OutputsBag; @@ -60,8 +59,6 @@ final class RenderContext */ protected array $interrupts = []; - protected ?Profiler $profiler; - public function __construct( /** * Environment variables only available in the current context @@ -83,7 +80,6 @@ public function __construct( * @var array $registers */ array $registers = [], - bool $profile = false, public readonly RenderContextOptions $options = new RenderContextOptions, ?ResourceLimits $resourceLimits = null, ?Environment $environment = null, @@ -96,10 +92,8 @@ public function __construct( $this->sharedState = new ContextSharedState( staticVariables: $staticData, - registers: $registers, + registers: array_merge($this->environment->getRegisters(), $registers), ); - - $this->profiler = $profile ? new Profiler : null; } public function isPartial(): bool @@ -288,7 +282,7 @@ public function hasInterrupt(): bool } /** - * @throws Throwable + * @throws LiquidException */ public function handleError(Throwable $error, ?int $lineNumber = null): string { @@ -317,11 +311,6 @@ public function getTemplateName(): ?string return $this->templateName; } - public function getProfiler(): ?Profiler - { - return $this->profiler; - } - public function loadPartial(string $templateName): Template { if (! Arr::has($this->sharedState->partialsCache, $templateName)) { @@ -372,7 +361,6 @@ public function newIsolatedSubContext(?string $templateName = null, ?RenderConte $subContext->baseScopeDepth = $this->baseScopeDepth + 1; $subContext->sharedState = $this->sharedState; $subContext->templateName = $templateName; - $subContext->profiler = $this->profiler; $subContext->partial = true; return $subContext; diff --git a/src/Support/Arr.php b/src/Support/Arr.php index 74cd682..c57e0c2 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -117,6 +117,35 @@ public static function map(array $array, Closure|string $callbackOrProperty): ar return $result; } + /** + * Run an associative map over each of the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TKey + * @template TValue + * @template TMapWithKeysKey of array-key + * @template TMapWithKeysValue + * + * @param array $array + * @param Closure(TValue, TKey): array $callback + * @return array + */ + public static function mapWithKeys(array $array, Closure $callback): array + { + $result = []; + + foreach ($array as $key => $value) { + $assoc = $callback($value, $key); + + foreach ($assoc as $mapKey => $mapValue) { + $result[$mapKey] = $mapValue; + } + } + + return $result; + } + public static function filter(array $array, Closure|string $callbackOrProperty): array { $result = []; diff --git a/src/Support/FilterRegistry.php b/src/Support/FilterRegistry.php index 5ac98a1..093566c 100644 --- a/src/Support/FilterRegistry.php +++ b/src/Support/FilterRegistry.php @@ -2,45 +2,53 @@ namespace Keepsuit\Liquid\Support; +use Keepsuit\Liquid\Attributes\Hidden; use Keepsuit\Liquid\Contracts\IsContextAware; use Keepsuit\Liquid\Exceptions\InvalidArgumentException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; use Keepsuit\Liquid\Exceptions\UndefinedVariableException; use Keepsuit\Liquid\Filters\FiltersProvider; -use Keepsuit\Liquid\Filters\StandardFilters; use Keepsuit\Liquid\Render\RenderContext; class FilterRegistry { /** - * @var array + * @var array */ protected array $filters = []; /** - * @param class-string $filterClass + * @param class-string $filtersClass */ - public function register(string $filterClass): static + public function register(string $filtersClass): static { - if (! class_exists($filterClass)) { - throw new InvalidArgumentException("Filter class $filterClass does not exist."); + if (! class_exists($filtersClass)) { + throw new InvalidArgumentException("Filter class $filtersClass does not exist."); } - $reflection = new \ReflectionClass($filterClass); - foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - if (str_starts_with($method->getName(), '__')) { - continue; - } + $filters = $this->extractFiltersFromClass($filtersClass); - $this->filters[Str::snake($method->getName())] = function (RenderContext $context, mixed $value, array $args) use ($filterClass, $method) { - $filterClassInstance = new $filterClass; + $instance = new $filtersClass; + foreach ($filters as $filter => $method) { + $this->filters[$filter] = [$instance, $method]; + } - if ($filterClassInstance instanceof IsContextAware) { - $filterClassInstance->setContext($context); - } + return $this; + } - return $filterClassInstance->{$method->getName()}($value, ...$args); - }; + /** + * @param class-string $filtersClass + */ + public function delete(string $filtersClass): static + { + if (! class_exists($filtersClass)) { + return $this; + } + + $filters = $this->extractFiltersFromClass($filtersClass); + + foreach ($filters as $key => $value) { + unset($this->filters[$key]); } return $this; @@ -68,7 +76,14 @@ public function invoke(RenderContext $context, string $filterName, mixed $value, if ($filter !== null) { try { - return $filter($context, $value, $args); + $instance = $filter[0]; + $method = $filter[1]; + + if ($instance instanceof IsContextAware) { + $instance->setContext($context); + } + + return $instance->{$method}($value, ...$args); } catch (\TypeError $e) { if ($value instanceof UndefinedVariable) { throw new UndefinedVariableException($value->variableName); @@ -86,11 +101,30 @@ public function invoke(RenderContext $context, string $filterName, mixed $value, } /** - * Return a FilterRegistry instance with the standard filters registered. + * @param class-string $filtersClass + * @return array */ - public static function default(): FilterRegistry + protected function extractFiltersFromClass(string $filtersClass): array { - return (new FilterRegistry) - ->register(StandardFilters::class); + $filters = []; + + $reflection = new \ReflectionClass($filtersClass); + foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + if (str_starts_with($method->getName(), '__')) { + continue; + } + + if ($method->isStatic()) { + continue; + } + + if ($method->getAttributes(Hidden::class) !== []) { + continue; + } + + $filters[Str::snake($method->getName())] = $method->getName(); + } + + return $filters; } } diff --git a/src/Support/TagRegistry.php b/src/Support/TagRegistry.php index bc4ab97..e5cc190 100644 --- a/src/Support/TagRegistry.php +++ b/src/Support/TagRegistry.php @@ -3,7 +3,6 @@ namespace Keepsuit\Liquid\Support; use Keepsuit\Liquid\Tag; -use Keepsuit\Liquid\Tags; class TagRegistry { @@ -45,28 +44,4 @@ public function all(): array { return $this->tags; } - - /** - * Returns TagRegistry instance with standard tags registered. - */ - public static function default(): TagRegistry - { - return (new TagRegistry) - ->register(Tags\AssignTag::class) - ->register(Tags\BreakTag::class) - ->register(Tags\CaptureTag::class) - ->register(Tags\CaseTag::class) - ->register(Tags\ContinueTag::class) - ->register(Tags\CycleTag::class) - ->register(Tags\DecrementTag::class) - ->register(Tags\EchoTag::class) - ->register(Tags\ForTag::class) - ->register(Tags\IfChanged::class) - ->register(Tags\IfTag::class) - ->register(Tags\IncrementTag::class) - ->register(Tags\LiquidTag::class) - ->register(Tags\RenderTag::class) - ->register(Tags\TableRowTag::class) - ->register(Tags\UnlessTag::class); - } } diff --git a/src/Tag.php b/src/Tag.php index 221af7b..25b9479 100644 --- a/src/Tag.php +++ b/src/Tag.php @@ -13,6 +13,11 @@ abstract class Tag extends Node { abstract public static function tagName(): string; + public function debugLabel(): ?string + { + return static::tagName(); + } + /** * @throws SyntaxException */ diff --git a/src/Template.php b/src/Template.php index 1a54ec7..09d4d1f 100644 --- a/src/Template.php +++ b/src/Template.php @@ -6,15 +6,12 @@ use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Parse\ParseContext; -use Keepsuit\Liquid\Profiler\Profiler; use Keepsuit\Liquid\Render\RenderContext; class Template { protected TemplateSharedState $state; - protected ?Profiler $profiler = null; - public function __construct( public readonly Document $root, public readonly ?string $name = null, @@ -31,7 +28,7 @@ public static function parse(ParseContext $parseContext, string $source, ?string $root = $parseContext->parse($parseContext->tokenize($source)); $template = new Template( - root: new Document($root), + root: $root, name: $name, ); @@ -58,8 +55,6 @@ public static function parse(ParseContext $parseContext, string $source, ?string */ public function render(RenderContext $context): string { - $this->profiler = $context->getProfiler(); - try { $context->mergePartialsCache($this->state->partialsCache); $context->mergeOutputs($this->state->outputs); @@ -79,8 +74,6 @@ public function render(RenderContext $context): string */ public function stream(RenderContext $context): \Generator { - $this->profiler = $context->getProfiler(); - try { $context->mergePartialsCache($this->state->partialsCache); $context->mergeOutputs($this->state->outputs); @@ -104,9 +97,4 @@ public function getErrors(): array { return $this->state->errors; } - - public function getProfiler(): ?Profiler - { - return $this->profiler; - } } diff --git a/tests/Integration/ProfilerTest.php b/tests/Integration/ProfilerTest.php index a38edec..02c9f12 100644 --- a/tests/Integration/ProfilerTest.php +++ b/tests/Integration/ProfilerTest.php @@ -1,87 +1,81 @@ factory = EnvironmentFactory::new() - ->setFilesystem(new ProfilingFileSystem) - ->registerTag(SleepTag::class) - ->setProfile(); -}); +test('profiling can be enabled with extension', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString("{{ 'a string' | upcase }}"); -test('context allows flagging profiling', function () { - $template = parseTemplate("{{ 'a string' | upcase }}"); - $template->render(new RenderContext); - expect($template->getProfiler())->toBeNull(); + $template->render($context = $environment->newRenderContext()); + expect($context->getRegister('profiler'))->toBeNull(); - $template->render(new RenderContext(profile: true)); - expect($template->getProfiler())->toBeInstanceOf(Profiler::class); + $environment->addExtension(new ProfilerExtension($profiler = new Profiler)); + $template->render($context = $environment->newRenderContext()); + expect($context->getRegister('profiler'))->toBe($profiler); }); test('simple profiling', function () { - $profiler = profileTemplate("{{ 'a string' | upcase }}"); + $profile = profileTemplate("{{ 'a string' | upcase }}"); - expect($profiler->getTiming()) - ->toBeInstanceOf(Timing::class) + expect($profile) + ->type->toBe(ProfileType::Template) + ->name->toBe('template') ->getChildren()->toHaveCount(1); -}); -test('profiler ignore raw strings', function () { - $profiler = profileTemplate("This is raw string\nstuff\nNewline"); - - expect($profiler->getTiming()) - ->getChildren()->toHaveCount(0); + expect($profile->getChildren()[0]) + ->type->toBe(ProfileType::Variable) + ->name->toBe('a string'); }); -test('profiler include line numbers of nodes', function () { - $profiler = profileTemplate("{{ 'a string' | upcase }}\n{% increment test %}"); +test('profiler ignore raw strings', function () { + $profile = profileTemplate("This is raw string\nstuff\nNewline"); - expect($profiler->getTiming()) - ->getChildren()->toHaveCount(2) - ->getChildren()->{0}->lineNumber->toBe(1) - ->getChildren()->{1}->lineNumber->toBe(2); + expect($profile->getChildren()) + ->toHaveCount(0); }); test('profile render tag', function () { - $profiler = profileTemplate("{% render 'a_template' %}"); + $profile = profileTemplate("{% render 'a_template' %}"); - expect($profiler->getTiming()) + expect($profile) ->getChildren()->toHaveCount(1); - $renderChildren = $profiler->getTiming()->getChildren()[0]->getChildren(); + $renderTag = $profile->getChildren()[0]; - expect($renderChildren) - ->toHaveCount(2) - ->{0}->lineNumber->toBe(1) - ->{1}->lineNumber->toBe(2); + expect($renderTag) + ->type->toBe(ProfileType::Tag) + ->name->toBe('render') + ->getChildren()->toHaveCount(1); - foreach ($renderChildren as $child) { - expect($child->templateName)->toBe('a_template'); - } + expect($renderTag->getChildren()[0]) + ->type->toBe(ProfileType::Template) + ->name->toBe('a_template') + ->getChildren()->toHaveCount(2) + ->getChildren()->{0}->type->toBe(ProfileType::Tag) + ->getChildren()->{1}->type->toBe(ProfileType::Variable); }); test('profile rendering time', function () { - $profiler = profileTemplate("{% render 'a_template' %}"); + $profile = profileTemplate("{% render 'a_template' %}"); - expect($profiler->getTotalTime()) - ->toBeGreaterThan(0); + expect($profile->getDuration())->toBeGreaterThan(0); - expect($profiler->getTiming()->getTotalTime()) - ->toBeGreaterThan(0); + expect($profile->getStartTime())->toBeLessThan($profile->getEndTime()); - expect($profiler->getTotalTime()) - ->toBeGreaterThan($profiler->getTiming()->getChildren()[0]->getTotalTime()); + expect($profile->getDuration())->toBeGreaterThan($profile->getChildren()[0]->getDuration()); }); test('profiling multiple renders', function () { - $environment = $this->factory + $environment = EnvironmentFactory::new() ->setFilesystem(new ProfilingFileSystem) + ->registerTag(SleepTag::class) + ->addExtension(new ProfilerExtension($profiler = new Profiler, tags: true, variables: true)) ->build(); $context = $environment->newRenderContext(); @@ -89,115 +83,136 @@ invade($context)->templateName = 'index'; $template->render($context); - $firstRenderTime = $context->getProfiler()->getTotalTime(); + expect($profiler->getProfiles())->toHaveCount(1); + $firstRenderProfile = $profiler->getProfiles()[0]; + invade($context)->templateName = 'layout'; $template->render($context); + expect($profiler->getProfiles())->toHaveCount(2); + $secondRenderProfile = $profiler->getProfiles()[1]; - $profiler = $context->getProfiler(); - $rootTimings = $profiler->getAllTimings(); + expect($firstRenderProfile) + ->name->toBe('index') + ->getDuration()->toBeGreaterThan(0.001); - expect($firstRenderTime) - ->toBeGreaterThan(1_000_000); - expect($profiler->getTotalTime())->toBeGreaterThan(1_000_000 + $firstRenderTime); + expect($secondRenderProfile) + ->name->toBe('layout') + ->getDuration()->toBeGreaterThan(0.001); - expect($rootTimings) - ->toHaveCount(2) - ->{0}->templateName->toBe('index') - ->{0}->code->toBeNull() - ->{1}->templateName->toBe('layout') - ->{1}->code->toBeNull(); - - $rootTotalTiming = array_sum(Arr::map($rootTimings, fn ($timing) => $timing->getTotalTime())); - - expect($rootTotalTiming) - ->toEqual($profiler->getTotalTime()); + expect($profiler) + ->getStartTime()->toBe($firstRenderProfile->getStartTime()) + ->getEndTime()->toBe($secondRenderProfile->getEndTime()) + ->getDuration()->toBe($firstRenderProfile->getDuration() + $secondRenderProfile->getDuration()); }); test('profiling supports multiple templates', function () { - $profiler = profileTemplate("{{ 'a string' | upcase }}\n{% render 'a_template' %}\n{% render 'b_template' %}"); + $profile = profileTemplate("{{ 'a string' | upcase }}\n{% render 'a_template' %}\n{% render 'b_template' %}"); - expect($profiler->getTiming()) + expect($profile) ->getChildren()->toHaveCount(3); - $aTemplate = $profiler->getTiming()->getChildren()[1]; - expect($aTemplate->getChildren())->toHaveCount(2); - foreach ($aTemplate->getChildren() as $child) { - expect($child->templateName)->toBe('a_template'); - } - - $bTemplate = $profiler->getTiming()->getChildren()[2]; - expect($bTemplate->getChildren())->toHaveCount(2); - foreach ($bTemplate->getChildren() as $child) { - expect($child->templateName)->toBe('b_template'); - } + $renderTagA = $profile->getChildren()[1]; + expect($renderTagA) + ->type->toBe(ProfileType::Tag) + ->name->toBe('render') + ->getChildren()->toHaveCount(1) + ->getChildren()->{0}->type->toBe(ProfileType::Template) + ->getChildren()->{0}->name->toBe('a_template'); + + $renderTagB = $profile->getChildren()[2]; + expect($renderTagB) + ->type->toBe(ProfileType::Tag) + ->name->toBe('render') + ->getChildren()->toHaveCount(1) + ->getChildren()->{0}->type->toBe(ProfileType::Template) + ->getChildren()->{0}->name->toBe('b_template'); }); test('profiling supports rendering the same partial multiple times', function () { - $profiler = profileTemplate("{{ 'a string' | upcase }}\n{% render 'a_template' %}\n{% render 'a_template' %}"); + $profile = profileTemplate("{{ 'a string' | upcase }}\n{% render 'a_template' %}\n{% render 'a_template' %}"); - expect($profiler->getTiming()) - ->getChildren()->toHaveCount(3); + $renderTagA = $profile->getChildren()[1]; + expect($renderTagA) + ->type->toBe(ProfileType::Tag) + ->name->toBe('render') + ->getChildren()->toHaveCount(1) + ->getChildren()->{0}->type->toBe(ProfileType::Template) + ->getChildren()->{0}->name->toBe('a_template'); - $aTemplate = $profiler->getTiming()->getChildren()[1]; - expect($aTemplate->getChildren())->toHaveCount(2); - foreach ($aTemplate->getChildren() as $child) { - expect($child->templateName)->toBe('a_template'); - } - - $aTemplate2 = $profiler->getTiming()->getChildren()[2]; - expect($aTemplate2->getChildren())->toHaveCount(2); - foreach ($aTemplate2->getChildren() as $child) { - expect($child->templateName)->toBe('a_template'); - } + $renderTagB = $profile->getChildren()[2]; + expect($renderTagB) + ->type->toBe(ProfileType::Tag) + ->name->toBe('render') + ->getChildren()->toHaveCount(1) + ->getChildren()->{0}->type->toBe(ProfileType::Template) + ->getChildren()->{0}->name->toBe('a_template'); }); test('profiling marks children of if blocks', function () { - $profiler = profileTemplate('{% if true %} {% increment test %} {{ test }} {% endif %}'); - - expect($profiler->getTiming()) - ->getChildren()->toHaveCount(1) - ->getChildren()->{0}->getChildren()->toHaveCount(2); + $profile = profileTemplate('{% if true %} {% increment test %} {{ test }} {% endif %}'); + + expect($profile->getChildren()) + ->toHaveCount(1) + ->{0}->type->toBe(ProfileType::Tag) + ->{0}->name->toBe('if') + ->{0}->getChildren()->toHaveCount(2); + + expect($profile->getChildren()[0]->getChildren()) + ->{0}->type->toBe(ProfileType::Tag) + ->{0}->name->toBe('increment') + ->{1}->type->toBe(ProfileType::Variable) + ->{1}->name->toBe('test'); }); test('profiling marks children of for blocks', function () { - $profiler = profileTemplate('{% for item in collection %} {{ item }} {% endfor %}', [ + $profile = profileTemplate('{% for item in collection %} {{ item }} {% endfor %}', [ 'collection' => ['one', 'two'], ]); - expect($profiler->getTiming()) - ->getChildren()->toHaveCount(1) - ->getChildren()->{0}->getChildren()->toHaveCount(2); + expect($profile->getChildren()) + ->toHaveCount(1) + ->{0}->type->toBe(ProfileType::Tag) + ->{0}->name->toBe('for') + ->{0}->getChildren()->toHaveCount(2); + + expect($profile->getChildren()[0]->getChildren()) + ->{1}->type->toBe(ProfileType::Variable) + ->{1}->name->toBe('item'); }); -test('profiling support self time', function () { - $profiler = profileTemplate('{% for item in collection %} {% sleep item %} {% endfor %}', [ +test('profiling support self duration', function () { + $profile = profileTemplate('{% for item in collection %} {% sleep item %} {% endfor %}', [ 'collection' => [0.001, 0.002], ]); - $node = $profiler->getTiming()->getChildren()[0]; + $node = $profile->getChildren()[0]; $leaf = $node->getChildren()[0]; - expect($leaf->getSelfTime())->toBeGreaterThan(0); - expect($node->getSelfTime()) - ->toBeLessThanOrEqual($node->getTotalTime() - $leaf->getTotalTime()); + expect($leaf->getSelfDuration())->toBeGreaterThan(0); + expect($node->getSelfDuration())->toBeLessThanOrEqual($node->getDuration() - $leaf->getDuration()); }); -test('profiling support total time', function () { - $profiler = profileTemplate('{% if true %} {% sleep 0.001 %} {% endif %}'); +test('profiling support duration', function () { + $profile = profileTemplate('{% if true %} {% sleep 0.001 %} {% endif %}'); - expect($profiler->getTotalTime())->toBeGreaterThan(0); - expect($profiler->getTiming()->getTotalTime())->toBeGreaterThan(0); + expect($profile->getDuration())->toBeGreaterThan(0); + expect($profile->getChildren()[0]->getDuration())->toBeGreaterThan(0); }); -function profileTemplate(string $source, array $assigns = []): ?Profiler +function profileTemplate(string $source, array $assigns = []): Profile { - /** @var \Keepsuit\Liquid\EnvironmentFactory $factory */ - $factory = test()->factory; - $environment = $factory->setProfile()->build(); + $environment = EnvironmentFactory::new() + ->setFilesystem(new ProfilingFileSystem) + ->registerTag(SleepTag::class) + ->addExtension(new ProfilerExtension($profiler = new Profiler, tags: true, variables: true)) + ->build(); + $template = $environment->parseString($source); - $template->render($environment->newRenderContext( + + $context = $environment->newRenderContext( staticData: $assigns, - )); + ); + $template->render($context); - return $template->getProfiler(); + return $profiler->getProfiles()[0]; } diff --git a/tests/Stubs/FunnyFilter.php b/tests/Stubs/FunnyFilter.php index 174b23c..721fac7 100644 --- a/tests/Stubs/FunnyFilter.php +++ b/tests/Stubs/FunnyFilter.php @@ -2,34 +2,36 @@ namespace Keepsuit\Liquid\Tests\Stubs; -class FunnyFilter +use Keepsuit\Liquid\Filters\FiltersProvider; + +class FunnyFilter extends FiltersProvider { - public static function makeFunny(string $input): string + public function makeFunny(string $input): string { return 'LOL'; } - public static function citeFunny(string $input): string + public function citeFunny(string $input): string { return 'LOL: '.$input; } - public static function addSmiley(string $input, string $smiley = ':-)'): string + public function addSmiley(string $input, string $smiley = ':-)'): string { return sprintf('%s %s', $input, $smiley); } - public static function addTag(string $input, string $tag = 'p', string $id = 'foo'): string + public function addTag(string $input, string $tag = 'p', string $id = 'foo'): string { return sprintf('<%s id="%s">%s', $tag, $id, $input, $tag); } - public static function paragraph(string $input): string + public function paragraph(string $input): string { return sprintf('

%s

', $input); } - public static function linkTo(string $input, string $url): string + public function linkTo(string $input, string $url): string { return sprintf('%s', $url, $input); } diff --git a/tests/Stubs/NodeTreeItem.php b/tests/Stubs/NodeTreeItem.php new file mode 100644 index 0000000..59f2485 --- /dev/null +++ b/tests/Stubs/NodeTreeItem.php @@ -0,0 +1,23 @@ +children === []) { + return [$this->type, $this->value]; + } + + $children = array_map(fn (NodeTreeItem $item) => $item->serialize(), $this->children); + + return [$this->type, $this->value, $children]; + } +} diff --git a/tests/Stubs/NodeTreeVisitor.php b/tests/Stubs/NodeTreeVisitor.php new file mode 100644 index 0000000..04d864c --- /dev/null +++ b/tests/Stubs/NodeTreeVisitor.php @@ -0,0 +1,83 @@ +buildItem($node); + + if (count($this->activeNodes) === 0) { + array_unshift($this->rootNodes, $element); + } else { + $active = $this->activeNodes[0]; + $active->children[] = $element; + } + + array_unshift($this->activeNodes, $element); + } + + public function leaveNode(Node $node): void + { + if ($node instanceof BodyNode) { + return; + } + + array_shift($this->activeNodes); + } + + /** + * @return NodeTreeItem[] + */ + public function getTrees(): array + { + return $this->rootNodes; + } + + public function reset(): void + { + $this->rootNodes = []; + $this->activeNodes = []; + } + + protected function buildItem(Node $node): NodeTreeItem + { + $type = match (true) { + $node instanceof Document => 'document', + $node instanceof BodyNode => 'body', + $node instanceof Tag => 'tag', + $node instanceof Variable => 'variable', + $node instanceof Range => 'range', + $node instanceof Raw => 'raw', + $node instanceof Text => 'text', + default => Node::class, + }; + + return new NodeTreeItem($type, $node->debugLabel()); + } +} diff --git a/tests/Stubs/StubExtension.php b/tests/Stubs/StubExtension.php new file mode 100644 index 0000000..ea539d2 --- /dev/null +++ b/tests/Stubs/StubExtension.php @@ -0,0 +1,22 @@ + 'stub', + ]; + } +} diff --git a/tests/Stubs/StubNodeVisitor.php b/tests/Stubs/StubNodeVisitor.php new file mode 100644 index 0000000..b9108a8 --- /dev/null +++ b/tests/Stubs/StubNodeVisitor.php @@ -0,0 +1,26 @@ +nodes[] = $node; + } + + public function getNodes(): array + { + return $this->nodes; + } +} diff --git a/tests/Unit/BlockTest.php b/tests/Unit/BlockTest.php index 7216b0e..74db631 100644 --- a/tests/Unit/BlockTest.php +++ b/tests/Unit/BlockTest.php @@ -7,7 +7,7 @@ test('blankspace', function () { $template = parseTemplate(' '); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(Text::class) ->{0}->value->toBe(' '); @@ -16,7 +16,7 @@ test('variable beginning', function () { $template = parseTemplate('{{funk}} '); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(2) ->{0}->toBeInstanceOf(Variable::class) ->{1}->toBeInstanceOf(Text::class); @@ -25,7 +25,7 @@ test('variable end', function () { $template = parseTemplate(' {{funk}}'); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(2) ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(Variable::class); @@ -34,7 +34,7 @@ test('variable middle', function () { $template = parseTemplate(' {{funk}} '); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(3) ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(Variable::class) @@ -44,7 +44,7 @@ test('variable many embedded fragments', function () { $template = parseTemplate(' {{funk}} {{so}} {{brother}} '); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(7) ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(Variable::class) @@ -58,7 +58,7 @@ test('with block', function () { $template = parseTemplate(' {% if hi %} hi {% endif %} '); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(3) ->{0}->toBeInstanceOf(Text::class) ->{1}->toBeInstanceOf(IfTag::class) diff --git a/tests/Unit/EnvironmentFactoryTest.php b/tests/Unit/EnvironmentFactoryTest.php index ab9ed3e..1c3df41 100644 --- a/tests/Unit/EnvironmentFactoryTest.php +++ b/tests/Unit/EnvironmentFactoryTest.php @@ -14,6 +14,18 @@ expect($environment->tagRegistry->all())->not->toHaveKey('testblock'); }); +test('add extension', function () { + $environment = EnvironmentFactory::new() + ->addExtension(new \Keepsuit\Liquid\Tests\Stubs\StubExtension) + ->build(); + + expect($environment) + ->getExtensions()->toHaveCount(2) + ->getNodeVisitors()->toHaveCount(1) + ->getNodeVisitors()->{0}->toBeInstanceOf(\Keepsuit\Liquid\Tests\Stubs\StubNodeVisitor::class) + ->getRegisters()->toHaveKey('test'); +}); + test('get registered tags', function () { $environment = EnvironmentFactory::new() ->registerTag(\Keepsuit\Liquid\Tests\Stubs\TestTagBlockTag::class) diff --git a/tests/Unit/EnvironmentTest.php b/tests/Unit/EnvironmentTest.php index cfb9820..8a82387 100644 --- a/tests/Unit/EnvironmentTest.php +++ b/tests/Unit/EnvironmentTest.php @@ -1,6 +1,7 @@ toContain('join') ->toContain('last'); }); + +test('standard extension can be removed', function () { + $environment = EnvironmentFactory::new()->build(); + + expect($environment) + ->getExtensions()->toHaveCount(1) + ->tagRegistry->all()->toBeGreaterThan(0) + ->filterRegistry->all()->toBeGreaterThan(0); + + $environment->removeExtension(\Keepsuit\Liquid\Extensions\StandardExtension::class); + + expect($environment) + ->getExtensions()->toHaveCount(0) + ->tagRegistry->all()->toHaveCount(0) + ->filterRegistry->all()->toHaveCount(0); +}); + +test('add extension', function () { + $environment = EnvironmentFactory::new()->build(); + + $environment->addExtension(new \Keepsuit\Liquid\Tests\Stubs\StubExtension); + + expect($environment) + ->getExtensions()->toHaveCount(2) + ->getNodeVisitors()->toHaveCount(1) + ->getNodeVisitors()->{0}->toBeInstanceOf(\Keepsuit\Liquid\Tests\Stubs\StubNodeVisitor::class) + ->getRegisters()->toHaveKey('test'); +}); + +test('remove extension', function () { + $environment = EnvironmentFactory::new()->build(); + + $environment->addExtension(new \Keepsuit\Liquid\Tests\Stubs\StubExtension); + expect($environment)->getExtensions()->toHaveCount(2); + + $environment->removeExtension(\Keepsuit\Liquid\Tests\Stubs\StubExtension::class); + + expect($environment) + ->getExtensions()->toHaveCount(1) + ->getNodeVisitors()->toHaveCount(0) + ->getRegisters()->not->toHaveKey('test'); +}); diff --git a/tests/Unit/NodeTraverserTest.php b/tests/Unit/NodeTraverserTest.php new file mode 100644 index 0000000..2b2c2ba --- /dev/null +++ b/tests/Unit/NodeTraverserTest.php @@ -0,0 +1,136 @@ +addVisitor($visitor = new \Keepsuit\Liquid\Tests\Stubs\StubNodeVisitor); + + $traverser->traverse($template->root); + + expect($visitor->getNodes()) + ->toHaveCount(5) + ->{0}->toBeInstanceOf(\Keepsuit\Liquid\Nodes\Variable::class) + ->{1}->toBeInstanceOf(\Keepsuit\Liquid\Nodes\BodyNode::class) + ->{2}->toBeInstanceOf(\Keepsuit\Liquid\Tags\ForTag::class) + ->{3}->toBeInstanceOf(\Keepsuit\Liquid\Nodes\BodyNode::class) + ->{4}->toBeInstanceOf(\Keepsuit\Liquid\Nodes\Document::class); +}); + +test('node visitor can replace body node children', function () { + $template = parseTemplate('{{ var1 }} {{ var2 }}'); + + $traverser = (new \Keepsuit\Liquid\Parse\NodeTraverser) + ->addVisitor(new class implements NodeVisitor + { + public function enterNode(\Keepsuit\Liquid\Nodes\Node $node): void {} + + public function leaveNode(\Keepsuit\Liquid\Nodes\Node $node): void + { + if ($node instanceof \Keepsuit\Liquid\Nodes\BodyNode) { + $node->setChildren([ + new \Keepsuit\Liquid\Nodes\Text('replaced'), + ]); + } + } + }); + + $traverser->traverse($template->root); + + expect($template->root->body->children()) + ->toHaveCount(1) + ->{0}->toBeInstanceOf(\Keepsuit\Liquid\Nodes\Text::class); + + expect($template->render(new \Keepsuit\Liquid\Render\RenderContext))->toBe('replaced'); +}); + +test('variable', function () { + expect(buildNodeTree('{{ test }}'))->toBe([ + [ + 'document', + null, + [ + ['variable', 'test'], + ], + ], + ]); +}); + +test('tag', function () { + expect(buildNodeTree('{% if test %}{% endif %}'))->toBe([ + [ + 'document', + null, + [ + ['tag', 'if'], + ], + ], + ]); +}); + +test('tag with body', function () { + expect(buildNodeTree('{% if 1 == 1 %}{{ test }}{% endif %}'))->toBe([ + [ + 'document', + null, + [ + [ + 'tag', + 'if', + [ + ['variable', 'test'], + ], + ], + ], + ], + ]); +}); + +test('render tag', function () { + expect(buildNodeTree('{% render "hai" %}'))->toBe([ + [ + 'document', + null, + [ + ['tag', 'render'], + ], + ], + [ + 'document', + null, + [ + ['variable', 'hai'], + ], + ], + ]); +}); + +function buildNodeTree(string $source): array +{ + $visitor = new \Keepsuit\Liquid\Tests\Stubs\NodeTreeVisitor; + + $environment = \Keepsuit\Liquid\EnvironmentFactory::new() + ->setFilesystem(new StubFileSystem(['hai' => '{{ hai }}'])) + ->addExtension(new class($visitor) extends \Keepsuit\Liquid\Extensions\Extension + { + public function __construct(protected NodeVisitor $nodeVisitor) {} + + public function getNodeVisitors(): array + { + return [ + $this->nodeVisitor, + ]; + } + }) + ->build(); + + $template = $environment->parseString($source); + + return \Keepsuit\Liquid\Support\Arr::map( + $visitor->getTrees(), + fn (\Keepsuit\Liquid\Tests\Stubs\NodeTreeItem $item) => $item->serialize() + ); +} diff --git a/tests/Unit/ParseTreeVisitorTest.php b/tests/Unit/ParseTreeVisitorTest.php index 3511249..fbe2813 100644 --- a/tests/Unit/ParseTreeVisitorTest.php +++ b/tests/Unit/ParseTreeVisitorTest.php @@ -139,9 +139,14 @@ [ null, [ - [null, [[null, [['other', [[null, []]]]]]]], - ['test', [[null, []]]], - ['xs', [[null, []]]], + [ + null, + [ + [null, [[null, [['other', [[null, []]]]]]]], + ['test', [[null, []]]], + ['xs', [[null, []]]], + ], + ], ], ], ]); diff --git a/tests/Unit/Tags/CaseTagTest.php b/tests/Unit/Tags/CaseTagTest.php index 6546760..cb8978e 100644 --- a/tests/Unit/Tags/CaseTagTest.php +++ b/tests/Unit/Tags/CaseTagTest.php @@ -5,7 +5,7 @@ test('case children', function () { $template = parseTemplate('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}'); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(CaseTag::class) ->{0}->children()->toHaveCount(2) diff --git a/tests/Unit/Tags/ForTagTest.php b/tests/Unit/Tags/ForTagTest.php index 37b130f..0cb43f2 100644 --- a/tests/Unit/Tags/ForTagTest.php +++ b/tests/Unit/Tags/ForTagTest.php @@ -5,7 +5,7 @@ test('for children', function () { $template = parseTemplate('{% for item in items %}FOR{% endfor %}'); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(ForTag::class) ->{0}->children()->toHaveCount(1) @@ -15,7 +15,7 @@ test('for else children', function () { $template = parseTemplate('{% for item in items %}FOR{% else %}ELSE{% endfor %}'); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(ForTag::class) ->{0}->children()->toHaveCount(2) diff --git a/tests/Unit/Tags/IfTagTest.php b/tests/Unit/Tags/IfTagTest.php index faca9f1..b412a4e 100644 --- a/tests/Unit/Tags/IfTagTest.php +++ b/tests/Unit/Tags/IfTagTest.php @@ -5,7 +5,7 @@ test('if children', function () { $template = parseTemplate('{% if true %}IF{% else %}ELSE{% endif %}'); - expect($template->root->children()) + expect($template->root->body->children()) ->toHaveCount(1) ->{0}->toBeInstanceOf(IfTag::class) ->{0}->children()->toHaveCount(2) diff --git a/tests/Unit/VariableTest.php b/tests/Unit/VariableTest.php index a4db2eb..1377844 100644 --- a/tests/Unit/VariableTest.php +++ b/tests/Unit/VariableTest.php @@ -169,7 +169,7 @@ function createVariable(string $markup): Variable { - $body = parse(sprintf('{{ %s }}', $markup)); + $document = parse(sprintf('{{ %s }}', $markup)); - return $body->children()[0]; + return $document->body->children()[0]; }