Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
382 / 382
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
Compiler
100.00% covered (success)
100.00%
382 / 382
100.00% covered (success)
100.00%
21 / 21
104
100.00% covered (success)
100.00%
1 / 1
 compile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compileArrays
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 assertNoConditionalSpansLoop
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 compileLoop
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 compileLoopScalarRow
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 parseInLoopIfBlock
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
7
 compileLoopScalarRowBody
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 compileLoopNamedRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compileLoopConditionals
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 compileLoopNamedRowScalars
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
12
 findSubLoopTags
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 compileLoopSubLoopDispatch
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 compileSubLoopDispatchBody
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 compileSubLoopDispatchTextSpan
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 compileSubLoopEntry
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 compileLoopOuterScopeOnly
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
11
 compileConditionals
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
10
 compileScalars
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 compileDataLookup
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 compileIndexedDataLookup
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 phpEcho
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\View\Template\Stream;
16
17/**
18 * View stream template compiler class
19 *
20 * Compiles a resolved Stream template string (i.e. one that has already been through
21 * Stream::parseParent()/parseIncludes()/parseBlocks()) into PHP source implementing the same
22 * behavior as Parser::parseArrays() + parseConditionals() + parseScalars(), as native PHP control
23 * structures rather than string substitution. Covers every loop-body shape Parser::parseArrays()
24 * supports: numeric lists of scalars, numeric lists of named-field rows, nested named sub-loops, and
25 * in-loop [{if(...)}] conditionals - with a small number of deliberate, documented divergences from
26 * Parser's own (buggy or crash-prone) behavior for edge cases; see docs/superpowers/specs for details.
27 *
28 * @category   Pop
29 * @package    Pop\View
30 * @author     Nick Sagona, III <nick@popphp.org>
31 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
32 * @license    https://www.popphp.org/license     New BSD License
33 * @version    5.0.0
34 */
35class Compiler
36{
37
38    /**
39     * Compile a resolved template string to PHP source
40     *
41     * @param  string $template
42     * @throws Exception
43     * @return string
44     */
45    public static function compile(string $template): string
46    {
47        return self::compileArrays($template);
48    }
49
50    /**
51     * Compile top-level [{name}]...[{/name}] loop blocks
52     *
53     * Detects each loop block the same way the (now-removed) assertNoLoopSyntax() did — a bare
54     * '[{name}]' whose name has a later matching '[{/name}]' — but now validates and compiles it
55     * instead of unconditionally rejecting it. Text outside loop blocks flows through
56     * compileConditionals() unchanged, exactly as compile() did before this method existed.
57     *
58     * @param  string $template
59     * @throws Exception
60     * @return string
61     */
62    protected static function compileArrays(string $template): string
63    {
64        $php     = '';
65        $cursor  = 0;
66        $matches = [];
67
68        preg_match_all('/\[\{([^\[\]{}]+?)\}\]/', $template, $matches, PREG_OFFSET_CAPTURE);
69
70        foreach ($matches[0] as $i => $full) {
71            [$openTag, $openOffset] = $full;
72
73            if ($openOffset < $cursor) {
74                continue;
75            }
76
77            $name        = $matches[1][$i][0];
78            $closeTag    = '[{/' . $name . '}]';
79            $bodyStart   = $openOffset + strlen($openTag);
80            $closeOffset = strpos($template, $closeTag, $bodyStart);
81
82            if ($closeOffset === false) {
83                continue;
84            }
85
86            $preText      = substr($template, $cursor, $openOffset - $cursor);
87            $afterLoopEnd = $closeOffset + strlen($closeTag);
88            self::assertNoConditionalSpansLoop($name, $preText, $template, $afterLoopEnd);
89            $php .= self::compileConditionals($preText);
90
91            $body = substr($template, $bodyStart, $closeOffset - $bodyStart);
92            $php .= self::compileLoop($name, $body, $openOffset);
93
94            $cursor = $afterLoopEnd;
95        }
96
97        $php .= self::compileConditionals(substr($template, $cursor));
98
99        return $php;
100    }
101
102    /**
103     * Detect a top-level '[{if(...)}]' block that opens in the text before a loop but whose matching
104     * '[{/if}]' falls after the loop closes. compileArrays() slices the template at loop boundaries
105     * before handing surrounding text to compileConditionals(), so a conditional spanning a loop this
106     * way would otherwise only ever be seen by compileConditionals() as an opening tag with no matching
107     * close in that slice - producing a misleading "unclosed block" error even though a '[{/if}]' really
108     * does exist later in the template. This isn't a case that needs to be SUPPORTED (Parser itself
109     * doesn't handle a conditional spanning a loop correctly either, so rejecting it is correct), but the
110     * error message should say what's actually going on instead of sending someone looking for a missing
111     * closing tag that isn't actually missing.
112     *
113     * @param  string $name         the loop name, for the error message
114     * @param  string $preText      the text between the previous cursor and this loop's open tag
115     * @param  string $template     the full template, searched for a '[{/if}]' after the loop closes
116     * @param  int    $afterLoopEnd offset just past this loop's '[{/name}]' close tag
117     * @throws Exception
118     * @return void
119     */
120    protected static function assertNoConditionalSpansLoop(string $name, string $preText, string $template, int $afterLoopEnd): void
121    {
122        $ifPos = stripos($preText, '[{if(');
123        if ($ifPos === false || str_contains(substr($preText, $ifPos), '[{/if}]')) {
124            return;
125        }
126
127        if (strpos($template, '[{/if}]', $afterLoopEnd) !== false) {
128            throw new Exception(
129                "Error: Stream caching does not yet support a '[{if(...)}]' block that spans a loop " .
130                "('[{" . $name . "}]' begins inside an '[{if(...)}]' block that doesn't close before the " .
131                "loop starts). Render this template without a cache directory, or restructure the template " .
132                "so the conditional doesn't wrap the loop."
133            );
134        }
135    }
136
137    /**
138     * Compile a single [{name}]...[{/name}] loop into real PHP control flow, replicating
139     * Parser::parseArrays()'s exact per-row runtime dispatch (is_array($val) / is_numeric($key))
140     * as native if/foreach - the shape isn't knowable until data arrives, so it can't be resolved
141     * at compile time. ArrayAccess/ArrayObject throw rather than attempt parity with Parser's own
142     * buggy handling of those types (design decision #4); a missing/null/scalar outer collection
143     * normalizes to an empty array - zero iterations, no error (design decision #5).
144     *
145     * @param  string $name
146     * @param  string $body
147     * @param  int    $offset
148     * @throws Exception
149     * @return string
150     */
151    protected static function compileLoop(string $name, string $body, int $offset): string
152    {
153        if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
154            throw new Exception(
155                "Error: Stream caching does not support the loop name '" . $name . "' (only [a-zA-Z0-9_] " .
156                "characters are supported). Render this template without a cache directory, or rename the loop."
157            );
158        }
159
160        $var           = '__loop_' . $name . '_' . $offset;
161        $scalarBranch  = self::compileLoopScalarRow($body, $var);
162        $namedBranch   = self::compileLoopNamedRow($body, $var);
163        $subLoopBranch = self::compileLoopSubLoopDispatch($name, $body, $var);
164        $exceptionFqcn = '\\Pop\\View\\Template\\Stream\\Exception';
165
166        $php  = "<?php \$" . $var . " = \$data['" . $name . "'] ?? null;";
167        $php .= " if (\$" . $var . " instanceof \\ArrayAccess || \$" . $var . " instanceof \\ArrayObject) {";
168        $php .= " throw new " . $exceptionFqcn . "(\"Error: Compiled loop '" . $name . "' requires a plain " .
169            "array; ArrayAccess/ArrayObject is not supported on the compiled path.\"); }";
170        $php .= " if (!is_array(\$" . $var . ")) { \$" . $var . " = []; }";
171        $php .= " \$" . $var . "_i = 0; \$" . $var . "_count = count(\$" . $var . ");";
172        $php .= " foreach (\$" . $var . " as \$" . $var . "_key => \$" . $var . "_val):";
173        $php .= " if (\$" . $var . "_val instanceof \\ArrayAccess || \$" . $var . "_val instanceof \\ArrayObject):";
174        $php .= " throw new " . $exceptionFqcn . "(\"Error: A row in compiled loop '" . $name . "' is " .
175            "ArrayAccess/ArrayObject, which is not supported on the compiled path.\");";
176        $php .= " elseif (is_array(\$" . $var . "_val)):";
177        $php .= " if (is_numeric(\$" . $var . "_key)): ?>" . $namedBranch . "<?php else: ?>" . $subLoopBranch . "<?php endif;";
178        $php .= " else:";
179        $php .= " if (!is_object(\$" . $var . "_val) || method_exists(\$" . $var . "_val, '__toString')): ?>" .
180            $scalarBranch . "<?php endif;";
181        $php .= " endif;";
182        $php .= " \$" . $var . "_i++; if (\$" . $var . "_i < \$" . $var . "_count) { echo PHP_EOL; }";
183        $php .= " endforeach; ?>";
184
185        return $php;
186    }
187
188    /**
189     * Compile a loop body for the "scalar row" runtime branch (is_array($val) false):
190     * [{key}]/[{value}]/[{i}] are reserved substitutions; any other placeholder falls back to the
191     * outer $data scope, matching Parser::parseArrays()'s scalar-row substitution list exactly.
192     * Also handles in-loop [{if(...)}] conditionals, which are always false for scalar rows.
193     *
194     * @param  string $body
195     * @param  string $var
196     * @throws Exception
197     * @return string
198     */
199    protected static function compileLoopScalarRow(string $body, string $var): string
200    {
201        $php    = '';
202        $cursor = 0;
203
204        while (($start = stripos($body, '[{if(', $cursor)) !== false) {
205            $php .= self::compileLoopScalarRowBody(substr($body, $cursor, $start - $cursor), $var);
206
207            $block = self::parseInLoopIfBlock($body, $start);
208
209            // For scalar rows, the condition is always false (scalars have no array fields)
210            // So we always render the else branch if it exists, otherwise nothing
211            if ($block['else'] !== null) {
212                $php .= self::compileLoopScalarRowBody($block['else'], $var);
213            }
214
215            $cursor = $block['nextCursor'];
216        }
217
218        $php .= self::compileLoopScalarRowBody(substr($body, $cursor), $var);
219
220        return $php;
221    }
222
223    /**
224     * Parse a single '[{if(rowvar)}]...[{else}]...[{/if}]' block starting at $start within $body -
225     * the shared parsing core for every in-loop conditional consumer (compileLoopScalarRow()'s
226     * always-false scalar-row branch, compileLoopConditionals()'s named-row branch, and
227     * compileSubLoopDispatchBody()'s nested-sub-loop-row branch, Finding C2). Extracts and validates
228     * the condition variable (and optional array-index form), splits the body on '[{else}]' if
229     * present, and locates where the caller's cursor should resume after this block. Throws on an
230     * unclosed block or an invalid variable name - structural syntax, same throw-not-fallback policy
231     * as every other loop/conditional name in this compiler.
232     *
233     * @param  string $body
234     * @param  int    $start offset of the '[{if(' this block begins at
235     * @throws Exception
236     * @return array{rowVar: string, index: ?string, then: string, else: ?string, nextCursor: int}
237     */
238    protected static function parseInLoopIfBlock(string $body, int $start): array
239    {
240        $condEnd = strpos($body, '[{/if}]', $start);
241        if ($condEnd === false) {
242            throw new Exception(
243                "Error: Stream caching encountered an unclosed '[{if(...)}]' block inside a loop body " .
244                "(no matching '[{/if}]' found). Render this template without a cache directory, or fix " .
245                "the template's conditional syntax."
246            );
247        }
248        $block = substr($body, $start, ($condEnd + 7) - $start);
249
250        $rowVar = substr($block, strpos($block, '(') + 1);
251        $rowVar = substr($rowVar, 0, strpos($rowVar, ')'));
252
253        $index = null;
254        if (str_contains($rowVar, '[')) {
255            $index  = substr($rowVar, strpos($rowVar, '[') + 1);
256            $index  = substr($index, 0, strpos($index, ']'));
257            $rowVar = substr($rowVar, 0, strpos($rowVar, '['));
258        }
259
260        if (!preg_match('/^[a-zA-Z0-9_]+$/', $rowVar) || ($index !== null && !preg_match('/^[a-zA-Z0-9_]+$/', $index))) {
261            throw new Exception(
262                "Error: Stream caching does not support the in-loop conditional variable name '" . $rowVar .
263                "' (only [a-zA-Z0-9_] characters are supported). Render this template without a cache " .
264                "directory, or rename the variable."
265            );
266        }
267
268        $openEnd = strpos($block, ')}]') + 3;
269        $ifBody  = substr($block, $openEnd, strlen($block) - $openEnd - 7);
270
271        if (str_contains($ifBody, '[{else}]')) {
272            $then = substr($ifBody, 0, strpos($ifBody, '[{else}]'));
273            $else = substr($ifBody, strpos($ifBody, '[{else}]') + 8);
274        } else {
275            $then = $ifBody;
276            $else = null;
277        }
278
279        return [
280            'rowVar'     => $rowVar,
281            'index'      => $index,
282            'then'       => $then,
283            'else'       => $else,
284            'nextCursor' => $condEnd + 7,
285        ];
286    }
287
288    /**
289     * Helper method to compile placeholder substitution for scalar row body
290     *
291     * @param  string $body
292     * @param  string $var
293     * @return string
294     */
295    protected static function compileLoopScalarRowBody(string $body, string $var): string
296    {
297        $php     = '';
298        $cursor  = 0;
299        $matches = [];
300
301        preg_match_all('/\[\{([^\[\]{}]+?)(?:\[([^\[\]{}]+)\])?\}\]/', $body, $matches, PREG_OFFSET_CAPTURE);
302
303        foreach ($matches[0] as $i => $full) {
304            [$matchText, $offset] = $full;
305            $php .= self::phpEcho(substr($body, $cursor, $offset - $cursor));
306
307            $name  = $matches[1][$i][0];
308            $index = ($matches[2][$i][0] !== '') ? $matches[2][$i][0] : null;
309
310            if ($index !== null) {
311                if (preg_match('/^[a-zA-Z0-9_]+$/', $name) && preg_match('/^[a-zA-Z0-9_]+$/', $index)) {
312                    $php .= self::compileIndexedDataLookup('$data', $name, $index, self::phpEcho($matchText));
313                } else {
314                    $php .= self::phpEcho($matchText);
315                }
316            } elseif ($name === 'key') {
317                $php .= "<?= \$" . $var . "_key ?>";
318            } elseif ($name === 'value') {
319                $php .= "<?= \$" . $var . "_val ?>";
320            } elseif ($name === 'i') {
321                $php .= "<?= (\$" . $var . "_i + 1) ?>";
322            } elseif (preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
323                $php .= self::compileDataLookup('$data', $name, self::phpEcho($matchText));
324            } else {
325                $php .= self::phpEcho($matchText);
326            }
327
328            $cursor = $offset + strlen($matchText);
329        }
330
331        $php .= self::phpEcho(substr($body, $cursor));
332
333        return $php;
334    }
335
336    /**
337     * Compile a loop body for the "named-field row" runtime branch (is_array($val) true, numeric
338     * outer key) - entry point that resolves in-loop [{if(...)}] conditionals first, then everything
339     * else via compileLoopNamedRowScalars().
340     *
341     * @param  string $body
342     * @param  string $var
343     * @throws Exception
344     * @return string
345     */
346    protected static function compileLoopNamedRow(string $body, string $var): string
347    {
348        return self::compileLoopConditionals($body, $var);
349    }
350
351    /**
352     * Compile in-loop [{if(rowfield)}]...[{else}]...[{/if}] blocks (Feature B), evaluated purely
353     * against the current row - no outer-$data fallback, unlike plain row-field substitution. Mirrors
354     * compileConditionals()'s cursor-based single-pass technique. The condition's own placeholder gets
355     * a row-only, stringability-guarded inline substitution (compileLoopNamedRowScalars()'s $selfName/
356     * $selfIndex params); everything else in the surviving branch goes through the normal row-then-
357     * outer-fallback path.
358     *
359     * @param  string $body
360     * @param  string $var
361     * @throws Exception
362     * @return string
363     */
364    protected static function compileLoopConditionals(string $body, string $var): string
365    {
366        $php    = '';
367        $cursor = 0;
368
369        while (($start = stripos($body, '[{if(', $cursor)) !== false) {
370            $php .= self::compileLoopNamedRowScalars(substr($body, $cursor, $start - $cursor), $var);
371
372            $block = self::parseInLoopIfBlock($body, $start);
373
374            $condExpr = ($block['index'] !== null)
375                ? "!empty(\$" . $var . "_val['" . $block['rowVar'] . "']['" . $block['index'] . "'])"
376                : "!empty(\$" . $var . "_val['" . $block['rowVar'] . "'])";
377
378            $php .= '<?php if (' . $condExpr . '): ?>' .
379                self::compileLoopNamedRowScalars($block['then'], $var, $block['rowVar'], $block['index']);
380            if ($block['else'] !== null) {
381                $php .= '<?php else: ?>' . self::compileLoopNamedRowScalars($block['else'], $var);
382            }
383            $php .= '<?php endif; ?>';
384
385            $cursor = $block['nextCursor'];
386        }
387
388        $php .= self::compileLoopNamedRowScalars(substr($body, $cursor), $var);
389
390        return $php;
391    }
392
393    /**
394     * Compile plain placeholder substitution for a named-field-row loop body. Same logic as Task 1's
395     * compileLoopNamedRow(), renamed, plus an optional "self" name/index (from an enclosing in-loop
396     * [{if(...)}]'s own condition variable, Feature B): a placeholder matching $selfName/$selfIndex
397     * gets a row-only, stringability-guarded substitution instead of the normal row-then-outer-
398     * fallback path - matching Parser's inline if-branch substitution, which has no outer-scope
399     * fallback and (per design decision #2) gets a stringability guard Parser's own version lacks.
400     *
401     * @param  string  $body
402     * @param  string  $var
403     * @param  ?string $selfName
404     * @param  ?string $selfIndex
405     * @return string
406     */
407    protected static function compileLoopNamedRowScalars(string $body, string $var, ?string $selfName = null, ?string $selfIndex = null): string
408    {
409        $php     = '';
410        $cursor  = 0;
411        $matches = [];
412
413        preg_match_all('/\[\{([^\[\]{}]+?)(?:\[([^\[\]{}]+)\])?\}\]/', $body, $matches, PREG_OFFSET_CAPTURE);
414
415        foreach ($matches[0] as $i => $full) {
416            [$matchText, $offset] = $full;
417            $php .= self::phpEcho(substr($body, $cursor, $offset - $cursor));
418
419            $name  = $matches[1][$i][0];
420            $index = ($matches[2][$i][0] !== '') ? $matches[2][$i][0] : null;
421
422            if ($selfName !== null && $name === $selfName && $index === $selfIndex) {
423                $valueExpr = ($index !== null)
424                    ? "\$" . $var . "_val['" . $name . "']['" . $index . "']"
425                    : "\$" . $var . "_val['" . $name . "']";
426                $php .= "<?php if ((is_object(" . $valueExpr . ") && method_exists(" . $valueExpr . ", '__toString')) "
427                    . "|| (!is_object(" . $valueExpr . ") && !is_array(" . $valueExpr . "))): ?>"
428                    . "<?= " . $valueExpr . " ?><?php endif; ?>";
429            } elseif ($index !== null) {
430                if (preg_match('/^[a-zA-Z0-9_]+$/', $name) && preg_match('/^[a-zA-Z0-9_]+$/', $index)) {
431                    $php .= self::compileIndexedDataLookup('$data', $name, $index, self::phpEcho($matchText));
432                } else {
433                    $php .= self::phpEcho($matchText);
434                }
435            } elseif ($name === 'i') {
436                $php .= "<?= (\$" . $var . "_i + 1) ?>";
437            } elseif (preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
438                $outerFallback = self::compileDataLookup('$data', $name, self::phpEcho($matchText));
439                $php .= self::compileDataLookup('$' . $var . '_val', $name, $outerFallback);
440            } else {
441                $php .= self::phpEcho($matchText);
442            }
443
444            $cursor = $offset + strlen($matchText);
445        }
446
447        $php .= self::phpEcho(substr($body, $cursor));
448
449        return $php;
450    }
451
452    /**
453     * Find every [{tag}]...[{/tag}] pair in a loop body, for the nested-sub-loop dispatch (Feature A).
454     * Uses the same broad detection regex as compileArrays()'s top-level loop scanner. An unclosed
455     * tag-looking span is silently skipped (not an error) - same "continue past an unmatched open tag"
456     * precedent compileArrays() itself already establishes for the top-level scanner.
457     *
458     * @param  string $name the outer loop's name, for error messages
459     * @param  string $body
460     * @throws Exception
461     * @return array list of ['name'=>, 'start'=>, 'end'=>, 'inner'=>, 'full'=>], in document order
462     */
463    protected static function findSubLoopTags(string $name, string $body): array
464    {
465        $tags    = [];
466        $cursor  = 0;
467        $matches = [];
468
469        preg_match_all('/\[\{([^\[\]{}]+?)\}\]/', $body, $matches, PREG_OFFSET_CAPTURE);
470
471        foreach ($matches[0] as $i => $full) {
472            [$openTag, $openOffset] = $full;
473
474            if ($openOffset < $cursor) {
475                continue;
476            }
477
478            $tagName     = $matches[1][$i][0];
479            $closeTag    = '[{/' . $tagName . '}]';
480            $bodyStart   = $openOffset + strlen($openTag);
481            $closeOffset = strpos($body, $closeTag, $bodyStart);
482
483            if ($closeOffset === false) {
484                continue;
485            }
486
487            if (!preg_match('/^[a-zA-Z0-9_]+$/', $tagName)) {
488                throw new Exception(
489                    "Error: Stream caching does not support the nested loop name '" . $tagName . "' inside '[{" .
490                    $name . "}]' (only [a-zA-Z0-9_] characters are supported). Render this template without a " .
491                    "cache directory, or rename the nested loop."
492                );
493            }
494
495            $tags[] = [
496                'name'  => $tagName,
497                'start' => $openOffset,
498                'end'   => $closeOffset + strlen($closeTag),
499                'inner' => substr($body, $bodyStart, $closeOffset - $bodyStart),
500                'full'  => substr($body, $openOffset, ($closeOffset + strlen($closeTag)) - $openOffset),
501            ];
502
503            $cursor = $closeOffset + strlen($closeTag);
504        }
505
506        return $tags;
507    }
508
509    /**
510     * Compile the "non-numeric outer key, array-shaped row" branch: dispatches on the row's actual key
511     * value against every [{tag}]...[{/tag}] pair declared in the loop body (Feature A). A row whose
512     * key matches a declared tag renders that tag's sub-loop; a declared tag that ISN'T the one
513     * matching this row's key renders as its own raw literal text (matching Parser leaving a
514     * non-matching declared tag's markup untouched); if no tag matches the row's key at all, the row
515     * contributes nothing at all (no tags, no surrounding text) — matching Parser's behavior exactly.
516     * Text outside any tag span is compiled against the outer $data scope only (Parser never
517     * row-substitutes this branch's surrounding text at all).
518     *
519     * @param  string $name
520     * @param  string $body
521     * @param  string $var
522     * @throws Exception
523     * @return string
524     */
525    protected static function compileLoopSubLoopDispatch(string $name, string $body, string $var): string
526    {
527        $tags = self::findSubLoopTags($name, $body);
528
529        if (empty($tags)) {
530            return '';
531        }
532
533        $anyMatchCondition = implode(' || ', array_map(
534            fn($tag) => "\$" . $var . "_key === '" . $tag['name'] . "'",
535            $tags
536        ));
537
538        $counter = 0;
539        $inner   = self::compileSubLoopDispatchBody($name, $body, $var, $counter);
540
541        return "<?php if (" . $anyMatchCondition . "): ?>" . $inner . "<?php endif; ?>";
542    }
543
544    /**
545     * If-block-aware sweep over a non-numeric-key row's loop body (Finding C2): an in-loop
546     * '[{if(rowvar)}]...[{else}]...[{/if}]' can appear anywhere in this branch's body, including
547     * wrapping a nested sub-loop tag - Parser resolves these in-loop conditionals for every
548     * array-shaped row uniformly, before the numeric/non-numeric row-shape dispatch, so this branch
549     * needs the same conditional handling compileLoopConditionals() already gives the named-row
550     * branch. Mirrors that method's cursor-based single-pass technique via the shared
551     * parseInLoopIfBlock() helper; each surviving span (both the plain text outside any if-block and
552     * each if-block's own then/else content) is compiled by compileSubLoopDispatchTextSpan(), which
553     * re-discovers any sub-loop tags nested inside it fresh - correctly finding a tag declared inside
554     * an if-block's surviving branch, since if-blocks are resolved as the OUTER pass here rather than
555     * splitting the body into tag-bounded spans first.
556     *
557     * @param  string $name
558     * @param  string $body
559     * @param  string $var
560     * @param  int    $counter running counter (by reference) keeping generated sub-loop variable
561     *                         names globally unique across every span this sweep visits
562     * @throws Exception
563     * @return string
564     */
565    protected static function compileSubLoopDispatchBody(string $name, string $body, string $var, int &$counter): string
566    {
567        $php    = '';
568        $cursor = 0;
569
570        while (($start = stripos($body, '[{if(', $cursor)) !== false) {
571            $php .= self::compileSubLoopDispatchTextSpan($name, substr($body, $cursor, $start - $cursor), $var, $counter);
572
573            $block = self::parseInLoopIfBlock($body, $start);
574
575            $condExpr = ($block['index'] !== null)
576                ? "!empty(\$" . $var . "_val['" . $block['rowVar'] . "']['" . $block['index'] . "'])"
577                : "!empty(\$" . $var . "_val['" . $block['rowVar'] . "'])";
578
579            $php .= '<?php if (' . $condExpr . '): ?>' .
580                self::compileSubLoopDispatchTextSpan($name, $block['then'], $var, $counter, $block['rowVar'], $block['index']);
581            if ($block['else'] !== null) {
582                $php .= '<?php else: ?>' . self::compileSubLoopDispatchTextSpan($name, $block['else'], $var, $counter);
583            }
584            $php .= '<?php endif; ?>';
585
586            $cursor = $block['nextCursor'];
587        }
588
589        $php .= self::compileSubLoopDispatchTextSpan($name, substr($body, $cursor), $var, $counter);
590
591        return $php;
592    }
593
594    /**
595     * Compile one if-block-free text span within the non-numeric-key row branch (Finding C2):
596     * re-discovers any declared sub-loop tag(s) fresh within THIS span (correctly picking up a tag
597     * nested inside an in-loop-if's surviving branch, since compileSubLoopDispatchBody() calls this
598     * per-span rather than relying on whole-body tag offsets) and dispatches each one exactly as
599     * compileLoopSubLoopDispatch() always has; everything else - including an optional self-value
600     * substitution for an enclosing in-loop-if's own condition variable, row-scoped and
601     * stringability-guarded per design decision #2, mirroring compileLoopNamedRowScalars()'s
602     * $selfName/$selfIndex - is delegated to compileLoopOuterScopeOnly().
603     *
604     * @param  string  $name
605     * @param  string  $text
606     * @param  string  $var
607     * @param  int     $counter   running counter (by reference), see compileSubLoopDispatchBody()
608     * @param  ?string $selfName
609     * @param  ?string $selfIndex
610     * @throws Exception
611     * @return string
612     */
613    protected static function compileSubLoopDispatchTextSpan(
614        string $name,
615        string $text,
616        string $var,
617        int &$counter,
618        ?string $selfName = null,
619        ?string $selfIndex = null
620    ): string {
621        $tags = self::findSubLoopTags($name, $text);
622
623        if (empty($tags)) {
624            return self::compileLoopOuterScopeOnly($text, $var, $selfName, $selfIndex);
625        }
626
627        $php    = '';
628        $cursor = 0;
629
630        foreach ($tags as $tag) {
631            $php .= self::compileLoopOuterScopeOnly(substr($text, $cursor, $tag['start'] - $cursor), $var, $selfName, $selfIndex);
632
633            $subVar  = $var . '_sub_' . $tag['name'] . '_' . ($counter++);
634            $subBody = self::compileSubLoopEntry($tag['inner'], $subVar);
635
636            $php .= "<?php if (\$" . $var . "_key === '" . $tag['name'] . "'): ?>";
637            $php .= "<?php \$" . $subVar . "_j = 0; \$" . $subVar . "_count = count(\$" . $var . "_val); ?>";
638            $php .= "<?php foreach (\$" . $var . "_val as \$" . $subVar . "_key => \$" . $subVar . "_val): ?>";
639            $php .= "<?php if ((is_object(\$" . $subVar . "_val) && method_exists(\$" . $subVar . "_val, '__toString')) "
640                . "|| (!is_object(\$" . $subVar . "_val) && !is_array(\$" . $subVar . "_val))): ?>"
641                . $subBody . "<?php \$" . $subVar . "_j++; endif; ?>";
642            $php .= "<?php endforeach; ?>";
643            $php .= "<?php else: ?>" . self::phpEcho($tag['full']) . "<?php endif; ?>";
644
645            $cursor = $tag['end'];
646        }
647
648        $php .= self::compileLoopOuterScopeOnly(substr($text, $cursor), $var, $selfName, $selfIndex);
649
650        return $php;
651    }
652
653    /**
654     * Compile a sub-loop's own per-entry body: [{key}]/[{value}]/[{i}] with a separate, sub-loop-local
655     * 1-indexed counter (not the outer loop's), stringability-guarded; any other placeholder falls back
656     * to the outer $data scope. Matches Parser's sub-loop entry substitution exactly - a fixed
657     * ['[{key}]','[{value}]','[{i}]'] str_replace list, nothing else row-scoped.
658     *
659     * @param  string $body
660     * @param  string $subVar
661     * @return string
662     */
663    protected static function compileSubLoopEntry(string $body, string $subVar): string
664    {
665        $php     = '';
666        $cursor  = 0;
667        $matches = [];
668
669        preg_match_all('/\[\{([^\[\]{}]+?)(?:\[([^\[\]{}]+)\])?\}\]/', $body, $matches, PREG_OFFSET_CAPTURE);
670
671        foreach ($matches[0] as $i => $full) {
672            [$matchText, $offset] = $full;
673            $php .= self::phpEcho(substr($body, $cursor, $offset - $cursor));
674
675            $name  = $matches[1][$i][0];
676            $index = ($matches[2][$i][0] !== '') ? $matches[2][$i][0] : null;
677
678            if ($index !== null) {
679                if (preg_match('/^[a-zA-Z0-9_]+$/', $name) && preg_match('/^[a-zA-Z0-9_]+$/', $index)) {
680                    $php .= self::compileIndexedDataLookup('$data', $name, $index, self::phpEcho($matchText));
681                } else {
682                    $php .= self::phpEcho($matchText);
683                }
684            } elseif ($name === 'key') {
685                $php .= "<?= \$" . $subVar . "_key ?>";
686            } elseif ($name === 'value') {
687                $php .= "<?= \$" . $subVar . "_val ?>";
688            } elseif ($name === 'i') {
689                $php .= "<?= (\$" . $subVar . "_j + 1) ?>";
690            } elseif (preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
691                $php .= self::compileDataLookup('$data', $name, self::phpEcho($matchText));
692            } else {
693                $php .= self::phpEcho($matchText);
694            }
695
696            $cursor = $offset + strlen($matchText);
697        }
698
699        $php .= self::phpEcho(substr($body, $cursor));
700
701        return $php;
702    }
703
704    /**
705     * Compile a text span against the outer $data scope only - no row-scope check at all, not even for
706     * [{key}]/[{value}]/[{i}] (no special meaning outside a loop/sub-loop body). Used for text
707     * surrounding a matched sub-loop tag within a non-numeric-key row, which Parser never
708     * row-substitutes (Feature A design notes).
709     *
710     * Optionally also handles a row-scoped, stringability-guarded "self" substitution (Finding C2's
711     * fix, mirroring compileLoopNamedRowScalars()'s $selfName/$selfIndex): when this span is an
712     * in-loop-if's surviving branch, a placeholder matching $selfName/$selfIndex is the condition's
713     * own variable and substitutes from $<var>_val (the row's own data) instead of $data - everything
714     * else in the span still resolves outer-scope only, matching Feature A's "no row-substitution
715     * outside sub-loop tags" rule for this branch.
716     *
717     * @param  string  $text
718     * @param  ?string $var       required only when $selfName is non-null
719     * @param  ?string $selfName
720     * @param  ?string $selfIndex
721     * @return string
722     */
723    protected static function compileLoopOuterScopeOnly(string $text, ?string $var = null, ?string $selfName = null, ?string $selfIndex = null): string
724    {
725        $php     = '';
726        $cursor  = 0;
727        $matches = [];
728
729        preg_match_all('/\[\{([^\[\]{}]+?)(?:\[([^\[\]{}]+)\])?\}\]/', $text, $matches, PREG_OFFSET_CAPTURE);
730
731        foreach ($matches[0] as $i => $full) {
732            [$matchText, $offset] = $full;
733            $php .= self::phpEcho(substr($text, $cursor, $offset - $cursor));
734
735            $name  = $matches[1][$i][0];
736            $index = ($matches[2][$i][0] !== '') ? $matches[2][$i][0] : null;
737
738            if ($selfName !== null && $name === $selfName && $index === $selfIndex) {
739                $valueExpr = ($index !== null)
740                    ? "\$" . $var . "_val['" . $name . "']['" . $index . "']"
741                    : "\$" . $var . "_val['" . $name . "']";
742                $php .= "<?php if ((is_object(" . $valueExpr . ") && method_exists(" . $valueExpr . ", '__toString')) "
743                    . "|| (!is_object(" . $valueExpr . ") && !is_array(" . $valueExpr . "))): ?>"
744                    . "<?= " . $valueExpr . " ?><?php endif; ?>";
745            } elseif ($index !== null) {
746                if (preg_match('/^[a-zA-Z0-9_]+$/', $name) && preg_match('/^[a-zA-Z0-9_]+$/', $index)) {
747                    $php .= self::compileIndexedDataLookup('$data', $name, $index, self::phpEcho($matchText));
748                } else {
749                    $php .= self::phpEcho($matchText);
750                }
751            } elseif (preg_match('/^[a-zA-Z0-9_]+$/', $name)) {
752                $php .= self::compileDataLookup('$data', $name, self::phpEcho($matchText));
753            } else {
754                $php .= self::phpEcho($matchText);
755            }
756
757            $cursor = $offset + strlen($matchText);
758        }
759
760        $php .= self::phpEcho(substr($text, $cursor));
761
762        return $php;
763    }
764
765    /**
766     * Compile top-level [{if(var)}]...[{else}]...[{/if}] blocks
767     *
768     * Note: the opening '[{if(' tag is matched case-insensitively (via stripos()), matching
769     * Parser::parseConditionals()'s case-insensitive '/\[{if/mi' regex. The closing '[{/if}]' and
770     * '[{else}]' tags remain case-sensitive, also matching Parser's behavior (it locates those via
771     * plain strpos()/str_contains(), not a case-insensitive regex).
772     *
773     * @param  string $template
774     * @throws Exception
775     * @return string
776     */
777    protected static function compileConditionals(string $template): string
778    {
779        $php    = '';
780        $cursor = 0;
781
782        while (($start = stripos($template, '[{if(', $cursor)) !== false) {
783            $php .= self::compileScalars(substr($template, $cursor, $start - $cursor));
784
785            $condEnd = strpos($template, '[{/if}]', $start);
786            if ($condEnd === false) {
787                throw new Exception(
788                    "Error: Stream caching encountered an unclosed '[{if(...)}]' block (no matching " .
789                    "'[{/if}]' found). Render this template without a cache directory, or fix the " .
790                    "template's conditional syntax."
791                );
792            }
793            $block = substr($template, $start, ($condEnd + 7) - $start);
794
795            $var = substr($block, strpos($block, '(') + 1);
796            $var = substr($var, 0, strpos($var, ')'));
797
798            $index = null;
799            if (str_contains($var, '[')) {
800                $index = substr($var, strpos($var, '[') + 1);
801                $index = substr($index, 0, strpos($index, ']'));
802                $var   = substr($var, 0, strpos($var, '['));
803            }
804
805            // Validate that var and index only contain safe identifier characters. This gate must stay
806            // exactly as narrow as [a-zA-Z0-9_] - it's the fix for a prior injection vulnerability.
807            // A name that fails this gate is not silently treated as literal text (that would silently
808            // diverge from Parser, which handles any variable name via plain str_replace()) - it throws.
809            if (!preg_match('/^[a-zA-Z0-9_]+$/', $var) || ($index !== null && !preg_match('/^[a-zA-Z0-9_]+$/', $index))) {
810                throw new Exception(
811                    "Error: Stream caching does not support the conditional variable name '" . $var .
812                    "' (only [a-zA-Z0-9_] characters are supported). Render this template without a " .
813                    "cache directory, or rename the variable."
814                );
815            }
816
817            $openEnd = strpos($block, ')}]') + 3;
818            $body    = substr($block, $openEnd, strlen($block) - $openEnd - 7);
819
820            if (str_contains($body, '[{else}]')) {
821                $then = substr($body, 0, strpos($body, '[{else}]'));
822                $else = substr($body, strpos($body, '[{else}]') + 8);
823            } else {
824                $then = $body;
825                $else = null;
826            }
827
828            $condExpr = ($index !== null)
829                ? "!empty(\$data['" . $var . "']['" . $index . "'])"
830                : "!empty(\$data['" . $var . "'])";
831
832            $php .= '<?php if (' . $condExpr . '): ?>' . self::compileScalars($then);
833            if ($else !== null) {
834                $php .= '<?php else: ?>' . self::compileScalars($else);
835            }
836            $php .= '<?php endif; ?>';
837
838            $cursor = $condEnd + 7;
839        }
840
841        $php .= self::compileScalars(substr($template, $cursor));
842
843        return $php;
844    }
845
846    /**
847     * Compile [{var}] and [{var[index]}] placeholders in a literal text chunk
848     *
849     * Detection of what "looks like" a placeholder (the outer regex) is intentionally broader than
850     * what's accepted (the [a-zA-Z0-9_] gate below): Parser::parseScalars() only ever substitutes a
851     * '[{...}]' span that exactly matches a key actually present in $data, via plain str_replace() -
852     * with no character restriction on the key itself, but also no effect whatsoever on a span that
853     * doesn't correspond to a real key. Purely incidental bracket-shaped prose (e.g. "[{10% off}]",
854     * stray "[{/if}]" fragments) is simply left untouched by Parser, regardless of what's in $data.
855     *
856     * A name this compiler can't safely lower to a $data[...] array-key expression (e.g. containing a
857     * quote, dash, dot, or non-ASCII character) is therefore left as literal text here too, matching
858     * Parser's real behavior for the common case of incidental/unsupported-character text. This is a
859     * deliberate, accepted scope narrowing (Phase 1): the one remaining gap is a placeholder whose name
860     * uses unsupported characters AND exactly matches a key actually present in $data - that specific
861     * case still silently diverges from Parser (uncached would substitute it, cached leaves it literal).
862     * Do NOT widen the accepted character class itself - it's the fix for a prior injection
863     * vulnerability. Do NOT make this throw - see compileConditionals() for why [{if(...)}] is treated
864     * differently (a narrow, deliberate syntax where false positives are effectively impossible, unlike
865     * this broad bracket-shaped-text scan).
866     *
867     * @param  string $text
868     * @return string
869     */
870    protected static function compileScalars(string $text): string
871    {
872        $php     = '';
873        $cursor  = 0;
874        $matches = [];
875
876        preg_match_all(
877            '/\[\{([^\[\]{}]+?)(?:\[([^\[\]{}]+)\])?\}\]/',
878            $text,
879            $matches,
880            PREG_OFFSET_CAPTURE
881        );
882
883        foreach ($matches[0] as $i => $full) {
884            [$matchText, $offset] = $full;
885            $php .= self::phpEcho(substr($text, $cursor, $offset - $cursor));
886
887            $name  = $matches[1][$i][0];
888            $index = ($matches[2][$i][0] !== '') ? $matches[2][$i][0] : null;
889
890            if (!preg_match('/^[a-zA-Z0-9_]+$/', $name) || ($index !== null && !preg_match('/^[a-zA-Z0-9_]+$/', $index))) {
891                // Unsupported characters: leave this span exactly as it appeared, as literal text.
892                $php   .= self::phpEcho($matchText);
893                $cursor = $offset + strlen($matchText);
894                continue;
895            }
896
897            if ($index !== null) {
898                $php .= self::compileIndexedDataLookup('$data', $name, $index, self::phpEcho('[{' . $name . '[' . $index . ']}]'));
899            } else {
900                $php .= self::compileDataLookup('$data', $name, self::phpEcho('[{' . $name . '}]'));
901            }
902
903            $cursor = $offset + strlen($matchText);
904        }
905
906        $php .= self::phpEcho(substr($text, $cursor));
907
908        return $php;
909    }
910
911    /**
912     * Compile a runtime-checked scalar lookup against a given PHP array expression, falling back to
913     * pre-compiled PHP when the key is missing, non-scalar/non-stringable, or array/ArrayAccess-valued
914     *
915     * @param  string $arrayExpr PHP source for the array expression to check (e.g. '$data')
916     * @param  string $name      an already-validated [a-zA-Z0-9_]+ key name
917     * @param  string $fallback  pre-compiled PHP to emit when the lookup doesn't apply
918     * @return string
919     */
920    protected static function compileDataLookup(string $arrayExpr, string $name, string $fallback): string
921    {
922        return "<?php if (array_key_exists('" . $name . "', " . $arrayExpr . ") && !is_array(" . $arrayExpr . "['" . $name . "']) "
923            . "&& !(" . $arrayExpr . "['" . $name . "'] instanceof \\ArrayAccess) "
924            . "&& (!is_object(" . $arrayExpr . "['" . $name . "']) || method_exists(" . $arrayExpr . "['" . $name . "'], '__toString'))): ?>"
925            . "<?= " . $arrayExpr . "['" . $name . "'] ?>"
926            . "<?php else: ?>" . $fallback . "<?php endif; ?>";
927    }
928
929    /**
930     * Compile a runtime-checked indexed array lookup against a given PHP array expression, falling
931     * back to pre-compiled PHP when the key/index doesn't apply. The indexed sibling of
932     * compileDataLookup() - kept separate since the array-index form's guard (is_array(...) on the
933     * outer key) differs from the plain form's stringability guard.
934     *
935     * @param  string $arrayExpr PHP source for the array expression to check (e.g. '$data')
936     * @param  string $name      an already-validated [a-zA-Z0-9_]+ key name
937     * @param  string $index     an already-validated [a-zA-Z0-9_]+ index name
938     * @param  string $fallback  pre-compiled PHP to emit when the lookup doesn't apply
939     * @return string
940     */
941    protected static function compileIndexedDataLookup(string $arrayExpr, string $name, string $index, string $fallback): string
942    {
943        return "<?php if (array_key_exists('" . $name . "', " . $arrayExpr . ") && is_array(" . $arrayExpr . "['" . $name . "'])): ?>"
944            . "<?= " . $arrayExpr . "['" . $name . "']['" . $index . "'] ?? '' ?>"
945            . "<?php else: ?>" . $fallback . "<?php endif; ?>";
946    }
947
948    /**
949     * Safely emit a literal text chunk as an escaped PHP string echo
950     *
951     * @param  string $literal
952     * @return string
953     */
954    protected static function phpEcho(string $literal): string
955    {
956        if ($literal === '') {
957            return '';
958        }
959        return "<?php echo '" . addcslashes($literal, "'\\") . "'; ?>";
960    }
961
962}