Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
382 / 382 |
|
100.00% |
21 / 21 |
CRAP | |
100.00% |
1 / 1 |
| Compiler | |
100.00% |
382 / 382 |
|
100.00% |
21 / 21 |
104 | |
100.00% |
1 / 1 |
| compile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| compileArrays | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
4 | |||
| assertNoConditionalSpansLoop | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| compileLoop | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
2 | |||
| compileLoopScalarRow | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| parseInLoopIfBlock | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
7 | |||
| compileLoopScalarRowBody | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
10 | |||
| compileLoopNamedRow | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| compileLoopConditionals | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| compileLoopNamedRowScalars | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
12 | |||
| findSubLoopTags | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
5 | |||
| compileLoopSubLoopDispatch | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| compileSubLoopDispatchBody | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| compileSubLoopDispatchTextSpan | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
3 | |||
| compileSubLoopEntry | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
10 | |||
| compileLoopOuterScopeOnly | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
11 | |||
| compileConditionals | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
10 | |||
| compileScalars | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
7 | |||
| compileDataLookup | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| compileIndexedDataLookup | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| phpEcho | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | declare(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 | */ |
| 15 | namespace 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 | */ |
| 35 | class 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 | } |