diff --git a/.github/workflows/phpbench.yml b/.github/workflows/phpbench.yml index d50d0a4..92b6033 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 @@ -23,12 +18,11 @@ env: jobs: benchmark: - if: github.event.pull_request.draft == false runs-on: ubuntu-latest 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: diff --git a/.scratch/compiler/issues/001-artifact-contract.md b/.scratch/compiler/issues/001-artifact-contract.md new file mode 100644 index 0000000..45663d0 --- /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; 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/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..d7ae21d --- /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 `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 new file mode 100644 index 0000000..433b84f --- /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 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 new file mode 100644 index 0000000..5341507 --- /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 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 `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 new file mode 100644 index 0000000..e753312 --- /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 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 new file mode 100644 index 0000000..c832082 --- /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 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. 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 new file mode 100644 index 0000000..ac03bdc --- /dev/null +++ b/.scratch/compiler/map.md @@ -0,0 +1,35 @@ +# 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 + +- Generated PHP line-to-Liquid debug maps beyond preserving Liquid source lines in runtime exceptions. + +## 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. diff --git a/composer.json b/composer.json index 4fe4b70..c9874c4 100644 --- a/composer.json +++ b/composer.json @@ -52,6 +52,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..0815f09 100644 --- a/performance/README.md +++ b/performance/README.md @@ -4,27 +4,59 @@ 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 -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. +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 +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 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 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 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: + +```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 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 theme's job. @@ -103,7 +135,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..9bc57ca --- /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 CompiledTemplate + ? $template + : null; + } +} diff --git a/performance/Support/CompilesThemeTemplates.php b/performance/Support/CompilesThemeTemplates.php new file mode 100644 index 0000000..5823181 --- /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, + ): CompiledTemplate { + $environment->compile($template, $artifactPath); + $compiledTemplate = require $artifactPath; + + if (! $compiledTemplate instanceof CompiledTemplate) { + 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 new file mode 100644 index 0000000..fffa572 --- /dev/null +++ b/performance/benchmarks/CompilerBench.php @@ -0,0 +1,390 @@ + */ + 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; + + private string $freshLoadScript; + + /** + * @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 = $this->newCompiledEnvironment(); + $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); + } + + $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. + $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_file($this->freshLoadScript)) { + unlink($this->freshLoadScript); + } + + 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 + { + $output = []; + $exitCode = 0; + exec( + escapeshellarg(PHP_BINARY).' '.escapeshellarg($this->freshLoadScript), + $output, + $exitCode, + ); + + if ($exitCode !== 0) { + throw new \RuntimeException('Fresh compiled artifact load failed.'); + } + } + + /** + * Prepare filesystem metadata before PHPBench starts timing the isolated load. + */ + 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 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\\CompiledTemplate) {\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 + */ + 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 + */ + private function collect(\Generator $stream): string + { + $output = ''; + foreach ($stream as $chunk) { + $output .= $chunk; + } + + return $output; + } +} diff --git a/performance/benchmarks/TemplateCacheBench.php b/performance/benchmarks/TemplateCacheBench.php index 0d7c3c4..f06671b 100644 --- a/performance/benchmarks/TemplateCacheBench.php +++ b/performance/benchmarks/TemplateCacheBench.php @@ -4,6 +4,8 @@ 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; @@ -24,6 +26,8 @@ #[AfterMethods('clearCache')] class TemplateCacheBench { + use CompilesThemeTemplates; + private const CACHE_DIRECTORY = 'keepsuit-liquid-phpbench'; private Environment $environment; @@ -74,6 +78,12 @@ public function benchLoadAndRenderVarExporter(): void $this->renderCachedTheme(); } + #[BeforeMethods('setUpCompiledCachedRender')] + public function benchLoadAndRenderCompiled(): void + { + $this->renderCachedTheme(); + } + public function setUpInMemoryBuild(): void { $this->setUpBuild('memory'); @@ -104,6 +114,25 @@ 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 = $this->newCompiledEnvironment(); + + foreach ($this->templateNames as $templateName) { + $template = $compilerEnvironment->parseTemplate($templateName); + $this->compileTemplateToPath($compilerEnvironment, $template, $compiledCache->pathFor($templateName)); + } + + $this->environment = StorefrontTheme::environmentFactory() + ->setTemplatesCache($this->cache) + ->build(); + } + public function clearCache(): void { $this->cache->clear(); diff --git a/performance/benchmarks/ThemeBench.php b/performance/benchmarks/ThemeBench.php index df958db..9f645e0 100644 --- a/performance/benchmarks/ThemeBench.php +++ b/performance/benchmarks/ThemeBench.php @@ -3,6 +3,7 @@ namespace Keepsuit\Liquid\Performance\benchmarks; use Keepsuit\Liquid\Environment; +use Keepsuit\Liquid\Performance\Support\CompilesThemeTemplates; use Keepsuit\Liquid\Performance\Support\StorefrontTheme; use PhpBench\Attributes\BeforeMethods; use PhpBench\Attributes\Groups; @@ -30,8 +31,12 @@ #[BeforeMethods('setUp')] class ThemeBench { + use CompilesThemeTemplates; + 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,6 +51,9 @@ class ThemeBench public function setUp(): void { $this->environment = StorefrontTheme::environment(); + $this->compiledEnvironment = $this->newCompiledEnvironment(); + $this->compileThemeTemplates($this->compiledEnvironment, __DIR__.'/cache/compiled'); + $this->sources = []; foreach (StorefrontTheme::templateNames() as $name) { @@ -77,6 +85,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,6 +99,13 @@ public function benchStream(): void } } + public function benchStreamCompiled(): void + { + foreach ($this->pageTemplateNames as $pageTemplateName) { + $this->drain(StorefrontTheme::streamPage($this->compiledEnvironment, $pageTemplateName)); + } + } + /** * @param \Generator $stream */ 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/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/AbstractTemplate.php b/src/AbstractTemplate.php new file mode 100644 index 0000000..07cf2bf --- /dev/null +++ b/src/AbstractTemplate.php @@ -0,0 +1,39 @@ +state; + } + + 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/CodeBuilder.php b/src/Compiler/CodeBuilder.php new file mode 100644 index 0000000..5861a9d --- /dev/null +++ b/src/Compiler/CodeBuilder.php @@ -0,0 +1,97 @@ +indentLevel++; + + return $this; + } + + public function dedent(): static + { + $this->indentLevel = max(0, $this->indentLevel - 1); + + return $this; + } + + 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): static + { + $this->source .= $fragment; + + return $this; + } + + /** + * @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,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[] + */ + public function getLines(): array + { + if ($this->source === '') { + return []; + } + + return explode("\n", rtrim($this->source, "\n")); + } + + public function getSource(): string + { + return $this->source; + } +} diff --git a/src/Compiler/CompiledTemplate.php b/src/Compiler/CompiledTemplate.php new file mode 100644 index 0000000..7bc82f2 --- /dev/null +++ b/src/Compiler/CompiledTemplate.php @@ -0,0 +1,125 @@ +stream($context) as $chunk) { + $output .= $chunk; + } + + return $output; + } + + /** + * @return \Generator + */ + final public function stream(RenderContext $context): \Generator + { + try { + $this->prepareContext($context); + + if ($context->isPartial()) { + foreach ($this->renderCompiled($context) as $chunk) { + yield (string) $chunk; + } + + return; + } + + $context->resourceLimits->resetStreamWriteScore(); + + foreach ($this->renderCompiled($context) as $chunk) { + $chunk = (string) $chunk; + $context->resourceLimits->incrementStreamWriteScore($chunk); + yield $chunk; + } + } catch (LiquidException $e) { + $this->attachTemplateName($e); + throw $e; + } finally { + $this->persistContext($context); + } + } + + /** + * Execute one lazily-created compiled node under Liquid's configured error + * handling policy. The generated closure is only invoked while this method + * owns the node-level error boundary. + * + * @param (Closure(): iterable)|Generator $node + * @return \Generator + */ + protected function yieldNode(RenderContext $context, ?int $lineNumber, Closure|Generator $node): \Generator + { + try { + foreach ($node instanceof Closure ? $node() : $node as $chunk) { + yield (string) $chunk; + } + } catch (UndefinedVariableException|UndefinedDropMethodException|UndefinedFilterException $exception) { + $context->handleError($exception, $lineNumber); + } catch (Throwable $exception) { + yield $context->handleError($exception, $lineNumber); + } + } + + protected function incrementCompiledRenderScore(RenderContext $context, int $renderScore): void + { + $context->resourceLimits->incrementRenderScore($renderScore); + } + + /** + * Stream a statically compiled render tag while retaining Liquid's partial + * isolation and runtime partial lookup semantics. + * + * @param array $attributes + * @return \Generator + */ + 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)); + } + + yield from $partial->stream($partialContext); + } + + abstract public function name(): ?string; + + abstract protected function renderCompiled(RenderContext $context): iterable; +} diff --git a/src/Compiler/Compiler.php b/src/Compiler/Compiler.php new file mode 100644 index 0000000..7f917b7 --- /dev/null +++ b/src/Compiler/Compiler.php @@ -0,0 +1,112 @@ +subcompile($template->root); + + $body = $bodyContext->getSource(); + $name = $bodyContext->writeValue($template->root->name); + $fallbackValues = $bodyContext->getFallbackValues(); + $fallbackValueSource = []; + + foreach ($fallbackValues as $property => $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( + 'sha256', + $name.$body.implode('', $fallbackValueSource), + ), 0, 32); + + $builder = new CodeBuilder; + $builder + ->writeLine('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 CompiledTemplate') + ->writeLine('{') + ->indent(); + + foreach ($fallbackValues as $property => $value) { + $builder->writeLine('private readonly mixed $'.$property.';'); + } + + if ($fallbackValues !== []) { + $builder + ->writeLine('public function __construct(TemplateSharedState $state = new 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 renderCompiled(RenderContext $context): iterable') + ->writeLine('{') + ->indent(); + + foreach (explode("\n", rtrim($body, "\n")) as $line) { + $builder->writeLine($line); + } + + $builder + ->dedent() + ->writeLine('}'); + + $builder + ->dedent() + ->writeLine('}') + ->dedent() + ->writeLine('}') + ->writeLine() + ->writeLine('return new '.$className.';'); + + return $builder->getSource(); + } +} diff --git a/src/Compiler/CompilerContext.php b/src/Compiler/CompilerContext.php new file mode 100644 index 0000000..5bac195 --- /dev/null +++ b/src/Compiler/CompilerContext.php @@ -0,0 +1,407 @@ + + */ + private array $fallbackValues = []; + + public function __construct(private CodeBuilder $builder = new CodeBuilder) {} + + /** + * Compile a body inline while keeping render-score accounting in the base + * compiled template. + */ + public function compileBody(Node $body): static + { + $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; + $source = $this->compileBodySource($body); + + $this->write(sprintf( + '$this->incrementCompiledRenderScore($context, %s);', + $this->writeValue($renderScore), + )); + $this->writeSource($source['source']); + + return $this; + } + + public function writeBodyCallback(Node $body, string $suffix = ''): static + { + $renderScore = $body instanceof BodyNode ? count($body->children()) : 1; + $source = $this->compileBodySource($body); + + $this->write('function (RenderContext $context): iterable {'); + $this->indent(); + $this->write(sprintf( + '$this->incrementCompiledRenderScore($context, %s);', + $this->writeValue($renderScore), + )); + $this->writeSource($source['source']); + if (! $source['hasYield']) { + $this->write('return [];'); + } + $this->outdent()->write('}'.$suffix); + + return $this; + } + + /** + * @return array{source:string,hasYield:bool} + */ + private function compileBodySource(Node $body): array + { + $outerBuilder = $this->builder; + $this->builder = new CodeBuilder; + + try { + $this->subcompile($body); + + return [ + 'source' => $this->builder->getSource(), + 'hasYield' => $this->builder->yieldCount() > 0, + ]; + } finally { + $this->builder = $outerBuilder; + } + } + + private function writeSource(string $source): void + { + foreach (explode("\n", rtrim($source, "\n")) as $line) { + if ($line !== '') { + $this->write($line); + } + } + } + + public function compileRootBody(BodyNode $body): void + { + $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('return [];'); + } + } + + public function write(string $line = ''): static + { + $this->builder->writeLine($line); + + if (str_starts_with(ltrim($line), 'yield ')) { + $this->builder->markYield(); + } + + return $this; + } + + /** + * Write a trusted compiler or plugin fragment without data encoding. + */ + public function raw(string $fragment): static + { + $this->builder->writeRaw($fragment); + + return $this; + } + + public function indent(): static + { + $this->builder->indent(); + + return $this; + } + + public function outdent(): static + { + $this->builder->dedent(); + + return $this; + } + + public function writeOutput(string $expression): static + { + $this->write('yield '.$expression.';'); + + 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); + } + + public function subcompile(Node $node): static + { + if ($node instanceof Text || $node instanceof Raw || $node instanceof BodyNode || $node instanceof Document) { + $node->compile($this); + + return $this; + } + + $this->compileNode($node); + + return $this; + } + + private function compileNode(Node $node): void + { + $checkpoint = $this->builder->checkpoint(); + $fallbackValueCount = count($this->fallbackValues); + + try { + $this->write(sprintf( + 'yield from $this->yieldNode($context, %s, function () use ($context): iterable {', + $this->writeValue($node->lineNumber()), + )); + $this->indent(); + $nodeBodyCheckpoint = $this->builder->checkpoint(); + $this->writeLineComment($node->lineNumber()); + + if ($node instanceof CanBeCompiled) { + $node->compile($this); + } else { + $this->compileFallback($node); + } + + if ($this->builder->yieldCount() === $nodeBodyCheckpoint['yieldCount']) { + $this->write('return [];'); + } + $this->outdent()->write('});'); + } catch (\Throwable) { + $this->rollbackCompilation($checkpoint, $fallbackValueCount); + + $this->write(sprintf( + '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('});'); + } + } + + /** + * 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); + + if ($node instanceof Disableable && $node instanceof Tag) { + $this->write($value.'->ensureTagIsEnabled($context);'); + } + + if ($node instanceof CanBeStreamed) { + $this->write('yield from '.$value.'->stream($context);'); + } else { + $this->writeOutput($value.'->render($context)'); + } + } + + public function writeValue(mixed $value): string + { + if ($value instanceof CanBeExported && ($exported = $value->export($this)) !== null) { + return $exported; + } + + if (is_string($value)) { + return $this->writeExpressionString($value); + } + + if (is_array($value)) { + $entries = []; + $isList = array_is_list($value); + + foreach ($value as $key => $item) { + $entries[] = ($isList ? '' : $this->writeValue($key).' => ').$this->writeValue($item); + } + + 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 { + $serialized = serialize($value); + } catch (\Throwable $exception) { + throw new \RuntimeException('Unable to safely encode a compiler value.', previous: $exception); + } + + return '\\unserialize('.$this->writeValue($serialized).')'; + } + + /** + * @param array $seenObjects + */ + private function assertNoResources(mixed $value, array &$seenObjects = []): void + { + 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); + } + } + + public function writeRuntimeValue(mixed $value): string + { + return $this->registerFallbackValue($value); + } + + /** + * @return array + */ + public function getFallbackValues(): array + { + return $this->fallbackValues; + } + + private function registerFallbackValue(mixed $value): string + { + $property = 'value'.count($this->fallbackValues); + $this->fallbackValues[$property] = $value; + + return '$this->'.$property; + } + + 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,yieldCount:int} $checkpoint + */ + private function rollbackCompilation(array $checkpoint, int $fallbackValueCount): void + { + $this->builder->rollback($checkpoint); + $this->rollbackFallbackValues($fallbackValueCount); + } + + public function getSource(): string + { + return $this->builder->getSource(); + } + + private function writeLiteral(string $value): string + { + $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( + ['\\', "'"], + ['\\\\', "\\'"], + $value, + )."'"; + } +} diff --git a/src/Condition/Condition.php b/src/Condition/Condition.php index ef74460..3e28f8e 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 @@ -29,6 +31,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/CanBeCompiled.php b/src/Contracts/CanBeCompiled.php new file mode 100644 index 0000000..744c1d3 --- /dev/null +++ b/src/Contracts/CanBeCompiled.php @@ -0,0 +1,13 @@ +writeValue() so they get the same + * treatment. + */ + public function export(CompilerContext $context): ?string; +} diff --git a/src/Environment.php b/src/Environment.php index a5ff901..bb99eae 100644 --- a/src/Environment.php +++ b/src/Environment.php @@ -2,6 +2,8 @@ namespace Keepsuit\Liquid; +use Keepsuit\Liquid\Compiler\CompiledTemplate; +use Keepsuit\Liquid\Compiler\Compiler; use Keepsuit\Liquid\Contracts\LiquidErrorHandler; use Keepsuit\Liquid\Contracts\LiquidExtension; use Keepsuit\Liquid\Contracts\LiquidFileSystem; @@ -132,6 +134,68 @@ 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 + { + if (! $template instanceof ParsedTemplate) { + throw new \InvalidArgumentException('Only parsed templates can be compiled.'); + } + + $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)); + } + + $source = (new Compiler)->compile($template); + $temporaryPath = tempnam($directory, '.'.basename($compiledPath).'.tmp-'); + + 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 CompiledTemplate) { + 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)); + } + } + public function addExtension(LiquidExtension $extension): static { $this->extensions[$extension::class] = $extension; diff --git a/src/Nodes/BodyNode.php b/src/Nodes/BodyNode.php index 711b427..29091a5 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 { private const MAX_BUFFERED_BYTES = 4096; @@ -45,6 +47,65 @@ public function setChildren(array $children): BodyNode return $this; } + /** + * 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. + * + * 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 + { + $lastIndex = count($this->children) - 1; + $interruptible = false; + foreach ($this->children as $index => $child) { + if ($index !== $lastIndex && $context->canInterrupt($child)) { + $interruptible = true; + + break; + } + } + + if ($interruptible) { + $context->write('do {')->indent(); + } + + $literal = ''; + + foreach ($this->children as $index => $child) { + if ($child instanceof Text || $child instanceof Raw) { + $literal .= $child->value; + + continue; + } + + 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);'); + } + } + /** * @throws LiquidException */ @@ -111,7 +172,16 @@ public function stream(RenderContext $context): \Generator $node->ensureTagIsEnabled($context); } - if ($node instanceof CanBeStreamed) { + if ($node instanceof CanBeStreamed && ! $node instanceof CanBeCompiled) { + if ($buffer !== '') { + yield $buffer; + $buffer = ''; + } + + foreach ($node->stream($context) as $output) { + yield $output; + } + } elseif ($node instanceof CanBeStreamed) { foreach ($node->stream($context) as $output) { $buffer .= $output; diff --git a/src/Nodes/Document.php b/src/Nodes/Document.php index 0b6d195..ed102db 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): void + { + $context->compileRootBody($this->body); + } + /** * @return \Generator * 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/Raw.php b/src/Nodes/Raw.php index 709f218..0681ef3 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): void + { + $context->writeText($this->value); + } + public function blank(): bool { return false; diff --git a/src/Nodes/Text.php b/src/Nodes/Text.php index 48afc14..43e2cf3 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): void + { + $context->writeText($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..f62d052 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 */ @@ -24,13 +26,20 @@ public function __construct( public function render(RenderContext $context): string { - $output = $this->evaluate($context); + return self::renderEvaluated($context, $this->evaluate($context)); + } - if ($output instanceof CanBeRendered) { - return $output->render($context); + public function compile(CompilerContext $context): void + { + $expression = 'new \\'.self::class.'(' + .$context->writeValue($this->name).', ' + .$context->writeValue($this->filters).')'; + + if ($this->lineNumber !== null) { + $expression = '('.$expression.')->setLineNumber('.$this->lineNumber.')'; } - return $this->renderOutput($output); + $context->write('yield from ('.$expression.')->stream($context);'); } public function stream(RenderContext $context): \Generator @@ -57,13 +66,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 @@ -73,9 +82,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; } @@ -83,17 +98,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); @@ -102,7 +117,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; @@ -125,7 +149,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 9d4d6d3..d19efd0 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 === []) { @@ -75,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; @@ -93,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); @@ -128,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/src/Parse/ParseContext.php b/src/Parse/ParseContext.php index 7b8c65f..854e3f0 100644 --- a/src/Parse/ParseContext.php +++ b/src/Parse/ParseContext.php @@ -8,6 +8,7 @@ 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\TemplateSharedState; @@ -91,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, 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..6d3187c --- /dev/null +++ b/src/ParsedTemplate.php @@ -0,0 +1,73 @@ +prepareContext($context); + + $output = $this->root->render($context); + + if (! $context->isPartial()) { + $context->resourceLimits->incrementWriteScore($output); + } + + return $output; + } catch (LiquidException $e) { + $this->attachTemplateName($e); + throw $e; + } finally { + $this->persistContext($context); + } + } + + /** + * @return \Generator + */ + public function stream(RenderContext $context): \Generator + { + try { + $this->prepareContext($context); + + if ($context->isPartial()) { + yield from $this->root->stream($context); + + return; + } + + $context->resourceLimits->resetStreamWriteScore(); + + foreach ($this->root->stream($context) as $output) { + $context->resourceLimits->incrementStreamWriteScore($output); + yield $output; + } + } 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 4520aee..a45f139 100644 --- a/src/Render/RenderContext.php +++ b/src/Render/RenderContext.php @@ -450,7 +450,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/CaseTag.php b/src/Tags/CaseTag.php index 51c5d25..3e46b75 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\Contracts\CanBeStreamed; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Nodes\BodyNode; @@ -16,7 +18,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class CaseTag extends TagBlock implements CanBeStreamed +class CaseTag extends TagBlock implements CanBeCompiled, CanBeStreamed { /** @var Condition[] */ protected array $conditions = []; @@ -67,6 +69,45 @@ public function render(RenderContext $context): string return ''; } + public function compile(CompilerContext $context): void + { + $first = true; + + foreach ($this->conditions as $condition) { + $isElse = $condition->else(); + + if ($isElse && $first) { + if ($condition->body !== null) { + $context->compileBody($condition->body); + } + + break; + } + + if ($isElse) { + $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->compileBody($condition->body); + } + + $context->outdent()->write('}'); + + if ($isElse) { + break; + } + + $first = false; + } + } + /** * @return \Generator */ diff --git a/src/Tags/ForTag.php b/src/Tags/ForTag.php index cdf020f..e98b8c3 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\CanBeStreamed; use Keepsuit\Liquid\Contracts\HasParseTreeVisitorChildren; use Keepsuit\Liquid\Drops\ForLoopDrop; @@ -23,7 +26,7 @@ /** * @phpstan-import-type Expression from ExpressionParser */ -class ForTag extends TagBlock implements CanBeStreamed, HasParseTreeVisitorChildren +class ForTag extends TagBlock implements CanBeCompiled, CanBeStreamed, HasParseTreeVisitorChildren { protected string $variableName; @@ -72,14 +75,56 @@ 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 streamed through closures. + */ + public function compile(CompilerContext $context): void + { + $tag = $context->writeRuntimeValue($this); + $context->write('yield from '.$tag.'->streamBlocks($context,'); + $context->indent(); + $context->writeBodyCallback($this->forBlock, ','); + + if ($this->elseBlock !== null) { + $context->writeBodyCallback($this->elseBlock); + } else { + $context->write('null'); + } + + $context->outdent()->write(');'); + } + + 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 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); } /** @@ -186,13 +231,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), @@ -207,7 +252,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(); @@ -230,13 +275,13 @@ protected function renderSegment(RenderContext $context, array $segment): string /** * @return \Generator */ - protected function streamSegment(RenderContext $context, array $segment): \Generator + 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->streamedStack(function () use ($context, $segment, $forStack): \Generator { + yield from $context->streamedStack(function () use ($context, $segment, $forStack, $forBody): \Generator { $loopVars = new ForLoopDrop( name: $this->name, length: count($segment), @@ -251,7 +296,13 @@ protected function streamSegment(RenderContext $context, array $segment): \Gener foreach ($segment as $value) { $context->set($this->variableName, $value); - yield from $this->forBlock->stream($context); + + if ($forBody !== null) { + yield from $forBody($context); + } else { + yield from $this->forBlock->stream($context); + } + $loopVars->increment(); $interrupt = $context->popInterrupt(); diff --git a/src/Tags/IfTag.php b/src/Tags/IfTag.php index f38f086..7b4c032 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\Contracts\CanBeStreamed; use Keepsuit\Liquid\Exceptions\SyntaxException; use Keepsuit\Liquid\Parse\TagParseContext; @@ -12,7 +14,7 @@ use Keepsuit\Liquid\Support\Arr; use Keepsuit\Liquid\TagBlock; -class IfTag extends TagBlock implements CanBeStreamed +class IfTag extends TagBlock implements CanBeCompiled, CanBeStreamed { /** @var Condition[] */ protected array $conditions = []; @@ -52,6 +54,11 @@ public function render(RenderContext $context): string return $output; } + public function compile(CompilerContext $context): void + { + $this->compileConditions($context, $this->conditions); + } + /** * @return \Generator */ @@ -62,6 +69,45 @@ public function stream(RenderContext $context): \Generator /** * @param array $conditions + */ + protected function compileConditions(CompilerContext $context, array $conditions, bool $first = true): void + { + foreach ($conditions as $condition) { + $isElse = $condition->else(); + + if ($isElse && $first) { + if ($condition->body !== null) { + $context->compileBody($condition->body); + } + + break; + } + + if ($isElse) { + $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->compileBody($condition->body); + } + + $context->outdent()->write('}'); + + if ($isElse) { + break; + } + + $first = false; + } + } + + /** * @return \Generator */ protected function streamConditions(RenderContext $context, array $conditions): \Generator diff --git a/src/Tags/RenderTag.php b/src/Tags/RenderTag.php index 833e261..e6384d6 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; @@ -102,12 +104,17 @@ 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))); + $contextVariableName = $this->aliasName ?? Arr::last(explode('/', $templateName)); assert(is_string($contextVariableName)); $variable = $this->variableNameExpression ? $context->evaluate($this->variableNameExpression) : null; @@ -135,6 +142,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/src/Tags/UnlessTag.php b/src/Tags/UnlessTag.php index 08e6478..85810ef 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->compileBody($this->unlessCondition->body); + } + + $context->outdent()->write('}'); + } + + $this->compileConditions($context, $this->conditions, false); + } + /** * @return \Generator */ diff --git a/src/Template.php b/src/Template.php index a13af6c..e330ce5 100644 --- a/src/Template.php +++ b/src/Template.php @@ -2,90 +2,23 @@ namespace Keepsuit\Liquid; -use Keepsuit\Liquid\Exceptions\LiquidException; -use Keepsuit\Liquid\Nodes\Document; use Keepsuit\Liquid\Render\RenderContext; -class Template +interface Template { - public function __construct( - public readonly Document $root, - public readonly TemplateSharedState $state = new TemplateSharedState - ) {} + public function render(RenderContext $context): string; /** - * @throws LiquidException + * @return \Generator */ - public function render(RenderContext $context): string - { - try { - $context->mergeOutputs($this->state->outputs); - - $output = $this->root->render($context); - - // Partials are already part of the root output - if (! $context->isPartial()) { - $context->resourceLimits->incrementWriteScore($output); - } + public function stream(RenderContext $context): \Generator; - return $output; - } catch (LiquidException $e) { - $e->templateName = $e->templateName ?? $this->root->name; - throw $e; - } finally { - $this->state->errors = $context->getErrors(); - $this->state->outputs = $context->getOutputs(); - } - } + public function getState(): TemplateSharedState; /** - * @return \Generator + * @return array<\Throwable> */ - public function stream(RenderContext $context): \Generator - { - try { - $context->mergeOutputs($this->state->outputs); - - // Partials are streamed through the root template's loop below - if ($context->isPartial()) { - yield from $this->root->stream($context); - - return; - } - - /* - * The one place every chunk is guaranteed to pass through exactly once, - * whichever node produced it. Two jobs happen here: - * - increment the write score, checked against a running total. - * - renumbering the keys, since nodes delegate with `yield from`, which passes the inner generators' keys through and restarts them at 0 - */ - $context->resourceLimits->resetStreamWriteScore(); - - foreach ($this->root->stream($context) as $output) { - $context->resourceLimits->incrementStreamWriteScore($output); - yield $output; - } - } catch (LiquidException $e) { - $e->templateName = $e->templateName ?? $this->root->name; - throw $e; - } finally { - $this->state->errors = $context->getErrors(); - $this->state->outputs = $context->getOutputs(); - } - } - - public function getState(): TemplateSharedState - { - return $this->state; - } - - public function getErrors(): array - { - return $this->state->errors; - } + public function getErrors(): array; - public function name(): ?string - { - return $this->root->name; - } + public function name(): ?string; } diff --git a/tests/Integration/CompilerArtifactSafetyTest.php b/tests/Integration/CompilerArtifactSafetyTest.php new file mode 100644 index 0000000..6294ddb --- /dev/null +++ b/tests/Integration/CompilerArtifactSafetyTest.php @@ -0,0 +1,99 @@ +build(); + $template = $environment->parseString('safe artifact'); + + try { + $environment->compile($template, $path); + + expect($path)->toBeFile(); + expect(glob($directory.'/.compiled.php.tmp-*'))->toBe([]); + + /** @var CompiledTemplate $compiled */ + $compiled = require $path; + + expect($compiled)->toBeInstanceOf(CompiledTemplate::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('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.'); + } + + 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 new file mode 100644 index 0000000..9b93b76 --- /dev/null +++ b/tests/Integration/CompilerTest.php @@ -0,0 +1,1336 @@ +value; + } + + public function compile(CompilerContext $context): void + { + $context->writeOutput($context->writeValue($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): void + { + $context->writeOutput($context->writeValue('tag output')); + } +} + +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 + { + return 'fallback output'; + } + + public function compile(CompilerContext $context): void + { + $context->writeOutput($context->writeValue('partial output')); + + throw new RuntimeException('compiler test failure'); + } +} + +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) {} + + public function render(RenderContext $context): string + { + return 'unsafe fallback'; + } +} + +function temporaryCompiledTemplatePath(): string +{ + $path = tempnam(sys_get_temp_dir(), 'liquid-compiled-'); + + if ($path === false) { + throw new RuntimeException('Unable to create a temporary compiled template path.'); + } + + unlink($path); + + return $path.'.php'; +} + +test('compiled render and stream both surface the compiled body', function () { + $compiled = new class extends CompiledTemplate + { + public function name(): ?string + { + return null; + } + + protected function renderCompiled(RenderContext $context): iterable + { + yield 'compiled '; + yield 'body'; + } + }; + + expect($compiled->render(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 ') + ->toContain('function () use ($context): iterable {') + ->not->toContain('$output') + ->not->toContain('yieldBody') + ->not->toContain('yield from [];') + ->not->toContain('private function body') + ->not->toContain('private function node') + ->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 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); + } +}); + +test('environment compiles a template to a requireable artifact', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('Hello {{ name }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect($compiledPath)->toBeFile(); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled)->toBeInstanceOf(CompiledTemplate::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); + } +}); + +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 CompiledTemplate $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 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 CompiledTemplate $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 static render tags stream partials without rebuilding the tag', 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('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 '); + + /** @var CompiledTemplate $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 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)') + ->not->toContain('yieldPartial') + ->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'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $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 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('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(); + + try { + $environment->compile($template, $compiledPath); + + // Both bodies are inline generator closures; the loop itself stays in the tag. + expect(file_get_contents($compiledPath)) + ->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'); + + /** @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('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); + $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('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(); + + 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('\unserialize(') + ->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 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 %}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + expect(file_get_contents($compiledPath)) + ->toContain('\unserialize(') + ->not->toContain('deepclone_from_array') + ->not->toContain('Symfony\Component\VarExporter'); + + /** @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) + ->build(); + $template = $environment->parseString('{% if "a" > 1 %}yes{% else %}no{% endif %}', name: 'condition-errors.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $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 }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $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 CompiledTemplate $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 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) + ->setRethrowErrors(true) + ->build(); + $template = $environment->parseString('{{ missing }}', name: 'errors.liquid'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $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 CompiledTemplate $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 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 }}!'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + $compiledSource = file_get_contents($compiledPath); + + expect($compiledSource) + ->toContain('final class Template_') + ->toContain('use Keepsuit\\Liquid\\Compiler\\CompiledTemplate;') + ->toContain('extends CompiledTemplate') + ->toContain('protected function renderCompiled') + ->not->toContain('unserialize') + ->not->toContain('return new \\Keepsuit\\Liquid\\Compiler\\CompiledTemplate('); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext(data: ['name' => 'World']))) + ->toBe('Hello WORLD!'); + } finally { + @unlink($compiledPath); + } +}); + +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('new \\Keepsuit\\Liquid\\Nodes\\Variable(') + ->toContain('// line 4') + ->toContain("'size'") + ->not->toContain('private readonly mixed $value') + ->not->toContain('yield from [];') + ->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 stream directly', 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('new \\Keepsuit\\Liquid\\Nodes\\Variable(') + ->not->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('new \\Keepsuit\\Liquid\\Nodes\\Variable(') + ->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 compiles static 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('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 */ + $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; + + $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('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'), + new Raw('raw'), + new Variable('name'), + new Document(new BodyNode), + new BodyNode, + ]; + + foreach ($nodes as $node) { + expect($node)->toBeInstanceOf(CanBeCompiled::class); + } +}); + +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 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 }}'); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $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 { + ob_start(); + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + $artifactOutput = ob_get_clean(); + + expect($artifactOutput)->toBe(''); + + 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 `backtick` "; + $template = $environment->parseString($literal); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe($template->render($environment->newRenderContext())); + } finally { + @unlink($compiledPath); + } +}); + +test('custom compilable nodes opt in through the compiler context', function () { + $environment = EnvironmentFactory::new()->build(); + $template = $environment->parseString('prefix'); + assert($template instanceof ParsedTemplate); + $template->root->body->pushChild(new CompilableCompilerTestNode('custom output')); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $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'); + assert($template instanceof ParsedTemplate); + $template->root->body->pushChild(new CompilableCompilerTestTag); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + $environment->compile($template, $compiledPath); + + /** @var CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe('prefixtag output'); + } finally { + @unlink($compiledPath); + } +}); + +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 CompiledTemplate $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 CompiledTemplate $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'); + assert($template instanceof ParsedTemplate); + $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 CompiledTemplate $compiled */ + $compiled = require $compiledPath; + + expect($compiled->render($environment->newRenderContext())) + ->toBe('prefixfallback output'); + } finally { + @unlink($compiledPath); + } +}); + +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'); + $resource = fopen('php://memory', 'r'); + + if ($resource === false) { + throw new RuntimeException('Unable to create a test resource.'); + } + + assert($template instanceof ParsedTemplate); + $template->root->body->pushChild( + (new UnsafeFallbackCompilerTestNode($resource))->setLineNumber(7), + ); + $compiledPath = temporaryCompiledTemplatePath(); + + try { + expect(fn () => $environment->compile($template, $compiledPath)) + ->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'); + } finally { + fclose($resource); + + if (is_file($compiledPath)) { + unlink($compiledPath); + } + } +}); diff --git a/tests/Integration/StreamTest.php b/tests/Integration/StreamTest.php index 374c0df..cf013ce 100644 --- a/tests/Integration/StreamTest.php +++ b/tests/Integration/StreamTest.php @@ -1,5 +1,65 @@ 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 @@ -235,3 +295,127 @@ public function handle(\Throwable $error): string expect(implode('', iterator_to_array($template->stream($context)))) ->toBe('axbcx'); }); + +test('compiled stream preserves complete output', 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(implode('', $optimized)) + ->toBe(implode('', $interpreted)) + ->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 }}'); + $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 unsupported tag output', 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', 'runtime1', 'runtime2', 'after']); +}); + +test('compiled stream preserves interrupts', 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(implode('', $optimized)) + ->toBe(implode('', $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(); +}); diff --git a/tests/Integration/TemplateTest.php b/tests/Integration/TemplateTest.php index 73c6aeb..f4badbc 100644 --- a/tests/Integration/TemplateTest.php +++ b/tests/Integration/TemplateTest.php @@ -1,5 +1,6 @@ parseString('hello', name: 'hello'); + + expect($template) + ->toBeInstanceOf(Template::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 Template + { + 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/Unit/Performance/PhpBenchCompareTest.php b/tests/Unit/Performance/PhpBenchCompareTest.php index efa7692..ecf320c 100644 --- a/tests/Unit/Performance/PhpBenchCompareTest.php +++ b/tests/Unit/Performance/PhpBenchCompareTest.php @@ -95,3 +95,35 @@ 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('| 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/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'); diff --git a/tools/phpbench-compare.php b/tools/phpbench-compare.php index 3825012..ff15a8f 100644 --- a/tools/phpbench-compare.php +++ b/tools/phpbench-compare.php @@ -15,9 +15,21 @@ $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 === [] && $missingInBase === []) { + echo "> No comparable benchmark rows: establish a matching baseline on `main` before drawing performance conclusions.\n\n"; + + 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"; + } -if ($sharedNames === []) { - echo "> No comparable benchmark rows: the PR benchmark suite has changed. Establish a matching baseline on `main` before drawing performance conclusions.\n"; exit(0); } @@ -104,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, ]; } @@ -114,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']), @@ -162,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