From 169a27d8f85db31e613ad2c9bca36bac936d70ae Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 10:41:52 +0200 Subject: [PATCH 01/45] docs: define compiler wayfinder map --- .../compiler/issues/001-artifact-contract.md | 16 +++++++++ .scratch/compiler/issues/002-partial-graph.md | 16 +++++++++ .../compiler/issues/003-runtime-parity.md | 16 +++++++++ .../compiler/issues/004-extension-seam.md | 16 +++++++++ .../compiler/issues/005-artifact-safety.md | 18 ++++++++++ .../compiler/issues/006-performance-gate.md | 16 +++++++++ .../issues/007-static-partial-inlining.md | 24 +++++++++++++ .scratch/compiler/map.md | 34 +++++++++++++++++++ 8 files changed, 156 insertions(+) create mode 100644 .scratch/compiler/issues/001-artifact-contract.md create mode 100644 .scratch/compiler/issues/002-partial-graph.md create mode 100644 .scratch/compiler/issues/003-runtime-parity.md create mode 100644 .scratch/compiler/issues/004-extension-seam.md create mode 100644 .scratch/compiler/issues/005-artifact-safety.md create mode 100644 .scratch/compiler/issues/006-performance-gate.md create mode 100644 .scratch/compiler/issues/007-static-partial-inlining.md create mode 100644 .scratch/compiler/map.md diff --git a/.scratch/compiler/issues/001-artifact-contract.md b/.scratch/compiler/issues/001-artifact-contract.md new file mode 100644 index 0000000..bf1b43a --- /dev/null +++ b/.scratch/compiler/issues/001-artifact-contract.md @@ -0,0 +1,16 @@ +--- +title: Define the public compiler artifact contract +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: [] +--- + +## Question + +What public compile API should write the PHP artifact, and what exactly should requiring that file return or define so callers can render it with the existing RenderContext contract? + +## Resolution + +`Environment::compile(Template $template, string $compiledPath)` is the additive public entry point. It writes a PHP artifact at the caller-provided path; requiring that artifact returns a `Template`-compatible compiled object that can render with the existing `RenderContext`. Existing parsing, rendering, and interpreted cache APIs remain unchanged. Cache identity and environment consistency remain application-managed. diff --git a/.scratch/compiler/issues/002-partial-graph.md b/.scratch/compiler/issues/002-partial-graph.md new file mode 100644 index 0000000..e4b606a --- /dev/null +++ b/.scratch/compiler/issues/002-partial-graph.md @@ -0,0 +1,16 @@ +--- +title: Define partial graph compilation and invalidation +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: 001-artifact-contract.md +--- + +## Question + +For templates that load partials through render/include tags, should compilation produce one artifact per template or a root artifact for the reachable graph, and what application-managed cache key and invalidation contract keeps the graph coherent? + +## Resolution + +Version one produces one PHP artifact per logical template. The compiled template keeps partial loading as a runtime lookup by template name, so the application can compile partials independently and replace only the artifact whose source changed. Static partial names discovered during parsing may drive precompilation or application-level dependency tracking; dynamic partials retain the existing runtime path. The compiler does not define cache keys or invalidation rules. Static partial inlining is deferred to [Evaluate static partial inlining](007-static-partial-inlining.md). diff --git a/.scratch/compiler/issues/003-runtime-parity.md b/.scratch/compiler/issues/003-runtime-parity.md new file mode 100644 index 0000000..8e2d823 --- /dev/null +++ b/.scratch/compiler/issues/003-runtime-parity.md @@ -0,0 +1,16 @@ +--- +title: Define compiled render and stream parity +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: 001-artifact-contract.md +--- + +## Question + +Which observable behaviors must compiled render and stream preserve—output chunking, lazy execution, state and outputs, resource limits, and Liquid exception metadata—and how should the generated artifact expose those semantics? + +## Resolution + +Compiled artifacts must preserve the existing `Template` contract for both `render()` and `stream()`. Streaming remains lazy and preserves the interpreter's observable chunk boundaries; it must not collapse output into one final chunk. Compiled execution must merge and persist shared outputs and errors, enforce the same render/assign/resource limits, preserve interrupt behavior, and attach the same template and line metadata to Liquid exceptions. A compiled path that cannot preserve these semantics falls back to the existing interpreter behavior for that operation. diff --git a/.scratch/compiler/issues/004-extension-seam.md b/.scratch/compiler/issues/004-extension-seam.md new file mode 100644 index 0000000..530db03 --- /dev/null +++ b/.scratch/compiler/issues/004-extension-seam.md @@ -0,0 +1,16 @@ +--- +title: Define compiler extension and fallback seams +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: 001-artifact-contract.md +--- + +## Question + +What stable interface should custom nodes and tags implement to emit optimized PHP, and what generic runtime fallback should handle existing or third-party tags that do not opt into direct compilation? + +## Resolution + +`CanBeCompiled` is an optional interface implemented by individual nodes or tags; its compiler-context method emits optimized PHP without changing `Tag`, `LiquidExtension`, `TagRegistry`, or filter registration APIs. Filters continue to resolve through the runtime context. Nodes and tags without the interface use their existing `render()` or `stream()` behavior through a compiler fallback. If a fallback node cannot be safely reconstructed in the artifact, compilation declines that optimized path and the caller retains the interpreted template. diff --git a/.scratch/compiler/issues/005-artifact-safety.md b/.scratch/compiler/issues/005-artifact-safety.md new file mode 100644 index 0000000..ffd0314 --- /dev/null +++ b/.scratch/compiler/issues/005-artifact-safety.md @@ -0,0 +1,18 @@ +--- +title: Define compiled artifact safety and deployment behavior +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: 001-artifact-contract.md +--- + +## Question + +What guarantees are required when writing and loading generated PHP files—safe source emission, path ownership, atomic replacement, corrupted artifacts, concurrent writers, and OPcache/deployment behavior? + +## Resolution + +The compiled artifact directory is trusted and application-owned; generated PHP is not sandboxed. Every template-originated string, name, and value must pass through a typed literal encoder such as `var_export`, and template content must never reach raw PHP emission or choose generated identifiers. Raw source-generation hooks are trusted compiler/plugin code, not template input. Compilation must decline the optimized path when a value or fallback node cannot be safely encoded or reconstructed. + +Artifacts are written to a same-directory temporary file and atomically published, with deterministic content-based artifact/class identities. Loading validates the returned `Template`-compatible object and treats corrupt or invalid files as cache misses. OPcache is invalidated after publication; deployments may use versioned or rebuilt artifact directories. Security coverage must include PHP-looking template payloads, quotes, escapes, control characters, and generated-source syntax validation. Existing interpreted caches remain unchanged. diff --git a/.scratch/compiler/issues/006-performance-gate.md b/.scratch/compiler/issues/006-performance-gate.md new file mode 100644 index 0000000..485bcf4 --- /dev/null +++ b/.scratch/compiler/issues/006-performance-gate.md @@ -0,0 +1,16 @@ +--- +title: Define compiler performance and rollout gates +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: 001-artifact-contract.md +--- + +## Question + +Which representative workloads and separate tokenize, parse, compile, load, render, and stream measurements prove that compiled templates improve the target path without regressing correctness, memory, or normal interpreter performance? + +## Resolution + +The gate uses an identical baseline on `main` and one deterministic production-shaped storefront workload. It measures tokenize, parse, compile/write, fresh artifact require/load, compiled render, compiled stream, interpreted render/stream, and existing template-cache load/render separately. Correctness checks compare exact rendered output and stream chunks outside timed subjects. A compiled path must improve beyond the existing ±2% noise band with RSD at or below 5%, avoid interpreter regressions and material memory growth, and report compile/write cost separately. Rollout remains opt-in; static partial inlining is evaluated only after this baseline is reliable. diff --git a/.scratch/compiler/issues/007-static-partial-inlining.md b/.scratch/compiler/issues/007-static-partial-inlining.md new file mode 100644 index 0000000..1e44524 --- /dev/null +++ b/.scratch/compiler/issues/007-static-partial-inlining.md @@ -0,0 +1,24 @@ +--- +title: Evaluate static partial inlining +type: wayfinder:grilling +status: closed +assignee: Fabio Capucci +parent: ../map.md +blocks: 003-runtime-parity.md, 006-performance-gate.md +--- + +## Question + +When static partial dependencies are known at compile time, under what measured performance and semantic conditions should the compiler inline them into a parent artifact, and how would that affect invalidation, errors, streaming, and deployment? + +## Resolution + +Static partial inlining is a future opt-in optimization; version one keeps one artifact per logical template with runtime partial lookup. + +A partial is eligible only when its name is a literal known during parsing, its complete transitive dependency graph is available and acyclic, and every participating node and tag can emit safe compiled code. Dynamic or unknown names, cycles, unsupported compilation, unsafe fallback, or incomplete dependency discovery retain runtime linking. + +Inlining embeds the compiled partial body in the parent artifact but retains the partial's isolated `RenderContext` boundary; it must preserve render and stream chunk behavior, output bags, template and line exception metadata, resource limits, interrupts, and current error handling. + +The parent artifact identity includes transitive dependency content hashes and compiler and artifact format versions. The application owns invalidation and must rebuild affected parents, publish a consistent artifact set atomically or through a versioned artifact directory, and never activate a parent with stale inlined dependencies. + +Inlining is accepted only when exact output, error, and stream tests pass and the representative storefront benchmark improves compiled render and stream beyond the established noise band (more than 2%, RSD at most 5%) without interpreter regressions or material memory growth. If it does not clear that gate, runtime-linked artifacts remain the implementation. diff --git a/.scratch/compiler/map.md b/.scratch/compiler/map.md new file mode 100644 index 0000000..f20603a --- /dev/null +++ b/.scratch/compiler/map.md @@ -0,0 +1,34 @@ +# Compiler Wayfinder + +## Destination + +Produce an implementation-ready, benchmark-backed design for an additive PHP compiler path in php-liquid: the existing interpreter remains unchanged; an explicit compile operation writes a PHP artifact that can be required and rendered; the design settles compiler interfaces, tag/node coverage, partial dependencies, runtime semantics, artifact handling, performance gates, and rollout. + +## Notes + +- Domain: php-liquid template compilation and compiled-template caching. +- Consult grilling, domain-modeling, research, and the existing benchmark conventions as tickets require. +- Planning only until the map is complete; implementation follows as a separate handoff. +- Compatibility is the default preference, not an absolute constraint. +- Existing tags remain supported; nodes/tags opt into direct compilation through an interface, with runtime fallback for non-compilable cases. +- The application owns environment consistency and invalidation, following the existing template-cache operational model. +- The generated artifact should be a PHP file that can be required and rendered; current interpreted behavior and current cache implementations are not changed by this effort. + +## Decisions so far + +- [Define the public compiler artifact contract](issues/001-artifact-contract.md) — Explicit compilation writes a caller-selected PHP artifact, and `require` returns a `Template`-compatible renderable object; existing APIs stay unchanged. +- [Define partial graph compilation and invalidation](issues/002-partial-graph.md) — Version one uses one artifact per logical template and runtime partial lookup; applications own precompilation and invalidation. +- [Define compiled render and stream parity](issues/003-runtime-parity.md) — Compiled execution preserves lazy chunked streams, state, limits, interrupts, and exception metadata, with interpreter fallback where needed. +- [Define compiler extension and fallback seams](issues/004-extension-seam.md) — Nodes and tags opt into direct PHP generation through `CanBeCompiled`; existing registrations and runtime fallbacks remain valid. +- [Define compiled artifact safety and deployment behavior](issues/005-artifact-safety.md) — Template literals are encoded as data, artifacts are trusted and atomically published, and invalid files fail closed as cache misses. +- [Define compiler performance and rollout gates](issues/006-performance-gate.md) — A main-baselined macro workload separates compile/load/render/stream costs, requires improvement beyond noise, and keeps rollout opt-in. +- [Evaluate static partial inlining](issues/007-static-partial-inlining.md) — Static, acyclic, fully compilable partial graphs may be inlined later with preserved partial context and stream semantics and transitive dependency hashes; runtime lookup remains the default until benchmark gates pass. + +## Not yet specified + + +## Out of scope + +- Making compilation the default execution path. +- Replacing or redesigning the existing interpreted template caches. +- Removing support for tags or requiring every existing tag to be rewritten before compilation can be used. From b10795d757d8b307436ab72bc1ef03ce4a670ee3 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 10:59:35 +0200 Subject: [PATCH 02/45] feat: add compiler artifact foundation --- src/Compiler/Cache/CompiledTemplateCache.php | 18 +++++ .../Cache/FilesystemCompiledTemplateCache.php | 65 +++++++++++++++++++ src/Compiler/CodeBuilder.php | 39 +++++++++++ src/Compiler/CompiledTemplate.php | 15 +++++ src/Compiler/CompiledTemplateInterface.php | 10 +++ src/Compiler/Compiler.php | 27 ++++++++ src/Environment.php | 19 ++++++ tests/Integration/CompilerTest.php | 53 +++++++++++++++ 8 files changed, 246 insertions(+) create mode 100644 src/Compiler/Cache/CompiledTemplateCache.php create mode 100644 src/Compiler/Cache/FilesystemCompiledTemplateCache.php create mode 100644 src/Compiler/CodeBuilder.php create mode 100644 src/Compiler/CompiledTemplate.php create mode 100644 src/Compiler/CompiledTemplateInterface.php create mode 100644 src/Compiler/Compiler.php create mode 100644 tests/Integration/CompilerTest.php diff --git a/src/Compiler/Cache/CompiledTemplateCache.php b/src/Compiler/Cache/CompiledTemplateCache.php new file mode 100644 index 0000000..2617af8 --- /dev/null +++ b/src/Compiler/Cache/CompiledTemplateCache.php @@ -0,0 +1,18 @@ +cachePath) && ! mkdir($this->cachePath, 0755, true) && ! is_dir($this->cachePath)) { + throw new \RuntimeException(sprintf('Unable to create compiled template cache directory: %s', $this->cachePath)); + } + } + + public function get(string $hash): ?CompiledTemplateInterface + { + if (! $this->has($hash)) { + return null; + } + + try { + $compiled = require $this->getPath($hash); + } catch (\Throwable) { + return null; + } + + return $compiled instanceof CompiledTemplateInterface ? $compiled : null; + } + + public function has(string $hash): bool + { + return is_file($this->getPath($hash)); + } + + public function set(string $hash, string $source): void + { + if (file_put_contents($this->getPath($hash), $source) === false) { + throw new \RuntimeException(sprintf('Unable to write compiled template cache entry: %s', $hash)); + } + } + + public function remove(string $hash): void + { + $path = $this->getPath($hash); + + if (is_file($path)) { + unlink($path); + } + } + + public function clear(): void + { + foreach (glob($this->cachePath.'/*') ?: [] as $path) { + if (is_file($path)) { + unlink($path); + } + } + } + + protected function getPath(string $hash): string + { + return $this->cachePath.'/'.$hash.'.php'; + } +} diff --git a/src/Compiler/CodeBuilder.php b/src/Compiler/CodeBuilder.php new file mode 100644 index 0000000..67016cc --- /dev/null +++ b/src/Compiler/CodeBuilder.php @@ -0,0 +1,39 @@ +indentLevel++; + } + + public function dedent(): void + { + $this->indentLevel = max(0, $this->indentLevel - 1); + } + + public function writeLine(string $line = ''): void + { + $this->lines[] = str_repeat(' ', $this->indentLevel).$line; + } + + /** + * @return string[] + */ + public function getLines(): array + { + return $this->lines; + } + + public function getSource(): string + { + return implode("\n", $this->lines)."\n"; + } +} diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php new file mode 100644 index 0000000..1772342 --- /dev/null +++ b/src/Compiler/CompiledTemplate.php @@ -0,0 +1,15 @@ +root)); + + $builder->writeLine('writeLine(''); + $builder->writeLine('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); + $builder->indent(); + $builder->writeLine(sprintf( + "unserialize(base64_decode(%s), ['allowed_classes' => true])", + var_export($serializedRoot, true), + )); + $builder->dedent(); + $builder->writeLine(');'); + + return $builder->getSource(); + } +} diff --git a/src/Environment.php b/src/Environment.php index a5ff901..dcb66eb 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid; +use Keepsuit\Liquid\Compiler\Compiler; use Keepsuit\Liquid\Contracts\LiquidErrorHandler; use Keepsuit\Liquid\Contracts\LiquidExtension; use Keepsuit\Liquid\Contracts\LiquidFileSystem; @@ -132,6 +133,24 @@ public function parseTemplate(string $templateName): Template return $this->newParseContext()->parseTemplate($templateName); } + /** + * Write a requireable compiled artifact for the given template. + */ + public function compile(Template $template, string $compiledPath): void + { + $directory = dirname($compiledPath); + + if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) { + throw new \RuntimeException(sprintf('Unable to create compiled template directory: %s', $directory)); + } + + $bytesWritten = file_put_contents($compiledPath, (new Compiler)->compile($template)); + + if ($bytesWritten === false) { + throw new \RuntimeException(sprintf('Unable to write compiled template artifact: %s', $compiledPath)); + } + } + public function addExtension(LiquidExtension $extension): static { $this->extensions[$extension::class] = $extension; diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php new file mode 100644 index 0000000..0f53dca --- /dev/null +++ b/tests/Integration/CompilerTest.php @@ -0,0 +1,53 @@ +build(); + $template = $environment->parseString('Hello {{ name }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect($compiledPath)->toBeFile(); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled)->toBeInstanceOf(Template::class); + expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) + ->toBe('Hello World'); + } finally { + @unlink($compiledPath); + } +}); + +test('compilation does not change interpreted template rendering', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('Hello {{ name }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect($template->render($environment->newRenderContext(data: ['name' => 'World']))) + ->toBe('Hello World'); + } finally { + @unlink($compiledPath); + } +}); From 01cf3a5620335f4aaad324238857ff56ddf28181 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 11:13:03 +0200 Subject: [PATCH 03/45] feat: add compiler extension fallback seam --- src/Compiler/CompiledTemplate.php | 86 ++++++++++- src/Compiler/Compiler.php | 40 ++++- src/Compiler/CompilerContext.php | 103 +++++++++++++ src/Compiler/NodeCompilerInterface.php | 10 ++ src/Compiler/NodeCompilers/BodyCompiler.php | 44 ++++++ .../NodeCompilers/DocumentCompiler.php | 16 ++ .../NodeCompilers/FallbackCompiler.php | 23 +++ src/Compiler/NodeCompilers/TextCompiler.php | 21 +++ .../NodeCompilers/VariableCompiler.php | 29 ++++ src/Contracts/CanBeCompiled.php | 14 ++ tests/Integration/CompilerTest.php | 141 ++++++++++++++++++ 11 files changed, 520 insertions(+), 7 deletions(-) create mode 100644 src/Compiler/CompilerContext.php create mode 100644 src/Compiler/NodeCompilerInterface.php create mode 100644 src/Compiler/NodeCompilers/BodyCompiler.php create mode 100644 src/Compiler/NodeCompilers/DocumentCompiler.php create mode 100644 src/Compiler/NodeCompilers/FallbackCompiler.php create mode 100644 src/Compiler/NodeCompilers/TextCompiler.php create mode 100644 src/Compiler/NodeCompilers/VariableCompiler.php create mode 100644 src/Contracts/CanBeCompiled.php diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index 1772342..aac93a2 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -2,14 +2,96 @@ namespace Keepsuit\Liquid\Compiler; +use Closure; +use Keepsuit\Liquid\Contracts\Disableable; +use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; +use Keepsuit\Liquid\Exceptions\UndefinedFilterException; +use Keepsuit\Liquid\Exceptions\UndefinedVariableException; use Keepsuit\Liquid\Nodes\Document; +use Keepsuit\Liquid\Nodes\Literal; +use Keepsuit\Liquid\Nodes\Node; +use Keepsuit\Liquid\Nodes\RangeLookup; +use Keepsuit\Liquid\Nodes\Variable; +use Keepsuit\Liquid\Nodes\VariableLookup; +use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplateSharedState; +use Throwable; class CompiledTemplate extends Template implements CompiledTemplateInterface { - public function __construct(Document $root, ?TemplateSharedState $state = null) - { + /** + * @param Closure(RenderContext): string|null $renderer + */ + public function __construct( + Document $root, + ?TemplateSharedState $state = null, + protected readonly ?Closure $renderer = null, + ) { parent::__construct($root, $state ?? new TemplateSharedState); } + + public function render(RenderContext $context): string + { + if ($this->renderer === null) { + return parent::render($context); + } + + try { + $context->mergeOutputs($this->state->outputs); + + return ($this->renderer)($context); + } catch (\Keepsuit\Liquid\Exceptions\LiquidException $e) { + $e->templateName = $e->templateName ?? $this->root->name; + throw $e; + } finally { + $this->state->errors = $context->getErrors(); + $this->state->outputs = $context->getOutputs(); + } + } + + public static function decodeValue(string $payload): mixed + { + return unserialize(base64_decode($payload), ['allowed_classes' => true]); + } + + public static function renderCompiledBody(RenderContext $context, Closure $renderer, int $childCount): string + { + $context->resourceLimits->incrementRenderScore($childCount); + $output = $renderer($context); + $context->resourceLimits->incrementWriteScore($output); + + return $output; + } + + public static function renderVariable( + RenderContext $context, + bool|float|int|Literal|RangeLookup|VariableLookup|string|null $name, + array $filters, + ?int $lineNumber, + ): string { + try { + return (new Variable($name, $filters))->render($context); + } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { + return $context->handleError($exception, $lineNumber); + } catch (Throwable $exception) { + return $context->handleError($exception, $lineNumber); + } + } + + public static function renderNode(RenderContext $context, Node $node, ?int $lineNumber): string + { + try { + if ($node instanceof Disableable && $node instanceof Tag) { + $node->ensureTagIsEnabled($context); + } + + return $node->render($context); + } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { + return $context->handleError($exception, $lineNumber); + } catch (Throwable $exception) { + return $context->handleError($exception, $lineNumber); + } + } } diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 5af65e8..9b55e16 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -7,18 +7,48 @@ class Compiler { public function compile(Template $template): string + { + $context = new CompilerContext; + $root = $context->exportSerializedValue($template->root); + + if ($root === null) { + throw new \RuntimeException('Unable to safely reconstruct the template root for compilation.'); + } + + $renderBody = $context->compileNode($template->root); + + if ($renderBody === null) { + return $this->compileInterpreterFallback($root); + } + + $builder = new CodeBuilder; + + $builder->writeLine('writeLine(''); + $builder->writeLine('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); + $builder->indent(); + $builder->writeLine($root.','); + $builder->writeLine('null,'); + $builder->writeLine('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); + $builder->indent(); + $builder->writeLine('return '.$renderBody.';'); + $builder->dedent(); + $builder->writeLine('},'); + $builder->dedent(); + $builder->writeLine(');'); + + return $builder->getSource(); + } + + protected function compileInterpreterFallback(string $root): string { $builder = new CodeBuilder; - $serializedRoot = base64_encode(serialize($template->root)); $builder->writeLine('writeLine(''); $builder->writeLine('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); $builder->indent(); - $builder->writeLine(sprintf( - "unserialize(base64_decode(%s), ['allowed_classes' => true])", - var_export($serializedRoot, true), - )); + $builder->writeLine($root); $builder->dedent(); $builder->writeLine(');'); diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php new file mode 100644 index 0000000..89fce98 --- /dev/null +++ b/src/Compiler/CompilerContext.php @@ -0,0 +1,103 @@ + + */ + private array $nodeCompilers; + + /** + * @param iterable|null $nodeCompilers + */ + public function __construct(?iterable $nodeCompilers = null) + { + $this->nodeCompilers = $nodeCompilers === null + ? [ + new NodeCompilers\DocumentCompiler, + new NodeCompilers\BodyCompiler, + new NodeCompilers\TextCompiler, + new NodeCompilers\VariableCompiler, + new NodeCompilers\FallbackCompiler, + ] + : array_values([...$nodeCompilers]); + } + + public function compileNode(Node $node): ?string + { + if ($node instanceof CanBeCompiled) { + try { + $compiled = $node->compile($this); + } catch (\Throwable) { + $compiled = null; + } + + if ($compiled !== null) { + return $compiled; + } + } + + foreach ($this->nodeCompilers as $nodeCompiler) { + $compiled = $nodeCompiler->compile($node, $this); + + if ($compiled !== null) { + return $compiled; + } + } + + return null; + } + + /** + * Export a value as PHP data. Scalars and scalar arrays stay readable in + * the artifact; other values use a serialized data payload. + */ + public function exportValue(mixed $value): ?string + { + if (is_null($value) || is_bool($value) || is_int($value) || is_string($value)) { + return var_export($value, true); + } + + if (is_float($value) && is_finite($value)) { + return var_export($value, true); + } + + if (is_array($value)) { + $parts = []; + + foreach ($value as $key => $item) { + $keyCode = $this->exportValue($key); + $itemCode = $this->exportValue($item); + + if ($keyCode === null || $itemCode === null) { + break; + } + + $parts[] = $keyCode.' => '.$itemCode; + } + + if (count($parts) === count($value)) { + return '['.implode(', ', $parts).']'; + } + } + + return $this->exportSerializedValue($value); + } + + public function exportSerializedValue(mixed $value): ?string + { + try { + $serialized = serialize($value); + } catch (\Throwable) { + return null; + } + + return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::decodeValue(' + .var_export(base64_encode($serialized), true).')'; + } +} diff --git a/src/Compiler/NodeCompilerInterface.php b/src/Compiler/NodeCompilerInterface.php new file mode 100644 index 0000000..06a4793 --- /dev/null +++ b/src/Compiler/NodeCompilerInterface.php @@ -0,0 +1,10 @@ +children() as $child) { + $compiled = $context->compileNode($child); + + if ($compiled === null) { + return null; + } + + $lines[] = ' if (! $context->hasInterrupt()) {'; + $lines[] = ' $output .= '.$compiled.';'; + $lines[] = ' }'; + } + + $lines[] = ' return $output;'; + $lines[] = ' },'; + $lines[] = ' '.count($node->children()).','; + $lines[] = ')'; + + return implode("\n", $lines); + } +} diff --git a/src/Compiler/NodeCompilers/DocumentCompiler.php b/src/Compiler/NodeCompilers/DocumentCompiler.php new file mode 100644 index 0000000..f8c737d --- /dev/null +++ b/src/Compiler/NodeCompilers/DocumentCompiler.php @@ -0,0 +1,16 @@ +compileNode($node->body) : null; + } +} diff --git a/src/Compiler/NodeCompilers/FallbackCompiler.php b/src/Compiler/NodeCompilers/FallbackCompiler.php new file mode 100644 index 0000000..5c8c4a2 --- /dev/null +++ b/src/Compiler/NodeCompilers/FallbackCompiler.php @@ -0,0 +1,23 @@ +exportSerializedValue($node); + $lineNumber = $context->exportValue($node->lineNumber()); + + if ($nodeCode === null || $lineNumber === null) { + return null; + } + + return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' + .'$context, '.$nodeCode.', '.$lineNumber.')'; + } +} diff --git a/src/Compiler/NodeCompilers/TextCompiler.php b/src/Compiler/NodeCompilers/TextCompiler.php new file mode 100644 index 0000000..f467e4c --- /dev/null +++ b/src/Compiler/NodeCompilers/TextCompiler.php @@ -0,0 +1,21 @@ +exportValue($node->value); + } +} diff --git a/src/Compiler/NodeCompilers/VariableCompiler.php b/src/Compiler/NodeCompilers/VariableCompiler.php new file mode 100644 index 0000000..4f67d35 --- /dev/null +++ b/src/Compiler/NodeCompilers/VariableCompiler.php @@ -0,0 +1,29 @@ +exportValue($node->name); + $filters = $context->exportValue($node->filters); + $lineNumber = $context->exportValue($node->lineNumber()); + + if ($name === null || $filters === null || $lineNumber === null) { + return null; + } + + return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderVariable(' + .'$context, '.$name.', '.$filters.', '.$lineNumber.')'; + } +} diff --git a/src/Contracts/CanBeCompiled.php b/src/Contracts/CanBeCompiled.php new file mode 100644 index 0000000..f121973 --- /dev/null +++ b/src/Contracts/CanBeCompiled.php @@ -0,0 +1,14 @@ +value; + } + + public function compile(CompilerContext $context): ?string + { + return $context->exportValue($this->value); + } +} + +class CompilableCompilerTestTag extends Tag implements CanBeCompiled +{ + public static function tagName(): string + { + return 'compiler_test'; + } + + public function parse(TagParseContext $context): static + { + return $this; + } + + public function render(RenderContext $context): string + { + return 'tag output'; + } + + public function compile(CompilerContext $context): ?string + { + return $context->exportValue('tag output'); + } +} + function temporaryCompiledTemplatePath(): string { $path = tempnam(sys_get_temp_dir(), 'liquid-compiled-'); @@ -51,3 +95,100 @@ function temporaryCompiledTemplatePath(): string @unlink($compiledPath); } }); + +test('compiled rendering emits safe core nodes directly', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('Hello {{ name | upcase }}!'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource)->toContain('renderVariable'); + expect(str_contains($compiledSource ?: '', 'unserialize(base64_decode'))->toBeFalse(); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) + ->toBe('Hello WORLD!'); + } finally { + @unlink($compiledPath); + } +}); + +test('unsupported nodes use the interpreter fallback', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{% assign greeting = "Hello" %}{{ greeting }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe('Hello'); + } finally { + @unlink($compiledPath); + } +}); + +test('template literals stay data when compiled', function () { + $environment = EnvironmentFactory::new()->build(); + $literal = "before after"; + $template = $environment->parseString($literal); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext()))->toBe($literal); + } finally { + @unlink($compiledPath); + } +}); + +test('custom compilable nodes opt in through the compiler context', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('prefix'); + $template->root->body->pushChild(new CompilableCompilerTestNode('custom output')); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe('prefixcustom output'); + } finally { + @unlink($compiledPath); + } +}); + +test('custom compilable tags opt in without changing tag registration', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('prefix'); + $template->root->body->pushChild(new CompilableCompilerTestTag); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe('prefixtag output'); + } finally { + @unlink($compiledPath); + } +}); From 1d435eb5946b410528cd78fda10b77ddb4c2227d Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 11:22:40 +0200 Subject: [PATCH 04/45] refactor: compile nodes through CanBeCompiled --- src/Compiler/CompilerContext.php | 38 +++++----------- src/Compiler/NodeCompilerInterface.php | 10 ----- src/Compiler/NodeCompilers/BodyCompiler.php | 44 ------------------- .../NodeCompilers/DocumentCompiler.php | 16 ------- .../NodeCompilers/FallbackCompiler.php | 23 ---------- src/Compiler/NodeCompilers/TextCompiler.php | 21 --------- .../NodeCompilers/VariableCompiler.php | 29 ------------ src/Nodes/BodyNode.php | 33 +++++++++++++- src/Nodes/Document.php | 9 +++- src/Nodes/Raw.php | 9 +++- src/Nodes/Text.php | 9 +++- src/Nodes/Variable.php | 18 +++++++- tests/Integration/CompilerTest.php | 19 ++++++++ 13 files changed, 103 insertions(+), 175 deletions(-) delete mode 100644 src/Compiler/NodeCompilerInterface.php delete mode 100644 src/Compiler/NodeCompilers/BodyCompiler.php delete mode 100644 src/Compiler/NodeCompilers/DocumentCompiler.php delete mode 100644 src/Compiler/NodeCompilers/FallbackCompiler.php delete mode 100644 src/Compiler/NodeCompilers/TextCompiler.php delete mode 100644 src/Compiler/NodeCompilers/VariableCompiler.php diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 89fce98..8e0fcc2 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -7,27 +7,6 @@ final class CompilerContext { - /** - * @var list - */ - private array $nodeCompilers; - - /** - * @param iterable|null $nodeCompilers - */ - public function __construct(?iterable $nodeCompilers = null) - { - $this->nodeCompilers = $nodeCompilers === null - ? [ - new NodeCompilers\DocumentCompiler, - new NodeCompilers\BodyCompiler, - new NodeCompilers\TextCompiler, - new NodeCompilers\VariableCompiler, - new NodeCompilers\FallbackCompiler, - ] - : array_values([...$nodeCompilers]); - } - public function compileNode(Node $node): ?string { if ($node instanceof CanBeCompiled) { @@ -42,15 +21,20 @@ public function compileNode(Node $node): ?string } } - foreach ($this->nodeCompilers as $nodeCompiler) { - $compiled = $nodeCompiler->compile($node, $this); + return $this->compileFallback($node); + } - if ($compiled !== null) { - return $compiled; - } + public function compileFallback(Node $node): ?string + { + $nodeCode = $this->exportSerializedValue($node); + $lineNumber = $this->exportValue($node->lineNumber()); + + if ($nodeCode === null || $lineNumber === null) { + return null; } - return null; + return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' + .'$context, '.$nodeCode.', '.$lineNumber.')'; } /** diff --git a/src/Compiler/NodeCompilerInterface.php b/src/Compiler/NodeCompilerInterface.php deleted file mode 100644 index 06a4793..0000000 --- a/src/Compiler/NodeCompilerInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -children() as $child) { - $compiled = $context->compileNode($child); - - if ($compiled === null) { - return null; - } - - $lines[] = ' if (! $context->hasInterrupt()) {'; - $lines[] = ' $output .= '.$compiled.';'; - $lines[] = ' }'; - } - - $lines[] = ' return $output;'; - $lines[] = ' },'; - $lines[] = ' '.count($node->children()).','; - $lines[] = ')'; - - return implode("\n", $lines); - } -} diff --git a/src/Compiler/NodeCompilers/DocumentCompiler.php b/src/Compiler/NodeCompilers/DocumentCompiler.php deleted file mode 100644 index f8c737d..0000000 --- a/src/Compiler/NodeCompilers/DocumentCompiler.php +++ /dev/null @@ -1,16 +0,0 @@ -compileNode($node->body) : null; - } -} diff --git a/src/Compiler/NodeCompilers/FallbackCompiler.php b/src/Compiler/NodeCompilers/FallbackCompiler.php deleted file mode 100644 index 5c8c4a2..0000000 --- a/src/Compiler/NodeCompilers/FallbackCompiler.php +++ /dev/null @@ -1,23 +0,0 @@ -exportSerializedValue($node); - $lineNumber = $context->exportValue($node->lineNumber()); - - if ($nodeCode === null || $lineNumber === null) { - return null; - } - - return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' - .'$context, '.$nodeCode.', '.$lineNumber.')'; - } -} diff --git a/src/Compiler/NodeCompilers/TextCompiler.php b/src/Compiler/NodeCompilers/TextCompiler.php deleted file mode 100644 index f467e4c..0000000 --- a/src/Compiler/NodeCompilers/TextCompiler.php +++ /dev/null @@ -1,21 +0,0 @@ -exportValue($node->value); - } -} diff --git a/src/Compiler/NodeCompilers/VariableCompiler.php b/src/Compiler/NodeCompilers/VariableCompiler.php deleted file mode 100644 index 4f67d35..0000000 --- a/src/Compiler/NodeCompilers/VariableCompiler.php +++ /dev/null @@ -1,29 +0,0 @@ -exportValue($node->name); - $filters = $context->exportValue($node->filters); - $lineNumber = $context->exportValue($node->lineNumber()); - - if ($name === null || $filters === null || $lineNumber === null) { - return null; - } - - return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderVariable(' - .'$context, '.$name.', '.$filters.', '.$lineNumber.')'; - } -} diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 3cfde82..75e2ffb 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -2,6 +2,8 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\Exceptions\LiquidException; @@ -11,7 +13,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; -class BodyNode extends Node implements CanBeStreamed +class BodyNode extends Node implements CanBeCompiled, CanBeStreamed { public function __construct( /** @var array */ @@ -43,6 +45,35 @@ public function setChildren(array $children): BodyNode return $this; } + public function compile(CompilerContext $context): ?string + { + $lines = [ + '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(', + ' $context,', + ' static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {', + ' $output = \'\';', + ]; + + foreach ($this->children as $child) { + $compiled = $context->compileNode($child); + + if ($compiled === null) { + return null; + } + + $lines[] = ' if (! $context->hasInterrupt()) {'; + $lines[] = ' $output .= '.$compiled.';'; + $lines[] = ' }'; + } + + $lines[] = ' return $output;'; + $lines[] = ' },'; + $lines[] = ' '.count($this->children).','; + $lines[] = ')'; + + return implode("\n", $lines); + } + /** * @throws LiquidException */ diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index 5ef9ef6..67e423e 100644 --- a/src/Nodes/Document.php +++ b/src/Nodes/Document.php @@ -2,11 +2,13 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Render\RenderContext; -class Document extends Node implements CanBeStreamed +class Document extends Node implements CanBeCompiled, CanBeStreamed { public function __construct( public readonly BodyNode $body, @@ -21,6 +23,11 @@ public function render(RenderContext $context): string return $this->body->render($context); } + public function compile(CompilerContext $context): ?string + { + return $context->compileNode($this->body); + } + /** * @throws LiquidException */ diff --git a/src/Nodes/Raw.php b/src/Nodes/Raw.php index 709f218..7a0b72a 100644 --- a/src/Nodes/Raw.php +++ b/src/Nodes/Raw.php @@ -2,10 +2,12 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Render\RenderContext; -class Raw extends Node implements HasParseTreeVisitorChildren +class Raw extends Node implements CanBeCompiled, HasParseTreeVisitorChildren { public function __construct( public readonly string $value, @@ -16,6 +18,11 @@ public function render(RenderContext $context): string return $this->value; } + public function compile(CompilerContext $context): ?string + { + return $context->exportValue($this->value); + } + public function blank(): bool { return false; diff --git a/src/Nodes/Text.php b/src/Nodes/Text.php index 48afc14..7115a82 100644 --- a/src/Nodes/Text.php +++ b/src/Nodes/Text.php @@ -2,11 +2,13 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Str; -class Text extends Node implements HasParseTreeVisitorChildren +class Text extends Node implements CanBeCompiled, HasParseTreeVisitorChildren { public function __construct( public readonly string $value, @@ -17,6 +19,11 @@ public function render(RenderContext $context): string return $this->value; } + public function compile(CompilerContext $context): ?string + { + return $context->exportValue($this->value); + } + public function blank(): bool { return Str::blank($this->value); diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index 9fd98ac..66ace92 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -2,6 +2,8 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeEvaluated; use Keepsuit\Liquid\Contracts\CanBeRendered; use Keepsuit\Liquid\Contracts\CanBeStreamed; @@ -13,7 +15,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class Variable extends Node implements CanBeEvaluated, CanBeStreamed, HasParseTreeVisitorChildren +class Variable extends Node implements CanBeCompiled, CanBeEvaluated, CanBeStreamed, HasParseTreeVisitorChildren { public function __construct( /** @var Expression $name */ @@ -33,6 +35,20 @@ public function render(RenderContext $context): string return $this->renderOutput($output); } + public function compile(CompilerContext $context): ?string + { + $name = $context->exportValue($this->name); + $filters = $context->exportValue($this->filters); + $lineNumber = $context->exportValue($this->lineNumber()); + + if ($name === null || $filters === null || $lineNumber === null) { + return null; + } + + return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderVariable(' + .'$context, '.$name.', '.$filters.', '.$lineNumber.')'; + } + public function stream(RenderContext $context): \Generator { if ($this->filters !== []) { diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index ffdf9f3..2ceeff7 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -3,7 +3,12 @@ use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\EnvironmentFactory; +use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Node; +use Keepsuit\Liquid\Nodes\Raw; +use Keepsuit\Liquid\Nodes\Text; +use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; @@ -119,6 +124,20 @@ function temporaryCompiledTemplatePath(): string } }); +test('built-in compilable nodes implement the compiler contract directly', function () { + $nodes = [ + new Text('text'), + new Raw('raw'), + new Document(new BodyNode), + new BodyNode, + new Variable('name'), + ]; + + foreach ($nodes as $node) { + expect($node)->toBeInstanceOf(CanBeCompiled::class); + } +}); + test('unsupported nodes use the interpreter fallback', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{% assign greeting = "Hello" %}{{ greeting }}'); From a6f72cfd259f15c3253b5a89589764ae2a5a3e07 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 11:45:16 +0200 Subject: [PATCH 05/45] refactor: add compiler writer API --- src/Compiler/CodeBuilder.php | 42 +++++++++++++++-- src/Compiler/Compiler.php | 64 ++++++++----------------- src/Compiler/CompilerContext.php | 75 ++++++++++++++++++++++++------ src/Contracts/CanBeCompiled.php | 5 +- src/Nodes/BodyNode.php | 40 +++++++--------- src/Nodes/Document.php | 4 +- src/Nodes/Raw.php | 4 +- src/Nodes/Text.php | 4 +- src/Nodes/Variable.php | 18 +++---- tests/Integration/CompilerTest.php | 59 +++++++++++++++++++++-- 10 files changed, 206 insertions(+), 109 deletions(-) diff --git a/src/Compiler/CodeBuilder.php b/src/Compiler/CodeBuilder.php index 67016cc..816dc00 100644 --- a/src/Compiler/CodeBuilder.php +++ b/src/Compiler/CodeBuilder.php @@ -6,8 +6,7 @@ class CodeBuilder { protected int $indentLevel = 0; - /** @var string[] */ - protected array $lines = []; + protected string $source = ''; public function indent(): void { @@ -21,7 +20,36 @@ public function dedent(): void public function writeLine(string $line = ''): void { - $this->lines[] = str_repeat(' ', $this->indentLevel).$line; + if ($this->source !== '' && ! str_ends_with($this->source, "\n")) { + $this->source .= "\n"; + } + + $this->source .= str_repeat(' ', $this->indentLevel).$line."\n"; + } + + public function writeRaw(string $fragment): void + { + $this->source .= $fragment; + } + + /** + * @return array{sourceLength:int,indentLevel:int} + */ + public function checkpoint(): array + { + return [ + 'sourceLength' => strlen($this->source), + 'indentLevel' => $this->indentLevel, + ]; + } + + /** + * @param array{sourceLength:int,indentLevel:int} $checkpoint + */ + public function rollback(array $checkpoint): void + { + $this->source = substr($this->source, 0, $checkpoint['sourceLength']); + $this->indentLevel = $checkpoint['indentLevel']; } /** @@ -29,11 +57,15 @@ public function writeLine(string $line = ''): void */ public function getLines(): array { - return $this->lines; + if ($this->source === '') { + return []; + } + + return explode("\n", rtrim($this->source, "\n")); } public function getSource(): string { - return implode("\n", $this->lines)."\n"; + return $this->source; } } diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 9b55e16..cab1d2e 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -7,51 +7,27 @@ class Compiler { public function compile(Template $template): string - { - $context = new CompilerContext; - $root = $context->exportSerializedValue($template->root); - - if ($root === null) { - throw new \RuntimeException('Unable to safely reconstruct the template root for compilation.'); - } - - $renderBody = $context->compileNode($template->root); - - if ($renderBody === null) { - return $this->compileInterpreterFallback($root); - } - - $builder = new CodeBuilder; - - $builder->writeLine('writeLine(''); - $builder->writeLine('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); - $builder->indent(); - $builder->writeLine($root.','); - $builder->writeLine('null,'); - $builder->writeLine('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); - $builder->indent(); - $builder->writeLine('return '.$renderBody.';'); - $builder->dedent(); - $builder->writeLine('},'); - $builder->dedent(); - $builder->writeLine(');'); - - return $builder->getSource(); - } - - protected function compileInterpreterFallback(string $root): string { $builder = new CodeBuilder; - - $builder->writeLine('writeLine(''); - $builder->writeLine('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); - $builder->indent(); - $builder->writeLine($root); - $builder->dedent(); - $builder->writeLine(');'); - - return $builder->getSource(); + $context = new CompilerContext($builder); + $root = $context->writeValue($template->root); + + $context->write('write(); + $context->write('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); + $context->indent(); + $context->write($root.','); + $context->write('null,'); + $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); + $context->indent(); + $context->write('$output = \'\';'); + $context->subcompile($template->root); + $context->write('return $output;'); + $context->outdent(); + $context->write('},'); + $context->outdent(); + $context->write(');'); + + return $context->getSource(); } } diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 8e0fcc2..8c180dd 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -7,34 +7,76 @@ final class CompilerContext { - public function compileNode(Node $node): ?string + public function __construct(private readonly CodeBuilder $builder = new CodeBuilder) {} + + public function write(string $line = ''): void + { + $this->builder->writeLine($line); + } + + /** + * Write a trusted compiler or plugin fragment without data encoding. + */ + public function raw(string $fragment): void + { + $this->builder->writeRaw($fragment); + } + + public function indent(): void + { + $this->builder->indent(); + } + + public function outdent(): void + { + $this->builder->dedent(); + } + + public function writeOutput(string $expression): void { + $this->write('$output .= '.$expression.';'); + } + + public function subcompile(Node $node): void + { + $checkpoint = $this->builder->checkpoint(); + if ($node instanceof CanBeCompiled) { try { - $compiled = $node->compile($this); + $node->compile($this); + + return; } catch (\Throwable) { - $compiled = null; + $this->builder->rollback($checkpoint); } + } - if ($compiled !== null) { - return $compiled; - } + try { + $this->compileFallback($node); + } catch (\Throwable $exception) { + $this->builder->rollback($checkpoint); + + throw $exception; } + } - return $this->compileFallback($node); + public function compileFallback(Node $node): void + { + $this->writeOutput( + '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' + .'$context, '.$this->writeValue($node).', '.$this->writeValue($node->lineNumber()).')' + ); } - public function compileFallback(Node $node): ?string + public function writeValue(mixed $value): string { - $nodeCode = $this->exportSerializedValue($node); - $lineNumber = $this->exportValue($node->lineNumber()); + $exported = $this->exportValue($value); - if ($nodeCode === null || $lineNumber === null) { - return null; + if ($exported === null) { + throw new \RuntimeException('Unable to safely encode a compiler value.'); } - return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' - .'$context, '.$nodeCode.', '.$lineNumber.')'; + return $exported; } /** @@ -84,4 +126,9 @@ public function exportSerializedValue(mixed $value): ?string return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::decodeValue(' .var_export(base64_encode($serialized), true).')'; } + + public function getSource(): string + { + return $this->builder->getSource(); + } } diff --git a/src/Contracts/CanBeCompiled.php b/src/Contracts/CanBeCompiled.php index f121973..744c1d3 100644 --- a/src/Contracts/CanBeCompiled.php +++ b/src/Contracts/CanBeCompiled.php @@ -7,8 +7,7 @@ interface CanBeCompiled { /** - * Return a PHP expression that renders this value, or null to use the - * compiler's runtime fallback. + * Emit generated PHP statements through the compiler context. */ - public function compile(CompilerContext $context): ?string; + public function compile(CompilerContext $context): void; } diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 75e2ffb..b1328b7 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -45,33 +45,29 @@ public function setChildren(array $children): BodyNode return $this; } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - $lines = [ - '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(', - ' $context,', - ' static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {', - ' $output = \'\';', - ]; + $context->write('$output = \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody('); + $context->indent(); + $context->write('$context,'); + $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); + $context->indent(); + $context->write('$output = \'\';'); foreach ($this->children as $child) { - $compiled = $context->compileNode($child); - - if ($compiled === null) { - return null; - } - - $lines[] = ' if (! $context->hasInterrupt()) {'; - $lines[] = ' $output .= '.$compiled.';'; - $lines[] = ' }'; + $context->write('if (! $context->hasInterrupt()) {'); + $context->indent(); + $context->subcompile($child); + $context->outdent(); + $context->write('}'); } - $lines[] = ' return $output;'; - $lines[] = ' },'; - $lines[] = ' '.count($this->children).','; - $lines[] = ')'; - - return implode("\n", $lines); + $context->write('return $output;'); + $context->outdent(); + $context->write('},'); + $context->write(count($this->children).','); + $context->outdent(); + $context->write(');'); } /** diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index 67e423e..e3b710c 100644 --- a/src/Nodes/Document.php +++ b/src/Nodes/Document.php @@ -23,9 +23,9 @@ public function render(RenderContext $context): string return $this->body->render($context); } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - return $context->compileNode($this->body); + $context->subcompile($this->body); } /** diff --git a/src/Nodes/Raw.php b/src/Nodes/Raw.php index 7a0b72a..e1329aa 100644 --- a/src/Nodes/Raw.php +++ b/src/Nodes/Raw.php @@ -18,9 +18,9 @@ public function render(RenderContext $context): string return $this->value; } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - return $context->exportValue($this->value); + $context->writeOutput($context->writeValue($this->value)); } public function blank(): bool diff --git a/src/Nodes/Text.php b/src/Nodes/Text.php index 7115a82..acbdb05 100644 --- a/src/Nodes/Text.php +++ b/src/Nodes/Text.php @@ -19,9 +19,9 @@ public function render(RenderContext $context): string return $this->value; } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - return $context->exportValue($this->value); + $context->writeOutput($context->writeValue($this->value)); } public function blank(): bool diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index 66ace92..d8824d6 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -35,18 +35,14 @@ public function render(RenderContext $context): string return $this->renderOutput($output); } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - $name = $context->exportValue($this->name); - $filters = $context->exportValue($this->filters); - $lineNumber = $context->exportValue($this->lineNumber()); - - if ($name === null || $filters === null || $lineNumber === null) { - return null; - } - - return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderVariable(' - .'$context, '.$name.', '.$filters.', '.$lineNumber.')'; + $context->writeOutput( + '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderVariable(' + .'$context, '.$context->writeValue($this->name).', ' + .$context->writeValue($this->filters).', ' + .$context->writeValue($this->lineNumber()).')' + ); } public function stream(RenderContext $context): \Generator diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 2ceeff7..ca28ea0 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -23,9 +23,9 @@ public function render(RenderContext $context): string return $this->value; } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - return $context->exportValue($this->value); + $context->writeOutput($context->writeValue($this->value)); } } @@ -46,9 +46,24 @@ public function render(RenderContext $context): string return 'tag output'; } - public function compile(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { - return $context->exportValue('tag output'); + $context->writeOutput($context->writeValue('tag output')); + } +} + +class FailingCompilableCompilerTestNode extends Node implements CanBeCompiled +{ + public function render(RenderContext $context): string + { + return 'fallback output'; + } + + public function compile(CompilerContext $context): void + { + $context->writeOutput($context->writeValue('partial output')); + + throw new RuntimeException('compiler test failure'); } } @@ -124,6 +139,19 @@ function temporaryCompiledTemplatePath(): string } }); +test('compiler context writes indented output statements', function () { + $context = new CompilerContext; + + $context->write('function generated() {'); + $context->indent(); + $context->raw("if (true) {\nreturn true;\n}"); + $context->outdent(); + $context->write('}'); + + expect($context->getSource()) + ->toBe("function generated() {\nif (true) {\nreturn true;\n}\n}\n"); +}); + test('built-in compilable nodes implement the compiler contract directly', function () { $nodes = [ new Text('text'), @@ -211,3 +239,26 @@ function temporaryCompiledTemplatePath(): string @unlink($compiledPath); } }); + +test('failed node compilation rolls back before runtime fallback', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('prefix'); + $template->root->body->pushChild(new FailingCompilableCompilerTestNode); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect(str_contains($compiledSource ?: '', 'partial output'))->toBeFalse(); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe('prefixfallback output'); + } finally { + @unlink($compiledPath); + } +}); From 6b6c5e601f9ba59a75c6d68d14f04bdbb182a6c9 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 11:49:30 +0200 Subject: [PATCH 06/45] refactor: make compiler writers fluent --- src/Compiler/CodeBuilder.php | 20 +++++++++++++++----- src/Compiler/CompilerContext.php | 26 +++++++++++++++++++------- tests/Integration/CompilerTest.php | 24 ++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/Compiler/CodeBuilder.php b/src/Compiler/CodeBuilder.php index 816dc00..feda2bf 100644 --- a/src/Compiler/CodeBuilder.php +++ b/src/Compiler/CodeBuilder.php @@ -8,28 +8,36 @@ class CodeBuilder protected string $source = ''; - public function indent(): void + public function indent(): static { $this->indentLevel++; + + return $this; } - public function dedent(): void + public function dedent(): static { $this->indentLevel = max(0, $this->indentLevel - 1); + + return $this; } - public function writeLine(string $line = ''): void + public function writeLine(string $line = ''): static { if ($this->source !== '' && ! str_ends_with($this->source, "\n")) { $this->source .= "\n"; } $this->source .= str_repeat(' ', $this->indentLevel).$line."\n"; + + return $this; } - public function writeRaw(string $fragment): void + public function writeRaw(string $fragment): static { $this->source .= $fragment; + + return $this; } /** @@ -46,10 +54,12 @@ public function checkpoint(): array /** * @param array{sourceLength:int,indentLevel:int} $checkpoint */ - public function rollback(array $checkpoint): void + public function rollback(array $checkpoint): static { $this->source = substr($this->source, 0, $checkpoint['sourceLength']); $this->indentLevel = $checkpoint['indentLevel']; + + return $this; } /** diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 8c180dd..018ca91 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -9,35 +9,45 @@ final class CompilerContext { public function __construct(private readonly CodeBuilder $builder = new CodeBuilder) {} - public function write(string $line = ''): void + public function write(string $line = ''): static { $this->builder->writeLine($line); + + return $this; } /** * Write a trusted compiler or plugin fragment without data encoding. */ - public function raw(string $fragment): void + public function raw(string $fragment): static { $this->builder->writeRaw($fragment); + + return $this; } - public function indent(): void + public function indent(): static { $this->builder->indent(); + + return $this; } - public function outdent(): void + public function outdent(): static { $this->builder->dedent(); + + return $this; } - public function writeOutput(string $expression): void + public function writeOutput(string $expression): static { $this->write('$output .= '.$expression.';'); + + return $this; } - public function subcompile(Node $node): void + public function subcompile(Node $node): static { $checkpoint = $this->builder->checkpoint(); @@ -45,7 +55,7 @@ public function subcompile(Node $node): void try { $node->compile($this); - return; + return $this; } catch (\Throwable) { $this->builder->rollback($checkpoint); } @@ -53,6 +63,8 @@ public function subcompile(Node $node): void try { $this->compileFallback($node); + + return $this; } catch (\Throwable $exception) { $this->builder->rollback($checkpoint); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index ca28ea0..8ade394 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -1,5 +1,6 @@ toBe("function generated() {\nif (true) {\nreturn true;\n}\n}\n"); }); +test('compiler and code builder writer methods are fluent', function () { + $builder = new CodeBuilder; + + expect($builder->indent())->toBe($builder); + expect($builder->writeLine('builder line'))->toBe($builder); + expect($builder->writeRaw("\nbuilder raw"))->toBe($builder); + expect($builder->dedent())->toBe($builder); + + $checkpoint = $builder->checkpoint(); + + expect($builder->writeLine('discarded'))->toBe($builder); + expect($builder->rollback($checkpoint))->toBe($builder); + + $context = new CompilerContext($builder); + + expect($context->write('context line'))->toBe($context); + expect($context->raw('context raw'))->toBe($context); + expect($context->indent())->toBe($context); + expect($context->writeOutput($context->writeValue('output')))->toBe($context); + expect($context->subcompile(new Text('child')))->toBe($context); + expect($context->outdent())->toBe($context); +}); + test('built-in compilable nodes implement the compiler contract directly', function () { $nodes = [ new Text('text'), From d542b013a41d71f02edf4346ca1f7d04f03af88c Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 11:52:27 +0200 Subject: [PATCH 07/45] refactor: chain body node compilation --- src/Nodes/BodyNode.php | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index b1328b7..f9c3bcd 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -47,27 +47,30 @@ public function setChildren(array $children): BodyNode public function compile(CompilerContext $context): void { - $context->write('$output = \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody('); - $context->indent(); - $context->write('$context,'); - $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); - $context->indent(); - $context->write('$output = \'\';'); + $context + ->write('$output = \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(') + ->indent() + ->write('$context,') + ->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {') + ->indent() + ->write('$output = \'\';'); foreach ($this->children as $child) { - $context->write('if (! $context->hasInterrupt()) {'); - $context->indent(); - $context->subcompile($child); - $context->outdent(); - $context->write('}'); + $context + ->write('if (! $context->hasInterrupt()) {') + ->indent() + ->subcompile($child) + ->outdent() + ->write('}'); } - $context->write('return $output;'); - $context->outdent(); - $context->write('},'); - $context->write(count($this->children).','); - $context->outdent(); - $context->write(');'); + $context + ->write('return $output;') + ->outdent() + ->write('},') + ->write(count($this->children).',') + ->outdent() + ->write(');'); } /** From 1aca53b1d8c67b9e34fc6549502c16b12cec6905 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 12:31:46 +0200 Subject: [PATCH 08/45] feat: preserve compiled render and stream parity --- src/Compiler/CompiledTemplate.php | 34 ++++- src/Compiler/CompiledTemplateInterface.php | 2 + src/Compiler/Compiler.php | 8 +- tests/Integration/CompilerTest.php | 140 +++++++++++++++++++++ tests/Integration/StreamTest.php | 122 ++++++++++++++++++ 5 files changed, 303 insertions(+), 3 deletions(-) diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index aac93a2..b3fef56 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -23,11 +23,13 @@ class CompiledTemplate extends Template implements CompiledTemplateInterface { /** * @param Closure(RenderContext): string|null $renderer + * @param Closure(RenderContext): \Generator|null $streamer */ public function __construct( Document $root, ?TemplateSharedState $state = null, protected readonly ?Closure $renderer = null, + protected readonly ?Closure $streamer = null, ) { parent::__construct($root, $state ?? new TemplateSharedState); } @@ -51,6 +53,30 @@ public function render(RenderContext $context): string } } + public function stream(RenderContext $context): \Generator + { + if ($this->streamer === null) { + yield from parent::stream($context); + + return; + } + + try { + $context->mergeOutputs($this->state->outputs); + + /** @var \Generator $stream */ + $stream = ($this->streamer)($context); + + yield from $stream; + } catch (\Keepsuit\Liquid\Exceptions\LiquidException $e) { + $e->templateName = $e->templateName ?? $this->root->name; + throw $e; + } finally { + $this->state->errors = $context->getErrors(); + $this->state->outputs = $context->getOutputs(); + } + } + public static function decodeValue(string $payload): mixed { return unserialize(base64_decode($payload), ['allowed_classes' => true]); @@ -74,7 +100,9 @@ public static function renderVariable( try { return (new Variable($name, $filters))->render($context); } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { - return $context->handleError($exception, $lineNumber); + $context->handleError($exception, $lineNumber); + + return ''; } catch (Throwable $exception) { return $context->handleError($exception, $lineNumber); } @@ -89,7 +117,9 @@ public static function renderNode(RenderContext $context, Node $node, ?int $line return $node->render($context); } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { - return $context->handleError($exception, $lineNumber); + $context->handleError($exception, $lineNumber); + + return ''; } catch (Throwable $exception) { return $context->handleError($exception, $lineNumber); } diff --git a/src/Compiler/CompiledTemplateInterface.php b/src/Compiler/CompiledTemplateInterface.php index 975dc2f..ad69a6c 100644 --- a/src/Compiler/CompiledTemplateInterface.php +++ b/src/Compiler/CompiledTemplateInterface.php @@ -7,4 +7,6 @@ interface CompiledTemplateInterface { public function render(RenderContext $context): string; + + public function stream(RenderContext $context): \Generator; } diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index cab1d2e..3b5e9d2 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -14,9 +14,10 @@ public function compile(Template $template): string $context->write('write(); + $context->write('$root = '.$root.';'); $context->write('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); $context->indent(); - $context->write($root.','); + $context->write('$root,'); $context->write('null,'); $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); $context->indent(); @@ -25,6 +26,11 @@ public function compile(Template $template): string $context->write('return $output;'); $context->outdent(); $context->write('},'); + $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context) use ($root): \\Generator {'); + $context->indent(); + $context->write('yield from $root->stream($context);'); + $context->outdent(); + $context->write('},'); $context->outdent(); $context->write(');'); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 8ade394..c2cce7a 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\EnvironmentFactory; +use Keepsuit\Liquid\Exceptions\ResourceLimitException; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Node; @@ -12,6 +13,7 @@ use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\Template; @@ -117,6 +119,144 @@ function temporaryCompiledTemplatePath(): string } }); +test('compiled rendering preserves state across repeated renders', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{{ value }}{% assign value = "one" %}{{ value }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + $interpretedContext = $environment->newRenderContext(); + $compiledContext = $environment->newRenderContext(); + + expect([ + $template->render($interpretedContext), + $template->render($interpretedContext), + ])->toBe([ + $compiled->render($compiledContext), + $compiled->render($compiledContext), + ])->toBe(['one', 'oneone']); + } finally { + @unlink($compiledPath); + } +}); + +test('compiled rendering preserves collected errors and exception metadata', function () { + $environment = EnvironmentFactory::new() + ->setStrictVariables(true) + ->setRethrowErrors(false) + ->build(); + $template = $environment->parseString('{{ missing }}', name: 'errors.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + $interpretedContext = $environment->newRenderContext(); + $compiledContext = $environment->newRenderContext(); + + expect($compiled->render($compiledContext))->toBe($template->render($interpretedContext)); + + $describeErrors = static fn (Template $rendered): array => array_map( + static fn (\Throwable $error): array => [ + $error::class, + $error->getMessage(), + $error->lineNumber, + $error->templateName, + ], + $rendered->getErrors(), + ); + + expect($describeErrors($compiled))->toBe($describeErrors($template)) + ->toBe([[ + \Keepsuit\Liquid\Exceptions\UndefinedVariableException::class, + 'Variable `missing` not found', + 1, + null, + ]]); + } finally { + @unlink($compiledPath); + } +}); + +test('compiled rendering attaches template metadata to rethrown exceptions', function () { + $environment = EnvironmentFactory::new() + ->setStrictVariables(true) + ->setRethrowErrors(true) + ->build(); + $template = $environment->parseString('{{ missing }}', name: 'errors.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + $exceptions = []; + + foreach ([$template, $compiled] as $candidate) { + try { + $candidate->render($environment->newRenderContext()); + } catch (\Keepsuit\Liquid\Exceptions\LiquidException $exception) { + $exceptions[] = [ + $exception::class, + $exception->lineNumber, + $exception->templateName, + ]; + } + } + + expect($exceptions)->toBe([ + [ + \Keepsuit\Liquid\Exceptions\UndefinedVariableException::class, + 1, + 'errors.liquid', + ], + [ + \Keepsuit\Liquid\Exceptions\UndefinedVariableException::class, + 1, + 'errors.liquid', + ], + ]); + } finally { + @unlink($compiledPath); + } +}); + +test('compiled rendering preserves resource-limit exceptions', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('0123456789', name: 'limited.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + $interpretedContext = $environment->newRenderContext( + resourceLimits: new ResourceLimits(renderLengthLimit: 9), + ); + $compiledContext = $environment->newRenderContext( + resourceLimits: new ResourceLimits(renderLengthLimit: 9), + ); + + expect(fn () => $template->render($interpretedContext)) + ->toThrow(ResourceLimitException::class); + expect(fn () => $compiled->render($compiledContext)) + ->toThrow(ResourceLimitException::class); + expect($compiledContext->resourceLimits->reached()) + ->toBe($interpretedContext->resourceLimits->reached()) + ->toBeTrue(); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering emits safe core nodes directly', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('Hello {{ name | upcase }}!'); diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 0282992..241035e 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -1,5 +1,37 @@ compile($template, $path); + + /** @var Template $compiled */ + return require $path; + } finally { + @unlink($path); + } +} + +function streamChunks(Template $template, RenderContext $context): array +{ + return iterator_to_array($template->stream($context)); +} + test('template can be streamed', function () { $stream = streamTemplate(<<<'LIQUID' text @@ -55,3 +87,93 @@ ->toHaveCount(1) ->{0}->toBe('text1,text2'); }); + +test('compiled stream preserves lazy chunk boundaries', function () { + $environment = Environment::default(); + $source = "text\n{{ var }}"; + $template = $environment->parseString($source, name: 'stream.liquid'); + $compiled = compileStreamTestTemplate($environment, $template); + + $interpreted = streamChunks($template, $environment->newRenderContext(staticData: [ + 'var' => static function () { + yield 'text1'; + yield 'text2'; + }, + ])); + $optimized = streamChunks($compiled, $environment->newRenderContext(staticData: [ + 'var' => static function () { + yield 'text1'; + yield 'text2'; + }, + ])); + + expect($optimized)->toBe($interpreted)->toBe(["text\n", 'text1', 'text2']); +}); + +test('compiled stream does not evaluate until the generator is consumed', function () { + $environment = Environment::default(); + $template = $environment->parseString('{{ value }}'); + $compiled = compileStreamTestTemplate($environment, $template); + $evaluations = 0; + $stream = $compiled->stream($environment->newRenderContext(staticData: [ + 'value' => static function () use (&$evaluations): string { + $evaluations++; + + return 'value'; + }, + ])); + + expect($stream)->toBeInstanceOf(Generator::class); + expect($evaluations)->toBe(0); + expect($stream->current())->toBe('value'); + expect($evaluations)->toBe(1); +}); + +test('compiled stream preserves filtered generator output as one chunk', function () { + $environment = Environment::default(); + $template = $environment->parseString('{{ var | join: "," }}'); + $compiled = compileStreamTestTemplate($environment, $template); + + $factory = static fn (): \Generator => (static function () { + yield 'text1'; + yield 'text2'; + })(); + + $interpreted = streamChunks($template, $environment->newRenderContext(staticData: ['var' => $factory])); + $optimized = streamChunks($compiled, $environment->newRenderContext(staticData: ['var' => $factory])); + + expect($optimized)->toBe($interpreted)->toBe(['text1,text2']); +}); + +test('compiled stream preserves interrupts and empty chunks', function () { + $environment = Environment::default(); + $template = $environment->parseString('before{% break %}after'); + $compiled = compileStreamTestTemplate($environment, $template); + + $interpreted = streamChunks($template, $environment->newRenderContext()); + $optimized = streamChunks($compiled, $environment->newRenderContext()); + + expect($optimized)->toBe($interpreted)->toBe(['before', '']); +}); + +test('compiled stream preserves resource-limit exceptions', function () { + $environment = Environment::default(); + $template = $environment->parseString('0123456789', name: 'limited.liquid'); + $compiled = compileStreamTestTemplate($environment, $template); + + $interpretedContext = $environment->newRenderContext( + resourceLimits: new ResourceLimits(renderLengthLimit: 9), + ); + $compiledContext = $environment->newRenderContext( + resourceLimits: new ResourceLimits(renderLengthLimit: 9), + ); + + expect(fn () => streamChunks($template, $interpretedContext)) + ->toThrow(ResourceLimitException::class); + expect(fn () => streamChunks($compiled, $compiledContext)) + ->toThrow(ResourceLimitException::class); + + expect($compiledContext->resourceLimits->reached()) + ->toBe($interpretedContext->resourceLimits->reached()) + ->toBeTrue(); +}); From f61e31d4e3b0326c14e8d0ecbab9ddefa7e6c8cc Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 12:42:52 +0200 Subject: [PATCH 09/45] feat: harden compiler fallback seam --- src/Compiler/CompilerContext.php | 47 ++++++++++ tests/Integration/CompilerTest.php | 140 +++++++++++++++++++++++++++++ tests/Integration/StreamTest.php | 34 +++++++ 3 files changed, 221 insertions(+) diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 018ca91..6a6ea41 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -129,6 +129,10 @@ public function exportValue(mixed $value): ?string public function exportSerializedValue(mixed $value): ?string { + if ($this->containsResource($value)) { + return null; + } + try { $serialized = serialize($value); } catch (\Throwable) { @@ -139,6 +143,49 @@ public function exportSerializedValue(mixed $value): ?string .var_export(base64_encode($serialized), true).')'; } + /** + * Resources are serialized as scalar placeholders and would not be + * reconstructed with their original runtime behavior. + * + * @param array $seenObjects + */ + private function containsResource(mixed $value, int $depth = 0, array &$seenObjects = []): bool + { + if ($depth > 256 || is_resource($value)) { + return true; + } + + if (is_array($value)) { + foreach ($value as $item) { + if ($this->containsResource($item, $depth + 1, $seenObjects)) { + return true; + } + } + + return false; + } + + if (! is_object($value)) { + return false; + } + + $objectId = spl_object_id($value); + + if (isset($seenObjects[$objectId])) { + return false; + } + + $seenObjects[$objectId] = true; + + foreach ((array) $value as $property) { + if ($this->containsResource($property, $depth + 1, $seenObjects)) { + return true; + } + } + + return false; + } + public function getSource(): string { return $this->builder->getSource(); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index c2cce7a..100eba1 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -3,8 +3,11 @@ use Keepsuit\Liquid\Compiler\CodeBuilder; use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeCompiled; +use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\EnvironmentFactory; use Keepsuit\Liquid\Exceptions\ResourceLimitException; +use Keepsuit\Liquid\Extensions\Extension; +use Keepsuit\Liquid\Filters\FiltersProvider; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Node; @@ -55,6 +58,45 @@ public function compile(CompilerContext $context): void } } +class CompilerTestFilters extends FiltersProvider +{ + public function compilerMarker(string $value): string + { + return 'filtered '.$value; + } +} + +class CompilerTestExtension extends Extension +{ + public function getTags(): array + { + return [CompilableCompilerTestTag::class, RuntimeFallbackCompilerTestTag::class]; + } + + public function getFiltersProviders(): array + { + return [CompilerTestFilters::class]; + } +} + +class RuntimeFallbackCompilerTestTag extends Tag implements Disableable +{ + public static function tagName(): string + { + return 'runtime_fallback'; + } + + public function parse(TagParseContext $context): static + { + return $this; + } + + public function render(RenderContext $context): string + { + return (string) $context->applyFilter('compiler_marker', 'runtime'); + } +} + class FailingCompilableCompilerTestNode extends Node implements CanBeCompiled { public function render(RenderContext $context): string @@ -70,6 +112,16 @@ public function compile(CompilerContext $context): void } } +class UnsafeFallbackCompilerTestNode extends Node +{ + public function __construct(private readonly mixed $value) {} + + public function render(RenderContext $context): string + { + return 'unsafe fallback'; + } +} + function temporaryCompiledTemplatePath(): string { $path = tempnam(sys_get_temp_dir(), 'liquid-compiled-'); @@ -404,6 +456,67 @@ function temporaryCompiledTemplatePath(): string } }); +test('compiler extensions retain custom tag and filter registration', function () { + $environment = EnvironmentFactory::new() + ->addExtension(new CompilerTestExtension) + ->build(); + $template = $environment->parseString('{% compiler_test %}{{ name | compiler_marker }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + expect($environment->tagRegistry->get('compiler_test')) + ->toBe(CompilableCompilerTestTag::class); + expect($environment->filterRegistry->has('compiler_marker'))->toBeTrue(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: ['name' => 'value']))) + ->toBe('tag outputfiltered value'); + } finally { + @unlink($compiledPath); + } +}); + +test('unsupported tags retain runtime filters and disabled-tag behavior', function () { + $environment = EnvironmentFactory::new() + ->addExtension(new CompilerTestExtension) + ->build(); + $template = $environment->parseString('{% runtime_fallback %}', name: 'fallback.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe($template->render($environment->newRenderContext())) + ->toBe('filtered runtime'); + + $renderDisabled = static function (Template $candidate, RenderContext $context): string { + return $context->withDisabledTags( + ['runtime_fallback'], + fn () => $candidate->render($context), + ); + }; + $interpretedContext = $environment->newRenderContext(); + $compiledContext = $environment->newRenderContext(); + + expect($renderDisabled($compiled, $compiledContext)) + ->toBe($renderDisabled($template, $interpretedContext)) + ->toBe('Liquid error (line 1): runtime_fallback usage is not allowed in this context'); + expect($compiled->getErrors()[0]::class) + ->toBe(\Keepsuit\Liquid\Exceptions\TagDisabledException::class); + expect($compiled->getErrors()[0]->lineNumber)->toBe(1); + } finally { + @unlink($compiledPath); + } +}); + test('failed node compilation rolls back before runtime fallback', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); @@ -426,3 +539,30 @@ function temporaryCompiledTemplatePath(): string @unlink($compiledPath); } }); + +test('compilation fails when a fallback node cannot be safely reconstructed', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('prefix'); + $resource = fopen('php://memory', 'r'); + + if ($resource === false) { + throw new RuntimeException('Unable to create a test resource.'); + } + + $template->root->body->pushChild(new UnsafeFallbackCompilerTestNode($resource)); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + expect(fn () => $environment->compile($template, $compiledPath)) + ->toThrow(RuntimeException::class); + expect($compiledPath)->not->toBeFile(); + expect($template->render($environment->newRenderContext())) + ->toBe('prefixunsafe fallback'); + } finally { + fclose($resource); + + if (is_file($compiledPath)) { + unlink($compiledPath); + } + } +}); diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 241035e..7225de5 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -1,11 +1,32 @@ toBe($interpreted)->toBe(['text1,text2']); }); +test('compiled stream falls back to unsupported tag streaming behavior', function () { + $environment = EnvironmentFactory::new() + ->registerTag(UnsupportedCompilerStreamTestTag::class) + ->build(); + $template = $environment->parseString('before{% unsupported_compiler_stream %}after'); + $compiled = compileStreamTestTemplate($environment, $template); + + $interpreted = streamChunks($template, $environment->newRenderContext()); + $optimized = streamChunks($compiled, $environment->newRenderContext()); + + expect($optimized)->toBe($interpreted)->toBe(['before', 'runtime', 'after']); +}); + test('compiled stream preserves interrupts and empty chunks', function () { $environment = Environment::default(); $template = $environment->parseString('before{% break %}after'); From fa067a2f497ed43d2fe2f22bf75accdfb8fe3977 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 12:59:06 +0200 Subject: [PATCH 10/45] feat: harden compiled artifact publication --- .../Cache/FilesystemCompiledTemplateCache.php | 45 +++++- src/Compiler/CompiledTemplate.php | 62 +++++++- src/Environment.php | 47 +++++- .../CompilerArtifactSafetyTest.php | 134 ++++++++++++++++++ tests/Integration/CompilerTest.php | 22 ++- 5 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 tests/Integration/CompilerArtifactSafetyTest.php diff --git a/src/Compiler/Cache/FilesystemCompiledTemplateCache.php b/src/Compiler/Cache/FilesystemCompiledTemplateCache.php index cea38e6..d7f41b2 100644 --- a/src/Compiler/Cache/FilesystemCompiledTemplateCache.php +++ b/src/Compiler/Cache/FilesystemCompiledTemplateCache.php @@ -3,6 +3,7 @@ namespace Keepsuit\Liquid\Compiler\Cache; use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; +use Keepsuit\Liquid\Template; class FilesystemCompiledTemplateCache implements CompiledTemplateCache { @@ -25,7 +26,9 @@ public function get(string $hash): ?CompiledTemplateInterface return null; } - return $compiled instanceof CompiledTemplateInterface ? $compiled : null; + return $compiled instanceof Template && $compiled instanceof CompiledTemplateInterface + ? $compiled + : null; } public function has(string $hash): bool @@ -35,8 +38,44 @@ public function has(string $hash): bool public function set(string $hash, string $source): void { - if (file_put_contents($this->getPath($hash), $source) === false) { - throw new \RuntimeException(sprintf('Unable to write compiled template cache entry: %s', $hash)); + $path = $this->getPath($hash); + $temporaryPath = tempnam($this->cachePath, '.'.basename($path).'.tmp-'); + + if ($temporaryPath === false) { + throw new \RuntimeException(sprintf('Unable to create temporary compiled template cache entry: %s', $hash)); + } + + try { + $bytesWritten = file_put_contents($temporaryPath, $source); + + if ($bytesWritten !== strlen($source)) { + throw new \RuntimeException(sprintf('Unable to write compiled template cache entry: %s', $hash)); + } + + $this->publish($temporaryPath, $path, $hash); + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($path, true); + } + } finally { + if (is_file($temporaryPath)) { + unlink($temporaryPath); + } + } + } + + protected function publish(string $temporaryPath, string $path, string $hash): void + { + set_error_handler(static fn (): bool => true); + + try { + $published = rename($temporaryPath, $path); + } finally { + restore_error_handler(); + } + + if (! $published) { + throw new \RuntimeException(sprintf('Unable to publish compiled template cache entry: %s', $hash)); } } diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index b3fef56..ff43be0 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -79,7 +79,67 @@ public function stream(RenderContext $context): \Generator public static function decodeValue(string $payload): mixed { - return unserialize(base64_decode($payload), ['allowed_classes' => true]); + $serialized = base64_decode($payload, true); + + if ($serialized === false) { + throw new \RuntimeException('Invalid compiler value encoding.'); + } + + set_error_handler(static function (int $severity, string $message): never { + throw new \RuntimeException($message, $severity); + }); + + try { + $value = unserialize($serialized, ['allowed_classes' => true]); + } finally { + restore_error_handler(); + } + + if ($value === false && $serialized !== 'b:0;') { + throw new \RuntimeException('Invalid serialized compiler value.'); + } + + self::assertDecodedValue($value); + + return $value; + } + + /** + * @param array $seenObjects + */ + private static function assertDecodedValue(mixed $value, int $depth = 0, array &$seenObjects = []): void + { + if ($depth > 256 || is_resource($value)) { + throw new \RuntimeException('Unsafe decoded compiler value.'); + } + + if (is_array($value)) { + foreach ($value as $item) { + self::assertDecodedValue($item, $depth + 1, $seenObjects); + } + + return; + } + + if (! is_object($value)) { + return; + } + + if (get_class($value) === '__PHP_Incomplete_Class') { + throw new \RuntimeException('Incomplete decoded compiler class.'); + } + + $objectId = spl_object_id($value); + + if (isset($seenObjects[$objectId])) { + return; + } + + $seenObjects[$objectId] = true; + + foreach ((array) $value as $property) { + self::assertDecodedValue($property, $depth + 1, $seenObjects); + } } public static function renderCompiledBody(RenderContext $context, Closure $renderer, int $childCount): string diff --git a/src/Environment.php b/src/Environment.php index dcb66eb..aae8186 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid; +use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Compiler\Compiler; use Keepsuit\Liquid\Contracts\LiquidErrorHandler; use Keepsuit\Liquid\Contracts\LiquidExtension; @@ -144,10 +145,50 @@ public function compile(Template $template, string $compiledPath): void throw new \RuntimeException(sprintf('Unable to create compiled template directory: %s', $directory)); } - $bytesWritten = file_put_contents($compiledPath, (new Compiler)->compile($template)); + $source = (new Compiler)->compile($template); + $temporaryPath = tempnam($directory, '.'.basename($compiledPath).'.tmp-'); - if ($bytesWritten === false) { - throw new \RuntimeException(sprintf('Unable to write compiled template artifact: %s', $compiledPath)); + if ($temporaryPath === false) { + throw new \RuntimeException(sprintf('Unable to create temporary compiled template artifact: %s', $compiledPath)); + } + + try { + $bytesWritten = file_put_contents($temporaryPath, $source); + + if ($bytesWritten !== strlen($source)) { + throw new \RuntimeException(sprintf('Unable to write compiled template artifact: %s', $compiledPath)); + } + + $compiled = require $temporaryPath; + + if (! $compiled instanceof Template || ! $compiled instanceof CompiledTemplateInterface) { + throw new \RuntimeException(sprintf('Invalid compiled template artifact: %s', $compiledPath)); + } + + $this->publishCompiledArtifact($temporaryPath, $compiledPath); + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($compiledPath, true); + } + } finally { + if (is_file($temporaryPath)) { + unlink($temporaryPath); + } + } + } + + protected function publishCompiledArtifact(string $temporaryPath, string $compiledPath): void + { + set_error_handler(static fn (): bool => true); + + try { + $published = rename($temporaryPath, $compiledPath); + } finally { + restore_error_handler(); + } + + if (! $published) { + throw new \RuntimeException(sprintf('Unable to publish compiled template artifact: %s', $compiledPath)); } } diff --git a/tests/Integration/CompilerArtifactSafetyTest.php b/tests/Integration/CompilerArtifactSafetyTest.php new file mode 100644 index 0000000..f222e8d --- /dev/null +++ b/tests/Integration/CompilerArtifactSafetyTest.php @@ -0,0 +1,134 @@ +build(); + $template = $environment->parseString('safe artifact'); + + try { + $environment->compile($template, $path); + + expect($path)->toBeFile(); + expect(glob($directory.'/.compiled.php.tmp-*'))->toBe([]); + + /** @var Template $compiled */ + $compiled = require $path; + + expect($compiled)->toBeInstanceOf(Template::class); + expect($compiled)->toBeInstanceOf(CompiledTemplateInterface::class); + } finally { + removeCompilerArtifactSafetyDirectory($directory); + } +}); + +test('environment removes staged artifacts when publication fails', function () { + $directory = compilerArtifactSafetyDirectory(); + $path = compilerArtifactSafetyPath($directory); + mkdir($path); + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('safe artifact'); + + try { + expect(fn () => $environment->compile($template, $path)) + ->toThrow(RuntimeException::class); + expect($path)->toBeDirectory(); + expect(glob($directory.'/.compiled.php.tmp-*'))->toBe([]); + } finally { + removeCompilerArtifactSafetyDirectory($directory); + } +}); + +test('filesystem compiler cache publishes atomically and fails closed on invalid artifacts', function () { + $directory = compilerArtifactSafetyDirectory(); + $cache = new FilesystemCompiledTemplateCache($directory); + + try { + file_put_contents($directory.'/corrupt.php', 'get('corrupt'))->toBeNull(); + expect($cache->get('wrong'))->toBeNull(); + + $source = "set('valid', $source); + + expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); + expect(glob($directory.'/.valid.php.tmp-*'))->toBe([]); + } finally { + removeCompilerArtifactSafetyDirectory($directory); + } +}); + +test('filesystem compiler cache leaves its target untouched when publication fails', function () { + $directory = compilerArtifactSafetyDirectory(); + $cache = new FilesystemCompiledTemplateCache($directory); + mkdir($directory.'/blocked.php'); + + try { + expect(fn () => $cache->set('blocked', 'toThrow(RuntimeException::class); + expect($directory.'/blocked.php')->toBeDirectory(); + expect(glob($directory.'/.blocked.php.tmp-*'))->toBe([]); + } finally { + removeCompilerArtifactSafetyDirectory($directory); + } +}); + +test('compiled value decoding rejects malformed payloads and incomplete classes', function () { + expect(fn () => CompiledTemplate::decodeValue('not-valid-base64!')) + ->toThrow(RuntimeException::class); + expect(fn () => CompiledTemplate::decodeValue(base64_encode('not serialized'))) + ->toThrow(RuntimeException::class); + expect(fn () => CompiledTemplate::decodeValue(base64_encode('O:12:"MissingClass":0:{}'))) + ->toThrow(RuntimeException::class); + + expect(CompiledTemplate::decodeValue(base64_encode('b:0;')))->toBeFalse(); +}); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 100eba1..d9097e9 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -412,7 +412,27 @@ function temporaryCompiledTemplatePath(): string /** @var Template $compiled */ $compiled = require $compiledPath; - expect($compiled->render($environment->newRenderContext()))->toBe($literal); + expect($compiled->render($environment->newRenderContext())) + ->toBe($template->render($environment->newRenderContext())); + } finally { + @unlink($compiledPath); + } +}); + +test('compiled literals preserve quotes escapes and control characters', function () { + $environment = EnvironmentFactory::new()->build(); + $literal = "quote ' and \"\nline\r\t\0 "; + $template = $environment->parseString($literal); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var Template $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe($template->render($environment->newRenderContext())); } finally { @unlink($compiledPath); } From 71efe9198946123db2e7acf92f752948fa6ab952 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 13:20:56 +0200 Subject: [PATCH 11/45] perf: add compiler benchmark gate --- composer.json | 1 + performance/README.md | 27 +- performance/benchmarks/CompilerBench.php | 367 ++++++++++++++++++ .../Unit/Performance/PhpBenchCompareTest.php | 15 + tools/phpbench-compare.php | 14 +- 5 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 performance/benchmarks/CompilerBench.php diff --git a/composer.json b/composer.json index d139568..c297566 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "benchmark": "phpbench run --group=default --warmup=1 --retry-threshold=5 --report=aggregate", "benchmark:cache": "phpbench run --group=cache --warmup=1 --retry-threshold=5 --report=aggregate", "benchmark:operations": "phpbench run --group=operations --warmup=1 --retry-threshold=5 --report=aggregate", + "benchmark:compiler": "phpbench run --group=compiler --warmup=1 --retry-threshold=5 --report=aggregate", "profile": "phpbench xdebug:profile" }, "config": { diff --git a/performance/README.md b/performance/README.md index 93a0c27..aca7d8a 100644 --- a/performance/README.md +++ b/performance/README.md @@ -4,12 +4,13 @@ composer benchmark # default group: the storefront theme composer benchmark:cache # cache group: template-cache backends composer benchmark:operations # operations group: individual operations +composer benchmark:compiler # compiler group: compiled/interpreted pipeline php performance/profile-theme.php --output=profile.json ``` ## What each group is for -The three groups have different jobs, and conflating them is how a benchmark suite +The benchmark groups have different jobs, and conflating them is how a benchmark suite stops being useful. **`default`** (`ThemeBench`) renders the storefront theme — 29 templates across @@ -25,6 +26,30 @@ This is where per-feature sensitivity lives, and where a benchmark is allowed to be unrealistic: an artificial template that does one thing 64 times is a better instrument than a realistic page. +**`compiler`** (`CompilerBench`) measures compile/write, fresh artifact +require/load, compiled render, compiled stream, interpreted render and +interpreted stream as separate subjects over the same storefront fixture. All +template source reads, parsing, artifact setup and render data construction are +performed in setup; render and stream subjects only exercise their named runtime +path. Setup also compares compiled and interpreted output and exact stream chunk +lists before timing begins, including templates reached through partial lookup. +The fresh artifact load subject invalidates filesystem/opcache state in a +`BeforeMethods` hook; its timed body only requires and validates artifacts. + +Run the compiler group with the same aggregate shape as the existing baseline: + +```bash +vendor/bin/phpbench run --group=compiler --warmup=1 --retry-threshold=5 \ + --report=aggregate --output=json > /tmp/php-liquid-compiler.json +php tools/phpbench-compare.php build/base.json /tmp/php-liquid-compiler.json +``` + +The current `build/base.json` contains only the four `ThemeBench` default-group +rows, so compiler rows are reported as branch-only and are not treated as an +improvement or regression. Establish a matching compiler baseline on `main` +before drawing compiler performance conclusions; the ignored baseline artifact +is intentionally not part of the repository. + The split is what lets the theme be realistic. Whenever realism and measurement sensitivity conflict inside the theme, realism wins — sensitivity is not the theme's job. diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php new file mode 100644 index 0000000..80332c2 --- /dev/null +++ b/performance/benchmarks/CompilerBench.php @@ -0,0 +1,367 @@ + */ + private array $templateNames; + + /** @var list */ + private array $pageTemplateNames; + + private string $layoutTemplateName; + + /** @var array */ + private array $interpretedTemplates; + + /** @var array */ + private array $compiledTemplates; + + /** @var array */ + private array $artifactPaths; + + /** + * @var list, layout: array}>> + */ + private array $renderDataSets; + + /** + * @var list, layout: array}>> + */ + private array $correctnessDataSets; + + private int $dataSetIndex = 0; + + public function setUp(): void + { + $this->templateNames = StorefrontTheme::templateNames(); + $this->pageTemplateNames = StorefrontTheme::pageTemplateNames(); + $this->layoutTemplateName = StorefrontTheme::layoutTemplateName(); + $this->artifactDirectory = sys_get_temp_dir().'/php-liquid-compiler-'.bin2hex(random_bytes(8)); + + if (! mkdir($this->artifactDirectory, 0755, true) && ! is_dir($this->artifactDirectory)) { + throw new \RuntimeException('Could not create the compiler benchmark artifact directory.'); + } + + $this->interpretedEnvironment = StorefrontTheme::environmentFactory() + ->setTemplatesCache(new MemoryTemplatesCache) + ->build(); + $this->compiledEnvironment = StorefrontTheme::environmentFactory() + ->setTemplatesCache(new MemoryTemplatesCache) + ->build(); + $this->interpretedTemplates = []; + $this->compiledTemplates = []; + $this->artifactPaths = []; + $this->dataSetIndex = 0; + + // Read and parse fixture sources before the benchmark subjects run. + foreach ($this->templateNames as $templateName) { + $source = StorefrontTheme::templateSource($templateName); + $template = $this->interpretedEnvironment->parseString($source, $templateName); + $this->interpretedTemplates[$templateName] = $template; + $this->interpretedEnvironment->templatesCache->set($templateName, $template); + + $artifactPath = $this->artifactDirectory.'/'.str_replace('.', '_', $templateName).'.php'; + $this->artifactPaths[$templateName] = $artifactPath; + $this->compiledEnvironment->compile($template, $artifactPath); + $compiledTemplate = $this->loadCompiledArtifact($artifactPath); + $this->compiledTemplates[$templateName] = $compiledTemplate; + $this->compiledEnvironment->templatesCache->set($templateName, $compiledTemplate); + } + + // Keep fixture/data creation out of render and stream timing. + $this->renderDataSets = $this->buildRenderDataSets(self::DATA_SET_COUNT); + $this->correctnessDataSets = $this->buildRenderDataSets(4); + + $this->assertCorrectness(); + $this->dataSetIndex = 0; + } + + public function tearDown(): void + { + foreach ($this->artifactPaths as $artifactPath) { + if (is_file($artifactPath)) { + unlink($artifactPath); + } + } + + if (is_dir($this->artifactDirectory)) { + rmdir($this->artifactDirectory); + } + } + + public function benchCompileWrite(): void + { + foreach ($this->interpretedTemplates as $templateName => $template) { + $this->compiledEnvironment->compile($template, $this->artifactPaths[$templateName]); + } + } + + #[BeforeMethods('prepareFreshArtifactLoad')] + public function benchFreshArtifactLoad(): void + { + foreach ($this->artifactPaths as $artifactPath) { + $this->loadCompiledArtifact($artifactPath); + } + } + + /** + * Prepare the artifact state before PHPBench starts timing this subject; + * the subject itself measures only require/load and contract validation. + */ + public function prepareFreshArtifactLoad(): void + { + foreach ($this->artifactPaths as $artifactPath) { + clearstatcache(true, $artifactPath); + + if (function_exists('opcache_invalidate')) { + opcache_invalidate($artifactPath, true); + } + } + } + + public function benchCompiledRender(): void + { + $renderData = $this->nextRenderDataSet(); + + foreach ($this->pageTemplateNames as $pageTemplateName) { + $this->renderPage( + $this->compiledEnvironment, + $this->compiledTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + ); + } + } + + public function benchCompiledStream(): void + { + $renderData = $this->nextRenderDataSet(); + + foreach ($this->pageTemplateNames as $pageTemplateName) { + $this->drain($this->streamPage( + $this->compiledEnvironment, + $this->compiledTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + )); + } + } + + public function benchInterpretedRender(): void + { + $renderData = $this->nextRenderDataSet(); + + foreach ($this->pageTemplateNames as $pageTemplateName) { + $this->renderPage( + $this->interpretedEnvironment, + $this->interpretedTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + ); + } + } + + public function benchInterpretedStream(): void + { + $renderData = $this->nextRenderDataSet(); + + foreach ($this->pageTemplateNames as $pageTemplateName) { + $this->drain($this->streamPage( + $this->interpretedEnvironment, + $this->interpretedTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + )); + } + } + + /** + * @param array $templates + * @param array{page: array, layout: array} $renderData + */ + private function renderPage( + Environment $environment, + array $templates, + string $pageTemplateName, + array $renderData, + ): string { + $content = $templates[$pageTemplateName]->render( + $environment->newRenderContext(staticData: $renderData['page']), + ); + + return $templates[$this->layoutTemplateName]->render($environment->newRenderContext( + staticData: [ + ...$renderData['layout'], + 'content_for_layout' => $content, + ], + )); + } + + /** + * @param array $templates + * @param array{page: array, layout: array} $renderData + * @return \Generator + */ + private function streamPage( + Environment $environment, + array $templates, + string $pageTemplateName, + array $renderData, + ): \Generator { + $content = $templates[$pageTemplateName]->stream( + $environment->newRenderContext(staticData: $renderData['page']), + ); + + return $templates[$this->layoutTemplateName]->stream($environment->newRenderContext( + staticData: [ + ...$renderData['layout'], + 'content_for_layout' => $content, + ], + )); + } + + /** + * @return array, layout: array}> + */ + private function nextRenderDataSet(): array + { + $renderData = $this->renderDataSets[$this->dataSetIndex % self::DATA_SET_COUNT]; + $this->dataSetIndex++; + + return $renderData; + } + + /** + * @return list, layout: array}>> + */ + private function buildRenderDataSets(int $count): array + { + $renderDataSets = []; + for ($dataSet = 0; $dataSet < $count; $dataSet++) { + $renderData = []; + foreach ($this->pageTemplateNames as $pageTemplateName) { + $renderData[$pageTemplateName] = StorefrontTheme::renderData($pageTemplateName); + } + $renderDataSets[] = $renderData; + } + + return $renderDataSets; + } + + private function loadCompiledArtifact(string $artifactPath): Template + { + $template = require $artifactPath; + + if (! $template instanceof Template || ! $template instanceof CompiledTemplateInterface) { + throw new \RuntimeException("Invalid compiler benchmark artifact: {$artifactPath}"); + } + + return $template; + } + + /** + * @param \Generator $stream + */ + private function drain(\Generator $stream): void + { + while ($stream->valid()) { + $stream->next(); + } + } + + private function assertCorrectness(): void + { + foreach (array_slice($this->correctnessDataSets, 0, 2) as $renderData) { + foreach ($this->pageTemplateNames as $pageTemplateName) { + $expected = $this->renderPage( + $this->interpretedEnvironment, + $this->interpretedTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + ); + $actual = $this->renderPage( + $this->compiledEnvironment, + $this->compiledTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + ); + + if ($actual !== $expected) { + throw new \RuntimeException("Compiled render mismatch for {$pageTemplateName}."); + } + } + } + + foreach (array_slice($this->correctnessDataSets, 2, 2) as $renderData) { + foreach ($this->pageTemplateNames as $pageTemplateName) { + $expected = $this->collect($this->streamPage( + $this->interpretedEnvironment, + $this->interpretedTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + )); + $actual = $this->collect($this->streamPage( + $this->compiledEnvironment, + $this->compiledTemplates, + $pageTemplateName, + $renderData[$pageTemplateName], + )); + + if ($actual !== $expected) { + throw new \RuntimeException("Compiled stream mismatch for {$pageTemplateName}."); + } + } + } + } + + /** + * @param \Generator $stream + * @return list + */ + private function collect(\Generator $stream): array + { + $chunks = []; + foreach ($stream as $chunk) { + $chunks[] = $chunk; + } + + return $chunks; + } +} diff --git a/tests/Unit/Performance/PhpBenchCompareTest.php b/tests/Unit/Performance/PhpBenchCompareTest.php index efa7692..05235d0 100644 --- a/tests/Unit/Performance/PhpBenchCompareTest.php +++ b/tests/Unit/Performance/PhpBenchCompareTest.php @@ -95,3 +95,18 @@ function runPhpBenchCompare(array $base, array $pr, ?string $threshold = null): expect($result['exit_code'])->toBe(2) ->and($result['stderr'])->toContain('Unexpected PHPBench aggregate JSON'); }); + +test('the PHPBench comparator reports branch-only subjects without comparing them', function () { + $branchOnlyRow = phpBenchAggregateRow(); + $branchOnlyRow['benchmark'] = 'CompilerBench'; + $branchOnlyRow['subject'] = 'benchCompiledRender'; + + $result = runPhpBenchCompare( + base: [phpBenchAggregateRow()], + pr: [$branchOnlyRow], + ); + + expect($result['exit_code'])->toBe(0) + ->and($result['stdout'])->toContain('No comparable benchmark rows') + ->toContain('Branch-only subjects (missing in base result): `CompilerBench::benchCompiledRender`'); +}); diff --git a/tools/phpbench-compare.php b/tools/phpbench-compare.php index 3825012..42eced2 100644 --- a/tools/phpbench-compare.php +++ b/tools/phpbench-compare.php @@ -17,7 +17,19 @@ sort($sharedNames); if ($sharedNames === []) { - echo "> No comparable benchmark rows: the PR benchmark suite has changed. Establish a matching baseline on `main` before drawing performance conclusions.\n"; + echo "> No comparable benchmark rows: establish a matching baseline on `main` before drawing performance conclusions.\n\n"; + + $missingInPr = array_values(array_diff(array_keys($baseBenchmarks), array_keys($prBenchmarks))); + $missingInBase = array_values(array_diff(array_keys($prBenchmarks), array_keys($baseBenchmarks))); + if ($missingInPr !== []) { + sort($missingInPr); + echo '- Missing in PR result: `'.implode('`, `', $missingInPr).'`'."\n"; + } + if ($missingInBase !== []) { + sort($missingInBase); + echo '- Branch-only subjects (missing in base result): `'.implode('`, `', $missingInBase).'`'."\n"; + } + exit(0); } From 383ad9461ad8ecabc902e27630d80cba086cc8bf Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 14:39:15 +0200 Subject: [PATCH 12/45] perf: benchmark compiled template cache loads --- performance/README.md | 9 ++-- .../Support/CompiledTemplatesCache.php | 54 +++++++++++++++++++ performance/benchmarks/TemplateCacheBench.php | 28 ++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 performance/Support/CompiledTemplatesCache.php diff --git a/performance/README.md b/performance/README.md index aca7d8a..9e9c736 100644 --- a/performance/README.md +++ b/performance/README.md @@ -18,8 +18,11 @@ four pages. It answers *"did rendering get slower"* and nothing more. It cannot tell you *what* got slower, because a regression in any one tag is averaged across everything else. Don't expect it to localize. -**`cache`** (`TemplateCacheBench`) measures compilation and fresh-environment -loading for every supported template-cache backend. +**`cache`** (`TemplateCacheBench`) measures template-cache build and +fresh-environment load+render for every supported backend. The compiled subject +builds deterministic PHP artifacts during setup, then +`benchLoadAndRenderCompiled` measures their filesystem-backed load+render path; +artifact compilation and cache setup are outside the timed boundary. **`operations`** (`OperationBench`) measures single operations on tiny templates. This is where per-feature sensitivity lives, and where a benchmark is allowed to @@ -128,7 +131,7 @@ Known gaps, in rough priority order: - **Coverage-only tags.** `tablerow`, `increment`, `decrement`, `ifchanged`, `raw` and `doc` are unbenchmarked. Real themes barely use them, so they belong in `operations` rather than in the theme. -- **`TemplateCacheBench` shape.** Six subjects are driven by six near-identical +- **`TemplateCacheBench` shape.** Seven subjects are driven by seven near-identical `setUp*` wrappers around a string `match`; `ParamProviders` could reduce that repetition. Each benchmark setup now receives a unique temporary cache path, so concurrent runs do not share cache files. diff --git a/performance/Support/CompiledTemplatesCache.php b/performance/Support/CompiledTemplatesCache.php new file mode 100644 index 0000000..c7a7db3 --- /dev/null +++ b/performance/Support/CompiledTemplatesCache.php @@ -0,0 +1,54 @@ +getCompiledPath($name); + + if (! is_file($compiledPath)) { + return null; + } + + return $this->loadCompiledTemplate($compiledPath); + } + + public function pathFor(string $name): string + { + return $this->getCompiledPath($name); + } + + protected function getCompiledPath(string $name): string + { + return parent::getCompiledPath($name).'.php'; + } + + protected function saveCompiledTemplate(string $compiledPath, Template $template): void + { + throw new \LogicException('The compiled templates cache is read-only.'); + } + + protected function loadCompiledTemplate(string $compiledPath): ?Template + { + try { + $template = require $compiledPath; + } catch (\Throwable) { + return null; + } + + return $template instanceof Template && $template instanceof CompiledTemplateInterface + ? $template + : null; + } +} diff --git a/performance/benchmarks/TemplateCacheBench.php b/performance/benchmarks/TemplateCacheBench.php index 0d7c3c4..84c4747 100644 --- a/performance/benchmarks/TemplateCacheBench.php +++ b/performance/benchmarks/TemplateCacheBench.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Contracts\LiquidTemplatesCache; use Keepsuit\Liquid\Environment; +use Keepsuit\Liquid\Performance\Support\CompiledTemplatesCache; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use Keepsuit\Liquid\TemplatesCache\SerializeTemplatesCache; @@ -74,6 +75,12 @@ public function benchLoadAndRenderVarExporter(): void $this->renderCachedTheme(); } + #[BeforeMethods('setUpCompiledCachedRender')] + public function benchLoadAndRenderCompiled(): void + { + $this->renderCachedTheme(); + } + public function setUpInMemoryBuild(): void { $this->setUpBuild('memory'); @@ -104,6 +111,27 @@ public function setUpVarExporterCachedRender(): void $this->setUpCachedRender('var-exporter'); } + public function setUpCompiledCachedRender(): void + { + $this->templateNames = StorefrontTheme::templateNames(); + $this->pageTemplateNames = StorefrontTheme::pageTemplateNames(); + $this->cacheDirectory = sys_get_temp_dir().'/'.self::CACHE_DIRECTORY.'-'.bin2hex(random_bytes(8)); + $compiledCache = new CompiledTemplatesCache($this->cachePath('compiled')); + $this->cache = $compiledCache; + $compilerEnvironment = StorefrontTheme::environmentFactory() + ->setTemplatesCache(new MemoryTemplatesCache) + ->build(); + + foreach ($this->templateNames as $templateName) { + $template = $compilerEnvironment->parseTemplate($templateName); + $compilerEnvironment->compile($template, $compiledCache->pathFor($templateName)); + } + + $this->environment = StorefrontTheme::environmentFactory() + ->setTemplatesCache($this->cache) + ->build(); + } + public function clearCache(): void { $this->cache->clear(); From 96cb1ca5db494105f7dfc63392fbe4048d38a576 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 15:06:28 +0200 Subject: [PATCH 13/45] perf: benchmark compiled theme rendering --- performance/README.md | 9 ++++-- performance/benchmarks/ThemeBench.php | 46 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/performance/README.md b/performance/README.md index 9e9c736..a49c0a4 100644 --- a/performance/README.md +++ b/performance/README.md @@ -14,9 +14,12 @@ The benchmark groups have different jobs, and conflating them is how a benchmark stops being useful. **`default`** (`ThemeBench`) renders the storefront theme — 29 templates across -four pages. It answers *"did rendering get slower"* and nothing more. It cannot -tell you *what* got slower, because a regression in any one tag is averaged -across everything else. Don't expect it to localize. +four pages — with both interpreted `benchRender` and precompiled +`benchRenderCompiled` subjects. Compilation and artifact loading happen during +setup, outside the timed compiled-render subject. It answers *"did rendering get +slower"* and nothing more. It cannot tell you *what* got slower, because a +regression in any one tag is averaged across everything else. Don't expect it to +localize. **`cache`** (`TemplateCacheBench`) measures template-cache build and fresh-environment load+render for every supported backend. The compiled subject diff --git a/performance/benchmarks/ThemeBench.php b/performance/benchmarks/ThemeBench.php index a327ab8..0b9ffb2 100644 --- a/performance/benchmarks/ThemeBench.php +++ b/performance/benchmarks/ThemeBench.php @@ -2,8 +2,10 @@ namespace Keepsuit\Liquid\Performance\benchmarks; +use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; +use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Groups; use PhpBench\Attributes\Iterations; @@ -32,6 +34,8 @@ class ThemeBench { private Environment $environment; + private Environment $compiledEnvironment; + /** * Sources are read up front: reading them inside a benchmark would measure * the filesystem instead of the tokenizer and the parser. @@ -46,11 +50,29 @@ class ThemeBench public function setUp(): void { $this->environment = StorefrontTheme::environment(); + $this->compiledEnvironment = StorefrontTheme::environmentFactory() + ->setTemplatesCache(new MemoryTemplatesCache) + ->build(); + + $compiledCacheDirectory = $this->prepareCompiledDirectory(__DIR__.'/cache/compiled'); + $this->sources = []; foreach (StorefrontTheme::templateNames() as $name) { $this->environment->parseTemplate($name); $this->sources[$name] = StorefrontTheme::templateSource($name); + + $template = $this->compiledEnvironment->parseTemplate($name); + $artifactPath = $compiledCacheDirectory.'/'.str_replace('.', '_', $name).'.php'; + $this->compiledEnvironment->compile($template, $artifactPath); + + $compiledTemplate = require $artifactPath; + + if (! $compiledTemplate instanceof CompiledTemplateInterface) { + throw new \RuntimeException("Invalid compiled theme benchmark artifact: {$artifactPath}"); + } + + $this->compiledEnvironment->templatesCache->set($name, $compiledTemplate); } $this->pageTemplateNames = StorefrontTheme::pageTemplateNames(); @@ -77,6 +99,13 @@ public function benchRender(): void } } + public function benchRenderCompiled(): void + { + foreach ($this->pageTemplateNames as $pageTemplateName) { + StorefrontTheme::renderPage($this->compiledEnvironment, $pageTemplateName); + } + } + public function benchStream(): void { foreach ($this->pageTemplateNames as $pageTemplateName) { @@ -84,4 +113,21 @@ public function benchStream(): void } } } + + protected function prepareCompiledDirectory(string $path): string + { + if (is_dir($path)) { + $items = new \FilesystemIterator($path); + foreach ($items as $item) { + unlink($item); + } + return $path; + } + + if (! mkdir($path, 0755, true)) { + throw new \RuntimeException('Could not create the compiled theme benchmark artifact directory.'); + } + + return $path; + } } From 47b4fb61251a77e1a3634c99044eacf7c7bc20f1 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 17:22:20 +0200 Subject: [PATCH 14/45] docs: finalize compiled template contracts --- .scratch/compiler/issues/001-artifact-contract.md | 2 +- .scratch/compiler/issues/003-runtime-parity.md | 2 +- .scratch/compiler/issues/004-extension-seam.md | 2 +- .scratch/compiler/issues/005-artifact-safety.md | 4 ++-- .scratch/compiler/issues/006-performance-gate.md | 2 +- .scratch/compiler/issues/007-static-partial-inlining.md | 4 ++-- .scratch/compiler/map.md | 1 + 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.scratch/compiler/issues/001-artifact-contract.md b/.scratch/compiler/issues/001-artifact-contract.md index bf1b43a..45663d0 100644 --- a/.scratch/compiler/issues/001-artifact-contract.md +++ b/.scratch/compiler/issues/001-artifact-contract.md @@ -13,4 +13,4 @@ What public compile API should write the PHP artifact, and what exactly should r ## Resolution -`Environment::compile(Template $template, string $compiledPath)` is the additive public entry point. It writes a PHP artifact at the caller-provided path; requiring that artifact returns a `Template`-compatible compiled object that can render with the existing `RenderContext`. Existing parsing, rendering, and interpreted cache APIs remain unchanged. Cache identity and environment consistency remain application-managed. +`Environment::compile(Template $template, string $compiledPath)` is the additive public entry point. It writes a PHP artifact at the caller-provided path; the artifact defines a deterministic final generated class extending the abstract compiled-template runtime base and returns an instance implementing `TemplateInterface`. The compiled template exposes both `render()` and lazy `stream()`, with `render()` collecting the stream output. Existing parsing, rendering, and interpreted cache APIs remain unchanged. Cache identity and environment consistency remain application-managed. diff --git a/.scratch/compiler/issues/003-runtime-parity.md b/.scratch/compiler/issues/003-runtime-parity.md index 8e2d823..d7ae21d 100644 --- a/.scratch/compiler/issues/003-runtime-parity.md +++ b/.scratch/compiler/issues/003-runtime-parity.md @@ -13,4 +13,4 @@ Which observable behaviors must compiled render and stream preserve—output chu ## Resolution -Compiled artifacts must preserve the existing `Template` contract for both `render()` and `stream()`. Streaming remains lazy and preserves the interpreter's observable chunk boundaries; it must not collapse output into one final chunk. Compiled execution must merge and persist shared outputs and errors, enforce the same render/assign/resource limits, preserve interrupt behavior, and attach the same template and line metadata to Liquid exceptions. A compiled path that cannot preserve these semantics falls back to the existing interpreter behavior for that operation. +Compiled artifacts must preserve the `TemplateInterface` contract for both `render()` and `stream()`. Streaming remains lazy and must produce the same complete output; chunk boundaries may differ from the interpreter. Compiled `render()` collects the compiled stream, while the standard `Template` retains separate render and stream implementations. Compiled execution must merge and persist shared outputs and errors, enforce the same render/assign/resource limits, preserve interrupt behavior, and attach the same template and source-line metadata to Liquid exceptions. A compiled path that cannot preserve these semantics uses a safe interpreter fallback for the affected node or fails compilation when that fallback cannot be reconstructed. diff --git a/.scratch/compiler/issues/004-extension-seam.md b/.scratch/compiler/issues/004-extension-seam.md index 530db03..433b84f 100644 --- a/.scratch/compiler/issues/004-extension-seam.md +++ b/.scratch/compiler/issues/004-extension-seam.md @@ -13,4 +13,4 @@ What stable interface should custom nodes and tags implement to emit optimized P ## Resolution -`CanBeCompiled` is an optional interface implemented by individual nodes or tags; its compiler-context method emits optimized PHP without changing `Tag`, `LiquidExtension`, `TagRegistry`, or filter registration APIs. Filters continue to resolve through the runtime context. Nodes and tags without the interface use their existing `render()` or `stream()` behavior through a compiler fallback. If a fallback node cannot be safely reconstructed in the artifact, compilation declines that optimized path and the caller retains the interpreted template. +`CanBeCompiled` is an optional trusted PHP extension interface implemented by individual nodes or tags; its fluent compiler-context method emits the stream-oriented PHP body without changing `Tag`, `LiquidExtension`, `TagRegistry`, or filter registration APIs. Filters continue to resolve through the runtime context. Nodes and tags without the interface use their existing `stream()` or `render()` behavior through a fallback that is reconstructed with Symfony VarExporter and loaded once per artifact. Template-controlled text, names, and values never reach raw PHP emission. If a fallback node cannot be safely represented by VarExporter, compilation fails with the template name, node class, and source line. diff --git a/.scratch/compiler/issues/005-artifact-safety.md b/.scratch/compiler/issues/005-artifact-safety.md index ffd0314..5341507 100644 --- a/.scratch/compiler/issues/005-artifact-safety.md +++ b/.scratch/compiler/issues/005-artifact-safety.md @@ -13,6 +13,6 @@ What guarantees are required when writing and loading generated PHP files—safe ## Resolution -The compiled artifact directory is trusted and application-owned; generated PHP is not sandboxed. Every template-originated string, name, and value must pass through a typed literal encoder such as `var_export`, and template content must never reach raw PHP emission or choose generated identifiers. Raw source-generation hooks are trusted compiler/plugin code, not template input. Compilation must decline the optimized path when a value or fallback node cannot be safely encoded or reconstructed. +The compiled artifact directory is trusted and application-owned; generated PHP is not sandboxed. Every template-originated string, name, and value must pass through Symfony VarExporter, and template content must never reach raw PHP emission or choose generated identifiers. Raw source-generation hooks are trusted compiler/plugin code, not template input. Compilation must fail clearly when a value or fallback node cannot be safely encoded or reconstructed. Generated class identities are deterministic from template/source content and do not include a compiler-version marker; the application owns invalidation. -Artifacts are written to a same-directory temporary file and atomically published, with deterministic content-based artifact/class identities. Loading validates the returned `Template`-compatible object and treats corrupt or invalid files as cache misses. OPcache is invalidated after publication; deployments may use versioned or rebuilt artifact directories. Security coverage must include PHP-looking template payloads, quotes, escapes, control characters, and generated-source syntax validation. Existing interpreted caches remain unchanged. +Artifacts are written to a same-directory temporary file and atomically published, with deterministic content-based artifact/class identities. Loading validates the returned `TemplateInterface` object and treats corrupt or invalid files as cache misses. OPcache is invalidated after publication; deployments may use versioned or rebuilt artifact directories. Security coverage must include PHP-looking template payloads, quotes, escapes, control characters, and generated-source syntax validation. Existing interpreted caches remain unchanged. diff --git a/.scratch/compiler/issues/006-performance-gate.md b/.scratch/compiler/issues/006-performance-gate.md index 485bcf4..e753312 100644 --- a/.scratch/compiler/issues/006-performance-gate.md +++ b/.scratch/compiler/issues/006-performance-gate.md @@ -13,4 +13,4 @@ Which representative workloads and separate tokenize, parse, compile, load, rend ## Resolution -The gate uses an identical baseline on `main` and one deterministic production-shaped storefront workload. It measures tokenize, parse, compile/write, fresh artifact require/load, compiled render, compiled stream, interpreted render/stream, and existing template-cache load/render separately. Correctness checks compare exact rendered output and stream chunks outside timed subjects. A compiled path must improve beyond the existing ±2% noise band with RSD at or below 5%, avoid interpreter regressions and material memory growth, and report compile/write cost separately. Rollout remains opt-in; static partial inlining is evaluated only after this baseline is reliable. +The gate uses an identical baseline on `main` and one deterministic production-shaped storefront workload. It measures tokenize, parse, compile/write, fresh artifact require/load, compiled render, compiled stream, interpreted render/stream, and existing template-cache load/render separately. Correctness checks compare exact complete rendered output and fully consumed stream output outside timed subjects; stream laziness and error behavior remain covered by focused tests. A compiled path must improve beyond the existing ±2% noise band with RSD at or below 5%, avoid interpreter regressions and material memory growth, and report compile/write cost separately. Rollout remains opt-in; static partial inlining is evaluated only after this baseline is reliable. diff --git a/.scratch/compiler/issues/007-static-partial-inlining.md b/.scratch/compiler/issues/007-static-partial-inlining.md index 1e44524..c832082 100644 --- a/.scratch/compiler/issues/007-static-partial-inlining.md +++ b/.scratch/compiler/issues/007-static-partial-inlining.md @@ -17,8 +17,8 @@ Static partial inlining is a future opt-in optimization; version one keeps one a A partial is eligible only when its name is a literal known during parsing, its complete transitive dependency graph is available and acyclic, and every participating node and tag can emit safe compiled code. Dynamic or unknown names, cycles, unsupported compilation, unsafe fallback, or incomplete dependency discovery retain runtime linking. -Inlining embeds the compiled partial body in the parent artifact but retains the partial's isolated `RenderContext` boundary; it must preserve render and stream chunk behavior, output bags, template and line exception metadata, resource limits, interrupts, and current error handling. +Inlining embeds the compiled partial body in the parent artifact but retains the partial's isolated `RenderContext` boundary; it must preserve complete render and stream output, output bags, template and line exception metadata, resource limits, interrupts, and current error handling. Chunk boundaries need not remain identical. -The parent artifact identity includes transitive dependency content hashes and compiler and artifact format versions. The application owns invalidation and must rebuild affected parents, publish a consistent artifact set atomically or through a versioned artifact directory, and never activate a parent with stale inlined dependencies. +The parent artifact identity includes transitive dependency content hashes. The compiler does not impose a compiler-version component on generated class names; the application owns invalidation and may include its own artifact-format key. It must rebuild affected parents, publish a consistent artifact set atomically or through a versioned artifact directory, and never activate a parent with stale inlined dependencies. Inlining is accepted only when exact output, error, and stream tests pass and the representative storefront benchmark improves compiled render and stream beyond the established noise band (more than 2%, RSD at most 5%) without interpreter regressions or material memory growth. If it does not clear that gate, runtime-linked artifacts remain the implementation. diff --git a/.scratch/compiler/map.md b/.scratch/compiler/map.md index f20603a..ac03bdc 100644 --- a/.scratch/compiler/map.md +++ b/.scratch/compiler/map.md @@ -26,6 +26,7 @@ Produce an implementation-ready, benchmark-backed design for an additive PHP com ## Not yet specified +- Generated PHP line-to-Liquid debug maps beyond preserving Liquid source lines in runtime exceptions. ## Out of scope From 6a1d728553305728563eec179e79a5cd18eac0aa Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 17:37:08 +0200 Subject: [PATCH 15/45] refactor: add template contract base --- src/AbstractTemplate.php | 20 ++++++++++++++++++++ src/Template.php | 18 +++++------------- src/TemplateInterface.php | 24 ++++++++++++++++++++++++ tests/Integration/TemplateTest.php | 13 +++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) create mode 100644 src/AbstractTemplate.php create mode 100644 src/TemplateInterface.php diff --git a/src/AbstractTemplate.php b/src/AbstractTemplate.php new file mode 100644 index 0000000..3000df5 --- /dev/null +++ b/src/AbstractTemplate.php @@ -0,0 +1,20 @@ +state; + } + + public function getErrors(): array + { + return $this->state->errors; + } +} diff --git a/src/Template.php b/src/Template.php index 4a553e2..20d1e16 100644 --- a/src/Template.php +++ b/src/Template.php @@ -6,12 +6,14 @@ use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Render\RenderContext; -class Template +class Template extends AbstractTemplate { public function __construct( public readonly Document $root, - public readonly TemplateSharedState $state = new TemplateSharedState - ) {} + TemplateSharedState $state = new TemplateSharedState, + ) { + parent::__construct($state); + } /** * @throws LiquidException @@ -49,16 +51,6 @@ public function stream(RenderContext $context): \Generator } } - public function getState(): TemplateSharedState - { - return $this->state; - } - - public function getErrors(): array - { - return $this->state->errors; - } - public function name(): ?string { return $this->root->name; diff --git a/src/TemplateInterface.php b/src/TemplateInterface.php new file mode 100644 index 0000000..5b65ffd --- /dev/null +++ b/src/TemplateInterface.php @@ -0,0 +1,24 @@ + + */ + public function stream(RenderContext $context): \Generator; + + public function getState(): TemplateSharedState; + + /** + * @return array<\Throwable> + */ + public function getErrors(): array; + + public function name(): ?string; +} diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index 29b0620..bcbc5ef 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -1,5 +1,6 @@ parseString('hello', name: 'hello'); + + expect($template) + ->toBeInstanceOf(TemplateInterface::class) + ->and($template->getState())->toBeInstanceOf(TemplateSharedState::class) + ->and($template->getErrors())->toBeEmpty() + ->and($template->name())->toBe('hello'); +}); + test('assigns persist on same context between renders', function () { $template = parseTemplate("{{ foo }}{% assign foo = 'foo' %}{{ foo }}"); From d4506ccbe0445cda5be3482696345d4c7cde5433 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 17:50:44 +0200 Subject: [PATCH 16/45] refactor: widen template execution seams --- .../Support/CompiledTemplatesCache.php | 10 ++--- performance/benchmarks/CompilerBench.php | 14 +++--- performance/benchmarks/OperationBench.php | 18 ++++---- phpstan.neon | 2 + pint.json | 3 +- .../Cache/FilesystemCompiledTemplateCache.php | 3 +- src/Compiler/CompiledTemplateInterface.php | 9 +--- src/Contracts/LiquidTemplatesCache.php | 6 +-- src/Environment.php | 12 +++-- src/Parse/ParseContext.php | 7 +-- src/Render/RenderContext.php | 6 +-- src/Tags/RenderTag.php | 4 +- .../FilesystemTemplatesCache.php | 10 ++--- src/TemplatesCache/MemoryTemplatesCache.php | 8 ++-- .../SerializeTemplatesCache.php | 8 ++-- .../VarExportTemplatesCache.php | 8 ++-- tests/Integration/CompilerTest.php | 9 +++- tests/Integration/StreamTest.php | 8 ++-- tests/Integration/Tags/RenderTagTest.php | 4 +- tests/Integration/TemplateTest.php | 45 +++++++++++++++++++ tests/Pest.php | 4 +- 21 files changed, 125 insertions(+), 73 deletions(-) diff --git a/performance/Support/CompiledTemplatesCache.php b/performance/Support/CompiledTemplatesCache.php index c7a7db3..1b78a62 100644 --- a/performance/Support/CompiledTemplatesCache.php +++ b/performance/Support/CompiledTemplatesCache.php @@ -3,7 +3,7 @@ namespace Keepsuit\Liquid\Performance\Support; use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\TemplatesCache\FilesystemTemplatesCache; final class CompiledTemplatesCache extends FilesystemTemplatesCache @@ -13,7 +13,7 @@ public function __construct(string $cachePath) parent::__construct($cachePath, keepInMemory: false); } - public function get(string $name): ?Template + public function get(string $name): ?TemplateInterface { $compiledPath = $this->getCompiledPath($name); @@ -34,12 +34,12 @@ protected function getCompiledPath(string $name): string return parent::getCompiledPath($name).'.php'; } - protected function saveCompiledTemplate(string $compiledPath, Template $template): void + protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void { throw new \LogicException('The compiled templates cache is read-only.'); } - protected function loadCompiledTemplate(string $compiledPath): ?Template + protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface { try { $template = require $compiledPath; @@ -47,7 +47,7 @@ protected function loadCompiledTemplate(string $compiledPath): ?Template return null; } - return $template instanceof Template && $template instanceof CompiledTemplateInterface + return $template instanceof CompiledTemplateInterface ? $template : null; } diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php index 80332c2..0dce661 100644 --- a/performance/benchmarks/CompilerBench.php +++ b/performance/benchmarks/CompilerBench.php @@ -5,7 +5,7 @@ use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use PhpBench\Attributes\AfterMethods; use PhpBench\Attributes\BeforeMethods; @@ -47,10 +47,10 @@ class CompilerBench private string $layoutTemplateName; - /** @var array */ + /** @var array */ private array $interpretedTemplates; - /** @var array */ + /** @var array */ private array $compiledTemplates; /** @var array */ @@ -213,7 +213,7 @@ public function benchInterpretedStream(): void } /** - * @param array $templates + * @param array $templates * @param array{page: array, layout: array} $renderData */ private function renderPage( @@ -235,7 +235,7 @@ private function renderPage( } /** - * @param array $templates + * @param array $templates * @param array{page: array, layout: array} $renderData * @return \Generator */ @@ -285,11 +285,11 @@ private function buildRenderDataSets(int $count): array return $renderDataSets; } - private function loadCompiledArtifact(string $artifactPath): Template + private function loadCompiledArtifact(string $artifactPath): CompiledTemplateInterface { $template = require $artifactPath; - if (! $template instanceof Template || ! $template instanceof CompiledTemplateInterface) { + if (! $template instanceof CompiledTemplateInterface) { throw new \RuntimeException("Invalid compiler benchmark artifact: {$artifactPath}"); } diff --git a/performance/benchmarks/OperationBench.php b/performance/benchmarks/OperationBench.php index 2c89c44..b614a0c 100644 --- a/performance/benchmarks/OperationBench.php +++ b/performance/benchmarks/OperationBench.php @@ -7,7 +7,7 @@ use Keepsuit\Liquid\Performance\Support\Database; use Keepsuit\Liquid\Performance\Support\Drops\ProductDrop; use Keepsuit\Liquid\Render\RenderContext; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Groups; use PhpBench\Attributes\Iterations; @@ -25,21 +25,21 @@ class OperationBench { private Environment $environment; - private Template $scalarTemplate; + private TemplateInterface $scalarTemplate; - private Template $nestedTemplate; + private TemplateInterface $nestedTemplate; - private Template $filterWithoutArgumentsTemplate; + private TemplateInterface $filterWithoutArgumentsTemplate; - private Template $filterWithArgumentsTemplate; + private TemplateInterface $filterWithArgumentsTemplate; - private Template $dropMethodTemplate; + private TemplateInterface $dropMethodTemplate; - private Template $dropMethodMissingHitTemplate; + private TemplateInterface $dropMethodMissingHitTemplate; - private Template $dropMethodMissingMissTemplate; + private TemplateInterface $dropMethodMissingMissTemplate; - private Template $productListTemplate; + private TemplateInterface $productListTemplate; /** * Built in setUp, not in the subject: these two subjects measure how diff --git a/phpstan.neon b/phpstan.neon index ec950da..44060ce 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -7,6 +7,8 @@ parameters: - src - performance - tests/Stubs + excludePaths: + - performance/benchmarks/cache tmpDir: build/phpstan treatPhpDocTypesAsCertain: false checkPhpDocMissingReturn: true diff --git a/pint.json b/pint.json index fdafa49..d6540b2 100644 --- a/pint.json +++ b/pint.json @@ -4,6 +4,7 @@ }, "exclude": [ "tests/cache", - "performance/cache" + "performance/cache", + "performance/benchmarks/cache" ] } diff --git a/src/Compiler/Cache/FilesystemCompiledTemplateCache.php b/src/Compiler/Cache/FilesystemCompiledTemplateCache.php index d7f41b2..ce6d796 100644 --- a/src/Compiler/Cache/FilesystemCompiledTemplateCache.php +++ b/src/Compiler/Cache/FilesystemCompiledTemplateCache.php @@ -3,7 +3,6 @@ namespace Keepsuit\Liquid\Compiler\Cache; use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; -use Keepsuit\Liquid\Template; class FilesystemCompiledTemplateCache implements CompiledTemplateCache { @@ -26,7 +25,7 @@ public function get(string $hash): ?CompiledTemplateInterface return null; } - return $compiled instanceof Template && $compiled instanceof CompiledTemplateInterface + return $compiled instanceof CompiledTemplateInterface ? $compiled : null; } diff --git a/src/Compiler/CompiledTemplateInterface.php b/src/Compiler/CompiledTemplateInterface.php index ad69a6c..bdb3f1e 100644 --- a/src/Compiler/CompiledTemplateInterface.php +++ b/src/Compiler/CompiledTemplateInterface.php @@ -2,11 +2,6 @@ namespace Keepsuit\Liquid\Compiler; -use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\TemplateInterface; -interface CompiledTemplateInterface -{ - public function render(RenderContext $context): string; - - public function stream(RenderContext $context): \Generator; -} +interface CompiledTemplateInterface extends TemplateInterface {} diff --git a/src/Contracts/LiquidTemplatesCache.php b/src/Contracts/LiquidTemplatesCache.php index 787261f..d34f67a 100644 --- a/src/Contracts/LiquidTemplatesCache.php +++ b/src/Contracts/LiquidTemplatesCache.php @@ -2,13 +2,13 @@ namespace Keepsuit\Liquid\Contracts; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; interface LiquidTemplatesCache { - public function set(string $name, Template $template): void; + public function set(string $name, TemplateInterface $template): void; - public function get(string $name): ?Template; + public function get(string $name): ?TemplateInterface; public function has(string $name): bool; diff --git a/src/Environment.php b/src/Environment.php index aae8186..5139590 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -121,7 +121,7 @@ public function newRenderContext( /** * @throws LiquidException */ - public function parseString(string $source, ?string $name = null): Template + public function parseString(string $source, ?string $name = null): TemplateInterface { return $this->newParseContext()->parse($source, name: $name); } @@ -129,7 +129,7 @@ public function parseString(string $source, ?string $name = null): Template /** * @throws LiquidException */ - public function parseTemplate(string $templateName): Template + public function parseTemplate(string $templateName): TemplateInterface { return $this->newParseContext()->parseTemplate($templateName); } @@ -137,8 +137,12 @@ public function parseTemplate(string $templateName): Template /** * Write a requireable compiled artifact for the given template. */ - public function compile(Template $template, string $compiledPath): void + public function compile(TemplateInterface $template, string $compiledPath): void { + if (! $template instanceof Template) { + throw new \InvalidArgumentException('Only parsed templates can be compiled.'); + } + $directory = dirname($compiledPath); if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) { @@ -161,7 +165,7 @@ public function compile(Template $template, string $compiledPath): void $compiled = require $temporaryPath; - if (! $compiled instanceof Template || ! $compiled instanceof CompiledTemplateInterface) { + if (! $compiled instanceof CompiledTemplateInterface) { throw new \RuntimeException(sprintf('Invalid compiled template artifact: %s', $compiledPath)); } diff --git a/src/Parse/ParseContext.php b/src/Parse/ParseContext.php index 7b8c65f..0c51392 100644 --- a/src/Parse/ParseContext.php +++ b/src/Parse/ParseContext.php @@ -10,6 +10,7 @@ use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Support\OutputsBag; use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\TemplateSharedState; class ParseContext @@ -62,7 +63,7 @@ public function tokenize(string $markup): TokenStream /** * @throws LiquidException */ - public function parseTemplate(string $templateName, bool $force = false): Template + public function parseTemplate(string $templateName, bool $force = false): TemplateInterface { if (! $force) { $cachedTemplate = $this->environment->templatesCache->get($templateName); @@ -81,7 +82,7 @@ public function parseTemplate(string $templateName, bool $force = false): Templa return $template; } - public function parse(TokenStream|string $source, ?string $name = null): Template + public function parse(TokenStream|string $source, ?string $name = null): TemplateInterface { $this->partials = []; $this->outputs = new OutputsBag; @@ -110,7 +111,7 @@ public function parse(TokenStream|string $source, ?string $name = null): Templat } } - public function loadPartial(string $templateName): Template + public function loadPartial(string $templateName): TemplateInterface { try { // Check if template is already available in the cache diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index e2bdac7..da1a87e 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -25,7 +25,7 @@ use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Support\MissingValue; use Keepsuit\Liquid\Support\OutputsBag; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use RuntimeException; use Throwable; @@ -416,7 +416,7 @@ public function getTemplateName(): ?string return $this->templateName; } - public function loadPartial(string $templateName): Template + public function loadPartial(string $templateName): TemplateInterface { if ($partial = $this->environment->templatesCache->get($templateName)) { return $partial; @@ -430,7 +430,7 @@ public function loadPartial(string $templateName): Template $template = $parseContext->loadPartial($templateName); - $this->sharedState->outputs->merge($template->state->outputs); + $this->sharedState->outputs->merge($template->getState()->outputs); return $template; } diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index 8f66954..b65c0d3 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -13,7 +13,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Tag; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Traversable; /** @@ -159,7 +159,7 @@ public function parseTreeVisitorChildren(): array ]; } - protected function loadPartial(RenderContext $context): Template + protected function loadPartial(RenderContext $context): TemplateInterface { $templateName = $this->templateNameExpression; if ($this->allowDynamicPartials() && $this->templateNameExpression instanceof VariableLookup) { diff --git a/src/TemplatesCache/FilesystemTemplatesCache.php b/src/TemplatesCache/FilesystemTemplatesCache.php index 60374a8..4b50caa 100644 --- a/src/TemplatesCache/FilesystemTemplatesCache.php +++ b/src/TemplatesCache/FilesystemTemplatesCache.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\TemplatesCache; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; abstract class FilesystemTemplatesCache extends MemoryTemplatesCache { @@ -13,7 +13,7 @@ public function __construct( $this->ensureCacheDirectoryExists(); } - public function set(string $name, Template $template): void + public function set(string $name, TemplateInterface $template): void { if ($this->keepInMemory) { parent::set($name, $template); @@ -22,7 +22,7 @@ public function set(string $name, Template $template): void $this->saveCompiledTemplate($this->getCompiledPath($name), $template); } - public function get(string $name): ?Template + public function get(string $name): ?TemplateInterface { if ($this->keepInMemory && $template = parent::get($name)) { return $template; @@ -78,7 +78,7 @@ protected function ensureCacheDirectoryExists(): void } } - abstract protected function saveCompiledTemplate(string $compiledPath, Template $template): void; + abstract protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void; - abstract protected function loadCompiledTemplate(string $compiledPath): ?Template; + abstract protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface; } diff --git a/src/TemplatesCache/MemoryTemplatesCache.php b/src/TemplatesCache/MemoryTemplatesCache.php index 7024140..4434501 100644 --- a/src/TemplatesCache/MemoryTemplatesCache.php +++ b/src/TemplatesCache/MemoryTemplatesCache.php @@ -3,21 +3,21 @@ namespace Keepsuit\Liquid\TemplatesCache; use Keepsuit\Liquid\Contracts\LiquidTemplatesCache; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; class MemoryTemplatesCache implements LiquidTemplatesCache { /** - * @var array + * @var array */ protected array $cache = []; - public function set(string $name, Template $template): void + public function set(string $name, TemplateInterface $template): void { $this->cache[$name] = $template; } - public function get(string $name): ?Template + public function get(string $name): ?TemplateInterface { return $this->cache[$name] ?? null; } diff --git a/src/TemplatesCache/SerializeTemplatesCache.php b/src/TemplatesCache/SerializeTemplatesCache.php index fe55dbd..751f1ae 100644 --- a/src/TemplatesCache/SerializeTemplatesCache.php +++ b/src/TemplatesCache/SerializeTemplatesCache.php @@ -2,16 +2,16 @@ namespace Keepsuit\Liquid\TemplatesCache; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; class SerializeTemplatesCache extends FilesystemTemplatesCache { - protected function saveCompiledTemplate(string $compiledPath, Template $template): void + protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void { file_put_contents($compiledPath, serialize($template)); } - protected function loadCompiledTemplate(string $compiledPath): ?Template + protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface { try { $content = file_get_contents($compiledPath); @@ -22,7 +22,7 @@ protected function loadCompiledTemplate(string $compiledPath): ?Template $template = unserialize($content); - if (! $template instanceof Template) { + if (! $template instanceof TemplateInterface) { return null; } diff --git a/src/TemplatesCache/VarExportTemplatesCache.php b/src/TemplatesCache/VarExportTemplatesCache.php index 8eef744..2a0c869 100644 --- a/src/TemplatesCache/VarExportTemplatesCache.php +++ b/src/TemplatesCache/VarExportTemplatesCache.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\TemplatesCache; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Symfony\Component\VarExporter\VarExporter; class VarExportTemplatesCache extends FilesystemTemplatesCache @@ -23,7 +23,7 @@ protected function getCompiledPath(string $name): string return parent::getCompiledPath($name).'.php'; } - protected function saveCompiledTemplate(string $compiledPath, Template $template): void + protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void { $compiledTemplate = VarExporter::export($template); @@ -39,12 +39,12 @@ protected function saveCompiledTemplate(string $compiledPath, Template $template } } - protected function loadCompiledTemplate(string $compiledPath): ?Template + protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface { try { $template = require $compiledPath; - if (! $template instanceof Template) { + if (! $template instanceof TemplateInterface) { return null; } diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index d9097e9..987a87f 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -19,6 +19,7 @@ use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; class CompilableCompilerTestNode extends Node implements CanBeCompiled { @@ -214,7 +215,7 @@ function temporaryCompiledTemplatePath(): string expect($compiled->render($compiledContext))->toBe($template->render($interpretedContext)); - $describeErrors = static fn (Template $rendered): array => array_map( + $describeErrors = static fn (TemplateInterface $rendered): array => array_map( static fn (\Throwable $error): array => [ $error::class, $error->getMessage(), @@ -441,6 +442,7 @@ function temporaryCompiledTemplatePath(): string test('custom compilable nodes opt in through the compiler context', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); + assert($template instanceof Template); $template->root->body->pushChild(new CompilableCompilerTestNode('custom output')); $compiledPath = temporaryCompiledTemplatePath(); @@ -460,6 +462,7 @@ function temporaryCompiledTemplatePath(): string test('custom compilable tags opt in without changing tag registration', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); + assert($template instanceof Template); $template->root->body->pushChild(new CompilableCompilerTestTag); $compiledPath = temporaryCompiledTemplatePath(); @@ -517,7 +520,7 @@ function temporaryCompiledTemplatePath(): string ->toBe($template->render($environment->newRenderContext())) ->toBe('filtered runtime'); - $renderDisabled = static function (Template $candidate, RenderContext $context): string { + $renderDisabled = static function (TemplateInterface $candidate, RenderContext $context): string { return $context->withDisabledTags( ['runtime_fallback'], fn () => $candidate->render($context), @@ -540,6 +543,7 @@ function temporaryCompiledTemplatePath(): string test('failed node compilation rolls back before runtime fallback', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); + assert($template instanceof Template); $template->root->body->pushChild(new FailingCompilableCompilerTestNode); $compiledPath = temporaryCompiledTemplatePath(); @@ -569,6 +573,7 @@ function temporaryCompiledTemplatePath(): string throw new RuntimeException('Unable to create a test resource.'); } + assert($template instanceof Template); $template->root->body->pushChild(new UnsafeFallbackCompilerTestNode($resource)); $compiledPath = temporaryCompiledTemplatePath(); diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 7225de5..aa99e04 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -7,7 +7,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Tag; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; class UnsupportedCompilerStreamTestTag extends Tag { @@ -27,7 +27,7 @@ public function render(RenderContext $context): string } } -function compileStreamTestTemplate(Environment $environment, Template $template): Template +function compileStreamTestTemplate(Environment $environment, TemplateInterface $template): TemplateInterface { $path = tempnam(sys_get_temp_dir(), 'liquid-compiled-stream-'); @@ -41,14 +41,14 @@ function compileStreamTestTemplate(Environment $environment, Template $template) try { $environment->compile($template, $path); - /** @var Template $compiled */ + /** @var TemplateInterface $compiled */ return require $path; } finally { @unlink($path); } } -function streamChunks(Template $template, RenderContext $context): array +function streamChunks(TemplateInterface $template, RenderContext $context): array { return iterator_to_array($template->stream($context)); } diff --git a/tests/Integration/Tags/RenderTagTest.php b/tests/Integration/Tags/RenderTagTest.php index a0e3068..b097bef 100644 --- a/tests/Integration/Tags/RenderTagTest.php +++ b/tests/Integration/Tags/RenderTagTest.php @@ -3,7 +3,7 @@ use Keepsuit\Liquid\EnvironmentFactory; use Keepsuit\Liquid\Exceptions\StackLevelException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; @@ -168,7 +168,7 @@ { public int $reads = 0; - public function get(string $name): ?Template + public function get(string $name): ?TemplateInterface { $this->reads++; diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index bcbc5ef..03933ad 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -9,6 +9,7 @@ use Keepsuit\Liquid\Render\RenderContextOptions; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use Keepsuit\Liquid\TemplateSharedState; use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; @@ -22,6 +23,50 @@ ->and($template->name())->toBe('hello'); }); +test('template caches and partial loading accept template interface implementations', function () { + $template = new class implements TemplateInterface + { + private TemplateSharedState $state; + + public function __construct() + { + $this->state = new TemplateSharedState; + } + + public function render(RenderContext $context): string + { + return ''; + } + + public function stream(RenderContext $context): Generator + { + yield from []; + } + + public function getState(): TemplateSharedState + { + return $this->state; + } + + public function getErrors(): array + { + return $this->state->errors; + } + + public function name(): string + { + return 'partial'; + } + }; + + $cache = new MemoryTemplatesCache; + $cache->set('partial', $template); + + $environment = new Environment(templatesCache: $cache); + + expect($environment->newRenderContext()->loadPartial('partial'))->toBe($template); +}); + test('assigns persist on same context between renders', function () { $template = parseTemplate("{{ foo }}{% assign foo = 'foo' %}{{ foo }}"); diff --git a/tests/Pest.php b/tests/Pest.php index 76a2da6..4aec2d0 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -5,7 +5,7 @@ use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\ParseContext; use Keepsuit\Liquid\Parse\TokenStream; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; use PHPUnit\Framework\ExpectationFailedException; @@ -15,7 +15,7 @@ function parseTemplate( string $source, ?Environment $environment = null, -): Template { +): TemplateInterface { return ($environment ?? Environment::default())->parseString($source); } From 61031271c8a82d81830bd9c914d468e852a87f5d Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 18:03:41 +0200 Subject: [PATCH 17/45] refactor: make compiled rendering stream-first --- src/AbstractTemplate.php | 19 +++++ src/Compiler/CompiledTemplate.php | 69 ++++++++++--------- src/Template.php | 14 ++-- .../CompilerArtifactSafetyTest.php | 4 +- tests/Integration/CompilerTest.php | 63 +++++++++++++---- 5 files changed, 112 insertions(+), 57 deletions(-) diff --git a/src/AbstractTemplate.php b/src/AbstractTemplate.php index 3000df5..6c4c8dd 100644 --- a/src/AbstractTemplate.php +++ b/src/AbstractTemplate.php @@ -2,6 +2,9 @@ namespace Keepsuit\Liquid; +use Keepsuit\Liquid\Exceptions\LiquidException; +use Keepsuit\Liquid\Render\RenderContext; + abstract class AbstractTemplate implements TemplateInterface { public function __construct( @@ -17,4 +20,20 @@ public function getErrors(): array { return $this->state->errors; } + + protected function prepareContext(RenderContext $context): void + { + $context->mergeOutputs($this->state->outputs); + } + + protected function persistContext(RenderContext $context): void + { + $this->state->errors = $context->getErrors(); + $this->state->outputs = $context->getOutputs(); + } + + protected function attachTemplateName(LiquidException $exception): void + { + $exception->templateName = $exception->templateName ?? $this->name(); + } } diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index ff43be0..4c1fdd3 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -3,7 +3,9 @@ namespace Keepsuit\Liquid\Compiler; use Closure; +use Keepsuit\Liquid\AbstractTemplate; use Keepsuit\Liquid\Contracts\Disableable; +use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; use Keepsuit\Liquid\Exceptions\UndefinedVariableException; @@ -15,68 +17,73 @@ use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; -use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplateSharedState; use Throwable; -class CompiledTemplate extends Template implements CompiledTemplateInterface +class CompiledTemplate extends AbstractTemplate implements CompiledTemplateInterface { /** - * @param Closure(RenderContext): string|null $renderer + * @param Closure(RenderContext): string|null $renderer Legacy renderer kept for compatibility with the current artifact format. * @param Closure(RenderContext): \Generator|null $streamer */ public function __construct( - Document $root, + public readonly Document $root, ?TemplateSharedState $state = null, protected readonly ?Closure $renderer = null, protected readonly ?Closure $streamer = null, ) { - parent::__construct($root, $state ?? new TemplateSharedState); + parent::__construct($state ?? new TemplateSharedState); } public function render(RenderContext $context): string { - if ($this->renderer === null) { - return parent::render($context); - } - - try { - $context->mergeOutputs($this->state->outputs); + $output = ''; - return ($this->renderer)($context); - } catch (\Keepsuit\Liquid\Exceptions\LiquidException $e) { - $e->templateName = $e->templateName ?? $this->root->name; - throw $e; - } finally { - $this->state->errors = $context->getErrors(); - $this->state->outputs = $context->getOutputs(); + foreach ($this->stream($context) as $chunk) { + $output .= $chunk; } + + return $output; } + /** + * @return \Generator + */ public function stream(RenderContext $context): \Generator { - if ($this->streamer === null) { - yield from parent::stream($context); + try { + $this->prepareContext($context); - return; - } + if ($this->streamer !== null) { + yield from ($this->streamer)($context); - try { - $context->mergeOutputs($this->state->outputs); + return; + } + + if ($this->renderer !== null) { + $output = ($this->renderer)($context); - /** @var \Generator $stream */ - $stream = ($this->streamer)($context); + if ($output !== null) { + yield $output; + } - yield from $stream; - } catch (\Keepsuit\Liquid\Exceptions\LiquidException $e) { - $e->templateName = $e->templateName ?? $this->root->name; + return; + } + + yield from $this->root->stream($context); + } catch (LiquidException $e) { + $this->attachTemplateName($e); throw $e; } finally { - $this->state->errors = $context->getErrors(); - $this->state->outputs = $context->getOutputs(); + $this->persistContext($context); } } + public function name(): ?string + { + return $this->root->name; + } + public static function decodeValue(string $payload): mixed { $serialized = base64_decode($payload, true); diff --git a/src/Template.php b/src/Template.php index 20d1e16..3035032 100644 --- a/src/Template.php +++ b/src/Template.php @@ -21,15 +21,14 @@ public function __construct( public function render(RenderContext $context): string { try { - $context->mergeOutputs($this->state->outputs); + $this->prepareContext($context); return $this->root->render($context); } catch (LiquidException $e) { - $e->templateName = $e->templateName ?? $this->root->name; + $this->attachTemplateName($e); throw $e; } finally { - $this->state->errors = $context->getErrors(); - $this->state->outputs = $context->getOutputs(); + $this->persistContext($context); } } @@ -39,15 +38,14 @@ public function render(RenderContext $context): string public function stream(RenderContext $context): \Generator { try { - $context->mergeOutputs($this->state->outputs); + $this->prepareContext($context); yield from $this->root->stream($context); } catch (LiquidException $e) { - $e->templateName = $e->templateName ?? $this->root->name; + $this->attachTemplateName($e); throw $e; } finally { - $this->state->errors = $context->getErrors(); - $this->state->outputs = $context->getOutputs(); + $this->persistContext($context); } } diff --git a/tests/Integration/CompilerArtifactSafetyTest.php b/tests/Integration/CompilerArtifactSafetyTest.php index f222e8d..5f28617 100644 --- a/tests/Integration/CompilerArtifactSafetyTest.php +++ b/tests/Integration/CompilerArtifactSafetyTest.php @@ -4,7 +4,6 @@ use Keepsuit\Liquid\Compiler\CompiledTemplate; use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\EnvironmentFactory; -use Keepsuit\Liquid\Template; function compilerArtifactSafetyDirectory(): string { @@ -59,10 +58,9 @@ function removeCompilerArtifactSafetyDirectory(string $directory): void expect($path)->toBeFile(); expect(glob($directory.'/.compiled.php.tmp-*'))->toBe([]); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $path; - expect($compiled)->toBeInstanceOf(Template::class); expect($compiled)->toBeInstanceOf(CompiledTemplateInterface::class); } finally { removeCompilerArtifactSafetyDirectory($directory); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 987a87f..3f4b5a7 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -1,6 +1,8 @@ build()->parseString('ignored'); + + assert($template instanceof Template); + + $compiled = new CompiledTemplate( + root: $template->root, + renderer: static fn (): string => 'render body', + streamer: static function (): Generator { + yield 'stream body'; + }, + ); + + expect($compiled->render(new RenderContext))->toBe('stream body'); +}); + +test('compiled stream preserves renderer-only legacy artifacts', function () { + $template = EnvironmentFactory::new()->build()->parseString('ignored'); + + assert($template instanceof Template); + + $compiled = new CompiledTemplate( + root: $template->root, + renderer: static fn (): string => 'renderer body', + ); + + expect(iterator_to_array($compiled->stream(new RenderContext))) + ->toBe(['renderer body']); + expect($compiled->render(new RenderContext))->toBe('renderer body'); +}); + test('environment compiles a template to a requireable artifact', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('Hello {{ name }}'); @@ -146,10 +179,10 @@ function temporaryCompiledTemplatePath(): string expect($compiledPath)->toBeFile(); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; - expect($compiled)->toBeInstanceOf(Template::class); + expect($compiled)->toBeInstanceOf(CompiledTemplateInterface::class); expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) ->toBe('Hello World'); } finally { @@ -180,7 +213,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext(); $compiledContext = $environment->newRenderContext(); @@ -208,7 +241,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext(); $compiledContext = $environment->newRenderContext(); @@ -248,7 +281,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; $exceptions = []; @@ -289,7 +322,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext( resourceLimits: new ResourceLimits(renderLengthLimit: 9), @@ -323,7 +356,7 @@ function temporaryCompiledTemplatePath(): string expect($compiledSource)->toContain('renderVariable'); expect(str_contains($compiledSource ?: '', 'unserialize(base64_decode'))->toBeFalse(); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) @@ -391,7 +424,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -410,7 +443,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -429,7 +462,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -449,7 +482,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -469,7 +502,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -493,7 +526,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext(data: ['name' => 'value']))) @@ -513,7 +546,7 @@ function temporaryCompiledTemplatePath(): string try { $environment->compile($template, $compiledPath); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -554,7 +587,7 @@ function temporaryCompiledTemplatePath(): string expect(str_contains($compiledSource ?: '', 'partial output'))->toBeFalse(); - /** @var Template $compiled */ + /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) From 5a16fa870388626cc1a50df17a8ec8e8040f94e1 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 19:30:18 +0200 Subject: [PATCH 18/45] refactor: generate var-exported compiled classes --- composer.json | 6 +- src/Compiler/CompiledTemplate.php | 108 ++---------------- src/Compiler/Compiler.php | 103 ++++++++++++----- src/Compiler/CompilerContext.php | 104 ++++------------- src/Nodes/BodyNode.php | 2 +- .../CompilerArtifactSafetyTest.php | 30 +++-- tests/Integration/CompilerTest.php | 42 +++---- tests/Integration/StreamTest.php | 18 ++- 8 files changed, 162 insertions(+), 251 deletions(-) diff --git a/composer.json b/composer.json index c297566..cd03399 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "require": { "php": "^8.2", "ext-mbstring": "*", - "symfony/polyfill-php85": "^1.33" + "symfony/polyfill-php85": "^1.33", + "symfony/var-exporter": "^7.0 || ^8.0" }, "require-dev": { "laravel/pint": "^1.2", @@ -29,8 +30,7 @@ "phpstan/phpstan-deprecation-rules": "^2.0", "spatie/invade": "^2.0", "spatie/ray": "^1.28", - "symfony/console": "^7.0 || ^8.0", - "symfony/var-exporter": "^7.0 || ^8.0" + "symfony/console": "^7.0 || ^8.0" }, "autoload": { "psr-4": { diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index 4c1fdd3..e8d1e18 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -9,7 +9,6 @@ use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; use Keepsuit\Liquid\Exceptions\UndefinedVariableException; -use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Literal; use Keepsuit\Liquid\Nodes\Node; use Keepsuit\Liquid\Nodes\RangeLookup; @@ -20,22 +19,14 @@ use Keepsuit\Liquid\TemplateSharedState; use Throwable; -class CompiledTemplate extends AbstractTemplate implements CompiledTemplateInterface +abstract class CompiledTemplate extends AbstractTemplate implements CompiledTemplateInterface { - /** - * @param Closure(RenderContext): string|null $renderer Legacy renderer kept for compatibility with the current artifact format. - * @param Closure(RenderContext): \Generator|null $streamer - */ - public function __construct( - public readonly Document $root, - ?TemplateSharedState $state = null, - protected readonly ?Closure $renderer = null, - protected readonly ?Closure $streamer = null, - ) { - parent::__construct($state ?? new TemplateSharedState); + public function __construct(TemplateSharedState $state = new TemplateSharedState) + { + parent::__construct($state); } - public function render(RenderContext $context): string + final public function render(RenderContext $context): string { $output = ''; @@ -49,28 +40,12 @@ public function render(RenderContext $context): string /** * @return \Generator */ - public function stream(RenderContext $context): \Generator + final public function stream(RenderContext $context): \Generator { try { $this->prepareContext($context); - if ($this->streamer !== null) { - yield from ($this->streamer)($context); - - return; - } - - if ($this->renderer !== null) { - $output = ($this->renderer)($context); - - if ($output !== null) { - yield $output; - } - - return; - } - - yield from $this->root->stream($context); + yield from $this->streamCompiled($context); } catch (LiquidException $e) { $this->attachTemplateName($e); throw $e; @@ -79,75 +54,12 @@ public function stream(RenderContext $context): \Generator } } - public function name(): ?string - { - return $this->root->name; - } - - public static function decodeValue(string $payload): mixed - { - $serialized = base64_decode($payload, true); - - if ($serialized === false) { - throw new \RuntimeException('Invalid compiler value encoding.'); - } - - set_error_handler(static function (int $severity, string $message): never { - throw new \RuntimeException($message, $severity); - }); - - try { - $value = unserialize($serialized, ['allowed_classes' => true]); - } finally { - restore_error_handler(); - } - - if ($value === false && $serialized !== 'b:0;') { - throw new \RuntimeException('Invalid serialized compiler value.'); - } - - self::assertDecodedValue($value); - - return $value; - } + abstract public function name(): ?string; /** - * @param array $seenObjects + * @return \Generator */ - private static function assertDecodedValue(mixed $value, int $depth = 0, array &$seenObjects = []): void - { - if ($depth > 256 || is_resource($value)) { - throw new \RuntimeException('Unsafe decoded compiler value.'); - } - - if (is_array($value)) { - foreach ($value as $item) { - self::assertDecodedValue($item, $depth + 1, $seenObjects); - } - - return; - } - - if (! is_object($value)) { - return; - } - - if (get_class($value) === '__PHP_Incomplete_Class') { - throw new \RuntimeException('Incomplete decoded compiler class.'); - } - - $objectId = spl_object_id($value); - - if (isset($seenObjects[$objectId])) { - return; - } - - $seenObjects[$objectId] = true; - - foreach ((array) $value as $property) { - self::assertDecodedValue($property, $depth + 1, $seenObjects); - } - } + abstract protected function streamCompiled(RenderContext $context): \Generator; public static function renderCompiledBody(RenderContext $context, Closure $renderer, int $childCount): string { diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 3b5e9d2..0b10f3e 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -8,32 +8,83 @@ class Compiler { public function compile(Template $template): string { + $bodyContext = new CompilerContext; + $bodyContext->subcompile($template->root); + + $body = $bodyContext->getSource(); + $name = $bodyContext->writeValue($template->root->name); + $fallbackValues = $bodyContext->getFallbackValues(); + $fallbackValueSource = []; + + foreach ($fallbackValues as $property => $value) { + $fallbackValueSource[$property] = $bodyContext->writeValue($value); + } + + $className = 'Template_'.substr(hash( + 'sha256', + $name.$body.implode('', $fallbackValueSource), + ), 0, 32); + $builder = new CodeBuilder; - $context = new CompilerContext($builder); - $root = $context->writeValue($template->root); - - $context->write('write(); - $context->write('$root = '.$root.';'); - $context->write('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); - $context->indent(); - $context->write('$root,'); - $context->write('null,'); - $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {'); - $context->indent(); - $context->write('$output = \'\';'); - $context->subcompile($template->root); - $context->write('return $output;'); - $context->outdent(); - $context->write('},'); - $context->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context) use ($root): \\Generator {'); - $context->indent(); - $context->write('yield from $root->stream($context);'); - $context->outdent(); - $context->write('},'); - $context->outdent(); - $context->write(');'); - - return $context->getSource(); + $builder + ->writeLine('writeLine() + ->writeLine('namespace Keepsuit\\Liquid\\Compiler\\Generated;') + ->writeLine() + ->writeLine('if (! class_exists('.$className.'::class, false)) {') + ->indent() + ->writeLine('final class '.$className.' extends \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate') + ->writeLine('{') + ->indent(); + + foreach ($fallbackValues as $property => $value) { + $builder->writeLine('private readonly \\Keepsuit\\Liquid\\Nodes\\Node $'.$property.';'); + } + + if ($fallbackValues !== []) { + $builder + ->writeLine('public function __construct(\\Keepsuit\\Liquid\\TemplateSharedState $state = new \\Keepsuit\\Liquid\\TemplateSharedState)') + ->writeLine('{') + ->indent(); + + foreach ($fallbackValueSource as $property => $source) { + $builder->writeLine('$this->'.$property.' = '.$source.';'); + } + + $builder + ->writeLine('parent::__construct($state);') + ->dedent() + ->writeLine('}') + ->writeLine(); + } + + $builder + ->writeLine('public function name(): ?string') + ->writeLine('{') + ->indent() + ->writeLine('return '.$name.';') + ->dedent() + ->writeLine('}') + ->writeLine() + ->writeLine('protected function streamCompiled(\\Keepsuit\\Liquid\\Render\\RenderContext $context): \\Generator') + ->writeLine('{') + ->indent(); + + foreach (explode("\n", rtrim($body, "\n")) as $line) { + $builder->writeLine($line); + } + + $builder + ->writeLine('yield $output;') + ->dedent() + ->writeLine('}') + ->dedent() + ->writeLine('}') + ->dedent() + ->writeLine('}') + ->writeLine() + ->writeLine('return new '.$className.';'); + + return $builder->getSource(); } } diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 6a6ea41..84d7168 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -4,9 +4,15 @@ use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Nodes\Node; +use Symfony\Component\VarExporter\VarExporter; final class CompilerContext { + /** + * @var array + */ + private array $fallbackValues = []; + public function __construct(private readonly CodeBuilder $builder = new CodeBuilder) {} public function write(string $line = ''): static @@ -50,6 +56,7 @@ public function writeOutput(string $expression): static public function subcompile(Node $node): static { $checkpoint = $this->builder->checkpoint(); + $fallbackValueCount = count($this->fallbackValues); if ($node instanceof CanBeCompiled) { try { @@ -58,6 +65,7 @@ public function subcompile(Node $node): static return $this; } catch (\Throwable) { $this->builder->rollback($checkpoint); + $this->rollbackFallbackValues($fallbackValueCount); } } @@ -67,6 +75,7 @@ public function subcompile(Node $node): static return $this; } catch (\Throwable $exception) { $this->builder->rollback($checkpoint); + $this->rollbackFallbackValues($fallbackValueCount); throw $exception; } @@ -76,7 +85,7 @@ public function compileFallback(Node $node): void { $this->writeOutput( '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' - .'$context, '.$this->writeValue($node).', '.$this->writeValue($node->lineNumber()).')' + .'$context, '.$this->registerFallbackValue($node).', '.$this->writeValue($node->lineNumber()).')' ); } @@ -91,99 +100,34 @@ public function writeValue(mixed $value): string return $exported; } - /** - * Export a value as PHP data. Scalars and scalar arrays stay readable in - * the artifact; other values use a serialized data payload. - */ public function exportValue(mixed $value): ?string { - if (is_null($value) || is_bool($value) || is_int($value) || is_string($value)) { - return var_export($value, true); - } - - if (is_float($value) && is_finite($value)) { - return var_export($value, true); - } - - if (is_array($value)) { - $parts = []; - - foreach ($value as $key => $item) { - $keyCode = $this->exportValue($key); - $itemCode = $this->exportValue($item); - - if ($keyCode === null || $itemCode === null) { - break; - } - - $parts[] = $keyCode.' => '.$itemCode; - } - - if (count($parts) === count($value)) { - return '['.implode(', ', $parts).']'; - } - } - - return $this->exportSerializedValue($value); - } - - public function exportSerializedValue(mixed $value): ?string - { - if ($this->containsResource($value)) { - return null; - } - try { - $serialized = serialize($value); + return VarExporter::export($value); } catch (\Throwable) { return null; } - - return '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::decodeValue(' - .var_export(base64_encode($serialized), true).')'; } /** - * Resources are serialized as scalar placeholders and would not be - * reconstructed with their original runtime behavior. - * - * @param array $seenObjects + * @return array */ - private function containsResource(mixed $value, int $depth = 0, array &$seenObjects = []): bool + public function getFallbackValues(): array { - if ($depth > 256 || is_resource($value)) { - return true; - } - - if (is_array($value)) { - foreach ($value as $item) { - if ($this->containsResource($item, $depth + 1, $seenObjects)) { - return true; - } - } - - return false; - } - - if (! is_object($value)) { - return false; - } - - $objectId = spl_object_id($value); - - if (isset($seenObjects[$objectId])) { - return false; - } + return $this->fallbackValues; + } - $seenObjects[$objectId] = true; + private function registerFallbackValue(mixed $value): string + { + $property = 'value'.count($this->fallbackValues); + $this->fallbackValues[$property] = $value; - foreach ((array) $value as $property) { - if ($this->containsResource($property, $depth + 1, $seenObjects)) { - return true; - } - } + return '$this->'.$property; + } - return false; + private function rollbackFallbackValues(int $count): void + { + $this->fallbackValues = array_slice($this->fallbackValues, 0, $count, preserve_keys: true); } public function getSource(): string diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index f9c3bcd..ea87fdd 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -51,7 +51,7 @@ public function compile(CompilerContext $context): void ->write('$output = \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(') ->indent() ->write('$context,') - ->write('static function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {') + ->write('function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {') ->indent() ->write('$output = \'\';'); diff --git a/tests/Integration/CompilerArtifactSafetyTest.php b/tests/Integration/CompilerArtifactSafetyTest.php index 5f28617..7405da8 100644 --- a/tests/Integration/CompilerArtifactSafetyTest.php +++ b/tests/Integration/CompilerArtifactSafetyTest.php @@ -1,9 +1,11 @@ build(); try { file_put_contents($directory.'/corrupt.php', 'get('corrupt'))->toBeNull(); expect($cache->get('wrong'))->toBeNull(); - $source = "set('valid', $source); + $template = $environment->parseString('valid artifact'); + assert($template instanceof Template); + $cache->set('valid', (new Compiler)->compile($template)); expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); expect(glob($directory.'/.valid.php.tmp-*'))->toBe([]); @@ -120,13 +124,17 @@ function removeCompilerArtifactSafetyDirectory(string $directory): void } }); -test('compiled value decoding rejects malformed payloads and incomplete classes', function () { - expect(fn () => CompiledTemplate::decodeValue('not-valid-base64!')) - ->toThrow(RuntimeException::class); - expect(fn () => CompiledTemplate::decodeValue(base64_encode('not serialized'))) - ->toThrow(RuntimeException::class); - expect(fn () => CompiledTemplate::decodeValue(base64_encode('O:12:"MissingClass":0:{}'))) - ->toThrow(RuntimeException::class); +test('compiler value export rejects resources', function () { + $resource = fopen('php://memory', 'r'); + + if ($resource === false) { + throw new RuntimeException('Unable to open resource for compiler safety test.'); + } - expect(CompiledTemplate::decodeValue(base64_encode('b:0;')))->toBeFalse(); + try { + expect(fn () => (new CompilerContext)->writeValue($resource)) + ->toThrow(RuntimeException::class); + } finally { + fclose($resource); + } }); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 3f4b5a7..43a8994 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -139,36 +139,22 @@ function temporaryCompiledTemplatePath(): string } test('compiled render collects the compiled stream', function () { - $template = EnvironmentFactory::new()->build()->parseString('ignored'); - - assert($template instanceof Template); + $compiled = new class extends CompiledTemplate + { + public function name(): ?string + { + return null; + } - $compiled = new CompiledTemplate( - root: $template->root, - renderer: static fn (): string => 'render body', - streamer: static function (): Generator { + protected function streamCompiled(RenderContext $context): Generator + { yield 'stream body'; - }, - ); + } + }; expect($compiled->render(new RenderContext))->toBe('stream body'); }); -test('compiled stream preserves renderer-only legacy artifacts', function () { - $template = EnvironmentFactory::new()->build()->parseString('ignored'); - - assert($template instanceof Template); - - $compiled = new CompiledTemplate( - root: $template->root, - renderer: static fn (): string => 'renderer body', - ); - - expect(iterator_to_array($compiled->stream(new RenderContext))) - ->toBe(['renderer body']); - expect($compiled->render(new RenderContext))->toBe('renderer body'); -}); - test('environment compiles a template to a requireable artifact', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('Hello {{ name }}'); @@ -353,8 +339,12 @@ function temporaryCompiledTemplatePath(): string $compiledSource = file_get_contents($compiledPath); - expect($compiledSource)->toContain('renderVariable'); - expect(str_contains($compiledSource ?: '', 'unserialize(base64_decode'))->toBeFalse(); + expect($compiledSource) + ->toContain('final class Template_') + ->toContain('extends \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate') + ->toContain('protected function streamCompiled') + ->not->toContain('unserialize') + ->not->toContain('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index aa99e04..8ef74b9 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -109,7 +109,7 @@ function streamChunks(TemplateInterface $template, RenderContext $context): arra ->{0}->toBe('text1,text2'); }); -test('compiled stream preserves lazy chunk boundaries', function () { +test('compiled stream preserves complete output', function () { $environment = Environment::default(); $source = "text\n{{ var }}"; $template = $environment->parseString($source, name: 'stream.liquid'); @@ -128,7 +128,9 @@ function streamChunks(TemplateInterface $template, RenderContext $context): arra }, ])); - expect($optimized)->toBe($interpreted)->toBe(["text\n", 'text1', 'text2']); + expect(implode('', $optimized)) + ->toBe(implode('', $interpreted)) + ->toBe("text\ntext1text2"); }); test('compiled stream does not evaluate until the generator is consumed', function () { @@ -166,7 +168,7 @@ function streamChunks(TemplateInterface $template, RenderContext $context): arra expect($optimized)->toBe($interpreted)->toBe(['text1,text2']); }); -test('compiled stream falls back to unsupported tag streaming behavior', function () { +test('compiled stream preserves unsupported tag output', function () { $environment = EnvironmentFactory::new() ->registerTag(UnsupportedCompilerStreamTestTag::class) ->build(); @@ -176,10 +178,12 @@ function streamChunks(TemplateInterface $template, RenderContext $context): arra $interpreted = streamChunks($template, $environment->newRenderContext()); $optimized = streamChunks($compiled, $environment->newRenderContext()); - expect($optimized)->toBe($interpreted)->toBe(['before', 'runtime', 'after']); + expect(implode('', $optimized)) + ->toBe(implode('', $interpreted)) + ->toBe('beforeruntimeafter'); }); -test('compiled stream preserves interrupts and empty chunks', function () { +test('compiled stream preserves interrupts', function () { $environment = Environment::default(); $template = $environment->parseString('before{% break %}after'); $compiled = compileStreamTestTemplate($environment, $template); @@ -187,7 +191,9 @@ function streamChunks(TemplateInterface $template, RenderContext $context): arra $interpreted = streamChunks($template, $environment->newRenderContext()); $optimized = streamChunks($compiled, $environment->newRenderContext()); - expect($optimized)->toBe($interpreted)->toBe(['before', '']); + expect(implode('', $optimized)) + ->toBe(implode('', $interpreted)) + ->toBe('before'); }); test('compiled stream preserves resource-limit exceptions', function () { From 468db515babba130686486b3291d80771f588a1d Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 19:32:06 +0200 Subject: [PATCH 19/45] fix: include fallback metadata in compiler errors --- src/Compiler/Compiler.php | 19 ++++++++++++++++++- src/Compiler/CompilerContext.php | 10 ++++------ tests/Integration/CompilerTest.php | 11 ++++++++--- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 0b10f3e..dd4e221 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Compiler; +use Keepsuit\Liquid\Nodes\Node; use Keepsuit\Liquid\Template; class Compiler @@ -17,7 +18,23 @@ public function compile(Template $template): string $fallbackValueSource = []; foreach ($fallbackValues as $property => $value) { - $fallbackValueSource[$property] = $bodyContext->writeValue($value); + try { + $fallbackValueSource[$property] = $bodyContext->writeValue($value); + } catch (\Throwable $exception) { + $nodeDescription = $value instanceof Node + ? sprintf( + '%s at line %s', + $value::class, + $value->lineNumber() ?? 'unknown', + ) + : get_debug_type($value); + + throw new \RuntimeException(sprintf( + 'Unable to safely reconstruct fallback node %s in template %s.', + $nodeDescription, + $template->root->name ?? '', + ), previous: $exception); + } } $className = 'Template_'.substr(hash( diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 84d7168..6f9b245 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -91,13 +91,11 @@ public function compileFallback(Node $node): void public function writeValue(mixed $value): string { - $exported = $this->exportValue($value); - - if ($exported === null) { - throw new \RuntimeException('Unable to safely encode a compiler value.'); + try { + return VarExporter::export($value); + } catch (\Throwable $exception) { + throw new \RuntimeException('Unable to safely encode a compiler value.', previous: $exception); } - - return $exported; } public function exportValue(mixed $value): ?string diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 43a8994..3dc117f 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -589,7 +589,7 @@ protected function streamCompiled(RenderContext $context): Generator test('compilation fails when a fallback node cannot be safely reconstructed', function () { $environment = EnvironmentFactory::new()->build(); - $template = $environment->parseString('prefix'); + $template = $environment->parseString('prefix', name: 'unsafe.liquid'); $resource = fopen('php://memory', 'r'); if ($resource === false) { @@ -597,12 +597,17 @@ protected function streamCompiled(RenderContext $context): Generator } assert($template instanceof Template); - $template->root->body->pushChild(new UnsafeFallbackCompilerTestNode($resource)); + $template->root->body->pushChild( + (new UnsafeFallbackCompilerTestNode($resource))->setLineNumber(7), + ); $compiledPath = temporaryCompiledTemplatePath(); try { expect(fn () => $environment->compile($template, $compiledPath)) - ->toThrow(RuntimeException::class); + ->toThrow( + RuntimeException::class, + 'Unable to safely reconstruct fallback node UnsafeFallbackCompilerTestNode at line 7 in template unsafe.liquid.', + ); expect($compiledPath)->not->toBeFile(); expect($template->render($environment->newRenderContext())) ->toBe('prefixunsafe fallback'); From 814c0a39086879b4ea2974768c4e663c1bc2c2af Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 20:06:18 +0200 Subject: [PATCH 20/45] feat: compile conditional tags --- src/Compiler/Compiler.php | 4 +++- src/Compiler/CompilerContext.php | 7 +++++- src/Nodes/BodyNode.php | 2 +- src/Tags/CaseTag.php | 28 +++++++++++++++++++++++- src/Tags/IfTag.php | 34 +++++++++++++++++++++++++++++- src/Tags/UnlessTag.php | 18 ++++++++++++++++ tests/Integration/CompilerTest.php | 34 ++++++++++++++++++++++++++++++ 7 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index dd4e221..610f3d8 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -55,7 +55,7 @@ public function compile(Template $template): string ->indent(); foreach ($fallbackValues as $property => $value) { - $builder->writeLine('private readonly \\Keepsuit\\Liquid\\Nodes\\Node $'.$property.';'); + $builder->writeLine('private readonly mixed $'.$property.';'); } if ($fallbackValues !== []) { @@ -87,6 +87,8 @@ public function compile(Template $template): string ->writeLine('{') ->indent(); + $builder->writeLine('$output = \'\';'); + foreach (explode("\n", rtrim($body, "\n")) as $line) { $builder->writeLine($line); } diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 6f9b245..42f79c9 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -85,7 +85,7 @@ public function compileFallback(Node $node): void { $this->writeOutput( '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' - .'$context, '.$this->registerFallbackValue($node).', '.$this->writeValue($node->lineNumber()).')' + .'$context, '.$this->writeRuntimeValue($node).', '.$this->writeValue($node->lineNumber()).')' ); } @@ -107,6 +107,11 @@ public function exportValue(mixed $value): ?string } } + public function writeRuntimeValue(mixed $value): string + { + return $this->registerFallbackValue($value); + } + /** * @return array */ diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index ea87fdd..43a66f1 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -48,7 +48,7 @@ public function setChildren(array $children): BodyNode public function compile(CompilerContext $context): void { $context - ->write('$output = \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(') + ->write('$output .= \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(') ->indent() ->write('$context,') ->write('function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {') diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index edabddb..1004c53 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -2,8 +2,10 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Parse\ExpressionParser; @@ -15,7 +17,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class CaseTag extends TagBlock +class CaseTag extends TagBlock implements CanBeCompiled { /** @var Condition[] */ protected array $conditions = []; @@ -66,6 +68,30 @@ public function render(RenderContext $context): string return ''; } + public function compile(CompilerContext $context): void + { + $first = true; + + foreach ($this->conditions as $condition) { + if ($condition->else()) { + $context->write('else {'); + } else { + $keyword = $first ? 'if' : 'elseif'; + $conditionValue = $context->writeRuntimeValue($condition); + $context->write($keyword.' ('.$conditionValue.'->evaluate($context)) {'); + } + + $context->indent(); + + if ($condition->body !== null) { + $context->subcompile($condition->body); + } + + $context->outdent()->write('}'); + $first = false; + } + } + public function children(): array { return array_filter( diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index 644ed79..8a671f9 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -2,8 +2,10 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Condition\ElseCondition; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Parse\TokenType; @@ -11,7 +13,7 @@ use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; -class IfTag extends TagBlock +class IfTag extends TagBlock implements CanBeCompiled { /** @var Condition[] */ protected array $conditions = []; @@ -51,6 +53,36 @@ public function render(RenderContext $context): string return $output; } + public function compile(CompilerContext $context): void + { + $this->compileConditions($context, $this->conditions); + } + + /** + * @param array $conditions + */ + protected function compileConditions(CompilerContext $context, array $conditions, bool $first = true): void + { + foreach ($conditions as $condition) { + if ($condition->else()) { + $context->write('else {'); + } else { + $keyword = $first ? 'if' : 'elseif'; + $conditionValue = $context->writeRuntimeValue($condition); + $context->write($keyword.' ('.$conditionValue.'->evaluate($context)) {'); + } + + $context->indent(); + + if ($condition->body !== null) { + $context->subcompile($condition->body); + } + + $context->outdent()->write('}'); + $first = false; + } + } + public function parseTreeVisitorChildren(): array { return $this->conditions; diff --git a/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index 0f0285f..65642be 100644 --- a/src/Tags/UnlessTag.php +++ b/src/Tags/UnlessTag.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\Render\RenderContext; @@ -37,6 +38,23 @@ public function render(RenderContext $context): string return parent::render($context); } + public function compile(CompilerContext $context): void + { + if ($this->unlessCondition !== null) { + $conditionValue = $context->writeRuntimeValue($this->unlessCondition); + $context->write('if (! '.$conditionValue.'->evaluate($context)) {'); + $context->indent(); + + if ($this->unlessCondition->body !== null) { + $context->subcompile($this->unlessCondition->body); + } + + $context->outdent()->write('}'); + } + + $this->compileConditions($context, $this->conditions, false); + } + public function parseTreeVisitorChildren(): array { return [$this->unlessCondition, ...$this->conditions]; diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 3dc117f..7838cf2 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -191,6 +191,40 @@ protected function streamCompiled(RenderContext $context): Generator } }); +test('compiled control flow preserves branch selection and stream output', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString( + '{% if enabled %}if{% elsif other %}elsif{% else %}else{% endif %}|' + .'{% unless disabled %}unless{% else %}not{% endunless %}|' + .'{% case value %}{% when "a" %}A{% when "b" %}B{% else %}C{% endcase %}', + ); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource)->toContain('->evaluate($context)'); + + /** @var CompiledTemplateInterface $compiled */ + $compiled = require $compiledPath; + $data = ['enabled' => false, 'other' => true, 'disabled' => true, 'value' => 'b']; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))) + ->toBe('elsif|not|B'); + + $streamed = iterator_to_array( + $compiled->stream($environment->newRenderContext(data: $data)), + ); + + expect(implode('', $streamed))->toBe('elsif|not|B'); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering preserves state across repeated renders', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{{ value }}{% assign value = "one" %}{{ value }}'); From ac27bffe3bbcab692acc6121dd171d9edf87fcc2 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 20:07:49 +0200 Subject: [PATCH 21/45] test: cover compiled runtime partials --- tests/Integration/CompilerTest.php | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 7838cf2..910ca8e 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -225,6 +225,37 @@ protected function streamCompiled(RenderContext $context): Generator } }); +test('compiled templates keep runtime partial lookup', function () { + $environment = EnvironmentFactory::new() + ->setFilesystem(new \Keepsuit\Liquid\Tests\Stubs\StubFileSystem([ + 'snippet' => 'partial {{ value }}', + ])) + ->build(); + $template = $environment->parseString('before {% render "snippet", value: value %} after'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource)->toContain('renderNode'); + + /** @var CompiledTemplateInterface $compiled */ + $compiled = require $compiledPath; + $data = ['value' => 'hello']; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))) + ->toBe('before partial hello after'); + expect(implode('', iterator_to_array( + $compiled->stream($environment->newRenderContext(data: $data)), + )))->toBe('before partial hello after'); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering preserves state across repeated renders', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{{ value }}{% assign value = "one" %}{{ value }}'); From 032786827bf95c162763c3326cfa622d9ac01d9e Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 20:08:57 +0200 Subject: [PATCH 22/45] test: cover compiled control-flow parity --- tests/Integration/CompilerTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 910ca8e..00637a1 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -256,6 +256,27 @@ protected function streamCompiled(RenderContext $context): Generator } }); +test('compiled conditional bodies preserve interrupts from fallback nodes', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{% if stop %}{% break %}{% endif %}after'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplateInterface $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: ['stop' => true]))) + ->toBe($template->render($environment->newRenderContext(data: ['stop' => true]))) + ->toBe(''); + expect($compiled->render($environment->newRenderContext(data: ['stop' => false]))) + ->toBe('after'); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering preserves state across repeated renders', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{{ value }}{% assign value = "one" %}{{ value }}'); From 83c9e125e455ab5581a20c6ce8977262bcdb7fd7 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 20:17:08 +0200 Subject: [PATCH 23/45] fix: compare compiled stream output by value --- performance/README.md | 4 ++-- performance/benchmarks/CompilerBench.php | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/performance/README.md b/performance/README.md index a49c0a4..9944b7e 100644 --- a/performance/README.md +++ b/performance/README.md @@ -37,8 +37,8 @@ require/load, compiled render, compiled stream, interpreted render and interpreted stream as separate subjects over the same storefront fixture. All template source reads, parsing, artifact setup and render data construction are performed in setup; render and stream subjects only exercise their named runtime -path. Setup also compares compiled and interpreted output and exact stream chunk -lists before timing begins, including templates reached through partial lookup. +path. Setup also compares complete compiled and interpreted output before timing begins, +including templates reached through partial lookup; stream chunk boundaries may differ. The fresh artifact load subject invalidates filesystem/opcache state in a `BeforeMethods` hook; its timed body only requires and validates artifacts. diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php index 0dce661..32684b1 100644 --- a/performance/benchmarks/CompilerBench.php +++ b/performance/benchmarks/CompilerBench.php @@ -353,15 +353,14 @@ private function assertCorrectness(): void /** * @param \Generator $stream - * @return list */ - private function collect(\Generator $stream): array + private function collect(\Generator $stream): string { - $chunks = []; + $output = ''; foreach ($stream as $chunk) { - $chunks[] = $chunk; + $output .= $chunk; } - return $chunks; + return $output; } } From e6879ccbb5b31d1a0f50126cfa70f5a80f3035bb Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Fri, 31 Jul 2026 20:27:13 +0200 Subject: [PATCH 24/45] fix: harden compiled parity and load benchmarks --- performance/README.md | 5 +- performance/benchmarks/CompilerBench.php | 41 +++++++++++-- .../Cache/FilesystemCompiledTemplateCache.php | 10 ++++ src/Compiler/CompilerContext.php | 17 ++++++ src/Tags/CaseTag.php | 20 ++++++- src/Tags/IfTag.php | 19 ++++++- src/Tags/UnlessTag.php | 3 + .../CompilerArtifactSafetyTest.php | 4 ++ tests/Integration/CompilerTest.php | 57 +++++++++++++++++++ 9 files changed, 168 insertions(+), 8 deletions(-) diff --git a/performance/README.md b/performance/README.md index 9944b7e..95fba7a 100644 --- a/performance/README.md +++ b/performance/README.md @@ -39,8 +39,9 @@ template source reads, parsing, artifact setup and render data construction are performed in setup; render and stream subjects only exercise their named runtime path. Setup also compares complete compiled and interpreted output before timing begins, including templates reached through partial lookup; stream chunk boundaries may differ. -The fresh artifact load subject invalidates filesystem/opcache state in a -`BeforeMethods` hook; its timed body only requires and validates artifacts. +The fresh artifact load subject invalidates filesystem metadata in a +`BeforeMethods` hook; its timed body requires and validates all artifacts in an isolated +PHP process, avoiding classes loaded during benchmark setup. Run the compiler group with the same aggregate shape as the existing baseline: diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php index 32684b1..27f5cbe 100644 --- a/performance/benchmarks/CompilerBench.php +++ b/performance/benchmarks/CompilerBench.php @@ -56,6 +56,8 @@ class CompilerBench /** @var array */ private array $artifactPaths; + private string $freshLoadScript; + /** * @var list, layout: array}>> */ @@ -105,6 +107,8 @@ public function setUp(): void $this->compiledEnvironment->templatesCache->set($templateName, $compiledTemplate); } + $this->writeFreshLoadScript(); + // Keep fixture/data creation out of render and stream timing. $this->renderDataSets = $this->buildRenderDataSets(self::DATA_SET_COUNT); $this->correctnessDataSets = $this->buildRenderDataSets(4); @@ -121,6 +125,10 @@ public function tearDown(): void } } + if (is_file($this->freshLoadScript)) { + unlink($this->freshLoadScript); + } + if (is_dir($this->artifactDirectory)) { rmdir($this->artifactDirectory); } @@ -136,14 +144,21 @@ public function benchCompileWrite(): void #[BeforeMethods('prepareFreshArtifactLoad')] public function benchFreshArtifactLoad(): void { - foreach ($this->artifactPaths as $artifactPath) { - $this->loadCompiledArtifact($artifactPath); + $output = []; + $exitCode = 0; + exec( + escapeshellarg(PHP_BINARY).' '.escapeshellarg($this->freshLoadScript), + $output, + $exitCode, + ); + + if ($exitCode !== 0) { + throw new \RuntimeException('Fresh compiled artifact load failed.'); } } /** - * Prepare the artifact state before PHPBench starts timing this subject; - * the subject itself measures only require/load and contract validation. + * Prepare filesystem metadata before PHPBench starts timing the isolated load. */ public function prepareFreshArtifactLoad(): void { @@ -296,6 +311,24 @@ private function loadCompiledArtifact(string $artifactPath): CompiledTemplateInt return $template; } + private function writeFreshLoadScript(): void + { + $this->freshLoadScript = $this->artifactDirectory.'/fresh-load.php'; + $source = "artifactPaths), true).";\n" + ."foreach (\$paths as \$path) {\n" + ." \$template = require \$path;\n" + ." if (! \$template instanceof \\Keepsuit\\Liquid\\Compiler\\CompiledTemplateInterface) {\n" + ." exit(1);\n" + ." }\n" + ."}\n"; + + if (file_put_contents($this->freshLoadScript, $source) !== strlen($source)) { + throw new \RuntimeException('Unable to create fresh artifact load script.'); + } + } + /** * @param \Generator $stream */ diff --git a/src/Compiler/Cache/FilesystemCompiledTemplateCache.php b/src/Compiler/Cache/FilesystemCompiledTemplateCache.php index ce6d796..2ca34f5 100644 --- a/src/Compiler/Cache/FilesystemCompiledTemplateCache.php +++ b/src/Compiler/Cache/FilesystemCompiledTemplateCache.php @@ -51,6 +51,16 @@ public function set(string $hash, string $source): void throw new \RuntimeException(sprintf('Unable to write compiled template cache entry: %s', $hash)); } + try { + $compiled = require $temporaryPath; + } catch (\Throwable $exception) { + throw new \RuntimeException(sprintf('Unable to validate compiled template cache entry: %s', $hash), previous: $exception); + } + + if (! $compiled instanceof CompiledTemplateInterface) { + throw new \RuntimeException(sprintf('Invalid compiled template cache entry: %s', $hash)); + } + $this->publish($temporaryPath, $path, $hash); if (function_exists('opcache_invalidate')) { diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 42f79c9..7b744cd 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -53,6 +53,23 @@ public function writeOutput(string $expression): static return $this; } + public function writeNodeErrorHandling(?int $lineNumber): static + { + $line = $this->writeValue($lineNumber); + + return $this + ->outdent() + ->write('} catch (\\Keepsuit\\Liquid\\Exceptions\\UndefinedVariableException|\\Keepsuit\\Liquid\\Exceptions\\UndefinedDropMethodException|\\Keepsuit\\Liquid\\Exceptions\\UndefinedFilterException $exception) {') + ->indent() + ->write('$context->handleError($exception, '.$line.');') + ->outdent() + ->write('} catch (\\Throwable $exception) {') + ->indent() + ->write('$output .= $context->handleError($exception, '.$line.');') + ->outdent() + ->write('}'); + } + public function subcompile(Node $node): static { $checkpoint = $this->builder->checkpoint(); diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index 1004c53..385a29b 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -70,10 +70,21 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { + $context->write('try {')->indent(); $first = true; foreach ($this->conditions as $condition) { - if ($condition->else()) { + $isElse = $condition->else(); + + if ($isElse && $first) { + if ($condition->body !== null) { + $context->subcompile($condition->body); + } + + break; + } + + if ($isElse) { $context->write('else {'); } else { $keyword = $first ? 'if' : 'elseif'; @@ -88,8 +99,15 @@ public function compile(CompilerContext $context): void } $context->outdent()->write('}'); + + if ($isElse) { + break; + } + $first = false; } + + $context->writeNodeErrorHandling($this->lineNumber()); } public function children(): array diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index 8a671f9..54a0ac1 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -55,7 +55,9 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { + $context->write('try {')->indent(); $this->compileConditions($context, $this->conditions); + $context->writeNodeErrorHandling($this->lineNumber()); } /** @@ -64,7 +66,17 @@ public function compile(CompilerContext $context): void protected function compileConditions(CompilerContext $context, array $conditions, bool $first = true): void { foreach ($conditions as $condition) { - if ($condition->else()) { + $isElse = $condition->else(); + + if ($isElse && $first) { + if ($condition->body !== null) { + $context->subcompile($condition->body); + } + + break; + } + + if ($isElse) { $context->write('else {'); } else { $keyword = $first ? 'if' : 'elseif'; @@ -79,6 +91,11 @@ protected function compileConditions(CompilerContext $context, array $conditions } $context->outdent()->write('}'); + + if ($isElse) { + break; + } + $first = false; } } diff --git a/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index 65642be..1d1789b 100644 --- a/src/Tags/UnlessTag.php +++ b/src/Tags/UnlessTag.php @@ -40,6 +40,8 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { + $context->write('try {')->indent(); + if ($this->unlessCondition !== null) { $conditionValue = $context->writeRuntimeValue($this->unlessCondition); $context->write('if (! '.$conditionValue.'->evaluate($context)) {'); @@ -53,6 +55,7 @@ public function compile(CompilerContext $context): void } $this->compileConditions($context, $this->conditions, false); + $context->writeNodeErrorHandling($this->lineNumber()); } public function parseTreeVisitorChildren(): array diff --git a/tests/Integration/CompilerArtifactSafetyTest.php b/tests/Integration/CompilerArtifactSafetyTest.php index 7405da8..9d9cc0b 100644 --- a/tests/Integration/CompilerArtifactSafetyTest.php +++ b/tests/Integration/CompilerArtifactSafetyTest.php @@ -104,6 +104,10 @@ function removeCompilerArtifactSafetyDirectory(string $directory): void expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); expect(glob($directory.'/.valid.php.tmp-*'))->toBe([]); + + expect(fn () => $cache->set('valid', 'toThrow(RuntimeException::class); + expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); } finally { removeCompilerArtifactSafetyDirectory($directory); } diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 00637a1..dd37f31 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -225,6 +225,33 @@ protected function streamCompiled(RenderContext $context): Generator } }); +test('compiled conditions ignore branches after else', function () { + $environment = EnvironmentFactory::new()->build(); + $cases = [ + ['{% if false %}a{% else %}b{% elsif true %}c{% endif %}', [], 'b'], + ['{% case value %}{% else %}b{% when "a" %}a{% endcase %}', ['value' => 'a'], 'b'], + ]; + + foreach ($cases as [$source, $data, $expected]) { + $template = $environment->parseString($source); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplateInterface $compiled */ + $compiled = require $compiledPath; + $context = $environment->newRenderContext(data: $data); + + expect($compiled->render($context)) + ->toBe($template->render($environment->newRenderContext(data: $data))) + ->toBe($expected); + } finally { + @unlink($compiledPath); + } + } +}); + test('compiled templates keep runtime partial lookup', function () { $environment = EnvironmentFactory::new() ->setFilesystem(new \Keepsuit\Liquid\Tests\Stubs\StubFileSystem([ @@ -277,6 +304,32 @@ protected function streamCompiled(RenderContext $context): Generator } }); +test('compiled conditions preserve handled evaluation errors', function () { + $environment = EnvironmentFactory::new() + ->setRethrowErrors(false) + ->build(); + $template = $environment->parseString('{% if "a" > 1 %}yes{% else %}no{% endif %}', name: 'condition-errors.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplateInterface $compiled */ + $compiled = require $compiledPath; + $interpretedContext = $environment->newRenderContext(); + $compiledContext = $environment->newRenderContext(); + + expect($compiled->render($compiledContext)) + ->toBe($template->render($interpretedContext)) + ->toBe('Liquid error (line 1): Internal exception'); + expect($compiled->getErrors()[0]->lineNumber)->toBe(1); + expect($compiled->getErrors()[0]->templateName) + ->toBe($template->getErrors()[0]->templateName); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering preserves state across repeated renders', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{{ value }}{% assign value = "one" %}{{ value }}'); @@ -517,10 +570,14 @@ protected function streamCompiled(RenderContext $context): Generator $compiledPath = temporaryCompiledTemplatePath(); try { + ob_start(); $environment->compile($template, $compiledPath); /** @var CompiledTemplateInterface $compiled */ $compiled = require $compiledPath; + $artifactOutput = ob_get_clean(); + + expect($artifactOutput)->toBe(''); expect($compiled->render($environment->newRenderContext())) ->toBe($template->render($environment->newRenderContext())); From 5ae5aabeaa399c5c846f07ab37e0055bbfa32a55 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 09:32:58 +0200 Subject: [PATCH 25/45] refactor: rename template contract and parsed template --- .../Support/CompiledTemplatesCache.php | 8 +-- performance/benchmarks/CompilerBench.php | 8 +-- performance/benchmarks/OperationBench.php | 18 +++--- src/AbstractTemplate.php | 3 +- src/Compiler/CompiledTemplateInterface.php | 4 +- src/Compiler/Compiler.php | 4 +- src/Contracts/LiquidTemplatesCache.php | 6 +- src/Environment.php | 8 +-- src/Parse/ParseContext.php | 10 ++-- src/Parse/ParseTreeVisitor.php | 4 +- src/ParsedTemplate.php | 56 +++++++++++++++++++ src/Render/RenderContext.php | 4 +- src/Tags/RenderTag.php | 4 +- src/Template.php | 48 +++------------- src/TemplateInterface.php | 24 ++------ .../FilesystemTemplatesCache.php | 10 ++-- src/TemplatesCache/MemoryTemplatesCache.php | 8 +-- .../SerializeTemplatesCache.php | 8 +-- .../VarExportTemplatesCache.php | 8 +-- .../CompilerArtifactSafetyTest.php | 4 +- tests/Integration/CompilerTest.php | 14 ++--- tests/Integration/StreamTest.php | 8 +-- tests/Integration/Tags/RenderTagTest.php | 4 +- tests/Integration/TemplateTest.php | 4 +- tests/Pest.php | 4 +- tests/Unit/TemplatesCacheTest.php | 2 +- 26 files changed, 147 insertions(+), 136 deletions(-) create mode 100644 src/ParsedTemplate.php diff --git a/performance/Support/CompiledTemplatesCache.php b/performance/Support/CompiledTemplatesCache.php index 1b78a62..62d42e2 100644 --- a/performance/Support/CompiledTemplatesCache.php +++ b/performance/Support/CompiledTemplatesCache.php @@ -3,7 +3,7 @@ namespace Keepsuit\Liquid\Performance\Support; use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplatesCache\FilesystemTemplatesCache; final class CompiledTemplatesCache extends FilesystemTemplatesCache @@ -13,7 +13,7 @@ public function __construct(string $cachePath) parent::__construct($cachePath, keepInMemory: false); } - public function get(string $name): ?TemplateInterface + public function get(string $name): ?Template { $compiledPath = $this->getCompiledPath($name); @@ -34,12 +34,12 @@ protected function getCompiledPath(string $name): string return parent::getCompiledPath($name).'.php'; } - protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void + protected function saveCompiledTemplate(string $compiledPath, Template $template): void { throw new \LogicException('The compiled templates cache is read-only.'); } - protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface + protected function loadCompiledTemplate(string $compiledPath): ?Template { try { $template = require $compiledPath; diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php index 27f5cbe..0389390 100644 --- a/performance/benchmarks/CompilerBench.php +++ b/performance/benchmarks/CompilerBench.php @@ -5,7 +5,7 @@ use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use PhpBench\Attributes\AfterMethods; use PhpBench\Attributes\BeforeMethods; @@ -47,7 +47,7 @@ class CompilerBench private string $layoutTemplateName; - /** @var array */ + /** @var array */ private array $interpretedTemplates; /** @var array */ @@ -228,7 +228,7 @@ public function benchInterpretedStream(): void } /** - * @param array $templates + * @param array $templates * @param array{page: array, layout: array} $renderData */ private function renderPage( @@ -250,7 +250,7 @@ private function renderPage( } /** - * @param array $templates + * @param array $templates * @param array{page: array, layout: array} $renderData * @return \Generator */ diff --git a/performance/benchmarks/OperationBench.php b/performance/benchmarks/OperationBench.php index b614a0c..2c89c44 100644 --- a/performance/benchmarks/OperationBench.php +++ b/performance/benchmarks/OperationBench.php @@ -7,7 +7,7 @@ use Keepsuit\Liquid\Performance\Support\Database; use Keepsuit\Liquid\Performance\Support\Drops\ProductDrop; use Keepsuit\Liquid\Render\RenderContext; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Groups; use PhpBench\Attributes\Iterations; @@ -25,21 +25,21 @@ class OperationBench { private Environment $environment; - private TemplateInterface $scalarTemplate; + private Template $scalarTemplate; - private TemplateInterface $nestedTemplate; + private Template $nestedTemplate; - private TemplateInterface $filterWithoutArgumentsTemplate; + private Template $filterWithoutArgumentsTemplate; - private TemplateInterface $filterWithArgumentsTemplate; + private Template $filterWithArgumentsTemplate; - private TemplateInterface $dropMethodTemplate; + private Template $dropMethodTemplate; - private TemplateInterface $dropMethodMissingHitTemplate; + private Template $dropMethodMissingHitTemplate; - private TemplateInterface $dropMethodMissingMissTemplate; + private Template $dropMethodMissingMissTemplate; - private TemplateInterface $productListTemplate; + private Template $productListTemplate; /** * Built in setUp, not in the subject: these two subjects measure how diff --git a/src/AbstractTemplate.php b/src/AbstractTemplate.php index 6c4c8dd..6d3782a 100644 --- a/src/AbstractTemplate.php +++ b/src/AbstractTemplate.php @@ -5,7 +5,8 @@ use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Render\RenderContext; -abstract class AbstractTemplate implements TemplateInterface +// @phpstan-ignore-next-line +abstract class AbstractTemplate implements Template, TemplateInterface { public function __construct( public readonly TemplateSharedState $state = new TemplateSharedState, diff --git a/src/Compiler/CompiledTemplateInterface.php b/src/Compiler/CompiledTemplateInterface.php index bdb3f1e..dd46408 100644 --- a/src/Compiler/CompiledTemplateInterface.php +++ b/src/Compiler/CompiledTemplateInterface.php @@ -2,6 +2,6 @@ namespace Keepsuit\Liquid\Compiler; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; -interface CompiledTemplateInterface extends TemplateInterface {} +interface CompiledTemplateInterface extends Template {} diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 610f3d8..3beffe5 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -3,11 +3,11 @@ namespace Keepsuit\Liquid\Compiler; use Keepsuit\Liquid\Nodes\Node; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\ParsedTemplate; class Compiler { - public function compile(Template $template): string + public function compile(ParsedTemplate $template): string { $bodyContext = new CompilerContext; $bodyContext->subcompile($template->root); diff --git a/src/Contracts/LiquidTemplatesCache.php b/src/Contracts/LiquidTemplatesCache.php index d34f67a..787261f 100644 --- a/src/Contracts/LiquidTemplatesCache.php +++ b/src/Contracts/LiquidTemplatesCache.php @@ -2,13 +2,13 @@ namespace Keepsuit\Liquid\Contracts; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; interface LiquidTemplatesCache { - public function set(string $name, TemplateInterface $template): void; + public function set(string $name, Template $template): void; - public function get(string $name): ?TemplateInterface; + public function get(string $name): ?Template; public function has(string $name): bool; diff --git a/src/Environment.php b/src/Environment.php index 5139590..dbfb4b9 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -121,7 +121,7 @@ public function newRenderContext( /** * @throws LiquidException */ - public function parseString(string $source, ?string $name = null): TemplateInterface + public function parseString(string $source, ?string $name = null): Template { return $this->newParseContext()->parse($source, name: $name); } @@ -129,7 +129,7 @@ public function parseString(string $source, ?string $name = null): TemplateInter /** * @throws LiquidException */ - public function parseTemplate(string $templateName): TemplateInterface + public function parseTemplate(string $templateName): Template { return $this->newParseContext()->parseTemplate($templateName); } @@ -137,9 +137,9 @@ public function parseTemplate(string $templateName): TemplateInterface /** * Write a requireable compiled artifact for the given template. */ - public function compile(TemplateInterface $template, string $compiledPath): void + public function compile(Template $template, string $compiledPath): void { - if (! $template instanceof Template) { + if (! $template instanceof ParsedTemplate) { throw new \InvalidArgumentException('Only parsed templates can be compiled.'); } diff --git a/src/Parse/ParseContext.php b/src/Parse/ParseContext.php index 0c51392..854e3f0 100644 --- a/src/Parse/ParseContext.php +++ b/src/Parse/ParseContext.php @@ -8,9 +8,9 @@ use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Exceptions\StackLevelException; use Keepsuit\Liquid\Exceptions\SyntaxException; +use Keepsuit\Liquid\ParsedTemplate; use Keepsuit\Liquid\Support\OutputsBag; use Keepsuit\Liquid\Template; -use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\TemplateSharedState; class ParseContext @@ -63,7 +63,7 @@ public function tokenize(string $markup): TokenStream /** * @throws LiquidException */ - public function parseTemplate(string $templateName, bool $force = false): TemplateInterface + public function parseTemplate(string $templateName, bool $force = false): Template { if (! $force) { $cachedTemplate = $this->environment->templatesCache->get($templateName); @@ -82,7 +82,7 @@ public function parseTemplate(string $templateName, bool $force = false): Templa return $template; } - public function parse(TokenStream|string $source, ?string $name = null): TemplateInterface + public function parse(TokenStream|string $source, ?string $name = null): Template { $this->partials = []; $this->outputs = new OutputsBag; @@ -92,7 +92,7 @@ public function parse(TokenStream|string $source, ?string $name = null): Templat $root = $this->parser->parse($tokenStream, $name); - return new Template( + return new ParsedTemplate( root: $root, state: new TemplateSharedState( partials: $this->partials, @@ -111,7 +111,7 @@ public function parse(TokenStream|string $source, ?string $name = null): Templat } } - public function loadPartial(string $templateName): TemplateInterface + public function loadPartial(string $templateName): Template { try { // Check if template is already available in the cache diff --git a/src/Parse/ParseTreeVisitor.php b/src/Parse/ParseTreeVisitor.php index 04232a8..7509cc0 100644 --- a/src/Parse/ParseTreeVisitor.php +++ b/src/Parse/ParseTreeVisitor.php @@ -5,7 +5,7 @@ use Closure; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Nodes\Node; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\ParsedTemplate; class ParseTreeVisitor { @@ -48,7 +48,7 @@ protected function children(): array return $this->node->children(); } - if ($this->node instanceof Template) { + if ($this->node instanceof ParsedTemplate) { return $this->node->root->children(); } diff --git a/src/ParsedTemplate.php b/src/ParsedTemplate.php new file mode 100644 index 0000000..018be5e --- /dev/null +++ b/src/ParsedTemplate.php @@ -0,0 +1,56 @@ +prepareContext($context); + + return $this->root->render($context); + } catch (LiquidException $e) { + $this->attachTemplateName($e); + throw $e; + } finally { + $this->persistContext($context); + } + } + + /** + * @return \Generator + */ + public function stream(RenderContext $context): \Generator + { + try { + $this->prepareContext($context); + + yield from $this->root->stream($context); + } catch (LiquidException $e) { + $this->attachTemplateName($e); + throw $e; + } finally { + $this->persistContext($context); + } + } + + public function name(): ?string + { + return $this->root->name; + } +} diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index da1a87e..263abcb 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -25,7 +25,7 @@ use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Support\MissingValue; use Keepsuit\Liquid\Support\OutputsBag; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use RuntimeException; use Throwable; @@ -416,7 +416,7 @@ public function getTemplateName(): ?string return $this->templateName; } - public function loadPartial(string $templateName): TemplateInterface + public function loadPartial(string $templateName): Template { if ($partial = $this->environment->templatesCache->get($templateName)) { return $partial; diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index b65c0d3..8f66954 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -13,7 +13,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\Tag; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use Traversable; /** @@ -159,7 +159,7 @@ public function parseTreeVisitorChildren(): array ]; } - protected function loadPartial(RenderContext $context): TemplateInterface + protected function loadPartial(RenderContext $context): Template { $templateName = $this->templateNameExpression; if ($this->allowDynamicPartials() && $this->templateNameExpression instanceof VariableLookup) { diff --git a/src/Template.php b/src/Template.php index 3035032..e330ce5 100644 --- a/src/Template.php +++ b/src/Template.php @@ -2,55 +2,23 @@ namespace Keepsuit\Liquid; -use Keepsuit\Liquid\Exceptions\LiquidException; -use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Render\RenderContext; -class Template extends AbstractTemplate +interface Template { - public function __construct( - public readonly Document $root, - TemplateSharedState $state = new TemplateSharedState, - ) { - parent::__construct($state); - } + public function render(RenderContext $context): string; /** - * @throws LiquidException + * @return \Generator */ - public function render(RenderContext $context): string - { - try { - $this->prepareContext($context); + public function stream(RenderContext $context): \Generator; - return $this->root->render($context); - } catch (LiquidException $e) { - $this->attachTemplateName($e); - throw $e; - } finally { - $this->persistContext($context); - } - } + public function getState(): TemplateSharedState; /** - * @return \Generator + * @return array<\Throwable> */ - public function stream(RenderContext $context): \Generator - { - try { - $this->prepareContext($context); - - yield from $this->root->stream($context); - } catch (LiquidException $e) { - $this->attachTemplateName($e); - throw $e; - } finally { - $this->persistContext($context); - } - } + public function getErrors(): array; - public function name(): ?string - { - return $this->root->name; - } + public function name(): ?string; } diff --git a/src/TemplateInterface.php b/src/TemplateInterface.php index 5b65ffd..b6aabe9 100644 --- a/src/TemplateInterface.php +++ b/src/TemplateInterface.php @@ -2,23 +2,7 @@ namespace Keepsuit\Liquid; -use Keepsuit\Liquid\Render\RenderContext; - -interface TemplateInterface -{ - public function render(RenderContext $context): string; - - /** - * @return \Generator - */ - public function stream(RenderContext $context): \Generator; - - public function getState(): TemplateSharedState; - - /** - * @return array<\Throwable> - */ - public function getErrors(): array; - - public function name(): ?string; -} +/** + * @deprecated Use Template instead. + */ +interface TemplateInterface extends Template {} diff --git a/src/TemplatesCache/FilesystemTemplatesCache.php b/src/TemplatesCache/FilesystemTemplatesCache.php index 4b50caa..60374a8 100644 --- a/src/TemplatesCache/FilesystemTemplatesCache.php +++ b/src/TemplatesCache/FilesystemTemplatesCache.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\TemplatesCache; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; abstract class FilesystemTemplatesCache extends MemoryTemplatesCache { @@ -13,7 +13,7 @@ public function __construct( $this->ensureCacheDirectoryExists(); } - public function set(string $name, TemplateInterface $template): void + public function set(string $name, Template $template): void { if ($this->keepInMemory) { parent::set($name, $template); @@ -22,7 +22,7 @@ public function set(string $name, TemplateInterface $template): void $this->saveCompiledTemplate($this->getCompiledPath($name), $template); } - public function get(string $name): ?TemplateInterface + public function get(string $name): ?Template { if ($this->keepInMemory && $template = parent::get($name)) { return $template; @@ -78,7 +78,7 @@ protected function ensureCacheDirectoryExists(): void } } - abstract protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void; + abstract protected function saveCompiledTemplate(string $compiledPath, Template $template): void; - abstract protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface; + abstract protected function loadCompiledTemplate(string $compiledPath): ?Template; } diff --git a/src/TemplatesCache/MemoryTemplatesCache.php b/src/TemplatesCache/MemoryTemplatesCache.php index 4434501..7024140 100644 --- a/src/TemplatesCache/MemoryTemplatesCache.php +++ b/src/TemplatesCache/MemoryTemplatesCache.php @@ -3,21 +3,21 @@ namespace Keepsuit\Liquid\TemplatesCache; use Keepsuit\Liquid\Contracts\LiquidTemplatesCache; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; class MemoryTemplatesCache implements LiquidTemplatesCache { /** - * @var array + * @var array */ protected array $cache = []; - public function set(string $name, TemplateInterface $template): void + public function set(string $name, Template $template): void { $this->cache[$name] = $template; } - public function get(string $name): ?TemplateInterface + public function get(string $name): ?Template { return $this->cache[$name] ?? null; } diff --git a/src/TemplatesCache/SerializeTemplatesCache.php b/src/TemplatesCache/SerializeTemplatesCache.php index 751f1ae..fe55dbd 100644 --- a/src/TemplatesCache/SerializeTemplatesCache.php +++ b/src/TemplatesCache/SerializeTemplatesCache.php @@ -2,16 +2,16 @@ namespace Keepsuit\Liquid\TemplatesCache; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; class SerializeTemplatesCache extends FilesystemTemplatesCache { - protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void + protected function saveCompiledTemplate(string $compiledPath, Template $template): void { file_put_contents($compiledPath, serialize($template)); } - protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface + protected function loadCompiledTemplate(string $compiledPath): ?Template { try { $content = file_get_contents($compiledPath); @@ -22,7 +22,7 @@ protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterfac $template = unserialize($content); - if (! $template instanceof TemplateInterface) { + if (! $template instanceof Template) { return null; } diff --git a/src/TemplatesCache/VarExportTemplatesCache.php b/src/TemplatesCache/VarExportTemplatesCache.php index 2a0c869..8eef744 100644 --- a/src/TemplatesCache/VarExportTemplatesCache.php +++ b/src/TemplatesCache/VarExportTemplatesCache.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\TemplatesCache; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use Symfony\Component\VarExporter\VarExporter; class VarExportTemplatesCache extends FilesystemTemplatesCache @@ -23,7 +23,7 @@ protected function getCompiledPath(string $name): string return parent::getCompiledPath($name).'.php'; } - protected function saveCompiledTemplate(string $compiledPath, TemplateInterface $template): void + protected function saveCompiledTemplate(string $compiledPath, Template $template): void { $compiledTemplate = VarExporter::export($template); @@ -39,12 +39,12 @@ protected function saveCompiledTemplate(string $compiledPath, TemplateInterface } } - protected function loadCompiledTemplate(string $compiledPath): ?TemplateInterface + protected function loadCompiledTemplate(string $compiledPath): ?Template { try { $template = require $compiledPath; - if (! $template instanceof TemplateInterface) { + if (! $template instanceof Template) { return null; } diff --git a/tests/Integration/CompilerArtifactSafetyTest.php b/tests/Integration/CompilerArtifactSafetyTest.php index 9d9cc0b..35f9b94 100644 --- a/tests/Integration/CompilerArtifactSafetyTest.php +++ b/tests/Integration/CompilerArtifactSafetyTest.php @@ -5,7 +5,7 @@ use Keepsuit\Liquid\Compiler\Compiler; use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\EnvironmentFactory; -use Keepsuit\Liquid\Template; +use Keepsuit\Liquid\ParsedTemplate; function compilerArtifactSafetyDirectory(): string { @@ -99,7 +99,7 @@ function removeCompilerArtifactSafetyDirectory(string $directory): void expect($cache->get('wrong'))->toBeNull(); $template = $environment->parseString('valid artifact'); - assert($template instanceof Template); + assert($template instanceof ParsedTemplate); $cache->set('valid', (new Compiler)->compile($template)); expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index dd37f31..6239b38 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -17,11 +17,11 @@ use Keepsuit\Liquid\Nodes\Text; use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Parse\TagParseContext; +use Keepsuit\Liquid\ParsedTemplate; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\Template; -use Keepsuit\Liquid\TemplateInterface; class CompilableCompilerTestNode extends Node implements CanBeCompiled { @@ -373,7 +373,7 @@ protected function streamCompiled(RenderContext $context): Generator expect($compiled->render($compiledContext))->toBe($template->render($interpretedContext)); - $describeErrors = static fn (TemplateInterface $rendered): array => array_map( + $describeErrors = static fn (Template $rendered): array => array_map( static fn (\Throwable $error): array => [ $error::class, $error->getMessage(), @@ -608,7 +608,7 @@ protected function streamCompiled(RenderContext $context): Generator test('custom compilable nodes opt in through the compiler context', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); - assert($template instanceof Template); + assert($template instanceof ParsedTemplate); $template->root->body->pushChild(new CompilableCompilerTestNode('custom output')); $compiledPath = temporaryCompiledTemplatePath(); @@ -628,7 +628,7 @@ protected function streamCompiled(RenderContext $context): Generator test('custom compilable tags opt in without changing tag registration', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); - assert($template instanceof Template); + assert($template instanceof ParsedTemplate); $template->root->body->pushChild(new CompilableCompilerTestTag); $compiledPath = temporaryCompiledTemplatePath(); @@ -686,7 +686,7 @@ protected function streamCompiled(RenderContext $context): Generator ->toBe($template->render($environment->newRenderContext())) ->toBe('filtered runtime'); - $renderDisabled = static function (TemplateInterface $candidate, RenderContext $context): string { + $renderDisabled = static function (Template $candidate, RenderContext $context): string { return $context->withDisabledTags( ['runtime_fallback'], fn () => $candidate->render($context), @@ -709,7 +709,7 @@ protected function streamCompiled(RenderContext $context): Generator test('failed node compilation rolls back before runtime fallback', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix'); - assert($template instanceof Template); + assert($template instanceof ParsedTemplate); $template->root->body->pushChild(new FailingCompilableCompilerTestNode); $compiledPath = temporaryCompiledTemplatePath(); @@ -739,7 +739,7 @@ protected function streamCompiled(RenderContext $context): Generator throw new RuntimeException('Unable to create a test resource.'); } - assert($template instanceof Template); + assert($template instanceof ParsedTemplate); $template->root->body->pushChild( (new UnsafeFallbackCompilerTestNode($resource))->setLineNumber(7), ); diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 8ef74b9..56a6daf 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -7,7 +7,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Tag; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; class UnsupportedCompilerStreamTestTag extends Tag { @@ -27,7 +27,7 @@ public function render(RenderContext $context): string } } -function compileStreamTestTemplate(Environment $environment, TemplateInterface $template): TemplateInterface +function compileStreamTestTemplate(Environment $environment, Template $template): Template { $path = tempnam(sys_get_temp_dir(), 'liquid-compiled-stream-'); @@ -41,14 +41,14 @@ function compileStreamTestTemplate(Environment $environment, TemplateInterface $ try { $environment->compile($template, $path); - /** @var TemplateInterface $compiled */ + /** @var Template $compiled */ return require $path; } finally { @unlink($path); } } -function streamChunks(TemplateInterface $template, RenderContext $context): array +function streamChunks(Template $template, RenderContext $context): array { return iterator_to_array($template->stream($context)); } diff --git a/tests/Integration/Tags/RenderTagTest.php b/tests/Integration/Tags/RenderTagTest.php index b097bef..a0e3068 100644 --- a/tests/Integration/Tags/RenderTagTest.php +++ b/tests/Integration/Tags/RenderTagTest.php @@ -3,7 +3,7 @@ use Keepsuit\Liquid\EnvironmentFactory; use Keepsuit\Liquid\Exceptions\StackLevelException; use Keepsuit\Liquid\Exceptions\SyntaxException; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; @@ -168,7 +168,7 @@ { public int $reads = 0; - public function get(string $name): ?TemplateInterface + public function get(string $name): ?Template { $this->reads++; diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index 03933ad..f64989b 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -8,6 +8,7 @@ use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\RenderContextOptions; use Keepsuit\Liquid\Render\ResourceLimits; +use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplateInterface; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use Keepsuit\Liquid\TemplateSharedState; @@ -17,7 +18,8 @@ $template = Environment::default()->parseString('hello', name: 'hello'); expect($template) - ->toBeInstanceOf(TemplateInterface::class) + ->toBeInstanceOf(Template::class) + ->and($template)->toBeInstanceOf(TemplateInterface::class) ->and($template->getState())->toBeInstanceOf(TemplateSharedState::class) ->and($template->getErrors())->toBeEmpty() ->and($template->name())->toBe('hello'); diff --git a/tests/Pest.php b/tests/Pest.php index 4aec2d0..76a2da6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -5,7 +5,7 @@ use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\ParseContext; use Keepsuit\Liquid\Parse\TokenStream; -use Keepsuit\Liquid\TemplateInterface; +use Keepsuit\Liquid\Template; use Keepsuit\Liquid\Tests\Stubs\StubFileSystem; use PHPUnit\Framework\ExpectationFailedException; @@ -15,7 +15,7 @@ function parseTemplate( string $source, ?Environment $environment = null, -): TemplateInterface { +): Template { return ($environment ?? Environment::default())->parseString($source); } diff --git a/tests/Unit/TemplatesCacheTest.php b/tests/Unit/TemplatesCacheTest.php index 08736d3..e45ca18 100644 --- a/tests/Unit/TemplatesCacheTest.php +++ b/tests/Unit/TemplatesCacheTest.php @@ -14,7 +14,7 @@ $cache->set('test', $template); expect($cache) ->has('test')->toBe(true) - ->get('test')->toBeInstanceOf(\Keepsuit\Liquid\Template::class); + ->get('test')->toBeInstanceOf(\Keepsuit\Liquid\ParsedTemplate::class); $renderContext = new \Keepsuit\Liquid\Render\RenderContext(['name' => 'John']); $cachedTemplate = $cache->get('test'); From a5fed566a62c1bc1aea3b9a5253dc54488fb29c5 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 09:34:53 +0200 Subject: [PATCH 26/45] refactor: remove legacy template interface --- src/AbstractTemplate.php | 3 +-- src/TemplateInterface.php | 8 -------- tests/Integration/TemplateTest.php | 4 +--- 3 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 src/TemplateInterface.php diff --git a/src/AbstractTemplate.php b/src/AbstractTemplate.php index 6d3782a..07cf2bf 100644 --- a/src/AbstractTemplate.php +++ b/src/AbstractTemplate.php @@ -5,8 +5,7 @@ use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Render\RenderContext; -// @phpstan-ignore-next-line -abstract class AbstractTemplate implements Template, TemplateInterface +abstract class AbstractTemplate implements Template { public function __construct( public readonly TemplateSharedState $state = new TemplateSharedState, diff --git a/src/TemplateInterface.php b/src/TemplateInterface.php deleted file mode 100644 index b6aabe9..0000000 --- a/src/TemplateInterface.php +++ /dev/null @@ -1,8 +0,0 @@ -toBeInstanceOf(Template::class) - ->and($template)->toBeInstanceOf(TemplateInterface::class) ->and($template->getState())->toBeInstanceOf(TemplateSharedState::class) ->and($template->getErrors())->toBeEmpty() ->and($template->name())->toBe('hello'); }); test('template caches and partial loading accept template interface implementations', function () { - $template = new class implements TemplateInterface + $template = new class implements Template { private TemplateSharedState $state; From 70be7a62b82e0be3a76f3efbd72b4b1ff25e8926 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 09:43:45 +0200 Subject: [PATCH 27/45] refactor: share compiled theme benchmark setup --- .../Support/CompilesThemeTemplates.php | 87 +++++++++++++++++++ performance/benchmarks/CompilerBench.php | 33 +++---- performance/benchmarks/TemplateCacheBench.php | 9 +- performance/benchmarks/ThemeBench.php | 41 ++------- 4 files changed, 109 insertions(+), 61 deletions(-) create mode 100644 performance/Support/CompilesThemeTemplates.php diff --git a/performance/Support/CompilesThemeTemplates.php b/performance/Support/CompilesThemeTemplates.php new file mode 100644 index 0000000..715fc28 --- /dev/null +++ b/performance/Support/CompilesThemeTemplates.php @@ -0,0 +1,87 @@ +setTemplatesCache(new MemoryTemplatesCache) + ->build(); + } + + /** + * @param array|null $templates + * @return array{templates: array, paths: array} + */ + protected function compileThemeTemplates( + Environment $environment, + string $cacheDirectory, + ?array $templates = null, + ): array { + $cacheDirectory = $this->prepareCompiledDirectory($cacheDirectory); + $compiledTemplates = []; + $artifactPaths = []; + + foreach (StorefrontTheme::templateNames() as $templateName) { + $template = $templates[$templateName] ?? $environment->parseTemplate($templateName); + $artifactPath = $this->compiledTemplatePath($cacheDirectory, $templateName); + $compiledTemplates[$templateName] = $this->compileTemplateToPath( + $environment, + $template, + $artifactPath, + ); + $artifactPaths[$templateName] = $artifactPath; + $environment->templatesCache->set($templateName, $compiledTemplates[$templateName]); + } + + return [ + 'templates' => $compiledTemplates, + 'paths' => $artifactPaths, + ]; + } + + protected function compileTemplateToPath( + Environment $environment, + Template $template, + string $artifactPath, + ): CompiledTemplateInterface { + $environment->compile($template, $artifactPath); + $compiledTemplate = require $artifactPath; + + if (! $compiledTemplate instanceof CompiledTemplateInterface) { + throw new \RuntimeException("Invalid compiled template benchmark artifact: {$artifactPath}"); + } + + return $compiledTemplate; + } + + protected function compiledTemplatePath(string $cacheDirectory, string $templateName): string + { + return $cacheDirectory.'/'.str_replace('.', '_', $templateName).'.php'; + } + + protected function prepareCompiledDirectory(string $path): string + { + if (is_dir($path)) { + $items = new \FilesystemIterator($path); + foreach ($items as $item) { + unlink($item); + } + + return $path; + } + + if (! mkdir($path, 0755, true)) { + throw new \RuntimeException('Could not create the compiled theme benchmark artifact directory.'); + } + + return $path; + } +} diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php index 0389390..817dd4e 100644 --- a/performance/benchmarks/CompilerBench.php +++ b/performance/benchmarks/CompilerBench.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Environment; +use Keepsuit\Liquid\Performance\Support\CompilesThemeTemplates; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; @@ -31,6 +32,8 @@ #[AfterMethods('tearDown')] class CompilerBench { + use CompilesThemeTemplates; + private const DATA_SET_COUNT = 20; private Environment $interpretedEnvironment; @@ -84,9 +87,7 @@ public function setUp(): void $this->interpretedEnvironment = StorefrontTheme::environmentFactory() ->setTemplatesCache(new MemoryTemplatesCache) ->build(); - $this->compiledEnvironment = StorefrontTheme::environmentFactory() - ->setTemplatesCache(new MemoryTemplatesCache) - ->build(); + $this->compiledEnvironment = $this->newCompiledEnvironment(); $this->interpretedTemplates = []; $this->compiledTemplates = []; $this->artifactPaths = []; @@ -98,15 +99,16 @@ public function setUp(): void $template = $this->interpretedEnvironment->parseString($source, $templateName); $this->interpretedTemplates[$templateName] = $template; $this->interpretedEnvironment->templatesCache->set($templateName, $template); - - $artifactPath = $this->artifactDirectory.'/'.str_replace('.', '_', $templateName).'.php'; - $this->artifactPaths[$templateName] = $artifactPath; - $this->compiledEnvironment->compile($template, $artifactPath); - $compiledTemplate = $this->loadCompiledArtifact($artifactPath); - $this->compiledTemplates[$templateName] = $compiledTemplate; - $this->compiledEnvironment->templatesCache->set($templateName, $compiledTemplate); } + $compiledTheme = $this->compileThemeTemplates( + $this->compiledEnvironment, + $this->artifactDirectory, + $this->interpretedTemplates, + ); + $this->compiledTemplates = $compiledTheme['templates']; + $this->artifactPaths = $compiledTheme['paths']; + $this->writeFreshLoadScript(); // Keep fixture/data creation out of render and stream timing. @@ -300,17 +302,6 @@ private function buildRenderDataSets(int $count): array return $renderDataSets; } - private function loadCompiledArtifact(string $artifactPath): CompiledTemplateInterface - { - $template = require $artifactPath; - - if (! $template instanceof CompiledTemplateInterface) { - throw new \RuntimeException("Invalid compiler benchmark artifact: {$artifactPath}"); - } - - return $template; - } - private function writeFreshLoadScript(): void { $this->freshLoadScript = $this->artifactDirectory.'/fresh-load.php'; diff --git a/performance/benchmarks/TemplateCacheBench.php b/performance/benchmarks/TemplateCacheBench.php index 84c4747..f06671b 100644 --- a/performance/benchmarks/TemplateCacheBench.php +++ b/performance/benchmarks/TemplateCacheBench.php @@ -5,6 +5,7 @@ use Keepsuit\Liquid\Contracts\LiquidTemplatesCache; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Performance\Support\CompiledTemplatesCache; +use Keepsuit\Liquid\Performance\Support\CompilesThemeTemplates; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use Keepsuit\Liquid\TemplatesCache\SerializeTemplatesCache; @@ -25,6 +26,8 @@ #[AfterMethods('clearCache')] class TemplateCacheBench { + use CompilesThemeTemplates; + private const CACHE_DIRECTORY = 'keepsuit-liquid-phpbench'; private Environment $environment; @@ -118,13 +121,11 @@ public function setUpCompiledCachedRender(): void $this->cacheDirectory = sys_get_temp_dir().'/'.self::CACHE_DIRECTORY.'-'.bin2hex(random_bytes(8)); $compiledCache = new CompiledTemplatesCache($this->cachePath('compiled')); $this->cache = $compiledCache; - $compilerEnvironment = StorefrontTheme::environmentFactory() - ->setTemplatesCache(new MemoryTemplatesCache) - ->build(); + $compilerEnvironment = $this->newCompiledEnvironment(); foreach ($this->templateNames as $templateName) { $template = $compilerEnvironment->parseTemplate($templateName); - $compilerEnvironment->compile($template, $compiledCache->pathFor($templateName)); + $this->compileTemplateToPath($compilerEnvironment, $template, $compiledCache->pathFor($templateName)); } $this->environment = StorefrontTheme::environmentFactory() diff --git a/performance/benchmarks/ThemeBench.php b/performance/benchmarks/ThemeBench.php index 0b9ffb2..e036423 100644 --- a/performance/benchmarks/ThemeBench.php +++ b/performance/benchmarks/ThemeBench.php @@ -2,10 +2,9 @@ namespace Keepsuit\Liquid\Performance\benchmarks; -use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Environment; +use Keepsuit\Liquid\Performance\Support\CompilesThemeTemplates; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; -use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Groups; use PhpBench\Attributes\Iterations; @@ -32,6 +31,8 @@ #[BeforeMethods('setUp')] class ThemeBench { + use CompilesThemeTemplates; + private Environment $environment; private Environment $compiledEnvironment; @@ -50,29 +51,14 @@ class ThemeBench public function setUp(): void { $this->environment = StorefrontTheme::environment(); - $this->compiledEnvironment = StorefrontTheme::environmentFactory() - ->setTemplatesCache(new MemoryTemplatesCache) - ->build(); - - $compiledCacheDirectory = $this->prepareCompiledDirectory(__DIR__.'/cache/compiled'); + $this->compiledEnvironment = $this->newCompiledEnvironment(); + $this->compileThemeTemplates($this->compiledEnvironment, __DIR__.'/cache/compiled'); $this->sources = []; foreach (StorefrontTheme::templateNames() as $name) { $this->environment->parseTemplate($name); $this->sources[$name] = StorefrontTheme::templateSource($name); - - $template = $this->compiledEnvironment->parseTemplate($name); - $artifactPath = $compiledCacheDirectory.'/'.str_replace('.', '_', $name).'.php'; - $this->compiledEnvironment->compile($template, $artifactPath); - - $compiledTemplate = require $artifactPath; - - if (! $compiledTemplate instanceof CompiledTemplateInterface) { - throw new \RuntimeException("Invalid compiled theme benchmark artifact: {$artifactPath}"); - } - - $this->compiledEnvironment->templatesCache->set($name, $compiledTemplate); } $this->pageTemplateNames = StorefrontTheme::pageTemplateNames(); @@ -113,21 +99,4 @@ public function benchStream(): void } } } - - protected function prepareCompiledDirectory(string $path): string - { - if (is_dir($path)) { - $items = new \FilesystemIterator($path); - foreach ($items as $item) { - unlink($item); - } - return $path; - } - - if (! mkdir($path, 0755, true)) { - throw new \RuntimeException('Could not create the compiled theme benchmark artifact directory.'); - } - - return $path; - } } From 5f7585e38d5abb461a9b6e6261b99db536ced670 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 09:49:38 +0200 Subject: [PATCH 28/45] refactor: remove unused compiler cache abstractions --- .../Support/CompiledTemplatesCache.php | 4 +- .../Support/CompilesThemeTemplates.php | 8 +- performance/benchmarks/CompilerBench.php | 6 +- src/Compiler/Cache/CompiledTemplateCache.php | 18 --- .../Cache/FilesystemCompiledTemplateCache.php | 113 ------------------ src/Compiler/CompiledTemplate.php | 2 +- src/Compiler/CompiledTemplateInterface.php | 7 -- src/Environment.php | 4 +- .../CompilerArtifactSafetyTest.php | 51 +------- tests/Integration/CompilerTest.php | 41 ++++--- 10 files changed, 35 insertions(+), 219 deletions(-) delete mode 100644 src/Compiler/Cache/CompiledTemplateCache.php delete mode 100644 src/Compiler/Cache/FilesystemCompiledTemplateCache.php delete mode 100644 src/Compiler/CompiledTemplateInterface.php diff --git a/performance/Support/CompiledTemplatesCache.php b/performance/Support/CompiledTemplatesCache.php index 62d42e2..9bc57ca 100644 --- a/performance/Support/CompiledTemplatesCache.php +++ b/performance/Support/CompiledTemplatesCache.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\Performance\Support; -use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; +use Keepsuit\Liquid\Compiler\CompiledTemplate; use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplatesCache\FilesystemTemplatesCache; @@ -47,7 +47,7 @@ protected function loadCompiledTemplate(string $compiledPath): ?Template return null; } - return $template instanceof CompiledTemplateInterface + return $template instanceof CompiledTemplate ? $template : null; } diff --git a/performance/Support/CompilesThemeTemplates.php b/performance/Support/CompilesThemeTemplates.php index 715fc28..5823181 100644 --- a/performance/Support/CompilesThemeTemplates.php +++ b/performance/Support/CompilesThemeTemplates.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\Performance\Support; -use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; +use Keepsuit\Liquid\Compiler\CompiledTemplate; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Template; use Keepsuit\Liquid\TemplatesCache\MemoryTemplatesCache; @@ -18,7 +18,7 @@ protected function newCompiledEnvironment(): Environment /** * @param array|null $templates - * @return array{templates: array, paths: array} + * @return array{templates: array, paths: array} */ protected function compileThemeTemplates( Environment $environment, @@ -51,11 +51,11 @@ protected function compileTemplateToPath( Environment $environment, Template $template, string $artifactPath, - ): CompiledTemplateInterface { + ): CompiledTemplate { $environment->compile($template, $artifactPath); $compiledTemplate = require $artifactPath; - if (! $compiledTemplate instanceof CompiledTemplateInterface) { + if (! $compiledTemplate instanceof CompiledTemplate) { throw new \RuntimeException("Invalid compiled template benchmark artifact: {$artifactPath}"); } diff --git a/performance/benchmarks/CompilerBench.php b/performance/benchmarks/CompilerBench.php index 817dd4e..fffa572 100644 --- a/performance/benchmarks/CompilerBench.php +++ b/performance/benchmarks/CompilerBench.php @@ -2,7 +2,7 @@ namespace Keepsuit\Liquid\Performance\benchmarks; -use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; +use Keepsuit\Liquid\Compiler\CompiledTemplate; use Keepsuit\Liquid\Environment; use Keepsuit\Liquid\Performance\Support\CompilesThemeTemplates; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; @@ -53,7 +53,7 @@ class CompilerBench /** @var array */ private array $interpretedTemplates; - /** @var array */ + /** @var array */ private array $compiledTemplates; /** @var array */ @@ -310,7 +310,7 @@ private function writeFreshLoadScript(): void .'$paths = '.var_export(array_values($this->artifactPaths), true).";\n" ."foreach (\$paths as \$path) {\n" ." \$template = require \$path;\n" - ." if (! \$template instanceof \\Keepsuit\\Liquid\\Compiler\\CompiledTemplateInterface) {\n" + ." if (! \$template instanceof \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate) {\n" ." exit(1);\n" ." }\n" ."}\n"; diff --git a/src/Compiler/Cache/CompiledTemplateCache.php b/src/Compiler/Cache/CompiledTemplateCache.php deleted file mode 100644 index 2617af8..0000000 --- a/src/Compiler/Cache/CompiledTemplateCache.php +++ /dev/null @@ -1,18 +0,0 @@ -cachePath) && ! mkdir($this->cachePath, 0755, true) && ! is_dir($this->cachePath)) { - throw new \RuntimeException(sprintf('Unable to create compiled template cache directory: %s', $this->cachePath)); - } - } - - public function get(string $hash): ?CompiledTemplateInterface - { - if (! $this->has($hash)) { - return null; - } - - try { - $compiled = require $this->getPath($hash); - } catch (\Throwable) { - return null; - } - - return $compiled instanceof CompiledTemplateInterface - ? $compiled - : null; - } - - public function has(string $hash): bool - { - return is_file($this->getPath($hash)); - } - - public function set(string $hash, string $source): void - { - $path = $this->getPath($hash); - $temporaryPath = tempnam($this->cachePath, '.'.basename($path).'.tmp-'); - - if ($temporaryPath === false) { - throw new \RuntimeException(sprintf('Unable to create temporary compiled template cache entry: %s', $hash)); - } - - try { - $bytesWritten = file_put_contents($temporaryPath, $source); - - if ($bytesWritten !== strlen($source)) { - throw new \RuntimeException(sprintf('Unable to write compiled template cache entry: %s', $hash)); - } - - try { - $compiled = require $temporaryPath; - } catch (\Throwable $exception) { - throw new \RuntimeException(sprintf('Unable to validate compiled template cache entry: %s', $hash), previous: $exception); - } - - if (! $compiled instanceof CompiledTemplateInterface) { - throw new \RuntimeException(sprintf('Invalid compiled template cache entry: %s', $hash)); - } - - $this->publish($temporaryPath, $path, $hash); - - if (function_exists('opcache_invalidate')) { - opcache_invalidate($path, true); - } - } finally { - if (is_file($temporaryPath)) { - unlink($temporaryPath); - } - } - } - - protected function publish(string $temporaryPath, string $path, string $hash): void - { - set_error_handler(static fn (): bool => true); - - try { - $published = rename($temporaryPath, $path); - } finally { - restore_error_handler(); - } - - if (! $published) { - throw new \RuntimeException(sprintf('Unable to publish compiled template cache entry: %s', $hash)); - } - } - - public function remove(string $hash): void - { - $path = $this->getPath($hash); - - if (is_file($path)) { - unlink($path); - } - } - - public function clear(): void - { - foreach (glob($this->cachePath.'/*') ?: [] as $path) { - if (is_file($path)) { - unlink($path); - } - } - } - - protected function getPath(string $hash): string - { - return $this->cachePath.'/'.$hash.'.php'; - } -} diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index e8d1e18..dd67431 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -19,7 +19,7 @@ use Keepsuit\Liquid\TemplateSharedState; use Throwable; -abstract class CompiledTemplate extends AbstractTemplate implements CompiledTemplateInterface +abstract class CompiledTemplate extends AbstractTemplate { public function __construct(TemplateSharedState $state = new TemplateSharedState) { diff --git a/src/Compiler/CompiledTemplateInterface.php b/src/Compiler/CompiledTemplateInterface.php deleted file mode 100644 index dd46408..0000000 --- a/src/Compiler/CompiledTemplateInterface.php +++ /dev/null @@ -1,7 +0,0 @@ -toBeFile(); expect(glob($directory.'/.compiled.php.tmp-*'))->toBe([]); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $path; - expect($compiled)->toBeInstanceOf(CompiledTemplateInterface::class); + expect($compiled)->toBeInstanceOf(CompiledTemplate::class); } finally { removeCompilerArtifactSafetyDirectory($directory); } @@ -86,48 +83,6 @@ function removeCompilerArtifactSafetyDirectory(string $directory): void } }); -test('filesystem compiler cache publishes atomically and fails closed on invalid artifacts', function () { - $directory = compilerArtifactSafetyDirectory(); - $cache = new FilesystemCompiledTemplateCache($directory); - $environment = EnvironmentFactory::new()->build(); - - try { - file_put_contents($directory.'/corrupt.php', 'get('corrupt'))->toBeNull(); - expect($cache->get('wrong'))->toBeNull(); - - $template = $environment->parseString('valid artifact'); - assert($template instanceof ParsedTemplate); - $cache->set('valid', (new Compiler)->compile($template)); - - expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); - expect(glob($directory.'/.valid.php.tmp-*'))->toBe([]); - - expect(fn () => $cache->set('valid', 'toThrow(RuntimeException::class); - expect($cache->get('valid'))->toBeInstanceOf(CompiledTemplateInterface::class); - } finally { - removeCompilerArtifactSafetyDirectory($directory); - } -}); - -test('filesystem compiler cache leaves its target untouched when publication fails', function () { - $directory = compilerArtifactSafetyDirectory(); - $cache = new FilesystemCompiledTemplateCache($directory); - mkdir($directory.'/blocked.php'); - - try { - expect(fn () => $cache->set('blocked', 'toThrow(RuntimeException::class); - expect($directory.'/blocked.php')->toBeDirectory(); - expect(glob($directory.'/.blocked.php.tmp-*'))->toBe([]); - } finally { - removeCompilerArtifactSafetyDirectory($directory); - } -}); - test('compiler value export rejects resources', function () { $resource = fopen('php://memory', 'r'); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 6239b38..89e96a1 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -2,7 +2,6 @@ use Keepsuit\Liquid\Compiler\CodeBuilder; use Keepsuit\Liquid\Compiler\CompiledTemplate; -use Keepsuit\Liquid\Compiler\CompiledTemplateInterface; use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\Disableable; @@ -165,10 +164,10 @@ protected function streamCompiled(RenderContext $context): Generator expect($compiledPath)->toBeFile(); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; - expect($compiled)->toBeInstanceOf(CompiledTemplateInterface::class); + expect($compiled)->toBeInstanceOf(CompiledTemplate::class); expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) ->toBe('Hello World'); } finally { @@ -207,7 +206,7 @@ protected function streamCompiled(RenderContext $context): Generator expect($compiledSource)->toContain('->evaluate($context)'); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $data = ['enabled' => false, 'other' => true, 'disabled' => true, 'value' => 'b']; @@ -239,7 +238,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $context = $environment->newRenderContext(data: $data); @@ -268,7 +267,7 @@ protected function streamCompiled(RenderContext $context): Generator expect($compiledSource)->toContain('renderNode'); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $data = ['value' => 'hello']; @@ -291,7 +290,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext(data: ['stop' => true]))) @@ -314,7 +313,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext(); $compiledContext = $environment->newRenderContext(); @@ -338,7 +337,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext(); $compiledContext = $environment->newRenderContext(); @@ -366,7 +365,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext(); $compiledContext = $environment->newRenderContext(); @@ -406,7 +405,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $exceptions = []; @@ -447,7 +446,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $interpretedContext = $environment->newRenderContext( resourceLimits: new ResourceLimits(renderLengthLimit: 9), @@ -485,7 +484,7 @@ protected function streamCompiled(RenderContext $context): Generator ->not->toContain('unserialize') ->not->toContain('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) @@ -553,7 +552,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -573,7 +572,7 @@ protected function streamCompiled(RenderContext $context): Generator ob_start(); $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; $artifactOutput = ob_get_clean(); @@ -595,7 +594,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -615,7 +614,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -635,7 +634,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -659,7 +658,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext(data: ['name' => 'value']))) @@ -679,7 +678,7 @@ protected function streamCompiled(RenderContext $context): Generator try { $environment->compile($template, $compiledPath); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) @@ -720,7 +719,7 @@ protected function streamCompiled(RenderContext $context): Generator expect(str_contains($compiledSource ?: '', 'partial output'))->toBeFalse(); - /** @var CompiledTemplateInterface $compiled */ + /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; expect($compiled->render($environment->newRenderContext())) From b2fd13c4a6d1412ff8c245c8fddcb806d6026eb8 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 10:01:57 +0200 Subject: [PATCH 29/45] perf: stop rebuilding variable nodes on every compiled render Variable::compile() exported the lookup node inline into the render body, so every {{ ... }} ran deepclone_from_array() to rehydrate its node graph plus a new Variable() on every render. Parsed templates build those once at parse time, so the compiled path was doing strictly more work than the thing it was meant to speed up. Hoist the node into a constructor-built property via compileFallback(), and mirror BodyNode::render() in BodyNode::compile(): Text children need no hasInterrupt() guard, and other children bail out after the fact instead of wrapping every child in a check. ThemeBench render, interleaved A/B min-of-60 against the parsed path: compiled went from 1.248x slower to 0.920x. Co-Authored-By: Claude Opus 5 (1M context) --- src/Compiler/CompiledTemplate.php | 21 --------------------- src/Nodes/BodyNode.php | 17 ++++++++++++++--- src/Nodes/Variable.php | 12 ++++++------ 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index dd67431..e2fe597 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -9,11 +9,7 @@ use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; use Keepsuit\Liquid\Exceptions\UndefinedVariableException; -use Keepsuit\Liquid\Nodes\Literal; use Keepsuit\Liquid\Nodes\Node; -use Keepsuit\Liquid\Nodes\RangeLookup; -use Keepsuit\Liquid\Nodes\Variable; -use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\TemplateSharedState; @@ -70,23 +66,6 @@ public static function renderCompiledBody(RenderContext $context, Closure $rende return $output; } - public static function renderVariable( - RenderContext $context, - bool|float|int|Literal|RangeLookup|VariableLookup|string|null $name, - array $filters, - ?int $lineNumber, - ): string { - try { - return (new Variable($name, $filters))->render($context); - } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { - $context->handleError($exception, $lineNumber); - - return ''; - } catch (Throwable $exception) { - return $context->handleError($exception, $lineNumber); - } - } - public static function renderNode(RenderContext $context, Node $node, ?int $lineNumber): string { try { diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 43a66f1..bf85f98 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -55,11 +55,22 @@ public function compile(CompilerContext $context): void ->indent() ->write('$output = \'\';'); - foreach ($this->children as $child) { + // Mirrors render(): Text cannot fail or interrupt, so it needs no guard, + // and every other child is followed by a bail-out instead of the whole + // body being wrapped in a per-child hasInterrupt() check. + $lastIndex = count($this->children) - 1; + + foreach ($this->children as $index => $child) { + $context->subcompile($child); + + if ($child instanceof Text || $index === $lastIndex) { + continue; + } + $context - ->write('if (! $context->hasInterrupt()) {') + ->write('if ($context->hasInterrupt()) {') ->indent() - ->subcompile($child) + ->write('return $output;') ->outdent() ->write('}'); } diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index d8824d6..f8cf91a 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -35,14 +35,14 @@ public function render(RenderContext $context): string return $this->renderOutput($output); } + /** + * Hoist the node into a constructor-built property instead of re-exporting + * the lookup inline: an inline export rebuilds the whole node graph on every + * render, which is strictly more work than a parsed template does. + */ public function compile(CompilerContext $context): void { - $context->writeOutput( - '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderVariable(' - .'$context, '.$context->writeValue($this->name).', ' - .$context->writeValue($this->filters).', ' - .$context->writeValue($this->lineNumber()).')' - ); + $context->compileFallback($this); } public function stream(RenderContext $context): \Generator From 20d7e833f1e481cf996b16b0d71bd2240c5b5f31 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 10:10:11 +0200 Subject: [PATCH 30/45] perf: drop the generator and closure layers from compiled rendering Four costs stood between a compiled template and a parsed one, none of them doing any work the compiler could not already do: - CompiledTemplate::render() collected a Generator whose body always yielded exactly one chunk, and RenderTag::render() reached its partial through that same chain. Together they cost a Generator per nesting level on the most common node in a real theme. streamCompiled() is now renderCompiled(): string, and stream() yields it once. - BodyNode compiled to a closure passed to renderCompiledBody(), costing an allocation and a call frame per body render (268 per theme run). The body is inlined instead, with a do/while(false) giving the interrupt bail-out a target; each nesting level gets its own accumulator. - compileFallback() routed every non-compiled node through the static renderNode() helper (940 calls per theme run) which re-derived the Disableable/Tag check at runtime. Both the dispatch and the check are now resolved at compile time and inlined. renderCompiledBody() and renderNode() are gone; CompiledTemplate has no runtime helpers left. ThemeBench render, interleaved A/B min-of-100 against the parsed path: compiled 2.16ms -> 2.00ms, parsed 2.35ms -> 2.01ms, ratio 0.92x -> ~1.00x. The parsed path shares the RenderTag win, so the ratio holds at parity while both got faster in absolute terms. Co-Authored-By: Claude Opus 5 (1M context) --- src/Compiler/CompiledTemplate.php | 56 +++++------------------------ src/Compiler/Compiler.php | 6 ++-- src/Compiler/CompilerContext.php | 51 ++++++++++++++++++++++---- src/Nodes/BodyNode.php | 57 ++++++++++++++++++++---------- src/Tags/RenderTag.php | 32 +++++++++++++++-- tests/Integration/CompilerTest.php | 44 +++++++++++++++++++---- 6 files changed, 163 insertions(+), 83 deletions(-) diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index e2fe597..cc12064 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -2,18 +2,10 @@ namespace Keepsuit\Liquid\Compiler; -use Closure; use Keepsuit\Liquid\AbstractTemplate; -use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\Exceptions\LiquidException; -use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; -use Keepsuit\Liquid\Exceptions\UndefinedFilterException; -use Keepsuit\Liquid\Exceptions\UndefinedVariableException; -use Keepsuit\Liquid\Nodes\Node; use Keepsuit\Liquid\Render\RenderContext; -use Keepsuit\Liquid\Tag; use Keepsuit\Liquid\TemplateSharedState; -use Throwable; abstract class CompiledTemplate extends AbstractTemplate { @@ -23,25 +15,11 @@ public function __construct(TemplateSharedState $state = new TemplateSharedState } final public function render(RenderContext $context): string - { - $output = ''; - - foreach ($this->stream($context) as $chunk) { - $output .= $chunk; - } - - return $output; - } - - /** - * @return \Generator - */ - final public function stream(RenderContext $context): \Generator { try { $this->prepareContext($context); - yield from $this->streamCompiled($context); + return $this->renderCompiled($context); } catch (LiquidException $e) { $this->attachTemplateName($e); throw $e; @@ -50,36 +28,18 @@ final public function stream(RenderContext $context): \Generator } } - abstract public function name(): ?string; - /** + * A compiled body builds one string, so there is nothing to stream + * incrementally: streaming it would only add a Generator per nesting level. + * * @return \Generator */ - abstract protected function streamCompiled(RenderContext $context): \Generator; - - public static function renderCompiledBody(RenderContext $context, Closure $renderer, int $childCount): string + final public function stream(RenderContext $context): \Generator { - $context->resourceLimits->incrementRenderScore($childCount); - $output = $renderer($context); - $context->resourceLimits->incrementWriteScore($output); - - return $output; + yield $this->render($context); } - public static function renderNode(RenderContext $context, Node $node, ?int $lineNumber): string - { - try { - if ($node instanceof Disableable && $node instanceof Tag) { - $node->ensureTagIsEnabled($context); - } - - return $node->render($context); - } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { - $context->handleError($exception, $lineNumber); + abstract public function name(): ?string; - return ''; - } catch (Throwable $exception) { - return $context->handleError($exception, $lineNumber); - } - } + abstract protected function renderCompiled(RenderContext $context): string; } diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 3beffe5..8588ff7 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -83,18 +83,18 @@ public function compile(ParsedTemplate $template): string ->dedent() ->writeLine('}') ->writeLine() - ->writeLine('protected function streamCompiled(\\Keepsuit\\Liquid\\Render\\RenderContext $context): \\Generator') + ->writeLine('protected function renderCompiled(\\Keepsuit\\Liquid\\Render\\RenderContext $context): string') ->writeLine('{') ->indent(); - $builder->writeLine('$output = \'\';'); + $builder->writeLine('$output0 = \'\';'); foreach (explode("\n", rtrim($body, "\n")) as $line) { $builder->writeLine($line); } $builder - ->writeLine('yield $output;') + ->writeLine('return $output0;') ->dedent() ->writeLine('}') ->dedent() diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 7b744cd..fabfc41 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -3,7 +3,9 @@ namespace Keepsuit\Liquid\Compiler; use Keepsuit\Liquid\Contracts\CanBeCompiled; +use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\Nodes\Node; +use Keepsuit\Liquid\Tag; use Symfony\Component\VarExporter\VarExporter; final class CompilerContext @@ -13,8 +15,31 @@ final class CompilerContext */ private array $fallbackValues = []; + /** + * Bodies are inlined rather than wrapped in a closure, so each nesting level + * needs its own accumulator variable. + */ + private int $outputDepth = 0; + public function __construct(private readonly CodeBuilder $builder = new CodeBuilder) {} + public function outputVariable(): string + { + return '$output'.$this->outputDepth; + } + + public function pushOutputScope(): string + { + $this->outputDepth++; + + return $this->outputVariable(); + } + + public function popOutputScope(): void + { + $this->outputDepth = max(0, $this->outputDepth - 1); + } + public function write(string $line = ''): static { $this->builder->writeLine($line); @@ -48,7 +73,7 @@ public function outdent(): static public function writeOutput(string $expression): static { - $this->write('$output .= '.$expression.';'); + $this->write($this->outputVariable().' .= '.$expression.';'); return $this; } @@ -65,7 +90,7 @@ public function writeNodeErrorHandling(?int $lineNumber): static ->outdent() ->write('} catch (\\Throwable $exception) {') ->indent() - ->write('$output .= $context->handleError($exception, '.$line.');') + ->write($this->outputVariable().' .= $context->handleError($exception, '.$line.');') ->outdent() ->write('}'); } @@ -74,6 +99,7 @@ public function subcompile(Node $node): static { $checkpoint = $this->builder->checkpoint(); $fallbackValueCount = count($this->fallbackValues); + $outputDepth = $this->outputDepth; if ($node instanceof CanBeCompiled) { try { @@ -83,6 +109,7 @@ public function subcompile(Node $node): static } catch (\Throwable) { $this->builder->rollback($checkpoint); $this->rollbackFallbackValues($fallbackValueCount); + $this->outputDepth = $outputDepth; } } @@ -93,17 +120,29 @@ public function subcompile(Node $node): static } catch (\Throwable $exception) { $this->builder->rollback($checkpoint); $this->rollbackFallbackValues($fallbackValueCount); + $this->outputDepth = $outputDepth; throw $exception; } } + /** + * The dispatch is inlined rather than routed through a runtime helper: the + * helper costs a call frame per node, and whether the node needs a + * tag-enabled check is already known here, at compile time. + */ public function compileFallback(Node $node): void { - $this->writeOutput( - '\\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderNode(' - .'$context, '.$this->writeRuntimeValue($node).', '.$this->writeValue($node->lineNumber()).')' - ); + $value = $this->writeRuntimeValue($node); + + $this->write('try {')->indent(); + + if ($node instanceof Disableable && $node instanceof Tag) { + $this->write($value.'->ensureTagIsEnabled($context);'); + } + + $this->writeOutput($value.'->render($context)'); + $this->writeNodeErrorHandling($node->lineNumber()); } public function writeValue(mixed $value): string diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index bf85f98..ffa4e89 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -45,21 +45,40 @@ public function setChildren(array $children): BodyNode return $this; } + /** + * The body is inlined instead of wrapped in a closure: a closure costs an + * allocation and a call frame on every render, and a body carries no state + * that needs its own scope beyond the accumulator. + * + * Mirrors render(): Text cannot fail or interrupt, so it needs no guard, and + * every other child is followed by a bail-out rather than the whole body + * being wrapped in a per-child hasInterrupt() check. + */ public function compile(CompilerContext $context): void { - $context - ->write('$output .= \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate::renderCompiledBody(') - ->indent() - ->write('$context,') - ->write('function (\\Keepsuit\\Liquid\\Render\\RenderContext $context): string {') - ->indent() - ->write('$output = \'\';'); - - // Mirrors render(): Text cannot fail or interrupt, so it needs no guard, - // and every other child is followed by a bail-out instead of the whole - // body being wrapped in a per-child hasInterrupt() check. $lastIndex = count($this->children) - 1; + $interruptible = false; + foreach ($this->children as $index => $child) { + if (! $child instanceof Text && $index !== $lastIndex) { + $interruptible = true; + + break; + } + } + + $parentOutput = $context->outputVariable(); + $output = $context->pushOutputScope(); + + $context + ->write('$context->resourceLimits->incrementRenderScore('.count($this->children).');') + ->write($output.' = \'\';'); + + // A do/while(false) gives the bail-out a target without a closure. + if ($interruptible) { + $context->write('do {')->indent(); + } + foreach ($this->children as $index => $child) { $context->subcompile($child); @@ -70,18 +89,20 @@ public function compile(CompilerContext $context): void $context ->write('if ($context->hasInterrupt()) {') ->indent() - ->write('return $output;') + ->write('break;') ->outdent() ->write('}'); } + if ($interruptible) { + $context->outdent()->write('} while (false);'); + } + $context - ->write('return $output;') - ->outdent() - ->write('},') - ->write(count($this->children).',') - ->outdent() - ->write(');'); + ->write('$context->resourceLimits->incrementWriteScore('.$output.');') + ->write($parentOutput.' .= '.$output.';'); + + $context->popOutputScope(); } /** diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index 8f66954..c163283 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -102,12 +102,40 @@ public function parse(TagParseContext $context): static return $this; } + /** + * Rendering does not go through stream(): a partial reached through the + * generator chain pays for a Generator per nesting level, and render tags + * are the most common node in a real theme. + */ public function render(RenderContext $context): string { + $partial = $this->loadPartial($context); + $templateName = $partial->name() ?? ''; + + $contextVariableName = $this->aliasName ?? Arr::last(explode('/', $templateName)); + assert(is_string($contextVariableName)); + + $variable = $this->variableNameExpression ? $context->evaluate($this->variableNameExpression) : null; + + if (! $this->isForLoop) { + return $partial->render($this->buildPartialContext($context, $templateName, [ + $contextVariableName => $variable, + ])); + } + + $variable = $variable instanceof Traversable ? iterator_to_array($variable) : $variable; + assert(is_array($variable)); + + $forLoop = new ForLoopDrop($templateName, count($variable)); $output = ''; - foreach ($this->stream($context) as $chunk) { - $output .= $chunk; + foreach ($variable as $value) { + $output .= $partial->render($this->buildPartialContext($context, $templateName, [ + 'forloop' => $forLoop, + $contextVariableName => $value, + ])); + + $forLoop->increment(); } return $output; diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 89e96a1..ece882d 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -137,7 +137,7 @@ function temporaryCompiledTemplatePath(): string return $path.'.php'; } -test('compiled render collects the compiled stream', function () { +test('compiled render and stream both surface the compiled body', function () { $compiled = new class extends CompiledTemplate { public function name(): ?string @@ -145,13 +145,14 @@ public function name(): ?string return null; } - protected function streamCompiled(RenderContext $context): Generator + protected function renderCompiled(RenderContext $context): string { - yield 'stream body'; + return 'compiled body'; } }; - expect($compiled->render(new RenderContext))->toBe('stream body'); + expect($compiled->render(new RenderContext))->toBe('compiled body'); + expect(iterator_to_array($compiled->stream(new RenderContext)))->toBe(['compiled body']); }); test('environment compiles a template to a requireable artifact', function () { @@ -265,7 +266,11 @@ protected function streamCompiled(RenderContext $context): Generator $compiledSource = file_get_contents($compiledPath); - expect($compiledSource)->toContain('renderNode'); + // The render tag stays a runtime node: the partial is looked up when the + // compiled template runs, never inlined into the artifact. + expect($compiledSource) + ->toContain('->render($context)') + ->not->toContain('partial '); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; @@ -303,6 +308,33 @@ protected function streamCompiled(RenderContext $context): Generator } }); +test('compiled nested bodies stop at an interrupt exactly where the parsed template does', function (string $source, array $data) { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString($source); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } finally { + @unlink($compiledPath); + } +})->with([ + // A break inside a for body must end that iteration and the loop, while + // leaving the text after the loop in the outer body intact. + 'break inside a loop' => ['a{% for i in (1..5) %}<{{ i }}{% if i > 2 %}{% break %}{% endif %}>{% endfor %}b', []], + 'continue inside a loop' => ['a{% for i in (1..5) %}<{{ i }}{% if i == 2 %}{% continue %}{% endif %}>{% endfor %}b', []], + // Text siblings after the interrupt must be skipped at every nesting level. + 'interrupt with trailing siblings' => ['a{% if stop %}x{% break %}y{% endif %}z', ['stop' => true]], + 'interrupt not taken' => ['a{% if stop %}x{% break %}y{% endif %}z', ['stop' => false]], + 'nested loops' => ['{% for i in (1..3) %}{% for j in (1..3) %}{{ i }}{{ j }}{% if j == 2 %}{% break %}{% endif %}{% endfor %}|{% endfor %}', []], +]); + test('compiled conditions preserve handled evaluation errors', function () { $environment = EnvironmentFactory::new() ->setRethrowErrors(false) @@ -480,7 +512,7 @@ protected function streamCompiled(RenderContext $context): Generator expect($compiledSource) ->toContain('final class Template_') ->toContain('extends \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate') - ->toContain('protected function streamCompiled') + ->toContain('protected function renderCompiled') ->not->toContain('unserialize') ->not->toContain('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); From ba7ff22226aae587c5c1add9b627caaf327a696e Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 12:16:30 +0200 Subject: [PATCH 31/45] perf: stop rebuilding shared state and walking paths for plain keys Two costs an xdebug profile of the storefront theme put at the top of the render path, both shared by compiled and parsed templates: - newIsolatedSubContext() built a RenderContext whose constructor merged the environment registers into a fresh ContextSharedState, then threw that state away on the next line by assigning the parent's. Every {% render %} paid for it: 1105 times per theme run. The constructor now accepts the state to adopt. - Arr::set() exploded the key and walked the path even when there was no path to walk. Setting a loop variable or a partial variable is the common case and is now a direct assignment. Interleaved A/B min-of-100 over the four storefront pages: 2.02ms -> 1.83ms on both paths (-9%), parity between them unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- src/Render/RenderContext.php | 9 +++++++-- src/Support/Arr.php | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index 263abcb..e22654f 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -88,6 +88,11 @@ public function __construct( public readonly RenderContextOptions $options = new RenderContextOptions, ?ResourceLimits $resourceLimits = null, ?Environment $environment = null, + /** + * Sub-contexts inherit the parent state; building a fresh one here would + * merge the environment registers only to have it replaced. + */ + ?ContextSharedState $sharedState = null, ) { $this->environment = $environment ?? Environment::default(); $this->resourceLimits = $resourceLimits ?? ResourceLimits::clone($this->environment->defaultResourceLimits); @@ -95,7 +100,7 @@ public function __construct( $this->scopes = [[]]; - $this->sharedState = new ContextSharedState( + $this->sharedState = $sharedState ?? new ContextSharedState( staticVariables: $staticData, registers: array_merge($this->environment->getRegisters(), $registers), ); @@ -458,9 +463,9 @@ public function newIsolatedSubContext(?string $templateName = null, ?RenderConte options: $options ?? $this->options, resourceLimits: $this->resourceLimits, environment: $this->environment, + sharedState: $this->sharedState, ); $subContext->baseScopeDepth = $this->baseScopeDepth + 1; - $subContext->sharedState = $this->sharedState; $subContext->templateName = $templateName; $subContext->partial = true; diff --git a/src/Support/Arr.php b/src/Support/Arr.php index 64b283c..7ecddd1 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -39,6 +39,14 @@ public static function has(array $array, string $key): bool public static function set(array &$array, string|int $key, mixed $value): array { + // A key without a path is the common case and needs no walking: scope + // assignment goes through here for every loop variable and every partial. + if (! str_contains((string) $key, '.')) { + $array[$key] = $value; + + return $array; + } + $keys = explode('.', (string) $key); foreach ($keys as $i => $key) { From 1ae87944175a38e257035b9edd3456ec9eed8917 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 12:20:05 +0200 Subject: [PATCH 32/45] perf: compile for-loop bodies into methods the tag drives A for body was the last thing in a compiled template still walked node by node on every iteration, so a loop-heavy template gained nothing from compilation. Rather than emit the loop semantics as code, only the two bodies are compiled, each into its own private method, and ForTag receives them as closures. The segment, scope, forloop drop, register and interrupt handling stay in the tag, already tested and unchanged, so nothing about break, continue or the else branch had to be reimplemented in codegen. CompilerContext grows compileBodyToMethod(), which any tag that cannot be compiled itself can now use for its bodies. Attributed by toggling the feature alone: - 500-row loop template: 1.001x -> 0.908x against the parsed path - ThemeBench pages: 0.998x -> 0.991x (its loops are short) Co-Authored-By: Claude Opus 5 (1M context) --- src/Compiler/Compiler.php | 23 +++++++++++-- src/Compiler/CompilerContext.php | 51 +++++++++++++++++++++++++++- src/Tags/ForTag.php | 38 +++++++++++++++++---- tests/Integration/CompilerTest.php | 53 ++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 8588ff7..9c0dfd2 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -14,6 +14,7 @@ public function compile(ParsedTemplate $template): string $body = $bodyContext->getSource(); $name = $bodyContext->writeValue($template->root->name); + $methods = $bodyContext->getMethods(); $fallbackValues = $bodyContext->getFallbackValues(); $fallbackValueSource = []; @@ -39,7 +40,7 @@ public function compile(ParsedTemplate $template): string $className = 'Template_'.substr(hash( 'sha256', - $name.$body.implode('', $fallbackValueSource), + $name.$body.implode('', $methods).implode('', $fallbackValueSource), ), 0, 32); $builder = new CodeBuilder; @@ -96,7 +97,25 @@ public function compile(ParsedTemplate $template): string $builder ->writeLine('return $output0;') ->dedent() - ->writeLine('}') + ->writeLine('}'); + + foreach ($methods as $methodName => $methodSource) { + $builder + ->writeLine() + ->writeLine('private function '.$methodName.'(\\Keepsuit\\Liquid\\Render\\RenderContext $context): string') + ->writeLine('{') + ->indent(); + + foreach (explode("\n", rtrim($methodSource, "\n")) as $line) { + $builder->writeLine($line); + } + + $builder + ->dedent() + ->writeLine('}'); + } + + $builder ->dedent() ->writeLine('}') ->dedent() diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index fabfc41..5a99499 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -21,7 +21,53 @@ final class CompilerContext */ private int $outputDepth = 0; - public function __construct(private readonly CodeBuilder $builder = new CodeBuilder) {} + /** + * Bodies a runtime tag drives itself, compiled to their own method so the + * tag keeps its loop and scope handling while the body stops being walked. + * + * @var array + */ + private array $methods = []; + + public function __construct(private CodeBuilder $builder = new CodeBuilder) {} + + /** + * Compiles $body into a standalone method and returns its name, so a tag + * that cannot be compiled itself can still be handed a compiled body. + */ + public function compileBodyToMethod(Node $body): string + { + $name = 'body'.count($this->methods); + // Reserve the name before compiling: a nested body must not reuse it. + $this->methods[$name] = ''; + + $outerBuilder = $this->builder; + $outerDepth = $this->outputDepth; + + $this->builder = new CodeBuilder; + $this->outputDepth = 0; + + try { + $this->write('$output0 = \'\';'); + $this->subcompile($body); + $this->write('return $output0;'); + + $this->methods[$name] = $this->builder->getSource(); + } finally { + $this->builder = $outerBuilder; + $this->outputDepth = $outerDepth; + } + + return $name; + } + + /** + * @return array + */ + public function getMethods(): array + { + return $this->methods; + } public function outputVariable(): string { @@ -100,6 +146,7 @@ public function subcompile(Node $node): static $checkpoint = $this->builder->checkpoint(); $fallbackValueCount = count($this->fallbackValues); $outputDepth = $this->outputDepth; + $methods = $this->methods; if ($node instanceof CanBeCompiled) { try { @@ -110,6 +157,7 @@ public function subcompile(Node $node): static $this->builder->rollback($checkpoint); $this->rollbackFallbackValues($fallbackValueCount); $this->outputDepth = $outputDepth; + $this->methods = $methods; } } @@ -121,6 +169,7 @@ public function subcompile(Node $node): static $this->builder->rollback($checkpoint); $this->rollbackFallbackValues($fallbackValueCount); $this->outputDepth = $outputDepth; + $this->methods = $methods; throw $exception; } diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index e11b7ea..9339950 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -2,6 +2,9 @@ namespace Keepsuit\Liquid\Tags; +use Closure; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\ForLoopDrop; use Keepsuit\Liquid\Exceptions\InvalidArgumentException; @@ -23,7 +26,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class ForTag extends TagBlock implements HasParseTreeVisitorChildren +class ForTag extends TagBlock implements CanBeCompiled, HasParseTreeVisitorChildren { protected string $variableName; @@ -72,14 +75,37 @@ public function parse(TagParseContext $context): static } public function render(RenderContext $context): string + { + return $this->renderBlocks($context); + } + + /** + * The loop, scope and interrupt handling stay here rather than being emitted + * as code: only the two bodies are compiled, and they are handed back as + * closures. Passing none renders the parsed bodies. + */ + public function compile(CompilerContext $context): void + { + $tag = $context->writeRuntimeValue($this); + $forBody = $context->compileBodyToMethod($this->forBlock); + $elseBody = $this->elseBlock !== null + ? '$this->'.$context->compileBodyToMethod($this->elseBlock).'(...)' + : 'null'; + + $context->write('try {')->indent(); + $context->writeOutput($tag.'->renderBlocks($context, $this->'.$forBody.'(...), '.$elseBody.')'); + $context->writeNodeErrorHandling($this->lineNumber()); + } + + public function renderBlocks(RenderContext $context, ?Closure $forBody = null, ?Closure $elseBody = null): string { $segment = $this->collectionSegment($context); if ($segment === []) { - return $this->renderElse($context); + return $elseBody !== null ? $elseBody($context) : $this->renderElse($context); } - return $this->renderSegment($context, $segment); + return $this->renderSegment($context, $segment, $forBody); } public function children(): array @@ -170,13 +196,13 @@ protected function collectionSegment(RenderContext $context): array return $segment; } - protected function renderSegment(RenderContext $context, array $segment): string + protected function renderSegment(RenderContext $context, array $segment, ?Closure $forBody = null): string { /** @var ForLoopDrop[] $forStack */ $forStack = $context->getRegister('for_stack') ?? []; assert(is_array($forStack)); - return $context->stack(function () use ($context, $segment, $forStack) { + return $context->stack(function () use ($context, $segment, $forStack, $forBody) { $loopVars = new ForLoopDrop( name: $this->name, length: count($segment), @@ -191,7 +217,7 @@ protected function renderSegment(RenderContext $context, array $segment): string $output = ''; foreach ($segment as $value) { $context->set($this->variableName, $value); - $output .= $this->forBlock->render($context); + $output .= $forBody !== null ? $forBody($context) : $this->forBlock->render($context); $loopVars->increment(); $interrupt = $context->popInterrupt(); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index ece882d..04160c9 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -335,6 +335,59 @@ protected function renderCompiled(RenderContext $context): string 'nested loops' => ['{% for i in (1..3) %}{% for j in (1..3) %}{{ i }}{{ j }}{% if j == 2 %}{% break %}{% endif %}{% endfor %}|{% endfor %}', []], ]); +test('for bodies are compiled into methods the tag drives', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{% for i in items %}{{ i }}{% else %}none{% endfor %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + // Both bodies become methods; the loop itself stays in the tag. + expect(file_get_contents($compiledPath)) + ->toContain('private function body0') + ->toContain('private function body1') + ->toContain('->renderBlocks($context, $this->body0(...), $this->body1(...))'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + foreach ([['items' => ['a', 'b', 'c']], ['items' => []]] as $data) { + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } + } finally { + @unlink($compiledPath); + } +}); + +test('compiled for loops match parsed rendering', function (string $source, array $data) { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString($source); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } finally { + @unlink($compiledPath); + } +})->with([ + 'forloop drop' => ['{% for i in items %}{{ forloop.index }}/{{ forloop.length }}{% if forloop.first %}F{% endif %}{% if forloop.last %}L{% endif %} {% endfor %}', ['items' => ['a', 'b', 'c']]], + 'nested loops share the parent drop' => ['{% for i in outer %}{% for j in inner %}{{ forloop.parentloop.index }}.{{ forloop.index }} {% endfor %}{% endfor %}', ['outer' => [1, 2], 'inner' => [1, 2]]], + 'limit and offset' => ['{% for i in items limit: 2 offset: 1 %}{{ i }}{% endfor %}', ['items' => [1, 2, 3, 4, 5]]], + 'reversed' => ['{% for i in items reversed %}{{ i }}{% endfor %}', ['items' => [1, 2, 3]]], + 'range' => ['{% for i in (1..4) %}{{ i }}{% endfor %}', []], + 'else branch' => ['{% for i in items %}{{ i }}{% else %}empty{% endfor %}', ['items' => []]], + 'break out of nested loop' => ['{% for i in outer %}{% for j in inner %}{{ j }}{% break %}{% endfor %}|{% endfor %}', ['outer' => [1, 2], 'inner' => [1, 2, 3]]], + 'continue skips' => ['{% for i in items %}{% if i == 2 %}{% continue %}{% endif %}{{ i }}{% endfor %}', ['items' => [1, 2, 3]]], +]); + test('compiled conditions preserve handled evaluation errors', function () { $environment = EnvironmentFactory::new() ->setRethrowErrors(false) From c5e7357d9970c075b800b503711b0bffacf57472 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 12:54:43 +0200 Subject: [PATCH 33/45] perf: rebuild the common compiled nodes with constructors, not VarExporter Every value a compiled template could not turn into code was exported through VarExporter, whose format rebuilds an object graph by writing properties directly. That works for any shape, but each graph costs a deepclone_from_array() hydration pass every time the template is instantiated -- and 61% of them were plain Variable/VariableLookup pairs that a constructor call describes exactly. CanBeExported lets a value emit its own constructor expression, with null declining so anything unusual keeps the VarExporter path. Implemented for Variable, VariableLookup, RangeLookup and Condition. What gets rebuilt is what the compiled template reads: a Condition body is left out because the compiler already emitted it as code and only evaluate() is ever called. Storefront theme, 29 templates: deepclone graphs 190 -> 62 artifact bytes 383K -> 291K instantiation 0.376ms -> 0.222ms with opcache (-41%) Render time is unchanged -- the objects are identical once built. Co-Authored-By: Claude Opus 5 (1M context) --- src/Compiler/CompilerContext.php | 35 ++++++++++++++ src/Condition/Condition.php | 20 +++++++- src/Condition/ElseCondition.php | 6 +++ src/Contracts/CanBeExported.php | 29 ++++++++++++ src/Nodes/RangeLookup.php | 11 ++++- src/Nodes/Variable.php | 14 +++++- src/Nodes/VariableLookup.php | 11 ++++- tests/Integration/CompilerTest.php | 76 ++++++++++++++++++++++++++++++ 8 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 src/Contracts/CanBeExported.php diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 5a99499..542badd 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -3,6 +3,7 @@ namespace Keepsuit\Liquid\Compiler; use Keepsuit\Liquid\Contracts\CanBeCompiled; +use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\Nodes\Node; use Keepsuit\Liquid\Tag; @@ -196,6 +197,22 @@ public function compileFallback(Node $node): void public function writeValue(mixed $value): string { + if ($value instanceof CanBeExported && ($exported = $value->export($this)) !== null) { + return $exported; + } + + // Arrays are only taken apart when they actually hold an exportable + // value; otherwise VarExporter's output is both smaller and faster. + if (is_array($value) && $this->containsExportable($value)) { + $entries = []; + + foreach ($value as $key => $item) { + $entries[] = $this->writeValue($key).' => '.$this->writeValue($item); + } + + return '['.implode(', ', $entries).']'; + } + try { return VarExporter::export($value); } catch (\Throwable $exception) { @@ -203,6 +220,24 @@ public function writeValue(mixed $value): string } } + /** + * @param array $value + */ + private function containsExportable(array $value): bool + { + foreach ($value as $item) { + if ($item instanceof CanBeExported) { + return true; + } + + if (is_array($item) && $this->containsExportable($item)) { + return true; + } + } + + return false; + } + public function exportValue(mixed $value): ?string { try { diff --git a/src/Condition/Condition.php b/src/Condition/Condition.php index 0953311..6e3f2e3 100644 --- a/src/Condition/Condition.php +++ b/src/Condition/Condition.php @@ -2,13 +2,15 @@ namespace Keepsuit\Liquid\Condition; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\AsLiquidValue; +use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Support\Arr; -class Condition implements HasParseTreeVisitorChildren +class Condition implements CanBeExported, HasParseTreeVisitorChildren { /** * @var array @@ -27,6 +29,22 @@ public function __construct( protected mixed $right = null ) {} + public function export(CompilerContext $context): ?string + { + // A chained condition would need statements rather than an expression, + // and a subclass need not accept these constructor arguments. + if ($this->childCondition !== null || static::class !== self::class) { + return null; + } + + // The body is deliberately left out: the compiler emits it as code and + // only ever calls evaluate() on the rebuilt condition. + return 'new \\'.self::class.'(' + .$context->writeValue($this->left).', ' + .$context->writeValue($this->operator).', ' + .$context->writeValue($this->right).')'; + } + public static function registerOperator(string $operator, \Closure $closure): void { static::$customOperators[$operator] = $closure; diff --git a/src/Condition/ElseCondition.php b/src/Condition/ElseCondition.php index 07bb3aa..4e141e5 100644 --- a/src/Condition/ElseCondition.php +++ b/src/Condition/ElseCondition.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Condition; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Render\RenderContext; class ElseCondition extends Condition @@ -11,6 +12,11 @@ public function __construct() parent::__construct(); } + public function export(CompilerContext $context): ?string + { + return 'new \\'.self::class.'()'; + } + public function else(): bool { return true; diff --git a/src/Contracts/CanBeExported.php b/src/Contracts/CanBeExported.php new file mode 100644 index 0000000..dd8b504 --- /dev/null +++ b/src/Contracts/CanBeExported.php @@ -0,0 +1,29 @@ +writeValue() so they get the same + * treatment. + */ + public function export(CompilerContext $context): ?string; +} diff --git a/src/Nodes/RangeLookup.php b/src/Nodes/RangeLookup.php index b9ed80d..da66171 100644 --- a/src/Nodes/RangeLookup.php +++ b/src/Nodes/RangeLookup.php @@ -2,18 +2,27 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeEvaluated; +use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Render\RenderContext; -class RangeLookup implements CanBeEvaluated, HasParseTreeVisitorChildren +class RangeLookup implements CanBeEvaluated, CanBeExported, HasParseTreeVisitorChildren { final public function __construct( public readonly mixed $start, public readonly mixed $end, ) {} + public function export(CompilerContext $context): ?string + { + return 'new \\'.static::class.'(' + .$context->writeValue($this->start).', ' + .$context->writeValue($this->end).')'; + } + public function parseTreeVisitorChildren(): array { return [$this->start, $this->end]; diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index f8cf91a..df674c3 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -5,6 +5,7 @@ use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeEvaluated; +use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\CanBeRendered; use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; @@ -15,7 +16,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class Variable extends Node implements CanBeCompiled, CanBeEvaluated, CanBeStreamed, HasParseTreeVisitorChildren +class Variable extends Node implements CanBeCompiled, CanBeEvaluated, CanBeExported, CanBeStreamed, HasParseTreeVisitorChildren { public function __construct( /** @var Expression $name */ @@ -45,6 +46,17 @@ public function compile(CompilerContext $context): void $context->compileFallback($this); } + public function export(CompilerContext $context): ?string + { + $expression = 'new \\'.self::class.'(' + .$context->writeValue($this->name).', ' + .$context->writeValue($this->filters).')'; + + return $this->lineNumber === null + ? $expression + : '('.$expression.')->setLineNumber('.$this->lineNumber.')'; + } + public function stream(RenderContext $context): \Generator { if ($this->filters !== []) { diff --git a/src/Nodes/VariableLookup.php b/src/Nodes/VariableLookup.php index 9d4d6d3..845ffa0 100644 --- a/src/Nodes/VariableLookup.php +++ b/src/Nodes/VariableLookup.php @@ -2,7 +2,9 @@ namespace Keepsuit\Liquid\Nodes; +use Keepsuit\Liquid\Compiler\CompilerContext; use Keepsuit\Liquid\Contracts\CanBeEvaluated; +use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Contracts\IsContextAware; use Keepsuit\Liquid\Exceptions\SyntaxException; @@ -10,7 +12,7 @@ use Keepsuit\Liquid\Support\MissingValue; use Keepsuit\Liquid\Support\UndefinedVariable; -class VariableLookup implements CanBeEvaluated, HasParseTreeVisitorChildren +class VariableLookup implements CanBeEvaluated, CanBeExported, HasParseTreeVisitorChildren { const FILTER_METHODS = ['size', 'first', 'last']; @@ -54,6 +56,13 @@ public static function fromMarkup(string $markup): VariableLookup return new VariableLookup(substr($markup, 0, $nameLength), $lookups); } + public function export(CompilerContext $context): ?string + { + return 'new \\'.self::class.'(' + .$context->writeValue($this->name).', ' + .$context->writeValue($this->lookups).')'; + } + public function toString(): string { if ($this->lookups === []) { diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 04160c9..6363d94 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -388,6 +388,82 @@ protected function renderCompiled(RenderContext $context): string 'continue skips' => ['{% for i in items %}{% if i == 2 %}{% continue %}{% endif %}{{ i }}{% endfor %}', ['items' => [1, 2, 3]]], ]); +test('exportable nodes are rebuilt with constructors instead of VarExporter', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{{ product.title | upcase }}{% if a > 1 %}x{% endif %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect(file_get_contents($compiledPath)) + ->toContain('new \Keepsuit\Liquid\Nodes\Variable(') + ->toContain('new \Keepsuit\Liquid\Nodes\VariableLookup(') + ->toContain('new \Keepsuit\Liquid\Condition\Condition(') + ->not->toContain('deepclone_from_array'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $data = ['product' => ['title' => 'hat'], 'a' => 2]; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))) + ->toBe('HATx'); + } finally { + @unlink($compiledPath); + } +}); + +test('a node that cannot describe itself still falls back to VarExporter', function () { + $environment = EnvironmentFactory::new()->build(); + // A chained condition needs statements, so Condition::export() declines it. + $template = $environment->parseString('{% if a > 1 and b %}yes{% else %}no{% endif %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect(file_get_contents($compiledPath))->toContain('deepclone_from_array'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + foreach ([['a' => 2, 'b' => true], ['a' => 2, 'b' => false], ['a' => 0, 'b' => true]] as $data) { + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } + } finally { + @unlink($compiledPath); + } +}); + +test('exported nodes keep the state rendering depends on', function (string $source, array $data) { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString($source); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } finally { + @unlink($compiledPath); + } +})->with([ + 'nested lookups' => ['{{ a.b.c }}', ['a' => ['b' => ['c' => 'deep']]]], + 'indexed lookup' => ['{{ a[0].b }}', ['a' => [['b' => 'idx']]]], + 'dynamic lookup key' => ['{{ a[k] }}', ['a' => ['x' => 'dyn'], 'k' => 'x']], + 'filter with lookup argument' => ['{{ a | append: b }}', ['a' => 'x', 'b' => 'y']], + 'filter with named arguments' => ['{{ n | default: d, allow_false: true }}', ['n' => null, 'd' => 'fallback']], + 'range lookup' => ['{% for i in (a..b) %}{{ i }}{% endfor %}', ['a' => 1, 'b' => 3]], + 'literal in condition' => ['{% if a == empty %}e{% else %}f{% endif %}', ['a' => []]], + 'else condition' => ['{% case a %}{% when 1 %}one{% else %}other{% endcase %}', ['a' => 9]], +]); + test('compiled conditions preserve handled evaluation errors', function () { $environment = EnvironmentFactory::new() ->setRethrowErrors(false) From 707dd3535f37096be6c1fe1323b2a1254733dbe3 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Sat, 1 Aug 2026 13:13:45 +0200 Subject: [PATCH 34/45] refactor: stop claiming Variable compiles to code Variable::compile() only called compileFallback(), which is exactly what subcompile() does for a node that does not implement CanBeCompiled, so implementing the contract bought nothing. Removing it leaves the generated artifacts byte-for-byte identical. The contract it does belong to is CanBeExported: a variable is not turned into code, its lookup is resolved at runtime, so the compiler keeps the object and only needs it rebuilt cheaply. Co-Authored-By: Claude Opus 5 (1M context) --- src/Nodes/Variable.php | 13 +------------ tests/Integration/CompilerTest.php | 21 ++++++++++++++++++++- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index df674c3..9bc7d6a 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -3,7 +3,6 @@ namespace Keepsuit\Liquid\Nodes; use Keepsuit\Liquid\Compiler\CompilerContext; -use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeEvaluated; use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\CanBeRendered; @@ -16,7 +15,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class Variable extends Node implements CanBeCompiled, CanBeEvaluated, CanBeExported, CanBeStreamed, HasParseTreeVisitorChildren +class Variable extends Node implements CanBeEvaluated, CanBeExported, CanBeStreamed, HasParseTreeVisitorChildren { public function __construct( /** @var Expression $name */ @@ -36,16 +35,6 @@ public function render(RenderContext $context): string return $this->renderOutput($output); } - /** - * Hoist the node into a constructor-built property instead of re-exporting - * the lookup inline: an inline export rebuilds the whole node graph on every - * render, which is strictly more work than a parsed template does. - */ - public function compile(CompilerContext $context): void - { - $context->compileFallback($this); - } - public function export(CompilerContext $context): ?string { $expression = 'new \\'.self::class.'(' diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 6363d94..0826dec 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -3,7 +3,9 @@ use Keepsuit\Liquid\Compiler\CodeBuilder; use Keepsuit\Liquid\Compiler\CompiledTemplate; use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Condition\Condition; use Keepsuit\Liquid\Contracts\CanBeCompiled; +use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\EnvironmentFactory; use Keepsuit\Liquid\Exceptions\ResourceLimitException; @@ -12,9 +14,11 @@ use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Node; +use Keepsuit\Liquid\Nodes\RangeLookup; use Keepsuit\Liquid\Nodes\Raw; use Keepsuit\Liquid\Nodes\Text; use Keepsuit\Liquid\Nodes\Variable; +use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\ParsedTemplate; use Keepsuit\Liquid\Render\RenderContext; @@ -697,7 +701,6 @@ protected function renderCompiled(RenderContext $context): string new Raw('raw'), new Document(new BodyNode), new BodyNode, - new Variable('name'), ]; foreach ($nodes as $node) { @@ -705,6 +708,22 @@ protected function renderCompiled(RenderContext $context): string } }); +test('values the compiler keeps as objects rebuild themselves without VarExporter', function () { + // A Variable is not compiled to code: its lookup is resolved at runtime, so + // the compiler keeps the object and only needs it rebuilt cheaply. + $values = [ + new Variable('name'), + new VariableLookup('name'), + new RangeLookup(1, 5), + new Condition(1, '==', 1), + ]; + + foreach ($values as $value) { + expect($value)->toBeInstanceOf(CanBeExported::class); + expect($value)->not->toBeInstanceOf(CanBeCompiled::class); + } +}); + test('unsupported nodes use the interpreter fallback', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{% assign greeting = "Hello" %}{{ greeting }}'); From 5126e9d75b02b459c3e4a0a0d96f8a281cee143b Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 11:38:17 +0200 Subject: [PATCH 35/45] refactor: simplify compiled template output --- plans/001-twig-shaped-compiled-output.md | 368 +++++++++++++++++++++++ plans/README.md | 28 ++ src/Compiler/CompiledTemplate.php | 12 + src/Compiler/Compiler.php | 16 +- src/Compiler/CompilerContext.php | 149 ++++++++- src/Nodes/BodyNode.php | 57 ++-- src/Nodes/Document.php | 2 +- src/Nodes/Variable.php | 53 +++- src/Nodes/VariableLookup.php | 39 ++- tests/Integration/CompilerTest.php | 125 +++++++- 10 files changed, 781 insertions(+), 68 deletions(-) create mode 100644 plans/001-twig-shaped-compiled-output.md create mode 100644 plans/README.md diff --git a/plans/001-twig-shaped-compiled-output.md b/plans/001-twig-shaped-compiled-output.md new file mode 100644 index 0000000..f19edad --- /dev/null +++ b/plans/001-twig-shaped-compiled-output.md @@ -0,0 +1,368 @@ +# Plan 001: Implement Twig-shaped compiled output without changing Liquid semantics + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving to the +> next step. If anything in the "STOP conditions" section occurs, stop and +> report — do not improvise. When done, update the status row for this plan in +> `plans/README.md`. +> +> **Drift check (run first)**: `git diff --stat 707dd35..HEAD -- src/Compiler src/Nodes/Document.php src/Nodes/BodyNode.php src/Nodes/Variable.php src/Nodes/VariableLookup.php tests/Integration/CompilerTest.php tests/Integration/CompilerOutputTest.php` +> The planned SHA is the current clean checkout. If any listed path changed, +> compare the excerpts below with live code before proceeding. + +## Status + +- **Priority**: P1 +- **Effort**: L +- **Risk**: MED +- **Depends on**: none +- **Category**: tech-debt / dx / perf +- **Planned at**: commit `707dd35`, 2026-08-03 + +## Why this matters + +The compiler currently emits valid artifacts, but a small ten-line storefront +snippet expands into per-variable fallback properties, a generated constructor, +fully qualified names, nested output accumulators, a `do { } while (false)` +escape hatch, and repeated string concatenations. That makes generated PHP hard +to inspect and hides the relationship between the Liquid source and its output. +Make common text and variable nodes read like the Twig reference—one readable +compiled method with literal template segments and explicit dynamic expressions— +while preserving Liquid’s error handling, scope lookup, filters, interrupts, +resource limits, runtime partials, and require-able artifact behavior. + +## Current state + +The relevant files are: + +- `src/Compiler/Compiler.php` — assembles the generated namespace, class, + fallback properties, render method, body methods, and return statement. +- `src/Compiler/CompilerContext.php` — emits node statements, error guards, + output accumulators, and runtime fallback properties. +- `src/Nodes/BodyNode.php` — emits a per-body accumulator and interrupt bailout. +- `src/Nodes/Variable.php` and `src/Nodes/VariableLookup.php` — own Liquid + lookup, filter, rendering, strict-variable, and stringification semantics. +- `src/Compiler/CompiledTemplate.php` — preserves the public compiled-template + contract: `render()` returns a string and `stream()` yields that string once. +- `tests/Integration/CompilerTest.php` — existing compiled parity, fallback, + source-shape, error, stream, and resource-limit coverage. +- `performance/themes/storefront/snippets/product/specs.liquid` — the + ten-line representative fixture used for the requested output shape. + +The current compiler header and method assembly are fully qualified and use +`$output0` (`src/Compiler/Compiler.php:46-124`): + +```php +->writeLine('namespace Keepsuit\\Liquid\\Compiler\\Generated;') +->writeLine('if (! class_exists('.$className.'::class, false)) {') +->writeLine('final class '.$className.' extends \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate') +... +->writeLine('protected function renderCompiled(\\Keepsuit\\Liquid\\Render\\RenderContext $context): string') +... +$builder->writeLine('$output0 = \'\';'); +``` + +The current body compiler always pushes a new output scope, treats every +non-text child as potentially interruptible, and appends every child separately +(`src/Nodes/BodyNode.php:57-105`). The output writer is a plain accumulator +(`src/Compiler/CompilerContext.php:121-143`), and unsupported nodes are kept as +constructor properties and rendered through the generic fallback +(`src/Compiler/CompilerContext.php:145-196` and `250-268`). + +The current `Variable` is intentionally not a `CanBeCompiled` node; it is +reconstructed as an exported runtime object (`src/Nodes/Variable.php:27-47` and +`tests/Integration/CompilerTest.php:711-724`). Do not make it claim to compile +itself. Add a compiler-owned direct-expression path that reuses its exact +runtime semantics, and retain the existing property fallback for complex +expressions. + +For the fixture at `performance/themes/storefront/snippets/product/specs.liquid:1-10`, +the current generated artifact contains six `private readonly mixed $valueN` +properties and a constructor rebuilding `Variable`/`VariableLookup` objects, +then emits a `$output1` accumulator with one try/catch block per value. The +target shape is structurally like this (class hash and exact helper arguments +are generated): + +```php +use Keepsuit\Liquid\Compiler\CompiledTemplate; +use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\TemplateSharedState; + +final class Template_ extends CompiledTemplate +{ + protected function renderCompiled(RenderContext $context): string + { + $output = '\n' + .' \n'; + // line 4 + try { + $output .= $this->renderCompiledVariable($context, 'product', ['vendor'], []); + } catch (...) { + // Existing Liquid error handling remains here. + } + + return $output; + } +} +``` + +This is a target shape, not a snapshot. Keep the `class_exists(..., false)` guard +unless repeated `require` coverage proves the artifact-loading contract can be +changed safely. Do not copy Twig-only `$env`, `Source`, `$blocks`, `$macros`, +`TemplateWrapper`, sandbox imports, or `getSourceContext()` APIs: Liquid’s +`ParsedTemplate` retains a document/name but not the original source text +(`src/ParsedTemplate.php:9-16`, `src/Parse/ParseContext.php:76-93`). Keep the +Liquid filter name `size`; the reference’s `length` is a Twig syntax change, +not part of this compiler-formatting work. + +The exact Twig generator contract is deliberately not part of this plan. +`CompiledTemplate::render()` requires a string and `stream()` yields that string +once (`src/Compiler/CompiledTemplate.php:17-44`); the current implementation was +also explicitly optimized to avoid a generator per nesting level. If exact +`yield`/`doDisplay` output is required, stop and split that into a separate API +and benchmark design. + +## Commands you will need + +| Purpose | Command | Expected on success | +|---------|---------|---------------------| +| Focused compiler tests | `vendor/bin/pest tests/Integration/CompilerTest.php tests/Integration/CompilerArtifactSafetyTest.php tests/Integration/Performance/StorefrontThemeTest.php` | 62 tests pass before the change; all pass after it | +| Full tests | `composer test` | 888 baseline tests pass; no regressions after the change | +| Formatting check | `vendor/bin/pint --test` | `PASS`, no files to format | +| Static analysis | `vendor/bin/phpstan analyse --no-progress` | `[OK] No errors` | +| Diff hygiene | `git diff --check` | no output, exit 0 | +| Compiler benchmark | `vendor/bin/phpbench run --group=compiler --warmup=1 --retry-threshold=5 --report=aggregate --output=json > /tmp/php-liquid-compiler-after.json` | exit 0 and valid aggregate JSON | + +The repository’s compiler benchmark documentation says to compare matching +aggregate rows and treat throughput regressions above 5% as a review failure +(`performance/README.md:46-66`). Establish a same-environment baseline before +using the comparator; a single high-variance or near-threshold run is not enough +to accept a performance change. + +## Scope + +**In scope** (the only source/test files to modify): + +- `src/Compiler/Compiler.php` +- `src/Compiler/CompilerContext.php` +- `src/Compiler/CompiledTemplate.php` +- `src/Compiler/CodeBuilder.php` only if the multiline literal writer belongs + there rather than in `CompilerContext` +- `src/Nodes/Document.php` — only to mark the document body as the root output + scope +- `src/Nodes/BodyNode.php` +- `src/Nodes/Variable.php` +- `src/Nodes/VariableLookup.php` +- `tests/Integration/CompilerTest.php` +- `tests/Integration/CompilerOutputTest.php` (create only if separating source + shape tests from the already large compiler integration file is cleaner) + +**Out of scope** (do not touch): + +- `src/Environment.php` and artifact publication/atomicity behavior. +- `src/Template.php`, `src/AbstractTemplate.php`, and the public + `CompiledTemplate::render()`/`stream()` contract. +- `src/Tags/RenderTag.php`, `src/Tags/ForTag.php`, or runtime partial lookup; + v1 partials remain runtime-linked. +- `performance/themes/storefront/*`, including the `size` filter in the fixture. +- Twig dependencies or Twig runtime classes. +- Snapshotting all 29 storefront templates; the benchmark documentation + deliberately rejects snapshots for this fast-changing fixture. +- Any file outside the list above, unless a STOP condition is reported first. + +## Git workflow + +- Match the repository’s existing conventional-commit style, for example + `refactor: ...` or `perf: ...` from the recent compiler history. +- Do not push or open a PR unless the operator instructs you to do so. +- Keep generated benchmark artifacts under `/tmp` or the repository’s ignored + cache paths; do not add generated PHP artifacts to the repository. + +## Steps + +### Step 1: Add parity and output-shape characterization tests + +Extend the existing compiler integration coverage (or create +`tests/Integration/CompilerOutputTest.php`) with focused tests for the requested +fixture and the direct-expression boundary: + +1. Parse `snippets.product.specs` through + `Keepsuit\Liquid\Performance\Support\StorefrontTheme::environment()`. +2. Compile it to the existing temporary artifact path helper pattern. +3. Assert the compiled artifact renders exactly the same bytes as the parsed + template using the product page data from + `StorefrontTheme::renderData('templates.product')['page']`. +4. Assert `implode('', iterator_to_array($compiled->stream(...)))` matches the + interpreted render. +5. Assert the generated source contains the generated `use` imports, extends + the imported `CompiledTemplate`, contains `renderCompiledVariable`, has a + `// line 4` marker, and contains the `size` filter descriptor. +6. Assert the specs artifact has no `private readonly mixed $valueN` properties, + no `new \Keepsuit\Liquid\Nodes\Variable(` constructor rebuilds, and no + `do {` block caused only by ordinary variable output. +7. Add a complex-expression case to prove fallback remains available, such as a + dynamic lookup key or a filter argument containing a `VariableLookup`; assert + parity and the presence of the existing fallback property path. +8. Require the same simple artifact twice in one process and assert both results + are `CompiledTemplate` instances, preserving the class guard contract. + +Do not assert the entire generated file as a snapshot. Assert stable structural +markers and behavior, following the existing source-shape assertions at +`tests/Integration/CompilerTest.php:635-660` and the parity style at +`tests/Integration/CompilerTest.php:198-230`. + +**Verify**: `vendor/bin/pest tests/Integration/CompilerTest.php tests/Integration/CompilerArtifactSafetyTest.php tests/Integration/Performance/StorefrontThemeTest.php` → existing tests pass; only newly added target-shape assertions may fail until Steps 2–3 are complete. + +### Step 2: Add a direct common-variable emission seam without changing Liquid semantics + +Refactor `VariableLookup` and `Variable` so interpreted rendering and generated +common-variable rendering share the same implementation: + +1. Extract the current lookup walk from `VariableLookup::evaluate()` into a + callable/static path evaluator that accepts a root name and the parsed lookup + segments. Preserve all existing behavior: scope-chain fallback when an inner + lookup breaks, `MissingValue`, strict undefined-variable errors, dynamic + lookup segments when the interpreted path is used, generators, + `IsContextAware`, and the implicit `size`/`first`/`last` lookup filters. +2. Extract filter application and output stringification from + `Variable::evaluate()`/`render()` into reusable methods. The shared path must + preserve generator materialization before filters, positional plus named + filter arguments, `CanBeRendered`, booleans, numerics, arrays, objects with + `__toString()`, and null output. +3. Add a protected helper on `CompiledTemplate`, named + `renderCompiledVariable(RenderContext $context, string $name, array $lookups, array $filters): string`, + which calls those shared methods. It must not cache context-bound objects or + bypass `RenderContext`. +4. Add a concrete `CompilerContext` special case for `Variable` nodes without + making `Variable` implement `CanBeCompiled`: emit the helper call inside the + same per-node error guard used by fallback nodes. +5. Emit the direct path only when the variable root is a `VariableLookup`, all + lookup segments are scalar string/int values, and all filter arguments are + safely exportable scalar expressions. If a name, lookup, or filter argument + contains a complex `CanBeEvaluated` object, fall back to the existing + `writeRuntimeValue()` property and `->render($context)` path. +6. Keep the line number in the generated error guard and add a line comment + immediately before each dynamic emission, matching the useful part of the + Twig output without introducing Twig’s source-context API. + +The direct emitter must be a runtime seam, not a PHP-native `$object->property` +shortcut: Liquid lookup semantics differ from Twig and include outer-scope +fallbacks, Drops, strict errors, context-aware values, and implicit lookup +filters. + +**Verify**: `vendor/bin/pest tests/Integration/CompilerTest.php tests/Integration/CompilerArtifactSafetyTest.php` → all existing compiled parity/error/resource-limit tests plus the new direct/fallback cases pass. + +### Step 3: Simplify the generated writer around the new seam + +Update the code writer while keeping the runtime behavior from Step 2: + +1. Emit `use` statements for the generated class’s fixed runtime types and use + short names in the class declaration, constructor, and render method. +2. Keep the repeated-`require` `class_exists(..., false)` guard and the current + `return new Template_;` artifact contract. +3. Rename the depth-zero accumulator to `$output` and compile the document body + directly into that root accumulator; retain numbered accumulators only for + nested body methods that need their own resource-accounting scope. Use an + explicit root-body marker from `Document::compile()`/`CompilerContext`, not + a heuristic based only on output depth, so nested bodies remain isolated. +4. Coalesce adjacent `Text`/`Raw` literal nodes in `BodyNode` before emitting a + dynamic node. Use a safe multiline PHP literal writer for printable template + text containing newlines, falling back to `VarExporter` for control-heavy + strings so quotes, backslashes, null bytes, and PHP-looking text remain data. +5. Add a conservative interruptability check: ordinary `Text`, `Raw`, and + direct `Variable` emissions cannot push Liquid interrupts, so do not emit a + `do { } while (false)` wrapper or `hasInterrupt()` check for those nodes; + retain the existing bailout for unknown/fallback nodes and control-flow tags. +6. Generate a constructor only when fallback properties are still required; + the specs artifact should therefore have no generated constructor. +7. Do not remove resource-limit accounting, per-node error handling, runtime + fallback properties, method bodies used by `for`, or partial behavior merely + to reduce line count. + +**Verify**: `vendor/bin/pest tests/Integration/CompilerTest.php tests/Integration/CompilerOutputTest.php` → the source-shape assertions pass and the compiled output remains byte-for-byte equal to interpreted output for all covered cases. + +### Step 4: Run the full safety and performance gates + +Run the focused tests, then the full suite and static checks. Capture a compiler +benchmark JSON before and after the implementation with identical PHPBench +settings. Compare only matching aggregate rows; if no `main` compiler baseline +exists, record that the result is branch-only instead of inventing a conclusion. + +**Verify**: + +- `composer test` → all tests pass; no new failures. +- `vendor/bin/pint --test` → formatting check passes. +- `vendor/bin/phpstan analyse --no-progress` → `[OK] No errors`. +- `git diff --check` → exit 0 with no output. +- `vendor/bin/phpbench run --group=compiler --warmup=1 --retry-threshold=5 --report=aggregate --output=json > /tmp/php-liquid-compiler-after.json` → valid aggregate JSON and no reproducible throughput regression above the repository’s 5% threshold. +- `git status --short` → only the in-scope source/test files are modified, plus the executor’s allowed status update in `plans/README.md`. + +## Test plan + +- Keep all existing compiler tests in `tests/Integration/CompilerTest.php`, + especially control-flow parity, partial fallback, interrupts, resource + limits, repeated state, unsafe fallback reconstruction, and extension tests. +- Add a focused storefront-spec source-shape/parity test as described in Step 1. +- Add direct-variable parity cases covering a plain lookup, nested lookup, + scalar filter (`size`), strict missing variable, a `CanBeRendered` value, and + a complex lookup/filter argument that deliberately uses fallback. +- Keep literal safety coverage for quotes, escapes, control characters, PHP + looking text, and multiline HTML. +- Verify the existing performance fixture test still passes; it already renders + every page with strict variables, strict filters, and rethrown errors + (`tests/Integration/Performance/StorefrontThemeTest.php:53-74`). + +## Done criteria + +- [ ] The specs artifact has imported runtime names, one readable compiled render + method, coalesced literal segments, line comments, and direct common + variable calls; it has no fallback properties for those six simple values. +- [ ] Complex variables and unsupported tags still use the existing safe runtime + fallback and constructor-property path. +- [ ] `render()` and `stream()` preserve the public string/one-chunk contract. +- [ ] Parsed and compiled renders, stream byte values, errors, interrupts, + resource limits, state persistence, and runtime partial lookup remain equal. +- [ ] `composer test` exits 0. +- [ ] `vendor/bin/pint --test` exits 0. +- [ ] `vendor/bin/phpstan analyse --no-progress` exits 0 with no errors. +- [ ] `git diff --check` exits 0. +- [ ] The compiler benchmark has a matching baseline or is explicitly recorded + as branch-only; no reproducible regression above 5% is accepted. +- [ ] No files outside the Scope list are modified. +- [ ] `plans/README.md` status row is updated to `DONE`, or `BLOCKED` with the + concrete reason. + +## STOP conditions + +Stop and report back instead of improvising if: + +- The compiler contract, `Variable`/`VariableLookup` semantics, or the current + generated source no longer matches the Current state excerpts. +- Exact Twig `yield`/`doDisplay` output is required; that needs a separate + decision about the `Template` and benchmark contracts. +- The direct helper cannot preserve outer-scope lookup fallback, strict errors, + implicit lookup filters, Drop context binding, or `CanBeRendered` behavior. +- Removing an interrupt check changes any existing break/continue/partial parity + test, or resource-limit counters differ from interpreted rendering. +- A simple artifact can be required only once after the class-header cleanup; + restore the guard and report rather than changing cache semantics. +- A benchmark shows a reproducible throughput regression above 5%, or a result + is too high-variance to classify after two representative runs. +- A change appears to require touching an out-of-scope file. +- Any verification command fails twice after a reasonable fix attempt. + +## Maintenance notes + +- The direct-variable helper is a compiler/runtime seam; any future Liquid + lookup or filter-semantic change must update both interpreted and compiled + tests before changing its implementation. +- Keep the conservative fallback boundary. New tags/nodes should remain runtime + fallback unless their semantics can be expressed without bypassing context, + error, interrupt, or resource-limit handling. +- Do not turn generated output into a full-file snapshot. Assert stable source + markers and use the existing storefront strict-render tests for fixture + correctness. +- A future exact Twig-style generator would need a separate plan covering + `CompiledTemplate`, stream chunk semantics, nested body methods, benchmark + subjects, and an explicit performance comparison; it is not a follow-up to + this formatting cleanup. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..edbd746 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,28 @@ +# Implementation Plans + +Generated by the improve skill on 2026-08-03. Execute the plan below in order. +Each executor must read the plan fully, honor its STOP conditions, and update +the status row when finished. + +## Execution order & status + +| Plan | Title | Priority | Effort | Depends on | Status | +|------|-------|----------|--------|------------|--------| +| 001 | Implement Twig-shaped compiled output without changing Liquid semantics | P1 | L | — | DONE | + +Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale). + +## Dependency notes + +- None. + +## Findings considered and rejected + +- Exact Twig `yield`/`doDisplay` generation: deferred because the current + `CompiledTemplate` contract deliberately returns a string and wraps stream + output as one chunk; changing it would be a separate API and benchmark + decision, not a source-format cleanup. +- Twig-only `Source`, sandbox, macro, block, and wrapper metadata: rejected for + this plan because Liquid has no corresponding runtime contract or retained + source-context object; Liquid line comments and the existing template name + are sufficient debugging metadata for this change. diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index cc12064..5f2ec47 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\AbstractTemplate; use Keepsuit\Liquid\Exceptions\LiquidException; +use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TemplateSharedState; @@ -39,6 +40,17 @@ final public function stream(RenderContext $context): \Generator yield $this->render($context); } + /** + * Render a common variable directly while retaining Liquid lookup semantics. + * + * @param array $lookups + * @param array}> $filters + */ + protected function renderCompiledVariable(RenderContext $context, string $name, array $lookups, array $filters): string + { + return Variable::renderParts($context, $name, $lookups, $filters); + } + abstract public function name(): ?string; abstract protected function renderCompiled(RenderContext $context): string; diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 9c0dfd2..2f5059d 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -49,9 +49,13 @@ public function compile(ParsedTemplate $template): string ->writeLine() ->writeLine('namespace Keepsuit\\Liquid\\Compiler\\Generated;') ->writeLine() + ->writeLine('use Keepsuit\\Liquid\\Compiler\\CompiledTemplate;') + ->writeLine('use Keepsuit\\Liquid\\Render\\RenderContext;') + ->writeLine('use Keepsuit\\Liquid\\TemplateSharedState;') + ->writeLine() ->writeLine('if (! class_exists('.$className.'::class, false)) {') ->indent() - ->writeLine('final class '.$className.' extends \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate') + ->writeLine('final class '.$className.' extends CompiledTemplate') ->writeLine('{') ->indent(); @@ -61,7 +65,7 @@ public function compile(ParsedTemplate $template): string if ($fallbackValues !== []) { $builder - ->writeLine('public function __construct(\\Keepsuit\\Liquid\\TemplateSharedState $state = new \\Keepsuit\\Liquid\\TemplateSharedState)') + ->writeLine('public function __construct(TemplateSharedState $state = new TemplateSharedState)') ->writeLine('{') ->indent(); @@ -84,25 +88,25 @@ public function compile(ParsedTemplate $template): string ->dedent() ->writeLine('}') ->writeLine() - ->writeLine('protected function renderCompiled(\\Keepsuit\\Liquid\\Render\\RenderContext $context): string') + ->writeLine('protected function renderCompiled(RenderContext $context): string') ->writeLine('{') ->indent(); - $builder->writeLine('$output0 = \'\';'); + $builder->writeLine('$output = \'\';'); foreach (explode("\n", rtrim($body, "\n")) as $line) { $builder->writeLine($line); } $builder - ->writeLine('return $output0;') + ->writeLine('return $output;') ->dedent() ->writeLine('}'); foreach ($methods as $methodName => $methodSource) { $builder ->writeLine() - ->writeLine('private function '.$methodName.'(\\Keepsuit\\Liquid\\Render\\RenderContext $context): string') + ->writeLine('private function '.$methodName.'(RenderContext $context): string') ->writeLine('{') ->indent(); diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 542badd..131f5f0 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -5,7 +5,12 @@ use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\Disableable; +use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Node; +use Keepsuit\Liquid\Nodes\Raw; +use Keepsuit\Liquid\Nodes\Text; +use Keepsuit\Liquid\Nodes\Variable; +use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Tag; use Symfony\Component\VarExporter\VarExporter; @@ -22,6 +27,8 @@ final class CompilerContext */ private int $outputDepth = 0; + private ?BodyNode $rootBody = null; + /** * Bodies a runtime tag drives itself, compiled to their own method so the * tag keeps its loop and scope handling while the body stops being walked. @@ -49,9 +56,9 @@ public function compileBodyToMethod(Node $body): string $this->outputDepth = 0; try { - $this->write('$output0 = \'\';'); + $this->write('$output = \'\';'); $this->subcompile($body); - $this->write('return $output0;'); + $this->write('return $output;'); $this->methods[$name] = $this->builder->getSource(); } finally { @@ -72,7 +79,24 @@ public function getMethods(): array public function outputVariable(): string { - return '$output'.$this->outputDepth; + return $this->outputDepth === 0 ? '$output' : '$output'.$this->outputDepth; + } + + public function compileRootBody(BodyNode $body): void + { + $previousRootBody = $this->rootBody; + $this->rootBody = $body; + + try { + $this->subcompile($body); + } finally { + $this->rootBody = $previousRootBody; + } + } + + public function isRootBody(BodyNode $body): bool + { + return $this->rootBody === $body; } public function pushOutputScope(): string @@ -125,6 +149,29 @@ public function writeOutput(string $expression): static return $this; } + public function writeText(string $value): static + { + if ($value !== '') { + $this->writeOutput($this->writeLiteral($value)); + } + + return $this; + } + + public function writeLineComment(?int $lineNumber): static + { + if ($lineNumber !== null) { + $this->write('// line '.$lineNumber); + } + + return $this; + } + + public function canInterrupt(Node $node): bool + { + return ! ($node instanceof Text || $node instanceof Raw || ($node instanceof Variable && $this->canCompileVariable($node))); + } + public function writeNodeErrorHandling(?int $lineNumber): static { $line = $this->writeValue($lineNumber); @@ -149,16 +196,23 @@ public function subcompile(Node $node): static $outputDepth = $this->outputDepth; $methods = $this->methods; + if ($node instanceof Variable && $this->canCompileVariable($node)) { + try { + $this->compileVariable($node); + + return $this; + } catch (\Throwable) { + $this->rollbackCompilation($checkpoint, $fallbackValueCount, $outputDepth, $methods); + } + } + if ($node instanceof CanBeCompiled) { try { $node->compile($this); return $this; } catch (\Throwable) { - $this->builder->rollback($checkpoint); - $this->rollbackFallbackValues($fallbackValueCount); - $this->outputDepth = $outputDepth; - $this->methods = $methods; + $this->rollbackCompilation($checkpoint, $fallbackValueCount, $outputDepth, $methods); } } @@ -167,10 +221,7 @@ public function subcompile(Node $node): static return $this; } catch (\Throwable $exception) { - $this->builder->rollback($checkpoint); - $this->rollbackFallbackValues($fallbackValueCount); - $this->outputDepth = $outputDepth; - $this->methods = $methods; + $this->rollbackCompilation($checkpoint, $fallbackValueCount, $outputDepth, $methods); throw $exception; } @@ -185,7 +236,7 @@ public function compileFallback(Node $node): void { $value = $this->writeRuntimeValue($node); - $this->write('try {')->indent(); + $this->writeLineComment($node->lineNumber())->write('try {')->indent(); if ($node instanceof Disableable && $node instanceof Tag) { $this->write($value.'->ensureTagIsEnabled($context);'); @@ -195,6 +246,53 @@ public function compileFallback(Node $node): void $this->writeNodeErrorHandling($node->lineNumber()); } + private function compileVariable(Variable $node): void + { + assert($node->name instanceof VariableLookup); + + $this->writeLineComment($node->lineNumber())->write('try {')->indent(); + $this->writeOutput(sprintf( + '$this->renderCompiledVariable($context, %s, %s, %s)', + $this->writeValue($node->name->name), + $this->writeValue($node->name->lookups), + $this->writeValue($node->filters), + )); + $this->writeNodeErrorHandling($node->lineNumber()); + } + + private function canCompileVariable(Variable $node): bool + { + if (! $node->name instanceof VariableLookup) { + return false; + } + + foreach ($node->name->lookups as $lookup) { + if (! is_string($lookup) && ! is_int($lookup)) { + return false; + } + } + + foreach ($node->filters as $filter) { + if (! is_array($filter) || count($filter) !== 3 || ! is_string($filter[0])) { + return false; + } + + foreach ([$filter[1], $filter[2]] as $arguments) { + if (! is_array($arguments)) { + return false; + } + + foreach ($arguments as $argument) { + if ($argument !== null && ! is_scalar($argument)) { + return false; + } + } + } + } + + return true; + } + public function writeValue(mixed $value): string { if ($value instanceof CanBeExported && ($exported = $value->export($this)) !== null) { @@ -273,8 +371,35 @@ private function rollbackFallbackValues(int $count): void $this->fallbackValues = array_slice($this->fallbackValues, 0, $count, preserve_keys: true); } + /** + * Restore compiler state after a node's direct or native compiler path fails. + * + * @param array{sourceLength:int,indentLevel:int} $checkpoint + * @param array $methods + */ + private function rollbackCompilation(array $checkpoint, int $fallbackValueCount, int $outputDepth, array $methods): void + { + $this->builder->rollback($checkpoint); + $this->rollbackFallbackValues($fallbackValueCount); + $this->outputDepth = $outputDepth; + $this->methods = $methods; + } + public function getSource(): string { return $this->builder->getSource(); } + + private function writeLiteral(string $value): string + { + if (preg_match('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/', $value) === 1) { + return $this->writeValue($value); + } + + return '"'.str_replace( + ['\\', '"', '$', "\n"], + ['\\\\', '\\"', '\\$', '\\n'], + $value, + ).'"'; + } } diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index ffa4e89..4ba2ca5 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -57,52 +57,71 @@ public function setChildren(array $children): BodyNode public function compile(CompilerContext $context): void { $lastIndex = count($this->children) - 1; - $interruptible = false; foreach ($this->children as $index => $child) { - if (! $child instanceof Text && $index !== $lastIndex) { + if ($index !== $lastIndex && $context->canInterrupt($child)) { $interruptible = true; break; } } + $root = $context->isRootBody($this); $parentOutput = $context->outputVariable(); - $output = $context->pushOutputScope(); + $output = $root ? $parentOutput : $context->pushOutputScope(); + + $context->write('$context->resourceLimits->incrementRenderScore('.count($this->children).');'); - $context - ->write('$context->resourceLimits->incrementRenderScore('.count($this->children).');') - ->write($output.' = \'\';'); + if (! $root) { + $context->write($output.' = \'\';'); + } - // A do/while(false) gives the bail-out a target without a closure. if ($interruptible) { $context->write('do {')->indent(); } + $literal = ''; + foreach ($this->children as $index => $child) { - $context->subcompile($child); + if ($child instanceof Text || $child instanceof Raw) { + $literal .= $child->value; - if ($child instanceof Text || $index === $lastIndex) { continue; } - $context - ->write('if ($context->hasInterrupt()) {') - ->indent() - ->write('break;') - ->outdent() - ->write('}'); + if ($literal !== '') { + $context->writeText($literal); + $literal = ''; + } + + $context->subcompile($child); + + if ($index !== $lastIndex && $context->canInterrupt($child)) { + $context->write('if ($context->hasInterrupt()) {') + ->indent() + ->write('break;') + ->outdent() + ->write('}'); + } + } + + if ($literal !== '') { + $context->writeText($literal); } if ($interruptible) { $context->outdent()->write('} while (false);'); } - $context - ->write('$context->resourceLimits->incrementWriteScore('.$output.');') - ->write($parentOutput.' .= '.$output.';'); + if ($root) { + $context->write('$context->resourceLimits->incrementWriteScore('.$output.');'); + } else { + $context + ->write('$context->resourceLimits->incrementWriteScore('.$output.');') + ->write($parentOutput.' .= '.$output.';'); - $context->popOutputScope(); + $context->popOutputScope(); + } } /** diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index e3b710c..40652dc 100644 --- a/src/Nodes/Document.php +++ b/src/Nodes/Document.php @@ -25,7 +25,7 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { - $context->subcompile($this->body); + $context->compileRootBody($this->body); } /** diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index 9bc7d6a..499740b 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -26,13 +26,21 @@ public function __construct( public function render(RenderContext $context): string { - $output = $this->evaluate($context); - - if ($output instanceof CanBeRendered) { - return $output->render($context); - } + return self::renderEvaluated($context, $this->evaluate($context)); + } - return $this->renderOutput($output); + /** + * Render a variable from its parsed parts without rebuilding a Variable node. + * + * @param array $lookups + * @param array}> $filters + */ + public static function renderParts(RenderContext $context, string $name, array $lookups, array $filters): string + { + return self::renderEvaluated( + $context, + self::applyFilters($context, VariableLookup::evaluateParts($context, $name, $lookups), $filters), + ); } public function export(CompilerContext $context): ?string @@ -70,13 +78,13 @@ public function stream(RenderContext $context): \Generator if ($output instanceof \Generator) { foreach ($output as $chunk) { - yield $this->renderOutput($chunk); + yield self::renderOutputValue($chunk); } return; } - yield $this->renderOutput($output); + yield self::renderOutputValue($output); } public function parseTreeVisitorChildren(): array @@ -86,9 +94,15 @@ public function parseTreeVisitorChildren(): array public function evaluate(RenderContext $context): mixed { - $output = $context->evaluate($this->name); + return self::applyFilters($context, $context->evaluate($this->name), $this->filters); + } - if ($this->filters === []) { + /** + * @param array}> $filters + */ + private static function applyFilters(RenderContext $context, mixed $output, array $filters): mixed + { + if ($filters === []) { return $output; } @@ -96,17 +110,17 @@ public function evaluate(RenderContext $context): mixed $output = iterator_to_array($output, preserve_keys: false); } - foreach ($this->filters as [$filterName, $filterArgs, $filterNamedArgs]) { + foreach ($filters as [$filterName, $filterArgs, $filterNamedArgs]) { if ($filterArgs === [] && $filterNamedArgs === []) { $output = $context->applyFilter($filterName, $output); continue; } - $filterArgs = $this->evaluateFilterExpressions($context, $filterArgs); + $filterArgs = self::evaluateFilterExpressions($context, $filterArgs); if ($filterNamedArgs !== []) { - $filterArgs = [...$filterArgs, ...$this->evaluateFilterExpressions($context, $filterNamedArgs)]; + $filterArgs = [...$filterArgs, ...self::evaluateFilterExpressions($context, $filterNamedArgs)]; } $output = $context->applyFilter($filterName, $output, $filterArgs); @@ -115,7 +129,16 @@ public function evaluate(RenderContext $context): mixed return $output; } - protected function renderOutput(mixed $output): string + private static function renderEvaluated(RenderContext $context, mixed $output): string + { + if ($output instanceof CanBeRendered) { + return $output->render($context); + } + + return self::renderOutputValue($output); + } + + private static function renderOutputValue(mixed $output): string { if (is_string($output)) { return $output; @@ -138,7 +161,7 @@ protected function renderOutput(mixed $output): string } if (is_array($output)) { - return implode('', array_map($this->renderOutput(...), $output)); + return implode('', array_map(self::renderOutputValue(...), $output)); } if (is_object($output) && method_exists($output, '__toString')) { diff --git a/src/Nodes/VariableLookup.php b/src/Nodes/VariableLookup.php index 845ffa0..d19efd0 100644 --- a/src/Nodes/VariableLookup.php +++ b/src/Nodes/VariableLookup.php @@ -84,17 +84,27 @@ public function parseTreeVisitorChildren(): array public function evaluate(RenderContext $context): mixed { - $variable = $context->findVariable($this->name); + return self::evaluateParts($context, $this->name, $this->lookups); + } + + /** + * Evaluate a parsed lookup without requiring a VariableLookup instance. + * + * @param array $lookups + */ + public static function evaluateParts(RenderContext $context, string $name, array $lookups): mixed + { + $variable = $context->findVariable($name); if ($variable instanceof MissingValue) { - return $this->undefined($context); + return self::undefinedValue($context, $name, $lookups); } - if ($this->lookups === []) { + if ($lookups === []) { return $variable; } - $result = $this->walkLookups($context, $variable); + $result = self::walkLookupParts($context, $variable, $lookups); if (! $result instanceof MissingValue) { return $result; @@ -102,32 +112,37 @@ public function evaluate(RenderContext $context): mixed // 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) { + foreach ($context->findVariables($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); + $result = self::walkLookupParts($context, $candidate, $lookups); if (! $result instanceof MissingValue) { return $result; } } - return $this->undefined($context); + return self::undefinedValue($context, $name, $lookups); } - protected function undefined(RenderContext $context): ?UndefinedVariable + /** + * @param array $lookups + */ + private static function undefinedValue(RenderContext $context, string $name, array $lookups): ?UndefinedVariable { - return $context->options->strictVariables ? new UndefinedVariable($this->toString()) : null; + return $context->options->strictVariables + ? new UndefinedVariable(implode('.', [$name, ...$lookups])) + : null; } /** - * Walks the lookup chain against $object, returning MissingValue if it breaks. + * @param array $lookups */ - protected function walkLookups(RenderContext $context, mixed $object): mixed + private static function walkLookupParts(RenderContext $context, mixed $object, array $lookups): mixed { if ($object instanceof CanBeEvaluated) { $object = $context->evaluate($object); @@ -137,7 +152,7 @@ protected function walkLookups(RenderContext $context, mixed $object): mixed $object = iterator_to_array($object, preserve_keys: false); } - foreach ($this->lookups as $lookup) { + foreach ($lookups as $lookup) { $key = $lookup instanceof VariableLookup ? $context->evaluate($lookup) : $lookup; if (! (is_string($key) || is_int($key))) { diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 0826dec..1f3c157 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -21,6 +21,7 @@ use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Parse\TagParseContext; use Keepsuit\Liquid\ParsedTemplate; +use Keepsuit\Liquid\Performance\Support\StorefrontTheme; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\Render\ResourceLimits; use Keepsuit\Liquid\Tag; @@ -401,7 +402,7 @@ protected function renderCompiled(RenderContext $context): string $environment->compile($template, $compiledPath); expect(file_get_contents($compiledPath)) - ->toContain('new \Keepsuit\Liquid\Nodes\Variable(') + ->not->toContain('new \Keepsuit\Liquid\Nodes\Variable(') ->toContain('new \Keepsuit\Liquid\Nodes\VariableLookup(') ->toContain('new \Keepsuit\Liquid\Condition\Condition(') ->not->toContain('deepclone_from_array'); @@ -644,7 +645,8 @@ protected function renderCompiled(RenderContext $context): string expect($compiledSource) ->toContain('final class Template_') - ->toContain('extends \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate') + ->toContain('use Keepsuit\\Liquid\\Compiler\\CompiledTemplate;') + ->toContain('extends CompiledTemplate') ->toContain('protected function renderCompiled') ->not->toContain('unserialize') ->not->toContain('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); @@ -659,6 +661,123 @@ protected function renderCompiled(RenderContext $context): string } }); +test('storefront specs compile into readable direct output', function () { + $environment = StorefrontTheme::environment(); + $template = $environment->parseTemplate('snippets.product.specs'); + $data = StorefrontTheme::renderData('templates.product')['page']; + $interpreted = $template->render($environment->newRenderContext(staticData: $data)); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('use Keepsuit\\Liquid\\Compiler\\CompiledTemplate;') + ->toContain('use Keepsuit\\Liquid\\Render\\RenderContext;') + ->toContain('extends CompiledTemplate') + ->toContain('renderCompiledVariable') + ->toContain('// line 4') + ->toContain("'size'") + ->not->toContain('private readonly mixed $value') + ->not->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') + ->not->toContain('do {'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $compiledOutput = $compiled->render($environment->newRenderContext(staticData: $data)); + + expect($compiledOutput)->toBe($interpreted); + expect(implode('', iterator_to_array($compiled->stream($environment->newRenderContext(staticData: $data))))) + ->toBe($interpreted); + + /** @var CompiledTemplate $secondCompiled */ + $secondCompiled = require $compiledPath; + expect($secondCompiled)->toBeInstanceOf(CompiledTemplate::class); + } finally { + @unlink($compiledPath); + } +}); + +test('complex compiled variables retain the runtime fallback', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{{ values[key] }}'); + $compiledPath = temporaryCompiledTemplatePath(); + $data = ['values' => ['sku' => 'ABC'], 'key' => 'sku']; + + try { + $environment->compile($template, $compiledPath); + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource)->toContain('private readonly mixed $value0'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } finally { + @unlink($compiledPath); + } +}); + +test('direct variable emission preserves common Liquid values', function (string $source, array $data, string $expected) { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString($source); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect(file_get_contents($compiledPath)) + ->toContain('renderCompiledVariable') + ->not->toContain('private readonly mixed $value'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $context = $environment->newRenderContext(data: $data); + + expect($compiled->render($context))->toBe($expected); + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))); + } finally { + @unlink($compiledPath); + } +})->with([ + 'plain lookup' => ['{{ name }}', ['name' => 'World'], 'World'], + 'nested lookup' => ['{{ product.title }}', ['product' => ['title' => 'Hat']], 'Hat'], + 'size filter' => ['{{ items | size }}', ['items' => [1, 2, 3]], '3'], + 'scalar filter argument' => ['{{ value | append: 2 }}', ['value' => 'x'], 'x2'], + 'renderable value' => ['{{ value }}', ['value' => new Text('rendered')], 'rendered'], +]); + +test('storefront header keeps runtime partial rendering with direct values', function () { + $environment = StorefrontTheme::environment(); + $template = $environment->parseTemplate('snippets.page.header'); + $data = [ + 'shop' => ['name' => 'Field Goods'], + 'page' => ['title' => 'About'], + ]; + $interpreted = $template->render($environment->newRenderContext(staticData: $data)); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('renderCompiledVariable') + ->toContain('private readonly mixed $value'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(staticData: $data)))->toBe($interpreted); + } finally { + @unlink($compiledPath); + } +}); + test('compiler context writes indented output statements', function () { $context = new CompilerContext; @@ -767,7 +886,7 @@ protected function renderCompiled(RenderContext $context): string test('compiled literals preserve quotes escapes and control characters', function () { $environment = EnvironmentFactory::new()->build(); - $literal = "quote ' and \"\nline\r\t\0 "; + $literal = "quote ' and \"\nline\r\t\0 `backtick` "; $template = $environment->parseString($literal); $compiledPath = temporaryCompiledTemplatePath(); From 7b89d85bef4074422ea16f6abe63c41a931ce18a Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 12:42:49 +0200 Subject: [PATCH 36/45] refactor: stream compiled template output --- src/Compiler/CompiledTemplate.php | 78 +++++++++++++-- src/Compiler/Compiler.php | 9 +- src/Compiler/CompilerContext.php | 156 +++++++++++------------------ src/Nodes/BodyNode.php | 26 +---- src/Tags/CaseTag.php | 9 +- src/Tags/ForTag.php | 15 +-- src/Tags/IfTag.php | 8 +- src/Tags/UnlessTag.php | 6 +- tests/Integration/CompilerTest.php | 81 ++++++++++++++- 9 files changed, 234 insertions(+), 154 deletions(-) diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index 5f2ec47..2259e81 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -2,11 +2,16 @@ namespace Keepsuit\Liquid\Compiler; +use Generator; use Keepsuit\Liquid\AbstractTemplate; use Keepsuit\Liquid\Exceptions\LiquidException; +use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; +use Keepsuit\Liquid\Exceptions\UndefinedFilterException; +use Keepsuit\Liquid\Exceptions\UndefinedVariableException; use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Render\RenderContext; use Keepsuit\Liquid\TemplateSharedState; +use Throwable; abstract class CompiledTemplate extends AbstractTemplate { @@ -16,11 +21,30 @@ public function __construct(TemplateSharedState $state = new TemplateSharedState } final public function render(RenderContext $context): string + { + $output = ''; + + foreach ($this->stream($context) as $chunk) { + $output .= $chunk; + } + + return $output; + } + + /** + * @return \Generator + */ + final public function stream(RenderContext $context): \Generator { try { $this->prepareContext($context); - return $this->renderCompiled($context); + foreach ($this->renderCompiled($context) as $chunk) { + $chunk = (string) $chunk; + $context->resourceLimits->incrementWriteScore($chunk); + + yield $chunk; + } } catch (LiquidException $e) { $this->attachTemplateName($e); throw $e; @@ -30,14 +54,56 @@ final public function render(RenderContext $context): string } /** - * A compiled body builds one string, so there is nothing to stream - * incrementally: streaming it would only add a Generator per nesting level. + * Execute one lazily-created compiled node under Liquid's configured error + * handling policy. The generator is created by the generated template but + * does not execute until this method iterates it. * + * @param Generator $node * @return \Generator */ - final public function stream(RenderContext $context): \Generator + protected function yieldNode(RenderContext $context, ?int $lineNumber, Generator $node): \Generator + { + try { + foreach ($node as $chunk) { + yield (string) $chunk; + } + } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { + $context->handleError($exception, $lineNumber); + } catch (Throwable $exception) { + yield $context->handleError($exception, $lineNumber); + } + } + + /** + * Account for a compiled body and forward its chunks unchanged. + * + * @param Generator $body + * @return \Generator + */ + protected function yieldBody(RenderContext $context, int $renderScore, Generator $body): \Generator + { + $context->resourceLimits->incrementRenderScore($renderScore); + + foreach ($body as $chunk) { + yield (string) $chunk; + } + } + + /** + * Collect a compiled body when a runtime tag still owns a string-based + * render loop. + * + * @param Generator $body + */ + protected function collectCompiled(RenderContext $context, Generator $body): string { - yield $this->render($context); + $output = ''; + + foreach ($body as $chunk) { + $output .= (string) $chunk; + } + + return $output; } /** @@ -53,5 +119,5 @@ protected function renderCompiledVariable(RenderContext $context, string $name, abstract public function name(): ?string; - abstract protected function renderCompiled(RenderContext $context): string; + abstract protected function renderCompiled(RenderContext $context): iterable; } diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 2f5059d..8c09a48 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -88,25 +88,23 @@ public function compile(ParsedTemplate $template): string ->dedent() ->writeLine('}') ->writeLine() - ->writeLine('protected function renderCompiled(RenderContext $context): string') + ->writeLine('protected function renderCompiled(RenderContext $context): iterable') ->writeLine('{') ->indent(); - $builder->writeLine('$output = \'\';'); - foreach (explode("\n", rtrim($body, "\n")) as $line) { $builder->writeLine($line); } $builder - ->writeLine('return $output;') + ->writeLine('yield from [];') ->dedent() ->writeLine('}'); foreach ($methods as $methodName => $methodSource) { $builder ->writeLine() - ->writeLine('private function '.$methodName.'(RenderContext $context): string') + ->writeLine('private function '.$methodName.'(RenderContext $context): \\Generator') ->writeLine('{') ->indent(); @@ -115,6 +113,7 @@ public function compile(ParsedTemplate $template): string } $builder + ->writeLine('yield from [];') ->dedent() ->writeLine('}'); } diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 131f5f0..08d18c2 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -6,6 +6,7 @@ use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\Nodes\BodyNode; +use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Nodes\Node; use Keepsuit\Liquid\Nodes\Raw; use Keepsuit\Liquid\Nodes\Text; @@ -21,14 +22,6 @@ final class CompilerContext */ private array $fallbackValues = []; - /** - * Bodies are inlined rather than wrapped in a closure, so each nesting level - * needs its own accumulator variable. - */ - private int $outputDepth = 0; - - private ?BodyNode $rootBody = null; - /** * Bodies a runtime tag drives itself, compiled to their own method so the * tag keeps its loop and scope handling while the body stops being walked. @@ -46,26 +39,30 @@ public function __construct(private CodeBuilder $builder = new CodeBuilder) {} public function compileBodyToMethod(Node $body): string { $name = 'body'.count($this->methods); - // Reserve the name before compiling: a nested body must not reuse it. + $rawName = 'body'.(count($this->methods) + 1); + // Reserve both names before compiling: nested bodies must not reuse them. $this->methods[$name] = ''; + $this->methods[$rawName] = ''; $outerBuilder = $this->builder; - $outerDepth = $this->outputDepth; $this->builder = new CodeBuilder; - $this->outputDepth = 0; try { - $this->write('$output = \'\';'); $this->subcompile($body); - $this->write('return $output;'); - $this->methods[$name] = $this->builder->getSource(); + $this->methods[$rawName] = $this->builder->getSource(); } finally { $this->builder = $outerBuilder; - $this->outputDepth = $outerDepth; } + $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; + $this->methods[$name] = sprintf( + 'yield from $this->yieldBody($context, %s, $this->%s($context));', + $this->writeValue($renderScore), + $rawName, + ); + return $name; } @@ -77,38 +74,10 @@ public function getMethods(): array return $this->methods; } - public function outputVariable(): string - { - return $this->outputDepth === 0 ? '$output' : '$output'.$this->outputDepth; - } - public function compileRootBody(BodyNode $body): void { - $previousRootBody = $this->rootBody; - $this->rootBody = $body; - - try { - $this->subcompile($body); - } finally { - $this->rootBody = $previousRootBody; - } - } - - public function isRootBody(BodyNode $body): bool - { - return $this->rootBody === $body; - } - - public function pushOutputScope(): string - { - $this->outputDepth++; - - return $this->outputVariable(); - } - - public function popOutputScope(): void - { - $this->outputDepth = max(0, $this->outputDepth - 1); + $method = $this->compileBodyToMethod($body); + $this->write('yield from $this->'.$method.'($context);'); } public function write(string $line = ''): static @@ -144,7 +113,7 @@ public function outdent(): static public function writeOutput(string $expression): static { - $this->write($this->outputVariable().' .= '.$expression.';'); + $this->write('yield '.$expression.';'); return $this; } @@ -172,92 +141,88 @@ public function canInterrupt(Node $node): bool return ! ($node instanceof Text || $node instanceof Raw || ($node instanceof Variable && $this->canCompileVariable($node))); } - public function writeNodeErrorHandling(?int $lineNumber): static + public function subcompile(Node $node): static { - $line = $this->writeValue($lineNumber); - - return $this - ->outdent() - ->write('} catch (\\Keepsuit\\Liquid\\Exceptions\\UndefinedVariableException|\\Keepsuit\\Liquid\\Exceptions\\UndefinedDropMethodException|\\Keepsuit\\Liquid\\Exceptions\\UndefinedFilterException $exception) {') - ->indent() - ->write('$context->handleError($exception, '.$line.');') - ->outdent() - ->write('} catch (\\Throwable $exception) {') - ->indent() - ->write($this->outputVariable().' .= $context->handleError($exception, '.$line.');') - ->outdent() - ->write('}'); + if ($node instanceof Text || $node instanceof Raw || $node instanceof BodyNode || $node instanceof Document) { + $node->compile($this); + + return $this; + } + + $method = $this->compileNodeToMethod($node); + + $this->write(sprintf( + 'yield from $this->yieldNode($context, %s, $this->%s($context));', + $this->writeValue($node->lineNumber()), + $method, + )); + + return $this; } - public function subcompile(Node $node): static + private function compileNodeToMethod(Node $node): string { - $checkpoint = $this->builder->checkpoint(); - $fallbackValueCount = count($this->fallbackValues); - $outputDepth = $this->outputDepth; - $methods = $this->methods; + $name = 'node'.count($this->methods); + $this->methods[$name] = ''; - if ($node instanceof Variable && $this->canCompileVariable($node)) { - try { - $this->compileVariable($node); + $outerBuilder = $this->builder; + $this->builder = new CodeBuilder; - return $this; - } catch (\Throwable) { - $this->rollbackCompilation($checkpoint, $fallbackValueCount, $outputDepth, $methods); - } - } + try { + $checkpoint = $this->builder->checkpoint(); + $fallbackValueCount = count($this->fallbackValues); + $methods = $this->methods; - if ($node instanceof CanBeCompiled) { try { - $node->compile($this); - - return $this; + if ($node instanceof Variable && $this->canCompileVariable($node)) { + $this->compileVariable($node); + } elseif ($node instanceof CanBeCompiled) { + $this->writeLineComment($node->lineNumber()); + $node->compile($this); + } else { + $this->compileFallback($node); + } } catch (\Throwable) { - $this->rollbackCompilation($checkpoint, $fallbackValueCount, $outputDepth, $methods); + $this->rollbackCompilation($checkpoint, $fallbackValueCount, $methods); + $this->compileFallback($node); } - } - try { - $this->compileFallback($node); - - return $this; - } catch (\Throwable $exception) { - $this->rollbackCompilation($checkpoint, $fallbackValueCount, $outputDepth, $methods); - - throw $exception; + $this->methods[$name] = $this->builder->getSource(); + } finally { + $this->builder = $outerBuilder; } + + return $name; } /** - * The dispatch is inlined rather than routed through a runtime helper: the - * helper costs a call frame per node, and whether the node needs a - * tag-enabled check is already known here, at compile time. + * Compile the node into a lazy generator so the base template can own the + * runtime error boundary while the surrounding body remains resumable. */ public function compileFallback(Node $node): void { $value = $this->writeRuntimeValue($node); - $this->writeLineComment($node->lineNumber())->write('try {')->indent(); + $this->writeLineComment($node->lineNumber()); if ($node instanceof Disableable && $node instanceof Tag) { $this->write($value.'->ensureTagIsEnabled($context);'); } $this->writeOutput($value.'->render($context)'); - $this->writeNodeErrorHandling($node->lineNumber()); } private function compileVariable(Variable $node): void { assert($node->name instanceof VariableLookup); - $this->writeLineComment($node->lineNumber())->write('try {')->indent(); + $this->writeLineComment($node->lineNumber()); $this->writeOutput(sprintf( '$this->renderCompiledVariable($context, %s, %s, %s)', $this->writeValue($node->name->name), $this->writeValue($node->name->lookups), $this->writeValue($node->filters), )); - $this->writeNodeErrorHandling($node->lineNumber()); } private function canCompileVariable(Variable $node): bool @@ -377,11 +342,10 @@ private function rollbackFallbackValues(int $count): void * @param array{sourceLength:int,indentLevel:int} $checkpoint * @param array $methods */ - private function rollbackCompilation(array $checkpoint, int $fallbackValueCount, int $outputDepth, array $methods): void + private function rollbackCompilation(array $checkpoint, int $fallbackValueCount, array $methods): void { $this->builder->rollback($checkpoint); $this->rollbackFallbackValues($fallbackValueCount); - $this->outputDepth = $outputDepth; $this->methods = $methods; } diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 4ba2ca5..8a3b70e 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -46,9 +46,9 @@ public function setChildren(array $children): BodyNode } /** - * The body is inlined instead of wrapped in a closure: a closure costs an - * allocation and a call frame on every render, and a body carries no state - * that needs its own scope beyond the accumulator. + * The body is compiled into a lazy generator method: the base template owns + * its error boundary, while the body carries no state that needs its own + * scope beyond the render context. * * Mirrors render(): Text cannot fail or interrupt, so it needs no guard, and * every other child is followed by a bail-out rather than the whole body @@ -66,16 +66,6 @@ public function compile(CompilerContext $context): void } } - $root = $context->isRootBody($this); - $parentOutput = $context->outputVariable(); - $output = $root ? $parentOutput : $context->pushOutputScope(); - - $context->write('$context->resourceLimits->incrementRenderScore('.count($this->children).');'); - - if (! $root) { - $context->write($output.' = \'\';'); - } - if ($interruptible) { $context->write('do {')->indent(); } @@ -112,16 +102,6 @@ public function compile(CompilerContext $context): void if ($interruptible) { $context->outdent()->write('} while (false);'); } - - if ($root) { - $context->write('$context->resourceLimits->incrementWriteScore('.$output.');'); - } else { - $context - ->write('$context->resourceLimits->incrementWriteScore('.$output.');') - ->write($parentOutput.' .= '.$output.';'); - - $context->popOutputScope(); - } } /** diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index 385a29b..18dce5d 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -70,7 +70,6 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { - $context->write('try {')->indent(); $first = true; foreach ($this->conditions as $condition) { @@ -78,7 +77,8 @@ public function compile(CompilerContext $context): void if ($isElse && $first) { if ($condition->body !== null) { - $context->subcompile($condition->body); + $body = $context->compileBodyToMethod($condition->body); + $context->write('yield from $this->'.$body.'($context);'); } break; @@ -95,7 +95,8 @@ public function compile(CompilerContext $context): void $context->indent(); if ($condition->body !== null) { - $context->subcompile($condition->body); + $body = $context->compileBodyToMethod($condition->body); + $context->write('yield from $this->'.$body.'($context);'); } $context->outdent()->write('}'); @@ -106,8 +107,6 @@ public function compile(CompilerContext $context): void $first = false; } - - $context->writeNodeErrorHandling($this->lineNumber()); } public function children(): array diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index 9339950..6e4b90d 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -87,14 +87,17 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { $tag = $context->writeRuntimeValue($this); - $forBody = $context->compileBodyToMethod($this->forBlock); - $elseBody = $this->elseBlock !== null - ? '$this->'.$context->compileBodyToMethod($this->elseBlock).'(...)' + $forBodyMethod = $context->compileBodyToMethod($this->forBlock); + $elseBodyMethod = $this->elseBlock !== null + ? $context->compileBodyToMethod($this->elseBlock) + : null; + + $forBody = 'fn (RenderContext $context) => $this->collectCompiled($context, $this->'.$forBodyMethod.'($context))'; + $elseBody = $elseBodyMethod !== null + ? 'fn (RenderContext $context) => $this->collectCompiled($context, $this->'.$elseBodyMethod.'($context))' : 'null'; - $context->write('try {')->indent(); - $context->writeOutput($tag.'->renderBlocks($context, $this->'.$forBody.'(...), '.$elseBody.')'); - $context->writeNodeErrorHandling($this->lineNumber()); + $context->write('yield '.$tag.'->renderBlocks($context, '.$forBody.', '.$elseBody.');'); } public function renderBlocks(RenderContext $context, ?Closure $forBody = null, ?Closure $elseBody = null): string diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index 54a0ac1..b1e6eff 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -55,9 +55,7 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { - $context->write('try {')->indent(); $this->compileConditions($context, $this->conditions); - $context->writeNodeErrorHandling($this->lineNumber()); } /** @@ -70,7 +68,8 @@ protected function compileConditions(CompilerContext $context, array $conditions if ($isElse && $first) { if ($condition->body !== null) { - $context->subcompile($condition->body); + $body = $context->compileBodyToMethod($condition->body); + $context->write('yield from $this->'.$body.'($context);'); } break; @@ -87,7 +86,8 @@ protected function compileConditions(CompilerContext $context, array $conditions $context->indent(); if ($condition->body !== null) { - $context->subcompile($condition->body); + $body = $context->compileBodyToMethod($condition->body); + $context->write('yield from $this->'.$body.'($context);'); } $context->outdent()->write('}'); diff --git a/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index 1d1789b..fd30eb8 100644 --- a/src/Tags/UnlessTag.php +++ b/src/Tags/UnlessTag.php @@ -40,22 +40,20 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { - $context->write('try {')->indent(); - if ($this->unlessCondition !== null) { $conditionValue = $context->writeRuntimeValue($this->unlessCondition); $context->write('if (! '.$conditionValue.'->evaluate($context)) {'); $context->indent(); if ($this->unlessCondition->body !== null) { - $context->subcompile($this->unlessCondition->body); + $body = $context->compileBodyToMethod($this->unlessCondition->body); + $context->write('yield from $this->'.$body.'($context);'); } $context->outdent()->write('}'); } $this->compileConditions($context, $this->conditions, false); - $context->writeNodeErrorHandling($this->lineNumber()); } public function parseTreeVisitorChildren(): array diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 1f3c157..0b524fa 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -150,14 +150,61 @@ public function name(): ?string return null; } - protected function renderCompiled(RenderContext $context): string + protected function renderCompiled(RenderContext $context): iterable { - return 'compiled body'; + yield 'compiled '; + yield 'body'; } }; expect($compiled->render(new RenderContext))->toBe('compiled body'); - expect(iterator_to_array($compiled->stream(new RenderContext)))->toBe(['compiled body']); + expect(iterator_to_array($compiled->stream(new RenderContext)))->toBe(['compiled ', 'body']); +}); + +test('compiled templates stream generated chunks without an output accumulator', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('Hello {{ name }}!'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('protected function renderCompiled(RenderContext $context): iterable') + ->toContain('yield ') + ->not->toContain('$output') + ->not->toContain('resourceLimits->') + ->not->toContain('try {') + ->not->toContain('catch ('); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $context = $environment->newRenderContext(data: ['name' => 'World']); + + expect(iterator_to_array($compiled->stream($context))) + ->toBe(['Hello ', 'World', '!']); + expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) + ->toBe('Hello World!'); + } finally { + @unlink($compiledPath); + } +}); + +test('compiled empty bodies still satisfy the generator contract', function () { + $environment = EnvironmentFactory::new()->build(); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($environment->parseString(''), $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext()))->toBe(''); + } finally { + @unlink($compiledPath); + } }); test('environment compiles a template to a requireable artifact', function () { @@ -351,8 +398,9 @@ protected function renderCompiled(RenderContext $context): string // Both bodies become methods; the loop itself stays in the tag. expect(file_get_contents($compiledPath)) ->toContain('private function body0') - ->toContain('private function body1') - ->toContain('->renderBlocks($context, $this->body0(...), $this->body1(...))'); + ->toContain('private function body3') + ->toContain('->renderBlocks($context') + ->toContain('collectCompiled'); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; @@ -560,6 +608,29 @@ protected function renderCompiled(RenderContext $context): string } }); +test('compiled streaming continues after handled node errors', function () { + $environment = EnvironmentFactory::new() + ->setStrictVariables(true) + ->setRethrowErrors(false) + ->build(); + $template = $environment->parseString('a{{ missing }}b{{ also_missing }}c', name: 'stream-errors.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $context = $environment->newRenderContext(); + + expect(implode('', iterator_to_array($compiled->stream($context)))) + ->toBe('abc') + ->and($compiled->getErrors())->toHaveCount(2); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering attaches template metadata to rethrown exceptions', function () { $environment = EnvironmentFactory::new() ->setStrictVariables(true) From a0d4e1f74d18f2fff44fbe611e46a8359f20c593 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 12:54:54 +0200 Subject: [PATCH 37/45] refactor: inline compiled generator bodies --- src/Compiler/Compiler.php | 20 +--- src/Compiler/CompilerContext.php | 143 +++++++++++++++-------------- src/Nodes/BodyNode.php | 2 +- src/Tags/CaseTag.php | 6 +- src/Tags/ForTag.php | 18 ++-- src/Tags/IfTag.php | 6 +- src/Tags/UnlessTag.php | 3 +- tests/Integration/CompilerTest.php | 14 ++- 8 files changed, 98 insertions(+), 114 deletions(-) diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 8c09a48..245b57e 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -14,7 +14,6 @@ public function compile(ParsedTemplate $template): string $body = $bodyContext->getSource(); $name = $bodyContext->writeValue($template->root->name); - $methods = $bodyContext->getMethods(); $fallbackValues = $bodyContext->getFallbackValues(); $fallbackValueSource = []; @@ -40,7 +39,7 @@ public function compile(ParsedTemplate $template): string $className = 'Template_'.substr(hash( 'sha256', - $name.$body.implode('', $methods).implode('', $fallbackValueSource), + $name.$body.implode('', $fallbackValueSource), ), 0, 32); $builder = new CodeBuilder; @@ -101,23 +100,6 @@ public function compile(ParsedTemplate $template): string ->dedent() ->writeLine('}'); - foreach ($methods as $methodName => $methodSource) { - $builder - ->writeLine() - ->writeLine('private function '.$methodName.'(RenderContext $context): \\Generator') - ->writeLine('{') - ->indent(); - - foreach (explode("\n", rtrim($methodSource, "\n")) as $line) { - $builder->writeLine($line); - } - - $builder - ->writeLine('yield from [];') - ->dedent() - ->writeLine('}'); - } - $builder ->dedent() ->writeLine('}') diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 08d18c2..9313ef6 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -22,62 +22,71 @@ final class CompilerContext */ private array $fallbackValues = []; - /** - * Bodies a runtime tag drives itself, compiled to their own method so the - * tag keeps its loop and scope handling while the body stops being walked. - * - * @var array - */ - private array $methods = []; - public function __construct(private CodeBuilder $builder = new CodeBuilder) {} /** - * Compiles $body into a standalone method and returns its name, so a tag - * that cannot be compiled itself can still be handed a compiled body. + * Compile a body into an inline lazy generator owned by the base template. */ - public function compileBodyToMethod(Node $body): string + public function compileBody(Node $body): static { - $name = 'body'.count($this->methods); - $rawName = 'body'.(count($this->methods) + 1); - // Reserve both names before compiling: nested bodies must not reuse them. - $this->methods[$name] = ''; - $this->methods[$rawName] = ''; + $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; + $source = $this->compileBodySource($body); - $outerBuilder = $this->builder; + $this->write(sprintf( + 'yield from $this->yieldBody($context, %s, (function () use ($context): \\Generator {', + $this->writeValue($renderScore), + )); + $this->indent(); + $this->writeSource($source); + $this->write('yield from [];'); + $this->outdent()->write('})());'); + + return $this; + } + + public function writeBodyCallback(Node $body, string $suffix = ''): static + { + $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; + $source = $this->compileBodySource($body); + + $this->write(sprintf( + 'fn (RenderContext $context) => $this->collectCompiled($context, $this->yieldBody($context, %s, (function () use ($context): \\Generator {', + $this->writeValue($renderScore), + )); + $this->indent(); + $this->writeSource($source); + $this->write('yield from [];'); + $this->outdent()->write('})()))'.$suffix); + + return $this; + } + private function compileBodySource(Node $body): string + { + $outerBuilder = $this->builder; $this->builder = new CodeBuilder; try { $this->subcompile($body); - $this->methods[$rawName] = $this->builder->getSource(); + return $this->builder->getSource(); } finally { $this->builder = $outerBuilder; } - - $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; - $this->methods[$name] = sprintf( - 'yield from $this->yieldBody($context, %s, $this->%s($context));', - $this->writeValue($renderScore), - $rawName, - ); - - return $name; } - /** - * @return array - */ - public function getMethods(): array + private function writeSource(string $source): void { - return $this->methods; + foreach (explode("\n", rtrim($source, "\n")) as $line) { + if ($line !== '') { + $this->write($line); + } + } } public function compileRootBody(BodyNode $body): void { - $method = $this->compileBodyToMethod($body); - $this->write('yield from $this->'.$method.'($context);'); + $this->compileBody($body); } public function write(string $line = ''): static @@ -149,50 +158,46 @@ public function subcompile(Node $node): static return $this; } - $method = $this->compileNodeToMethod($node); - - $this->write(sprintf( - 'yield from $this->yieldNode($context, %s, $this->%s($context));', - $this->writeValue($node->lineNumber()), - $method, - )); + $this->compileNode($node); return $this; } - private function compileNodeToMethod(Node $node): string + private function compileNode(Node $node): void { - $name = 'node'.count($this->methods); - $this->methods[$name] = ''; - - $outerBuilder = $this->builder; - $this->builder = new CodeBuilder; + $checkpoint = $this->builder->checkpoint(); + $fallbackValueCount = count($this->fallbackValues); try { - $checkpoint = $this->builder->checkpoint(); - $fallbackValueCount = count($this->fallbackValues); - $methods = $this->methods; - - try { - if ($node instanceof Variable && $this->canCompileVariable($node)) { - $this->compileVariable($node); - } elseif ($node instanceof CanBeCompiled) { - $this->writeLineComment($node->lineNumber()); - $node->compile($this); - } else { - $this->compileFallback($node); - } - } catch (\Throwable) { - $this->rollbackCompilation($checkpoint, $fallbackValueCount, $methods); + $this->write(sprintf( + 'yield from $this->yieldNode($context, %s, (function () use ($context): \\Generator {', + $this->writeValue($node->lineNumber()), + )); + $this->indent(); + + if ($node instanceof Variable && $this->canCompileVariable($node)) { + $this->compileVariable($node); + } elseif ($node instanceof CanBeCompiled) { + $this->writeLineComment($node->lineNumber()); + $node->compile($this); + } else { $this->compileFallback($node); } - $this->methods[$name] = $this->builder->getSource(); - } finally { - $this->builder = $outerBuilder; + $this->write('yield from [];'); + $this->outdent()->write('})());'); + } catch (\Throwable) { + $this->rollbackCompilation($checkpoint, $fallbackValueCount); + + $this->write(sprintf( + 'yield from $this->yieldNode($context, %s, (function () use ($context): \\Generator {', + $this->writeValue($node->lineNumber()), + )); + $this->indent(); + $this->compileFallback($node); + $this->write('yield from [];'); + $this->outdent()->write('})());'); } - - return $name; } /** @@ -340,13 +345,11 @@ private function rollbackFallbackValues(int $count): void * Restore compiler state after a node's direct or native compiler path fails. * * @param array{sourceLength:int,indentLevel:int} $checkpoint - * @param array $methods */ - private function rollbackCompilation(array $checkpoint, int $fallbackValueCount, array $methods): void + private function rollbackCompilation(array $checkpoint, int $fallbackValueCount): void { $this->builder->rollback($checkpoint); $this->rollbackFallbackValues($fallbackValueCount); - $this->methods = $methods; } public function getSource(): string diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 8a3b70e..e6412d2 100644 --- a/src/Nodes/BodyNode.php +++ b/src/Nodes/BodyNode.php @@ -46,7 +46,7 @@ public function setChildren(array $children): BodyNode } /** - * The body is compiled into a lazy generator method: the base template owns + * The body is compiled into an inline lazy generator: the base template owns * its error boundary, while the body carries no state that needs its own * scope beyond the render context. * diff --git a/src/Tags/CaseTag.php b/src/Tags/CaseTag.php index 18dce5d..798eff6 100644 --- a/src/Tags/CaseTag.php +++ b/src/Tags/CaseTag.php @@ -77,8 +77,7 @@ public function compile(CompilerContext $context): void if ($isElse && $first) { if ($condition->body !== null) { - $body = $context->compileBodyToMethod($condition->body); - $context->write('yield from $this->'.$body.'($context);'); + $context->compileBody($condition->body); } break; @@ -95,8 +94,7 @@ public function compile(CompilerContext $context): void $context->indent(); if ($condition->body !== null) { - $body = $context->compileBodyToMethod($condition->body); - $context->write('yield from $this->'.$body.'($context);'); + $context->compileBody($condition->body); } $context->outdent()->write('}'); diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index 6e4b90d..148aacb 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -87,17 +87,17 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { $tag = $context->writeRuntimeValue($this); - $forBodyMethod = $context->compileBodyToMethod($this->forBlock); - $elseBodyMethod = $this->elseBlock !== null - ? $context->compileBodyToMethod($this->elseBlock) - : null; + $context->write('yield '.$tag.'->renderBlocks($context,'); + $context->indent(); + $context->writeBodyCallback($this->forBlock, ','); - $forBody = 'fn (RenderContext $context) => $this->collectCompiled($context, $this->'.$forBodyMethod.'($context))'; - $elseBody = $elseBodyMethod !== null - ? 'fn (RenderContext $context) => $this->collectCompiled($context, $this->'.$elseBodyMethod.'($context))' - : 'null'; + if ($this->elseBlock !== null) { + $context->writeBodyCallback($this->elseBlock); + } else { + $context->write('null'); + } - $context->write('yield '.$tag.'->renderBlocks($context, '.$forBody.', '.$elseBody.');'); + $context->outdent()->write(');'); } public function renderBlocks(RenderContext $context, ?Closure $forBody = null, ?Closure $elseBody = null): string diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index b1e6eff..4d234d9 100644 --- a/src/Tags/IfTag.php +++ b/src/Tags/IfTag.php @@ -68,8 +68,7 @@ protected function compileConditions(CompilerContext $context, array $conditions if ($isElse && $first) { if ($condition->body !== null) { - $body = $context->compileBodyToMethod($condition->body); - $context->write('yield from $this->'.$body.'($context);'); + $context->compileBody($condition->body); } break; @@ -86,8 +85,7 @@ protected function compileConditions(CompilerContext $context, array $conditions $context->indent(); if ($condition->body !== null) { - $body = $context->compileBodyToMethod($condition->body); - $context->write('yield from $this->'.$body.'($context);'); + $context->compileBody($condition->body); } $context->outdent()->write('}'); diff --git a/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index fd30eb8..15e89f2 100644 --- a/src/Tags/UnlessTag.php +++ b/src/Tags/UnlessTag.php @@ -46,8 +46,7 @@ public function compile(CompilerContext $context): void $context->indent(); if ($this->unlessCondition->body !== null) { - $body = $context->compileBodyToMethod($this->unlessCondition->body); - $context->write('yield from $this->'.$body.'($context);'); + $context->compileBody($this->unlessCondition->body); } $context->outdent()->write('}'); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 0b524fa..30c71b3 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -173,7 +173,10 @@ protected function renderCompiled(RenderContext $context): iterable expect($compiledSource) ->toContain('protected function renderCompiled(RenderContext $context): iterable') ->toContain('yield ') + ->toContain('(function () use ($context): \\Generator') ->not->toContain('$output') + ->not->toContain('private function body') + ->not->toContain('private function node') ->not->toContain('resourceLimits->') ->not->toContain('try {') ->not->toContain('catch ('); @@ -387,7 +390,7 @@ protected function renderCompiled(RenderContext $context): iterable 'nested loops' => ['{% for i in (1..3) %}{% for j in (1..3) %}{{ i }}{{ j }}{% if j == 2 %}{% break %}{% endif %}{% endfor %}|{% endfor %}', []], ]); -test('for bodies are compiled into methods the tag drives', function () { +test('for bodies are compiled inline while the tag drives the loop', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{% for i in items %}{{ i }}{% else %}none{% endfor %}'); $compiledPath = temporaryCompiledTemplatePath(); @@ -395,12 +398,13 @@ protected function renderCompiled(RenderContext $context): iterable try { $environment->compile($template, $compiledPath); - // Both bodies become methods; the loop itself stays in the tag. + // Both bodies are inline generator closures; the loop itself stays in the tag. expect(file_get_contents($compiledPath)) - ->toContain('private function body0') - ->toContain('private function body3') + ->toContain('(function () use ($context): \\Generator') ->toContain('->renderBlocks($context') - ->toContain('collectCompiled'); + ->toContain('collectCompiled') + ->not->toContain('private function body') + ->not->toContain('private function node'); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; From 814c9589d62a325caebb367cf24b30756bec98e6 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 13:08:29 +0200 Subject: [PATCH 38/45] refactor: stream compiled fallback nodes --- src/Compiler/CodeBuilder.php | 20 +++++++++++++-- src/Compiler/Compiler.php | 1 - src/Compiler/CompilerContext.php | 41 ++++++++++++++++++++++-------- tests/Integration/CompilerTest.php | 3 ++- tests/Integration/StreamTest.php | 17 +++++++++---- 5 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/Compiler/CodeBuilder.php b/src/Compiler/CodeBuilder.php index feda2bf..5861a9d 100644 --- a/src/Compiler/CodeBuilder.php +++ b/src/Compiler/CodeBuilder.php @@ -8,6 +8,8 @@ class CodeBuilder protected string $source = ''; + protected int $yieldCount = 0; + public function indent(): static { $this->indentLevel++; @@ -41,27 +43,41 @@ public function writeRaw(string $fragment): static } /** - * @return array{sourceLength:int,indentLevel:int} + * @return array{sourceLength:int,indentLevel:int,yieldCount:int} */ public function checkpoint(): array { return [ 'sourceLength' => strlen($this->source), 'indentLevel' => $this->indentLevel, + 'yieldCount' => $this->yieldCount, ]; } /** - * @param array{sourceLength:int,indentLevel:int} $checkpoint + * @param array{sourceLength:int,indentLevel:int,yieldCount:int} $checkpoint */ public function rollback(array $checkpoint): static { $this->source = substr($this->source, 0, $checkpoint['sourceLength']); $this->indentLevel = $checkpoint['indentLevel']; + $this->yieldCount = $checkpoint['yieldCount']; return $this; } + public function markYield(): static + { + $this->yieldCount++; + + return $this; + } + + public function yieldCount(): int + { + return $this->yieldCount; + } + /** * @return string[] */ diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php index 245b57e..7f917b7 100644 --- a/src/Compiler/Compiler.php +++ b/src/Compiler/Compiler.php @@ -96,7 +96,6 @@ public function compile(ParsedTemplate $template): string } $builder - ->writeLine('yield from [];') ->dedent() ->writeLine('}'); diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 9313ef6..6eb787e 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -4,6 +4,7 @@ use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeExported; +use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\Disableable; use Keepsuit\Liquid\Nodes\BodyNode; use Keepsuit\Liquid\Nodes\Document; @@ -37,8 +38,10 @@ public function compileBody(Node $body): static $this->writeValue($renderScore), )); $this->indent(); - $this->writeSource($source); - $this->write('yield from [];'); + $this->writeSource($source['source']); + if (! $source['hasYield']) { + $this->write('yield from [];'); + } $this->outdent()->write('})());'); return $this; @@ -54,14 +57,19 @@ public function writeBodyCallback(Node $body, string $suffix = ''): static $this->writeValue($renderScore), )); $this->indent(); - $this->writeSource($source); - $this->write('yield from [];'); + $this->writeSource($source['source']); + if (! $source['hasYield']) { + $this->write('yield from [];'); + } $this->outdent()->write('})()))'.$suffix); return $this; } - private function compileBodySource(Node $body): string + /** + * @return array{source:string,hasYield:bool} + */ + private function compileBodySource(Node $body): array { $outerBuilder = $this->builder; $this->builder = new CodeBuilder; @@ -69,7 +77,10 @@ private function compileBodySource(Node $body): string try { $this->subcompile($body); - return $this->builder->getSource(); + return [ + 'source' => $this->builder->getSource(), + 'hasYield' => $this->builder->yieldCount() > 0, + ]; } finally { $this->builder = $outerBuilder; } @@ -93,6 +104,10 @@ public function write(string $line = ''): static { $this->builder->writeLine($line); + if (str_starts_with(ltrim($line), 'yield ')) { + $this->builder->markYield(); + } + return $this; } @@ -174,6 +189,7 @@ private function compileNode(Node $node): void $this->writeValue($node->lineNumber()), )); $this->indent(); + $nodeBodyCheckpoint = $this->builder->checkpoint(); if ($node instanceof Variable && $this->canCompileVariable($node)) { $this->compileVariable($node); @@ -184,7 +200,9 @@ private function compileNode(Node $node): void $this->compileFallback($node); } - $this->write('yield from [];'); + if ($this->builder->yieldCount() === $nodeBodyCheckpoint['yieldCount']) { + $this->write('yield from [];'); + } $this->outdent()->write('})());'); } catch (\Throwable) { $this->rollbackCompilation($checkpoint, $fallbackValueCount); @@ -195,7 +213,6 @@ private function compileNode(Node $node): void )); $this->indent(); $this->compileFallback($node); - $this->write('yield from [];'); $this->outdent()->write('})());'); } } @@ -214,7 +231,11 @@ public function compileFallback(Node $node): void $this->write($value.'->ensureTagIsEnabled($context);'); } - $this->writeOutput($value.'->render($context)'); + if ($node instanceof CanBeStreamed) { + $this->write('yield from '.$value.'->stream($context);'); + } else { + $this->writeOutput($value.'->render($context)'); + } } private function compileVariable(Variable $node): void @@ -344,7 +365,7 @@ private function rollbackFallbackValues(int $count): void /** * Restore compiler state after a node's direct or native compiler path fails. * - * @param array{sourceLength:int,indentLevel:int} $checkpoint + * @param array{sourceLength:int,indentLevel:int,yieldCount:int} $checkpoint */ private function rollbackCompilation(array $checkpoint, int $fallbackValueCount): void { diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 30c71b3..6b70f6b 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -175,6 +175,7 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('yield ') ->toContain('(function () use ($context): \\Generator') ->not->toContain('$output') + ->not->toContain('yield from [];') ->not->toContain('private function body') ->not->toContain('private function node') ->not->toContain('resourceLimits->') @@ -324,7 +325,7 @@ protected function renderCompiled(RenderContext $context): iterable // The render tag stays a runtime node: the partial is looked up when the // compiled template runs, never inlined into the artifact. expect($compiledSource) - ->toContain('->render($context)') + ->toContain('->stream($context)') ->not->toContain('partial '); /** @var CompiledTemplate $compiled */ diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 56a6daf..998e86c 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -1,5 +1,6 @@ newRenderContext()); $optimized = streamChunks($compiled, $environment->newRenderContext()); - expect(implode('', $optimized)) - ->toBe(implode('', $interpreted)) - ->toBe('beforeruntimeafter'); + expect($optimized) + ->toBe($interpreted) + ->toBe(['before', 'runtime1', 'runtime2', 'after']); }); test('compiled stream preserves interrupts', function () { From 39e5718cf8e7b54dd2f486c8c5fcb86a4d793c9e Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 14:56:20 +0200 Subject: [PATCH 39/45] perf: stream compiled for loop bodies --- src/Compiler/CompiledTemplate.php | 58 +++++++-------- src/Compiler/CompilerContext.php | 115 +++++++---------------------- src/Nodes/Variable.php | 28 ++----- src/Render/RenderContext.php | 17 +++++ src/Tags/ForTag.php | 67 ++++++++++++++++- src/Tags/RenderTag.php | 25 ++++++- tests/Integration/CompilerTest.php | 110 ++++++++++++++++++++++----- tests/Integration/StreamTest.php | 15 ++++ 8 files changed, 272 insertions(+), 163 deletions(-) diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index 2259e81..a1f34c3 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -8,8 +8,8 @@ use Keepsuit\Liquid\Exceptions\UndefinedDropMethodException; use Keepsuit\Liquid\Exceptions\UndefinedFilterException; use Keepsuit\Liquid\Exceptions\UndefinedVariableException; -use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Render\RenderContext; +use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TemplateSharedState; use Throwable; @@ -74,47 +74,39 @@ protected function yieldNode(RenderContext $context, ?int $lineNumber, Generator } } - /** - * Account for a compiled body and forward its chunks unchanged. - * - * @param Generator $body - * @return \Generator - */ - protected function yieldBody(RenderContext $context, int $renderScore, Generator $body): \Generator + protected function incrementCompiledRenderScore(RenderContext $context, int $renderScore): void { $context->resourceLimits->incrementRenderScore($renderScore); - - foreach ($body as $chunk) { - yield (string) $chunk; - } } /** - * Collect a compiled body when a runtime tag still owns a string-based - * render loop. + * Stream a statically compiled render tag while retaining Liquid's partial + * isolation and runtime partial lookup semantics. * - * @param Generator $body + * @param array $attributes + * @return \Generator */ - protected function collectCompiled(RenderContext $context, Generator $body): string - { - $output = ''; - - foreach ($body as $chunk) { - $output .= (string) $chunk; + protected function yieldPartial( + RenderContext $context, + string $templateName, + mixed $variable, + ?string $aliasName, + array $attributes, + ): \Generator { + $partial = $context->loadPartial($templateName); + $partialName = $partial->name() ?? ''; + + $contextVariableName = $aliasName ?? Arr::last(explode('/', $partialName)); + assert(is_string($contextVariableName)); + + $partialContext = $context->newIsolatedSubContext($partialName); + $partialContext->set($contextVariableName, $context->evaluate($variable)); + + foreach ($attributes as $key => $value) { + $partialContext->set($key, $context->evaluate($value)); } - return $output; - } - - /** - * Render a common variable directly while retaining Liquid lookup semantics. - * - * @param array $lookups - * @param array}> $filters - */ - protected function renderCompiledVariable(RenderContext $context, string $name, array $lookups, array $filters): string - { - return Variable::renderParts($context, $name, $lookups, $filters); + yield from $partial->stream($partialContext); } abstract public function name(): ?string; diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 6eb787e..05e4df2 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -12,7 +12,6 @@ use Keepsuit\Liquid\Nodes\Raw; use Keepsuit\Liquid\Nodes\Text; use Keepsuit\Liquid\Nodes\Variable; -use Keepsuit\Liquid\Nodes\VariableLookup; use Keepsuit\Liquid\Tag; use Symfony\Component\VarExporter\VarExporter; @@ -26,7 +25,8 @@ final class CompilerContext public function __construct(private CodeBuilder $builder = new CodeBuilder) {} /** - * Compile a body into an inline lazy generator owned by the base template. + * Compile a body inline while keeping render-score accounting in the base + * compiled template. */ public function compileBody(Node $body): static { @@ -34,15 +34,10 @@ public function compileBody(Node $body): static $source = $this->compileBodySource($body); $this->write(sprintf( - 'yield from $this->yieldBody($context, %s, (function () use ($context): \\Generator {', + '$this->incrementCompiledRenderScore($context, %s);', $this->writeValue($renderScore), )); - $this->indent(); $this->writeSource($source['source']); - if (! $source['hasYield']) { - $this->write('yield from [];'); - } - $this->outdent()->write('})());'); return $this; } @@ -53,15 +48,18 @@ public function writeBodyCallback(Node $body, string $suffix = ''): static $source = $this->compileBodySource($body); $this->write(sprintf( - 'fn (RenderContext $context) => $this->collectCompiled($context, $this->yieldBody($context, %s, (function () use ($context): \\Generator {', - $this->writeValue($renderScore), + 'fn (RenderContext $context) => (function () use ($context): \\Generator {', )); $this->indent(); + $this->write(sprintf( + '$this->incrementCompiledRenderScore($context, %s);', + $this->writeValue($renderScore), + )); $this->writeSource($source['source']); if (! $source['hasYield']) { $this->write('yield from [];'); } - $this->outdent()->write('})()))'.$suffix); + $this->outdent()->write('})()'.$suffix); return $this; } @@ -97,7 +95,18 @@ private function writeSource(string $source): void public function compileRootBody(BodyNode $body): void { - $this->compileBody($body); + $renderScore = count($body->children()); + $source = $this->compileBodySource($body); + + $this->write(sprintf( + '$this->incrementCompiledRenderScore($context, %s);', + $this->writeValue($renderScore), + )); + $this->writeSource($source['source']); + + if (! $source['hasYield']) { + $this->write('yield from [];'); + } } public function write(string $line = ''): static @@ -162,7 +171,7 @@ public function writeLineComment(?int $lineNumber): static public function canInterrupt(Node $node): bool { - return ! ($node instanceof Text || $node instanceof Raw || ($node instanceof Variable && $this->canCompileVariable($node))); + return ! ($node instanceof Text || $node instanceof Raw || $node instanceof Variable); } public function subcompile(Node $node): static @@ -190,11 +199,9 @@ private function compileNode(Node $node): void )); $this->indent(); $nodeBodyCheckpoint = $this->builder->checkpoint(); + $this->writeLineComment($node->lineNumber()); - if ($node instanceof Variable && $this->canCompileVariable($node)) { - $this->compileVariable($node); - } elseif ($node instanceof CanBeCompiled) { - $this->writeLineComment($node->lineNumber()); + if ($node instanceof CanBeCompiled) { $node->compile($this); } else { $this->compileFallback($node); @@ -212,6 +219,7 @@ private function compileNode(Node $node): void $this->writeValue($node->lineNumber()), )); $this->indent(); + $this->writeLineComment($node->lineNumber()); $this->compileFallback($node); $this->outdent()->write('})());'); } @@ -225,8 +233,6 @@ public function compileFallback(Node $node): void { $value = $this->writeRuntimeValue($node); - $this->writeLineComment($node->lineNumber()); - if ($node instanceof Disableable && $node instanceof Tag) { $this->write($value.'->ensureTagIsEnabled($context);'); } @@ -238,65 +244,18 @@ public function compileFallback(Node $node): void } } - private function compileVariable(Variable $node): void - { - assert($node->name instanceof VariableLookup); - - $this->writeLineComment($node->lineNumber()); - $this->writeOutput(sprintf( - '$this->renderCompiledVariable($context, %s, %s, %s)', - $this->writeValue($node->name->name), - $this->writeValue($node->name->lookups), - $this->writeValue($node->filters), - )); - } - - private function canCompileVariable(Variable $node): bool - { - if (! $node->name instanceof VariableLookup) { - return false; - } - - foreach ($node->name->lookups as $lookup) { - if (! is_string($lookup) && ! is_int($lookup)) { - return false; - } - } - - foreach ($node->filters as $filter) { - if (! is_array($filter) || count($filter) !== 3 || ! is_string($filter[0])) { - return false; - } - - foreach ([$filter[1], $filter[2]] as $arguments) { - if (! is_array($arguments)) { - return false; - } - - foreach ($arguments as $argument) { - if ($argument !== null && ! is_scalar($argument)) { - return false; - } - } - } - } - - return true; - } - public function writeValue(mixed $value): string { if ($value instanceof CanBeExported && ($exported = $value->export($this)) !== null) { return $exported; } - // Arrays are only taken apart when they actually hold an exportable - // value; otherwise VarExporter's output is both smaller and faster. - if (is_array($value) && $this->containsExportable($value)) { + if (is_array($value)) { $entries = []; + $isList = array_is_list($value); foreach ($value as $key => $item) { - $entries[] = $this->writeValue($key).' => '.$this->writeValue($item); + $entries[] = ($isList ? '' : $this->writeValue($key).' => ').$this->writeValue($item); } return '['.implode(', ', $entries).']'; @@ -309,24 +268,6 @@ public function writeValue(mixed $value): string } } - /** - * @param array $value - */ - private function containsExportable(array $value): bool - { - foreach ($value as $item) { - if ($item instanceof CanBeExported) { - return true; - } - - if (is_array($item) && $this->containsExportable($item)) { - return true; - } - } - - return false; - } - public function exportValue(mixed $value): ?string { try { diff --git a/src/Nodes/Variable.php b/src/Nodes/Variable.php index 499740b..f62d052 100644 --- a/src/Nodes/Variable.php +++ b/src/Nodes/Variable.php @@ -3,8 +3,8 @@ namespace Keepsuit\Liquid\Nodes; use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeEvaluated; -use Keepsuit\Liquid\Contracts\CanBeExported; use Keepsuit\Liquid\Contracts\CanBeRendered; use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; @@ -15,7 +15,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class Variable extends Node implements CanBeEvaluated, CanBeExported, CanBeStreamed, HasParseTreeVisitorChildren +class Variable extends Node implements CanBeCompiled, CanBeEvaluated, CanBeStreamed, HasParseTreeVisitorChildren { public function __construct( /** @var Expression $name */ @@ -29,29 +29,17 @@ public function render(RenderContext $context): string return self::renderEvaluated($context, $this->evaluate($context)); } - /** - * Render a variable from its parsed parts without rebuilding a Variable node. - * - * @param array $lookups - * @param array}> $filters - */ - public static function renderParts(RenderContext $context, string $name, array $lookups, array $filters): string - { - return self::renderEvaluated( - $context, - self::applyFilters($context, VariableLookup::evaluateParts($context, $name, $lookups), $filters), - ); - } - - public function export(CompilerContext $context): ?string + public function compile(CompilerContext $context): void { $expression = 'new \\'.self::class.'(' .$context->writeValue($this->name).', ' .$context->writeValue($this->filters).')'; - return $this->lineNumber === null - ? $expression - : '('.$expression.')->setLineNumber('.$this->lineNumber.')'; + if ($this->lineNumber !== null) { + $expression = '('.$expression.')->setLineNumber('.$this->lineNumber.')'; + } + + $context->write('yield from ('.$expression.')->stream($context);'); } public function stream(RenderContext $context): \Generator diff --git a/src/Render/RenderContext.php b/src/Render/RenderContext.php index e22654f..d8b8226 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -147,6 +147,23 @@ public function stack(Closure $closure) return $result; } + /** + * Execute a generator while keeping a temporary scope active until the + * generator is fully consumed. + * + * @param Closure(RenderContext): \Generator $closure + */ + public function streamStack(Closure $closure): \Generator + { + $this->push(); + + try { + yield from $closure($this); + } finally { + $this->pop(); + } + } + public function evaluate(mixed $value): mixed { while ($value instanceof CanBeEvaluated) { diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index 148aacb..0c5e1dd 100644 --- a/src/Tags/ForTag.php +++ b/src/Tags/ForTag.php @@ -81,13 +81,12 @@ public function render(RenderContext $context): string /** * The loop, scope and interrupt handling stay here rather than being emitted - * as code: only the two bodies are compiled, and they are handed back as - * closures. Passing none renders the parsed bodies. + * as code: only the two bodies are compiled and streamed through closures. */ public function compile(CompilerContext $context): void { $tag = $context->writeRuntimeValue($this); - $context->write('yield '.$tag.'->renderBlocks($context,'); + $context->write('yield from '.$tag.'->streamBlocks($context,'); $context->indent(); $context->writeBodyCallback($this->forBlock, ','); @@ -111,6 +110,23 @@ public function renderBlocks(RenderContext $context, ?Closure $forBody = null, ? return $this->renderSegment($context, $segment, $forBody); } + public function streamBlocks(RenderContext $context, ?Closure $forBody = null, ?Closure $elseBody = null): \Generator + { + $segment = $this->collectionSegment($context); + + if ($segment === []) { + if ($elseBody !== null) { + yield from $elseBody($context); + } elseif ($this->elseBlock !== null) { + yield from $this->elseBlock->stream($context); + } + + return; + } + + yield from $this->streamSegment($context, $segment, $forBody); + } + public function children(): array { return $this->elseBlock ? [$this->forBlock, $this->elseBlock] : [$this->forBlock]; @@ -240,6 +256,51 @@ protected function renderSegment(RenderContext $context, array $segment, ?Closur }); } + protected function streamSegment(RenderContext $context, array $segment, ?Closure $forBody = null): \Generator + { + /** @var ForLoopDrop[] $forStack */ + $forStack = $context->getRegister('for_stack') ?? []; + assert(is_array($forStack)); + + yield from $context->streamStack(function () use ($context, $segment, $forStack, $forBody): \Generator { + $loopVars = new ForLoopDrop( + name: $this->name, + length: count($segment), + parentLoop: $forStack !== [] ? $forStack[count($forStack) - 1] : null, + ); + + $forStack[] = $loopVars; + $context->setRegister('for_stack', $forStack); + + try { + $context->set('forloop', $loopVars); + + foreach ($segment as $value) { + $context->set($this->variableName, $value); + + if ($forBody !== null) { + yield from $forBody($context); + } else { + yield from $this->forBlock->stream($context); + } + + $loopVars->increment(); + + $interrupt = $context->popInterrupt(); + + if ($interrupt instanceof BreakInterrupt) { + break; + } + } + } finally { + $forStack = $context->getRegister('for_stack'); + assert(is_array($forStack)); + array_pop($forStack); + $context->setRegister('for_stack', $forStack); + } + }); + } + protected function renderElse(RenderContext $context): string { return $this->elseBlock?->render($context) ?? ''; diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index c163283..48e3ece 100644 --- a/src/Tags/RenderTag.php +++ b/src/Tags/RenderTag.php @@ -2,6 +2,8 @@ namespace Keepsuit\Liquid\Tags; +use Keepsuit\Liquid\Compiler\CompilerContext; +use Keepsuit\Liquid\Contracts\CanBeCompiled; use Keepsuit\Liquid\Contracts\CanBeStreamed; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\ForLoopDrop; @@ -19,7 +21,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class RenderTag extends Tag implements CanBeStreamed, HasParseTreeVisitorChildren +class RenderTag extends Tag implements CanBeCompiled, CanBeStreamed, HasParseTreeVisitorChildren { protected string|VariableLookup $templateNameExpression; @@ -141,6 +143,27 @@ public function render(RenderContext $context): string return $output; } + /** + * Compile the common static partial form without rebuilding the tag object + * in the generated template. + */ + public function compile(CompilerContext $context): void + { + if ($this->isForLoop || ! is_string($this->templateNameExpression)) { + $context->compileFallback($this); + + return; + } + + $context->write(sprintf( + 'yield from $this->yieldPartial($context, %s, %s, %s, %s);', + $context->writeValue($this->templateNameExpression), + $context->writeValue($this->variableNameExpression), + $context->writeValue($this->aliasName), + $context->writeValue($this->attributes), + )); + } + public function stream(RenderContext $context): \Generator { $partial = $this->loadPartial($context); diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 6b70f6b..122b208 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -175,6 +175,7 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('yield ') ->toContain('(function () use ($context): \\Generator') ->not->toContain('$output') + ->not->toContain('yieldBody') ->not->toContain('yield from [];') ->not->toContain('private function body') ->not->toContain('private function node') @@ -308,7 +309,7 @@ protected function renderCompiled(RenderContext $context): iterable } }); -test('compiled templates keep runtime partial lookup', function () { +test('compiled static render tags stream partials without rebuilding the tag', function () { $environment = EnvironmentFactory::new() ->setFilesystem(new \Keepsuit\Liquid\Tests\Stubs\StubFileSystem([ 'snippet' => 'partial {{ value }}', @@ -322,10 +323,12 @@ protected function renderCompiled(RenderContext $context): iterable $compiledSource = file_get_contents($compiledPath); - // The render tag stays a runtime node: the partial is looked up when the - // compiled template runs, never inlined into the artifact. expect($compiledSource) - ->toContain('->stream($context)') + ->toContain('yieldPartial') + ->not->toContain('deepclone_from_array') + ->not->toContain('private readonly mixed $value') + // The partial is looked up when the compiled template runs, never + // inlined into the artifact. ->not->toContain('partial '); /** @var CompiledTemplate $compiled */ @@ -343,6 +346,36 @@ protected function renderCompiled(RenderContext $context): iterable } }); +test('compiled render tag fallback preserves loop behavior', function () { + $environment = EnvironmentFactory::new() + ->setFilesystem(new \Keepsuit\Liquid\Tests\Stubs\StubFileSystem([ + 'product' => '{{ product.title }} ', + ])) + ->build(); + $template = $environment->parseString('{% render "product" for products %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('->stream($context)') + ->toContain('private readonly mixed $value'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $data = ['products' => [['title' => 'one'], ['title' => 'two']]]; + + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))) + ->toBe('one two '); + } finally { + @unlink($compiledPath); + } +}); + test('compiled conditional bodies preserve interrupts from fallback nodes', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{% if stop %}{% break %}{% endif %}after'); @@ -402,8 +435,9 @@ protected function renderCompiled(RenderContext $context): iterable // Both bodies are inline generator closures; the loop itself stays in the tag. expect(file_get_contents($compiledPath)) ->toContain('(function () use ($context): \\Generator') - ->toContain('->renderBlocks($context') - ->toContain('collectCompiled') + ->toContain('->streamBlocks($context') + ->not->toContain('collectCompiled') + ->not->toContain('yieldBody') ->not->toContain('private function body') ->not->toContain('private function node'); @@ -455,7 +489,7 @@ protected function renderCompiled(RenderContext $context): iterable $environment->compile($template, $compiledPath); expect(file_get_contents($compiledPath)) - ->not->toContain('new \Keepsuit\Liquid\Nodes\Variable(') + ->toContain('new \Keepsuit\Liquid\Nodes\Variable(') ->toContain('new \Keepsuit\Liquid\Nodes\VariableLookup(') ->toContain('new \Keepsuit\Liquid\Condition\Condition(') ->not->toContain('deepclone_from_array'); @@ -709,6 +743,37 @@ protected function renderCompiled(RenderContext $context): iterable } }); +test('compiled bodies preserve root and nested render-score accounting', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{% if enabled %}a{{ name }}b{% endif %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $interpretedContext = $environment->newRenderContext( + data: ['enabled' => true, 'name' => 'value'], + resourceLimits: new ResourceLimits(renderScoreLimit: 3), + ); + $compiledContext = $environment->newRenderContext( + data: ['enabled' => true, 'name' => 'value'], + resourceLimits: new ResourceLimits(renderScoreLimit: 3), + ); + + expect(fn () => $template->render($interpretedContext)) + ->toThrow(ResourceLimitException::class); + expect(fn () => $compiled->render($compiledContext)) + ->toThrow(ResourceLimitException::class); + expect($compiledContext->resourceLimits->getRenderScore()) + ->toBe($interpretedContext->resourceLimits->getRenderScore()) + ->toBe(4); + } finally { + @unlink($compiledPath); + } +}); + test('compiled rendering emits safe core nodes directly', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('Hello {{ name | upcase }}!'); @@ -752,11 +817,10 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('use Keepsuit\\Liquid\\Compiler\\CompiledTemplate;') ->toContain('use Keepsuit\\Liquid\\Render\\RenderContext;') ->toContain('extends CompiledTemplate') - ->toContain('renderCompiledVariable') + ->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') ->toContain('// line 4') ->toContain("'size'") ->not->toContain('private readonly mixed $value') - ->not->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') ->not->toContain('do {'); /** @var CompiledTemplate $compiled */ @@ -775,7 +839,7 @@ protected function renderCompiled(RenderContext $context): iterable } }); -test('complex compiled variables retain the runtime fallback', function () { +test('complex compiled variables stream directly', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{{ values[key] }}'); $compiledPath = temporaryCompiledTemplatePath(); @@ -785,7 +849,9 @@ protected function renderCompiled(RenderContext $context): iterable $environment->compile($template, $compiledPath); $compiledSource = file_get_contents($compiledPath); - expect($compiledSource)->toContain('private readonly mixed $value0'); + expect($compiledSource) + ->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') + ->not->toContain('private readonly mixed $value0'); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; @@ -806,7 +872,7 @@ protected function renderCompiled(RenderContext $context): iterable $environment->compile($template, $compiledPath); expect(file_get_contents($compiledPath)) - ->toContain('renderCompiledVariable') + ->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') ->not->toContain('private readonly mixed $value'); /** @var CompiledTemplate $compiled */ @@ -827,7 +893,7 @@ protected function renderCompiled(RenderContext $context): iterable 'renderable value' => ['{{ value }}', ['value' => new Text('rendered')], 'rendered'], ]); -test('storefront header keeps runtime partial rendering with direct values', function () { +test('storefront header compiles static partial rendering with direct values', function () { $environment = StorefrontTheme::environment(); $template = $environment->parseTemplate('snippets.page.header'); $data = [ @@ -842,8 +908,10 @@ protected function renderCompiled(RenderContext $context): iterable $compiledSource = file_get_contents($compiledPath); expect($compiledSource) - ->toContain('renderCompiledVariable') - ->toContain('private readonly mixed $value'); + ->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') + ->toContain("new \\Keepsuit\\Liquid\\Nodes\\VariableLookup('shop', ['name'])") + ->toContain('yieldPartial') + ->not->toContain('private readonly mixed $value'); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; @@ -894,6 +962,7 @@ protected function renderCompiled(RenderContext $context): iterable $nodes = [ new Text('text'), new Raw('raw'), + new Variable('name'), new Document(new BodyNode), new BodyNode, ]; @@ -903,11 +972,14 @@ protected function renderCompiled(RenderContext $context): iterable } }); -test('values the compiler keeps as objects rebuild themselves without VarExporter', function () { - // A Variable is not compiled to code: its lookup is resolved at runtime, so - // the compiler keeps the object and only needs it rebuilt cheaply. +test('expression values remain exportable while variables compile directly', function () { + $variable = new Variable('name'); + + expect($variable) + ->toBeInstanceOf(CanBeCompiled::class) + ->not->toBeInstanceOf(CanBeExported::class); + $values = [ - new Variable('name'), new VariableLookup('name'), new RangeLookup(1, 5), new Condition(1, '==', 1), diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 998e86c..65b21bd 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -140,6 +140,21 @@ function streamChunks(Template $template, RenderContext $context): array ->toBe("text\ntext1text2"); }); +test('compiled for loops stream each body chunk before the next iteration', function () { + $environment = Environment::default(); + $template = $environment->parseString('{% for item in items %}{{ item }}{% endfor %}'); + $compiled = compileStreamTestTemplate($environment, $template); + $context = $environment->newRenderContext( + staticData: ['items' => ['a', 'bb', 'c']], + resourceLimits: new ResourceLimits(renderLengthLimit: 1), + ); + + $stream = $compiled->stream($context); + + expect($stream->current())->toBe('a'); + expect(fn () => $stream->next())->toThrow(ResourceLimitException::class); +}); + test('compiled stream does not evaluate until the generator is consumed', function () { $environment = Environment::default(); $template = $environment->parseString('{{ value }}'); From 893f65ae01f8463301919328d128ad78ffbf5385 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 15:45:50 +0200 Subject: [PATCH 40/45] refactor: use lazy iterable closures for compiled node boundaries - Preserve node-level error handling for yieldless compiled nodes - Keep compatibility with legacy compiled node generators --- src/Compiler/CompiledTemplate.php | 11 +-- src/Compiler/CompilerContext.php | 20 ++--- tests/Integration/CompilerTest.php | 128 ++++++++++++++++++++++++++++- 3 files changed, 140 insertions(+), 19 deletions(-) diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php index a1f34c3..95d1547 100644 --- a/src/Compiler/CompiledTemplate.php +++ b/src/Compiler/CompiledTemplate.php @@ -2,6 +2,7 @@ namespace Keepsuit\Liquid\Compiler; +use Closure; use Generator; use Keepsuit\Liquid\AbstractTemplate; use Keepsuit\Liquid\Exceptions\LiquidException; @@ -55,16 +56,16 @@ final public function stream(RenderContext $context): \Generator /** * Execute one lazily-created compiled node under Liquid's configured error - * handling policy. The generator is created by the generated template but - * does not execute until this method iterates it. + * handling policy. The generated closure is only invoked while this method + * owns the node-level error boundary. * - * @param Generator $node + * @param (Closure(): iterable)|Generator $node * @return \Generator */ - protected function yieldNode(RenderContext $context, ?int $lineNumber, Generator $node): \Generator + protected function yieldNode(RenderContext $context, ?int $lineNumber, Closure|Generator $node): \Generator { try { - foreach ($node as $chunk) { + foreach ($node instanceof Closure ? $node() : $node as $chunk) { yield (string) $chunk; } } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index 05e4df2..bc47a73 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -47,9 +47,7 @@ public function writeBodyCallback(Node $body, string $suffix = ''): static $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; $source = $this->compileBodySource($body); - $this->write(sprintf( - 'fn (RenderContext $context) => (function () use ($context): \\Generator {', - )); + $this->write('function (RenderContext $context): iterable {'); $this->indent(); $this->write(sprintf( '$this->incrementCompiledRenderScore($context, %s);', @@ -57,9 +55,9 @@ public function writeBodyCallback(Node $body, string $suffix = ''): static )); $this->writeSource($source['source']); if (! $source['hasYield']) { - $this->write('yield from [];'); + $this->write('return [];'); } - $this->outdent()->write('})()'.$suffix); + $this->outdent()->write('}'.$suffix); return $this; } @@ -105,7 +103,7 @@ public function compileRootBody(BodyNode $body): void $this->writeSource($source['source']); if (! $source['hasYield']) { - $this->write('yield from [];'); + $this->write('return [];'); } } @@ -194,7 +192,7 @@ private function compileNode(Node $node): void try { $this->write(sprintf( - 'yield from $this->yieldNode($context, %s, (function () use ($context): \\Generator {', + 'yield from $this->yieldNode($context, %s, function () use ($context): iterable {', $this->writeValue($node->lineNumber()), )); $this->indent(); @@ -208,20 +206,20 @@ private function compileNode(Node $node): void } if ($this->builder->yieldCount() === $nodeBodyCheckpoint['yieldCount']) { - $this->write('yield from [];'); + $this->write('return [];'); } - $this->outdent()->write('})());'); + $this->outdent()->write('});'); } catch (\Throwable) { $this->rollbackCompilation($checkpoint, $fallbackValueCount); $this->write(sprintf( - 'yield from $this->yieldNode($context, %s, (function () use ($context): \\Generator {', + 'yield from $this->yieldNode($context, %s, function () use ($context): iterable {', $this->writeValue($node->lineNumber()), )); $this->indent(); $this->writeLineComment($node->lineNumber()); $this->compileFallback($node); - $this->outdent()->write('})());'); + $this->outdent()->write('});'); } } diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 122b208..70ce868 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -119,6 +119,22 @@ public function compile(CompilerContext $context): void } } +class RuntimeThrowingCompilableCompilerTestNode extends Node implements CanBeCompiled +{ + public function render(RenderContext $context): string + { + throw new RuntimeException('compiler test runtime failure'); + } + + public function compile(CompilerContext $context): void + { + $context->write(sprintf( + 'throw new \\RuntimeException(%s);', + $context->writeValue('compiler test runtime failure'), + )); + } +} + class UnsafeFallbackCompilerTestNode extends Node { public function __construct(private readonly mixed $value) {} @@ -173,7 +189,7 @@ protected function renderCompiled(RenderContext $context): iterable expect($compiledSource) ->toContain('protected function renderCompiled(RenderContext $context): iterable') ->toContain('yield ') - ->toContain('(function () use ($context): \\Generator') + ->toContain('function () use ($context): iterable {') ->not->toContain('$output') ->not->toContain('yieldBody') ->not->toContain('yield from [];') @@ -196,17 +212,22 @@ protected function renderCompiled(RenderContext $context): iterable } }); -test('compiled empty bodies still satisfy the generator contract', function () { +test('compiled empty bodies return an empty iterable without generator noise', function () { $environment = EnvironmentFactory::new()->build(); $compiledPath = temporaryCompiledTemplatePath(); try { $environment->compile($environment->parseString(''), $compiledPath); + $compiledSource = file_get_contents($compiledPath); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; + expect($compiledSource) + ->toContain('return [];') + ->not->toContain('yield from [];'); expect($compiled->render($environment->newRenderContext()))->toBe(''); + expect(iterator_to_array($compiled->stream($environment->newRenderContext())))->toBe([]); } finally { @unlink($compiledPath); } @@ -327,6 +348,7 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('yieldPartial') ->not->toContain('deepclone_from_array') ->not->toContain('private readonly mixed $value') + ->not->toContain('yield from [];') // The partial is looked up when the compiled template runs, never // inlined into the artifact. ->not->toContain('partial '); @@ -362,6 +384,7 @@ protected function renderCompiled(RenderContext $context): iterable expect($compiledSource) ->toContain('->stream($context)') + ->not->toContain('yieldPartial') ->toContain('private readonly mixed $value'); /** @var CompiledTemplate $compiled */ @@ -434,10 +457,11 @@ protected function renderCompiled(RenderContext $context): iterable // Both bodies are inline generator closures; the loop itself stays in the tag. expect(file_get_contents($compiledPath)) - ->toContain('(function () use ($context): \\Generator') + ->toContain('function (RenderContext $context): iterable {') ->toContain('->streamBlocks($context') ->not->toContain('collectCompiled') ->not->toContain('yieldBody') + ->not->toContain('yield from [];') ->not->toContain('private function body') ->not->toContain('private function node'); @@ -453,6 +477,34 @@ protected function renderCompiled(RenderContext $context): iterable } }); +test('empty compiled for bodies use empty iterables instead of empty generators', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('{% for i in items %}{% else %}{% endfor %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('function (RenderContext $context): iterable {') + ->not->toContain('yield from [];'); + expect(substr_count($compiledSource ?: '', 'return [];'))->toBe(2); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + foreach ([['items' => ['a']], ['items' => []]] as $data) { + expect($compiled->render($environment->newRenderContext(data: $data))) + ->toBe($template->render($environment->newRenderContext(data: $data))) + ->toBe(''); + } + } finally { + @unlink($compiledPath); + } +}); + test('compiled for loops match parsed rendering', function (string $source, array $data) { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString($source); @@ -821,6 +873,7 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('// line 4') ->toContain("'size'") ->not->toContain('private readonly mixed $value') + ->not->toContain('yield from [];') ->not->toContain('do {'); /** @var CompiledTemplate $compiled */ @@ -911,6 +964,7 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('new \\Keepsuit\\Liquid\\Nodes\\Variable(') ->toContain("new \\Keepsuit\\Liquid\\Nodes\\VariableLookup('shop', ['name'])") ->toContain('yieldPartial') + ->not->toContain('yield from [];') ->not->toContain('private readonly mixed $value'); /** @var CompiledTemplate $compiled */ @@ -1176,6 +1230,74 @@ protected function renderCompiled(RenderContext $context): iterable } }); +test('yieldless compiled nodes still use the node error boundary', function () { + $environment = EnvironmentFactory::new() + ->setRethrowErrors(false) + ->build(); + $template = $environment->parseString('prefixsuffix', name: 'yieldless-node.liquid'); + assert($template instanceof ParsedTemplate); + $template->root->body->setChildren([ + new Text('prefix'), + (new RuntimeThrowingCompilableCompilerTestNode)->setLineNumber(7), + new Text('suffix'), + ]); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('function () use ($context): iterable {') + ->not->toContain('yield from [];'); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $interpretedContext = $environment->newRenderContext(); + $compiledContext = $environment->newRenderContext(); + + expect($compiled->render($compiledContext)) + ->toBe($template->render($interpretedContext)); + expect($compiled->getErrors())->toHaveCount(1); + expect($compiled->getErrors()[0]->lineNumber) + ->toBe($template->getErrors()[0]->lineNumber) + ->toBe(7); + } finally { + @unlink($compiledPath); + } +}); + +test('legacy compiled node generators remain supported', function () { + $environment = EnvironmentFactory::new() + ->setRethrowErrors(false) + ->build(); + $compiled = new class extends CompiledTemplate + { + public function name(): ?string + { + return 'legacy-artifact.liquid'; + } + + protected function renderCompiled(RenderContext $context): iterable + { + yield 'before'; + yield from $this->yieldNode($context, 7, (function (): \Generator { + yield 'legacy'; + + throw new RuntimeException('legacy node failure'); + })()); + yield 'after'; + } + }; + $context = $environment->newRenderContext(); + + expect($compiled->render($context)) + ->toBe('beforelegacyLiquid error (line 7): Internal exceptionafter'); + expect($compiled->getErrors())->toHaveCount(1); + expect($compiled->getErrors()[0]->lineNumber)->toBe(7); +}); + test('compilation fails when a fallback node cannot be safely reconstructed', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('prefix', name: 'unsafe.liquid'); From b5d144da67bfa55404cf2635925aab46f27077e0 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:09:54 +0200 Subject: [PATCH 41/45] updated bench --- performance/benchmarks/ThemeBench.php | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/performance/benchmarks/ThemeBench.php b/performance/benchmarks/ThemeBench.php index e036423..9f645e0 100644 --- a/performance/benchmarks/ThemeBench.php +++ b/performance/benchmarks/ThemeBench.php @@ -95,8 +95,24 @@ public function benchRenderCompiled(): void public function benchStream(): void { foreach ($this->pageTemplateNames as $pageTemplateName) { - foreach (StorefrontTheme::streamPage($this->environment, $pageTemplateName) as $chunk) { - } + $this->drain(StorefrontTheme::streamPage($this->environment, $pageTemplateName)); + } + } + + public function benchStreamCompiled(): void + { + foreach ($this->pageTemplateNames as $pageTemplateName) { + $this->drain(StorefrontTheme::streamPage($this->compiledEnvironment, $pageTemplateName)); + } + } + + /** + * @param \Generator $stream + */ + private function drain(\Generator $stream): void + { + while ($stream->valid()) { + $stream->next(); } } } From 89bb072bb4f76e3be6b3e6b9b3aadcfcabf51833 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:12:33 +0200 Subject: [PATCH 42/45] run bench on draft pr --- .github/workflows/phpbench.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/phpbench.yml b/.github/workflows/phpbench.yml index d51f5dc..134c5ac 100644 --- a/.github/workflows/phpbench.yml +++ b/.github/workflows/phpbench.yml @@ -2,11 +2,6 @@ name: PHPBench PR Benchmark on: pull_request: - types: - - opened - - synchronize - - reopened - - ready_for_review permissions: contents: read @@ -28,7 +23,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4', '8.5'] + php: [ '8.2', '8.3', '8.4', '8.5' ] name: PHPBench (PHP ${{ matrix.php }}) steps: From bad1bb0b9a0ca9a1fda63c80a0165ecc820f2bec Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:32:54 +0200 Subject: [PATCH 43/45] removed var exporter requirement --- composer.json | 6 +- src/Compiler/CompilerContext.php | 101 +++++++++++++++++++++++++---- src/Contracts/CanBeExported.php | 9 ++- src/Nodes/Raw.php | 2 +- src/Nodes/Text.php | 2 +- tests/Integration/CompilerTest.php | 10 ++- 6 files changed, 104 insertions(+), 26 deletions(-) diff --git a/composer.json b/composer.json index cd03399..c297566 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,7 @@ "require": { "php": "^8.2", "ext-mbstring": "*", - "symfony/polyfill-php85": "^1.33", - "symfony/var-exporter": "^7.0 || ^8.0" + "symfony/polyfill-php85": "^1.33" }, "require-dev": { "laravel/pint": "^1.2", @@ -30,7 +29,8 @@ "phpstan/phpstan-deprecation-rules": "^2.0", "spatie/invade": "^2.0", "spatie/ray": "^1.28", - "symfony/console": "^7.0 || ^8.0" + "symfony/console": "^7.0 || ^8.0", + "symfony/var-exporter": "^7.0 || ^8.0" }, "autoload": { "psr-4": { diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php index bc47a73..5bac195 100644 --- a/src/Compiler/CompilerContext.php +++ b/src/Compiler/CompilerContext.php @@ -13,7 +13,6 @@ use Keepsuit\Liquid\Nodes\Text; use Keepsuit\Liquid\Nodes\Variable; use Keepsuit\Liquid\Tag; -use Symfony\Component\VarExporter\VarExporter; final class CompilerContext { @@ -248,6 +247,10 @@ public function writeValue(mixed $value): string return $exported; } + if (is_string($value)) { + return $this->writeExpressionString($value); + } + if (is_array($value)) { $entries = []; $isList = array_is_list($value); @@ -259,19 +262,66 @@ public function writeValue(mixed $value): string return '['.implode(', ', $entries).']'; } + if (is_object($value)) { + return $this->writeSerializedObject($value); + } + + if (is_resource($value)) { + throw new \RuntimeException('Unable to safely encode a compiler value containing a resource.'); + } + + return var_export($value, true); + } + + private function writeSerializedObject(object $value): string + { + // Keep generated artifacts independent from Symfony's object exporter. + $this->assertNoResources($value); + try { - return VarExporter::export($value); + $serialized = serialize($value); } catch (\Throwable $exception) { throw new \RuntimeException('Unable to safely encode a compiler value.', previous: $exception); } + + return '\\unserialize('.$this->writeValue($serialized).')'; } - public function exportValue(mixed $value): ?string + /** + * @param array $seenObjects + */ + private function assertNoResources(mixed $value, array &$seenObjects = []): void { - try { - return VarExporter::export($value); - } catch (\Throwable) { - return null; + if (is_resource($value)) { + throw new \RuntimeException('Unable to safely encode a compiler value containing a resource.'); + } + + if (is_array($value)) { + foreach ($value as $item) { + $this->assertNoResources($item, $seenObjects); + } + + return; + } + + if (! is_object($value)) { + return; + } + + $objectId = spl_object_id($value); + if (isset($seenObjects[$objectId])) { + return; + } + + $seenObjects[$objectId] = true; + $reflection = new \ReflectionObject($value); + + foreach ($reflection->getProperties() as $property) { + if ($property->isStatic() || ! $property->isInitialized($value)) { + continue; + } + + $this->assertNoResources($property->getValue($value), $seenObjects); } } @@ -319,14 +369,39 @@ public function getSource(): string private function writeLiteral(string $value): string { - if (preg_match('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/', $value) === 1) { - return $this->writeValue($value); + $value = strtr($value, [ + '\\' => '\\\\', + '"' => '\\"', + '$' => '\\$', + "\n" => '\\n', + "\r" => '\\r', + "\t" => '\\t', + "\v" => '\\v', + "\e" => '\\e', + "\f" => '\\f', + ]); + + $value = preg_replace_callback( + '/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/', + static fn (array $match): string => sprintf('\\x%02X', ord($match[0])), + $value, + ); + + assert($value !== null); + + return '"'.$value.'"'; + } + + private function writeExpressionString(string $value): string + { + if (preg_match('/[\\x00-\\x1F\\x7F]/', $value) === 1) { + return $this->writeLiteral($value); } - return '"'.str_replace( - ['\\', '"', '$', "\n"], - ['\\\\', '\\"', '\\$', '\\n'], + return "'".str_replace( + ['\\', "'"], + ['\\\\', "\\'"], $value, - ).'"'; + )."'"; } } diff --git a/src/Contracts/CanBeExported.php b/src/Contracts/CanBeExported.php index dd8b504..e5cef73 100644 --- a/src/Contracts/CanBeExported.php +++ b/src/Contracts/CanBeExported.php @@ -7,10 +7,9 @@ /** * A value that can rebuild itself from a plain constructor call. * - * The compiler otherwise falls back to VarExporter, which reconstructs an object - * graph by writing properties directly. That works for any shape but costs a - * hydration pass every time the compiled template is instantiated, so the values - * that dominate a real template describe themselves instead. + * The compiler otherwise falls back to native PHP serialization, which + * reconstructs an object graph every time the compiled template is instantiated. + * Values that dominate a real template should describe themselves instead. * * What gets rebuilt is what the compiled template reads: sub-nodes the compiler * has already turned into code (a condition body, a loop body) are not part of @@ -20,7 +19,7 @@ interface CanBeExported { /** * A PHP expression that reconstructs this value, or null to let the compiler - * fall back to VarExporter. + * fall back to native PHP serialization. * * Nested values must go through $context->writeValue() so they get the same * treatment. diff --git a/src/Nodes/Raw.php b/src/Nodes/Raw.php index e1329aa..0681ef3 100644 --- a/src/Nodes/Raw.php +++ b/src/Nodes/Raw.php @@ -20,7 +20,7 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { - $context->writeOutput($context->writeValue($this->value)); + $context->writeText($this->value); } public function blank(): bool diff --git a/src/Nodes/Text.php b/src/Nodes/Text.php index acbdb05..43e2cf3 100644 --- a/src/Nodes/Text.php +++ b/src/Nodes/Text.php @@ -21,7 +21,7 @@ public function render(RenderContext $context): string public function compile(CompilerContext $context): void { - $context->writeOutput($context->writeValue($this->value)); + $context->writeText($this->value); } public function blank(): bool diff --git a/tests/Integration/CompilerTest.php b/tests/Integration/CompilerTest.php index 70ce868..9b93b76 100644 --- a/tests/Integration/CompilerTest.php +++ b/tests/Integration/CompilerTest.php @@ -532,7 +532,7 @@ protected function renderCompiled(RenderContext $context): iterable 'continue skips' => ['{% for i in items %}{% if i == 2 %}{% continue %}{% endif %}{{ i }}{% endfor %}', ['items' => [1, 2, 3]]], ]); -test('exportable nodes are rebuilt with constructors instead of VarExporter', function () { +test('exportable nodes are rebuilt with constructors instead of serialization', function () { $environment = EnvironmentFactory::new()->build(); $template = $environment->parseString('{{ product.title | upcase }}{% if a > 1 %}x{% endif %}'); $compiledPath = temporaryCompiledTemplatePath(); @@ -544,6 +544,7 @@ protected function renderCompiled(RenderContext $context): iterable ->toContain('new \Keepsuit\Liquid\Nodes\Variable(') ->toContain('new \Keepsuit\Liquid\Nodes\VariableLookup(') ->toContain('new \Keepsuit\Liquid\Condition\Condition(') + ->not->toContain('\unserialize(') ->not->toContain('deepclone_from_array'); /** @var CompiledTemplate $compiled */ @@ -558,7 +559,7 @@ protected function renderCompiled(RenderContext $context): iterable } }); -test('a node that cannot describe itself still falls back to VarExporter', function () { +test('a node that cannot describe itself falls back to native serialization', function () { $environment = EnvironmentFactory::new()->build(); // A chained condition needs statements, so Condition::export() declines it. $template = $environment->parseString('{% if a > 1 and b %}yes{% else %}no{% endif %}'); @@ -567,7 +568,10 @@ protected function renderCompiled(RenderContext $context): iterable try { $environment->compile($template, $compiledPath); - expect(file_get_contents($compiledPath))->toContain('deepclone_from_array'); + expect(file_get_contents($compiledPath)) + ->toContain('\unserialize(') + ->not->toContain('deepclone_from_array') + ->not->toContain('Symfony\Component\VarExporter'); /** @var CompiledTemplate $compiled */ $compiled = require $compiledPath; From 19c0da649e468267b2f92846c02678c6832ed600 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:36:40 +0200 Subject: [PATCH 44/45] run benchmark on draft pr --- .github/workflows/phpbench.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/phpbench.yml b/.github/workflows/phpbench.yml index 134c5ac..86e4635 100644 --- a/.github/workflows/phpbench.yml +++ b/.github/workflows/phpbench.yml @@ -18,7 +18,6 @@ env: jobs: benchmark: - if: github.event.pull_request.draft == false runs-on: ubuntu-latest strategy: fail-fast: false From 60be1cc5e13d66b4e95fa95dab9e22aa8c90b1b3 Mon Sep 17 00:00:00 2001 From: Fabio Capucci Date: Mon, 3 Aug 2026 16:44:15 +0200 Subject: [PATCH 45/45] report branch-only benchmark rows - Show PR-only benchmarks with throughput in comparison output - Add regression coverage and document the updated behavior --- performance/README.md | 8 +-- .../Unit/Performance/PhpBenchCompareTest.php | 17 +++++ tools/phpbench-compare.php | 62 ++++++++++++++++--- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/performance/README.md b/performance/README.md index 95fba7a..0815f09 100644 --- a/performance/README.md +++ b/performance/README.md @@ -52,10 +52,10 @@ php tools/phpbench-compare.php build/base.json /tmp/php-liquid-compiler.json ``` The current `build/base.json` contains only the four `ThemeBench` default-group -rows, so compiler rows are reported as branch-only and are not treated as an -improvement or regression. Establish a matching compiler baseline on `main` -before drawing compiler performance conclusions; the ignored baseline artifact -is intentionally not part of the repository. +rows, so compiler rows appear as branch-only rows with their PR throughput and +are not treated as an improvement or regression. Establish a matching compiler +baseline on `main` before drawing compiler performance conclusions; the ignored +baseline artifact is intentionally not part of the repository. The split is what lets the theme be realistic. Whenever realism and measurement sensitivity conflict inside the theme, realism wins — sensitivity is not the diff --git a/tests/Unit/Performance/PhpBenchCompareTest.php b/tests/Unit/Performance/PhpBenchCompareTest.php index 05235d0..ecf320c 100644 --- a/tests/Unit/Performance/PhpBenchCompareTest.php +++ b/tests/Unit/Performance/PhpBenchCompareTest.php @@ -108,5 +108,22 @@ function runPhpBenchCompare(array $base, array $pr, ?string $threshold = null): expect($result['exit_code'])->toBe(0) ->and($result['stdout'])->toContain('No comparable benchmark rows') + ->toContain('| CompilerBench::benchCompiledRender | - | 1,000.00 ops/s | - | - | - | - |') ->toContain('Branch-only subjects (missing in base result): `CompilerBench::benchCompiledRender`'); }); + +test('the PHPBench comparator includes branch-only subjects with comparable rows', function () { + $branchOnlyRow = phpBenchAggregateRow(mode: 500.0); + $branchOnlyRow['benchmark'] = 'CompilerBench'; + $branchOnlyRow['subject'] = 'benchCompiledStream'; + + $result = runPhpBenchCompare( + base: [phpBenchAggregateRow()], + pr: [phpBenchAggregateRow(), $branchOnlyRow], + ); + + expect($result['exit_code'])->toBe(0) + ->and($result['stdout']) + ->toContain('| CompilerBench::benchCompiledStream | - | 2,000.00 ops/s | - | - | - | - |') + ->toContain('Missing in base result: `CompilerBench::benchCompiledStream`'); +}); diff --git a/tools/phpbench-compare.php b/tools/phpbench-compare.php index 42eced2..ff15a8f 100644 --- a/tools/phpbench-compare.php +++ b/tools/phpbench-compare.php @@ -15,12 +15,12 @@ $sharedNames = array_values(array_intersect(array_keys($baseBenchmarks), array_keys($prBenchmarks))); sort($sharedNames); +$missingInPr = array_values(array_diff(array_keys($baseBenchmarks), array_keys($prBenchmarks))); +$missingInBase = array_values(array_diff(array_keys($prBenchmarks), array_keys($baseBenchmarks))); -if ($sharedNames === []) { +if ($sharedNames === [] && $missingInBase === []) { echo "> No comparable benchmark rows: establish a matching baseline on `main` before drawing performance conclusions.\n\n"; - $missingInPr = array_values(array_diff(array_keys($baseBenchmarks), array_keys($prBenchmarks))); - $missingInBase = array_values(array_diff(array_keys($prBenchmarks), array_keys($baseBenchmarks))); if ($missingInPr !== []) { sort($missingInPr); echo '- Missing in PR result: `'.implode('`, `', $missingInPr).'`'."\n"; @@ -116,6 +116,28 @@ 'prMemory' => $pr['memory'], 'memoryDelta' => $pr['memory'] - $base['memory'], 'memoryDeltaPercent' => $memoryDeltaPercent, + 'prOnly' => false, + ]; +} + +foreach ($missingInBase as $name) { + $pr = $prBenchmarks[$name]; + $prOpsPerSecond = abs($pr['time']) > PHP_FLOAT_EPSILON + ? 1_000_000 / $pr['time'] + : null; + + $rows[] = [ + 'name' => $name, + 'baseOpsPerSecond' => null, + 'prOpsPerSecond' => $prOpsPerSecond, + 'deltaPercent' => null, + 'baseRstdev' => null, + 'prRstdev' => null, + 'baseMemory' => null, + 'prMemory' => null, + 'memoryDelta' => null, + 'memoryDeltaPercent' => null, + 'prOnly' => true, ]; } @@ -126,16 +148,44 @@ : null; $lines = []; -$context = benchmarkContext($baseBenchmarks[$sharedNames[0]]); +$contextBenchmark = $sharedNames !== [] + ? $baseBenchmarks[$sharedNames[0]] + : $prBenchmarks[$missingInBase[0]]; +$context = benchmarkContext($contextBenchmark); if ($context !== null) { $lines[] = $context; $lines[] = ''; } +if ($sharedNames === []) { + $lines[] = '> No comparable benchmark rows: establish a matching baseline on `main` before drawing performance conclusions.'; + $lines[] = ''; + if ($missingInPr !== []) { + sort($missingInPr); + $lines[] = '- Missing in PR result: `'.implode('`, `', $missingInPr).'`'; + } + if ($missingInBase !== []) { + sort($missingInBase); + $lines[] = '- Branch-only subjects (missing in base result): `'.implode('`, `', $missingInBase).'`'; + } + if ($missingInPr !== [] || $missingInBase !== []) { + $lines[] = ''; + } +} $lines[] = '> Positive ops/s is faster. RSD above 5% is marked high.'; $lines[] = ''; $lines[] = '| Benchmark | Base ops/s | PR ops/s | Delta ops/s | RSD (base / PR) | Delta memory | Memory % |'; $lines[] = '|-----------|-----------:|---------:|------------:|----------------:|-------------:|---------:|'; foreach ($rows as $row) { + if ($row['prOnly']) { + $lines[] = sprintf( + '| %s | - | %s | - | - | - | - |', + escapePipe($row['name']), + formatOperationsPerSecond($row['prOpsPerSecond']), + ); + + continue; + } + $lines[] = sprintf( '| %s | %s | %s | %s | %s | %s | %s |', escapePipe($row['name']), @@ -174,9 +224,7 @@ ); $lines[] = sprintf('- Total memory change: **%s**', formatPercent($totalMemoryChange)); -$missingInPr = array_values(array_diff(array_keys($baseBenchmarks), array_keys($prBenchmarks))); -$missingInBase = array_values(array_diff(array_keys($prBenchmarks), array_keys($baseBenchmarks))); -if ($missingInPr !== [] || $missingInBase !== []) { +if ($sharedNames !== [] && ($missingInPr !== [] || $missingInBase !== [])) { $lines[] = ''; if ($missingInPr !== []) { sort($missingInPr);
Details