Compiler
in package
View stream template compiler class
Compiles a resolved Stream template string (i.e. one that has already been through Stream::parseParent()/parseIncludes()/parseBlocks()) into PHP source implementing the same behavior as Parser::parseArrays() + parseConditionals() + parseScalars(), as native PHP control structures rather than string substitution. Covers every loop-body shape Parser::parseArrays() supports: numeric lists of scalars, numeric lists of named-field rows, nested named sub-loops, and in-loop [{if(...)}] conditionals - with a small number of deliberate, documented divergences from Parser's own (buggy or crash-prone) behavior for edge cases; see docs/superpowers/specs for details.
Tags
Table of Contents
Methods
- compile() : string
- Compile a resolved template string to PHP source
- assertNoConditionalSpansLoop() : void
- Detect a top-level '[{if(...)}]' block that opens in the text before a loop but whose matching '[{/if}]' falls after the loop closes. compileArrays() slices the template at loop boundaries before handing surrounding text to compileConditionals(), so a conditional spanning a loop this way would otherwise only ever be seen by compileConditionals() as an opening tag with no matching close in that slice - producing a misleading "unclosed block" error even though a '[{/if}]' really does exist later in the template. This isn't a case that needs to be SUPPORTED (Parser itself doesn't handle a conditional spanning a loop correctly either, so rejecting it is correct), but the error message should say what's actually going on instead of sending someone looking for a missing closing tag that isn't actually missing.
- compileArrays() : string
- Compile top-level [{name}]...[{/name}] loop blocks
- compileConditionals() : string
- Compile top-level [{if(var)}]...[{else}]...[{/if}] blocks
- compileDataLookup() : string
- Compile a runtime-checked scalar lookup against a given PHP array expression, falling back to pre-compiled PHP when the key is missing, non-scalar/non-stringable, or array/ArrayAccess-valued
- compileIndexedDataLookup() : string
- Compile a runtime-checked indexed array lookup against a given PHP array expression, falling back to pre-compiled PHP when the key/index doesn't apply. The indexed sibling of compileDataLookup() - kept separate since the array-index form's guard (is_array(...) on the outer key) differs from the plain form's stringability guard.
- compileLoop() : string
- Compile a single [{name}]...[{/name}] loop into real PHP control flow, replicating Parser::parseArrays()'s exact per-row runtime dispatch (is_array($val) / is_numeric($key)) as native if/foreach - the shape isn't knowable until data arrives, so it can't be resolved at compile time. ArrayAccess/ArrayObject throw rather than attempt parity with Parser's own buggy handling of those types (design decision #4); a missing/null/scalar outer collection normalizes to an empty array - zero iterations, no error (design decision #5).
- compileLoopConditionals() : string
- Compile in-loop [{if(rowfield)}]...[{else}]...[{/if}] blocks (Feature B), evaluated purely against the current row - no outer-$data fallback, unlike plain row-field substitution. Mirrors compileConditionals()'s cursor-based single-pass technique. The condition's own placeholder gets a row-only, stringability-guarded inline substitution (compileLoopNamedRowScalars()'s $selfName/ $selfIndex params); everything else in the surviving branch goes through the normal row-then- outer-fallback path.
- compileLoopNamedRow() : string
- Compile a loop body for the "named-field row" runtime branch (is_array($val) true, numeric outer key) - entry point that resolves in-loop [{if(...)}] conditionals first, then everything else via compileLoopNamedRowScalars().
- compileLoopNamedRowScalars() : string
- Compile plain placeholder substitution for a named-field-row loop body. Same logic as Task 1's compileLoopNamedRow(), renamed, plus an optional "self" name/index (from an enclosing in-loop [{if(...)}]'s own condition variable, Feature B): a placeholder matching $selfName/$selfIndex gets a row-only, stringability-guarded substitution instead of the normal row-then-outer- fallback path - matching Parser's inline if-branch substitution, which has no outer-scope fallback and (per design decision #2) gets a stringability guard Parser's own version lacks.
- compileLoopOuterScopeOnly() : string
- Compile a text span against the outer $data scope only - no row-scope check at all, not even for [{key}]/[{value}]/[{i}] (no special meaning outside a loop/sub-loop body). Used for text surrounding a matched sub-loop tag within a non-numeric-key row, which Parser never row-substitutes (Feature A design notes).
- compileLoopScalarRow() : string
- Compile a loop body for the "scalar row" runtime branch (is_array($val) false): [{key}]/[{value}]/[{i}] are reserved substitutions; any other placeholder falls back to the outer $data scope, matching Parser::parseArrays()'s scalar-row substitution list exactly.
- compileLoopScalarRowBody() : string
- Helper method to compile placeholder substitution for scalar row body
- compileLoopSubLoopDispatch() : string
- Compile the "non-numeric outer key, array-shaped row" branch: dispatches on the row's actual key value against every [{tag}]...[{/tag}] pair declared in the loop body (Feature A). A row whose key matches a declared tag renders that tag's sub-loop; a declared tag that ISN'T the one matching this row's key renders as its own raw literal text (matching Parser leaving a non-matching declared tag's markup untouched); if no tag matches the row's key at all, the row contributes nothing at all (no tags, no surrounding text) — matching Parser's behavior exactly.
- compileScalars() : string
- Compile [{var}] and [{var[index]}] placeholders in a literal text chunk
- compileSubLoopDispatchBody() : string
- If-block-aware sweep over a non-numeric-key row's loop body (Finding C2): an in-loop '[{if(rowvar)}]...[{else}]...[{/if}]' can appear anywhere in this branch's body, including wrapping a nested sub-loop tag - Parser resolves these in-loop conditionals for every array-shaped row uniformly, before the numeric/non-numeric row-shape dispatch, so this branch needs the same conditional handling compileLoopConditionals() already gives the named-row branch. Mirrors that method's cursor-based single-pass technique via the shared parseInLoopIfBlock() helper; each surviving span (both the plain text outside any if-block and each if-block's own then/else content) is compiled by compileSubLoopDispatchTextSpan(), which re-discovers any sub-loop tags nested inside it fresh - correctly finding a tag declared inside an if-block's surviving branch, since if-blocks are resolved as the OUTER pass here rather than splitting the body into tag-bounded spans first.
- compileSubLoopDispatchTextSpan() : string
- Compile one if-block-free text span within the non-numeric-key row branch (Finding C2): re-discovers any declared sub-loop tag(s) fresh within THIS span (correctly picking up a tag nested inside an in-loop-if's surviving branch, since compileSubLoopDispatchBody() calls this per-span rather than relying on whole-body tag offsets) and dispatches each one exactly as compileLoopSubLoopDispatch() always has; everything else - including an optional self-value substitution for an enclosing in-loop-if's own condition variable, row-scoped and stringability-guarded per design decision #2, mirroring compileLoopNamedRowScalars()'s $selfName/$selfIndex - is delegated to compileLoopOuterScopeOnly().
- compileSubLoopEntry() : string
- Compile a sub-loop's own per-entry body: [{key}]/[{value}]/[{i}] with a separate, sub-loop-local 1-indexed counter (not the outer loop's), stringability-guarded; any other placeholder falls back to the outer $data scope. Matches Parser's sub-loop entry substitution exactly - a fixed ['[{key}]','[{value}]','[{i}]'] str_replace list, nothing else row-scoped.
- findSubLoopTags() : array<string|int, mixed>
- Find every [{tag}]...[{/tag}] pair in a loop body, for the nested-sub-loop dispatch (Feature A).
- parseInLoopIfBlock() : array{rowVar: string, index: ?string, then: string, else: ?string, nextCursor: int}
- Parse a single '[{if(rowvar)}]...[{else}]...[{/if}]' block starting at $start within $body - the shared parsing core for every in-loop conditional consumer (compileLoopScalarRow()'s always-false scalar-row branch, compileLoopConditionals()'s named-row branch, and compileSubLoopDispatchBody()'s nested-sub-loop-row branch, Finding C2). Extracts and validates the condition variable (and optional array-index form), splits the body on '[{else}]' if present, and locates where the caller's cursor should resume after this block. Throws on an unclosed block or an invalid variable name - structural syntax, same throw-not-fallback policy as every other loop/conditional name in this compiler.
- phpEcho() : string
- Safely emit a literal text chunk as an escaped PHP string echo
Methods
compile()
Compile a resolved template string to PHP source
public
static compile(string $template) : string
Parameters
- $template : string
Tags
Return values
stringassertNoConditionalSpansLoop()
Detect a top-level '[{if(...)}]' block that opens in the text before a loop but whose matching '[{/if}]' falls after the loop closes. compileArrays() slices the template at loop boundaries before handing surrounding text to compileConditionals(), so a conditional spanning a loop this way would otherwise only ever be seen by compileConditionals() as an opening tag with no matching close in that slice - producing a misleading "unclosed block" error even though a '[{/if}]' really does exist later in the template. This isn't a case that needs to be SUPPORTED (Parser itself doesn't handle a conditional spanning a loop correctly either, so rejecting it is correct), but the error message should say what's actually going on instead of sending someone looking for a missing closing tag that isn't actually missing.
protected
static assertNoConditionalSpansLoop(string $name, string $preText, string $template, int $afterLoopEnd) : void
Parameters
- $name : string
-
the loop name, for the error message
- $preText : string
-
the text between the previous cursor and this loop's open tag
- $template : string
-
the full template, searched for a '[{/if}]' after the loop closes
- $afterLoopEnd : int
-
offset just past this loop's '[{/name}]' close tag
Tags
compileArrays()
Compile top-level [{name}]...[{/name}] loop blocks
protected
static compileArrays(string $template) : string
Detects each loop block the same way the (now-removed) assertNoLoopSyntax() did — a bare '[{name}]' whose name has a later matching '[{/name}]' — but now validates and compiles it instead of unconditionally rejecting it. Text outside loop blocks flows through compileConditionals() unchanged, exactly as compile() did before this method existed.
Parameters
- $template : string
Tags
Return values
stringcompileConditionals()
Compile top-level [{if(var)}]...[{else}]...[{/if}] blocks
protected
static compileConditionals(string $template) : string
Note: the opening '[{if(' tag is matched case-insensitively (via stripos()), matching Parser::parseConditionals()'s case-insensitive '/[{if/mi' regex. The closing '[{/if}]' and '[{else}]' tags remain case-sensitive, also matching Parser's behavior (it locates those via plain strpos()/str_contains(), not a case-insensitive regex).
Parameters
- $template : string
Tags
Return values
stringcompileDataLookup()
Compile a runtime-checked scalar lookup against a given PHP array expression, falling back to pre-compiled PHP when the key is missing, non-scalar/non-stringable, or array/ArrayAccess-valued
protected
static compileDataLookup(string $arrayExpr, string $name, string $fallback) : string
Parameters
- $arrayExpr : string
-
PHP source for the array expression to check (e.g. '$data')
- $name : string
-
an already-validated [a-zA-Z0-9_]+ key name
- $fallback : string
-
pre-compiled PHP to emit when the lookup doesn't apply
Return values
stringcompileIndexedDataLookup()
Compile a runtime-checked indexed array lookup against a given PHP array expression, falling back to pre-compiled PHP when the key/index doesn't apply. The indexed sibling of compileDataLookup() - kept separate since the array-index form's guard (is_array(...) on the outer key) differs from the plain form's stringability guard.
protected
static compileIndexedDataLookup(string $arrayExpr, string $name, string $index, string $fallback) : string
Parameters
- $arrayExpr : string
-
PHP source for the array expression to check (e.g. '$data')
- $name : string
-
an already-validated [a-zA-Z0-9_]+ key name
- $index : string
-
an already-validated [a-zA-Z0-9_]+ index name
- $fallback : string
-
pre-compiled PHP to emit when the lookup doesn't apply
Return values
stringcompileLoop()
Compile a single [{name}]...[{/name}] loop into real PHP control flow, replicating Parser::parseArrays()'s exact per-row runtime dispatch (is_array($val) / is_numeric($key)) as native if/foreach - the shape isn't knowable until data arrives, so it can't be resolved at compile time. ArrayAccess/ArrayObject throw rather than attempt parity with Parser's own buggy handling of those types (design decision #4); a missing/null/scalar outer collection normalizes to an empty array - zero iterations, no error (design decision #5).
protected
static compileLoop(string $name, string $body, int $offset) : string
Parameters
- $name : string
- $body : string
- $offset : int
Tags
Return values
stringcompileLoopConditionals()
Compile in-loop [{if(rowfield)}]...[{else}]...[{/if}] blocks (Feature B), evaluated purely against the current row - no outer-$data fallback, unlike plain row-field substitution. Mirrors compileConditionals()'s cursor-based single-pass technique. The condition's own placeholder gets a row-only, stringability-guarded inline substitution (compileLoopNamedRowScalars()'s $selfName/ $selfIndex params); everything else in the surviving branch goes through the normal row-then- outer-fallback path.
protected
static compileLoopConditionals(string $body, string $var) : string
Parameters
- $body : string
- $var : string
Tags
Return values
stringcompileLoopNamedRow()
Compile a loop body for the "named-field row" runtime branch (is_array($val) true, numeric outer key) - entry point that resolves in-loop [{if(...)}] conditionals first, then everything else via compileLoopNamedRowScalars().
protected
static compileLoopNamedRow(string $body, string $var) : string
Parameters
- $body : string
- $var : string
Tags
Return values
stringcompileLoopNamedRowScalars()
Compile plain placeholder substitution for a named-field-row loop body. Same logic as Task 1's compileLoopNamedRow(), renamed, plus an optional "self" name/index (from an enclosing in-loop [{if(...)}]'s own condition variable, Feature B): a placeholder matching $selfName/$selfIndex gets a row-only, stringability-guarded substitution instead of the normal row-then-outer- fallback path - matching Parser's inline if-branch substitution, which has no outer-scope fallback and (per design decision #2) gets a stringability guard Parser's own version lacks.
protected
static compileLoopNamedRowScalars(string $body, string $var[, string|null $selfName = null ][, string|null $selfIndex = null ]) : string
Parameters
- $body : string
- $var : string
- $selfName : string|null = null
- $selfIndex : string|null = null
Return values
stringcompileLoopOuterScopeOnly()
Compile a text span against the outer $data scope only - no row-scope check at all, not even for [{key}]/[{value}]/[{i}] (no special meaning outside a loop/sub-loop body). Used for text surrounding a matched sub-loop tag within a non-numeric-key row, which Parser never row-substitutes (Feature A design notes).
protected
static compileLoopOuterScopeOnly(string $text[, string|null $var = null ][, string|null $selfName = null ][, string|null $selfIndex = null ]) : string
Optionally also handles a row-scoped, stringability-guarded "self" substitution (Finding C2's fix, mirroring compileLoopNamedRowScalars()'s $selfName/$selfIndex): when this span is an in-loop-if's surviving branch, a placeholder matching $selfName/$selfIndex is the condition's own variable and substitutes from $_val (the row's own data) instead of $data - everything else in the span still resolves outer-scope only, matching Feature A's "no row-substitution outside sub-loop tags" rule for this branch.
Parameters
- $text : string
- $var : string|null = null
-
required only when $selfName is non-null
- $selfName : string|null = null
- $selfIndex : string|null = null
Return values
stringcompileLoopScalarRow()
Compile a loop body for the "scalar row" runtime branch (is_array($val) false): [{key}]/[{value}]/[{i}] are reserved substitutions; any other placeholder falls back to the outer $data scope, matching Parser::parseArrays()'s scalar-row substitution list exactly.
protected
static compileLoopScalarRow(string $body, string $var) : string
Also handles in-loop [{if(...)}] conditionals, which are always false for scalar rows.
Parameters
- $body : string
- $var : string
Tags
Return values
stringcompileLoopScalarRowBody()
Helper method to compile placeholder substitution for scalar row body
protected
static compileLoopScalarRowBody(string $body, string $var) : string
Parameters
- $body : string
- $var : string
Return values
stringcompileLoopSubLoopDispatch()
Compile the "non-numeric outer key, array-shaped row" branch: dispatches on the row's actual key value against every [{tag}]...[{/tag}] pair declared in the loop body (Feature A). A row whose key matches a declared tag renders that tag's sub-loop; a declared tag that ISN'T the one matching this row's key renders as its own raw literal text (matching Parser leaving a non-matching declared tag's markup untouched); if no tag matches the row's key at all, the row contributes nothing at all (no tags, no surrounding text) — matching Parser's behavior exactly.
protected
static compileLoopSubLoopDispatch(string $name, string $body, string $var) : string
Text outside any tag span is compiled against the outer $data scope only (Parser never row-substitutes this branch's surrounding text at all).
Parameters
- $name : string
- $body : string
- $var : string
Tags
Return values
stringcompileScalars()
Compile [{var}] and [{var[index]}] placeholders in a literal text chunk
protected
static compileScalars(string $text) : string
Detection of what "looks like" a placeholder (the outer regex) is intentionally broader than what's accepted (the [a-zA-Z0-9_] gate below): Parser::parseScalars() only ever substitutes a '[{...}]' span that exactly matches a key actually present in $data, via plain str_replace() - with no character restriction on the key itself, but also no effect whatsoever on a span that doesn't correspond to a real key. Purely incidental bracket-shaped prose (e.g. "[{10% off}]", stray "[{/if}]" fragments) is simply left untouched by Parser, regardless of what's in $data.
A name this compiler can't safely lower to a $data[...] array-key expression (e.g. containing a quote, dash, dot, or non-ASCII character) is therefore left as literal text here too, matching Parser's real behavior for the common case of incidental/unsupported-character text. This is a deliberate, accepted scope narrowing (Phase 1): the one remaining gap is a placeholder whose name uses unsupported characters AND exactly matches a key actually present in $data - that specific case still silently diverges from Parser (uncached would substitute it, cached leaves it literal). Do NOT widen the accepted character class itself - it's the fix for a prior injection vulnerability. Do NOT make this throw - see compileConditionals() for why [{if(...)}] is treated differently (a narrow, deliberate syntax where false positives are effectively impossible, unlike this broad bracket-shaped-text scan).
Parameters
- $text : string
Return values
stringcompileSubLoopDispatchBody()
If-block-aware sweep over a non-numeric-key row's loop body (Finding C2): an in-loop '[{if(rowvar)}]...[{else}]...[{/if}]' can appear anywhere in this branch's body, including wrapping a nested sub-loop tag - Parser resolves these in-loop conditionals for every array-shaped row uniformly, before the numeric/non-numeric row-shape dispatch, so this branch needs the same conditional handling compileLoopConditionals() already gives the named-row branch. Mirrors that method's cursor-based single-pass technique via the shared parseInLoopIfBlock() helper; each surviving span (both the plain text outside any if-block and each if-block's own then/else content) is compiled by compileSubLoopDispatchTextSpan(), which re-discovers any sub-loop tags nested inside it fresh - correctly finding a tag declared inside an if-block's surviving branch, since if-blocks are resolved as the OUTER pass here rather than splitting the body into tag-bounded spans first.
protected
static compileSubLoopDispatchBody(string $name, string $body, string $var, int &$counter) : string
Parameters
- $name : string
- $body : string
- $var : string
- $counter : int
-
running counter (by reference) keeping generated sub-loop variable names globally unique across every span this sweep visits
Tags
Return values
stringcompileSubLoopDispatchTextSpan()
Compile one if-block-free text span within the non-numeric-key row branch (Finding C2): re-discovers any declared sub-loop tag(s) fresh within THIS span (correctly picking up a tag nested inside an in-loop-if's surviving branch, since compileSubLoopDispatchBody() calls this per-span rather than relying on whole-body tag offsets) and dispatches each one exactly as compileLoopSubLoopDispatch() always has; everything else - including an optional self-value substitution for an enclosing in-loop-if's own condition variable, row-scoped and stringability-guarded per design decision #2, mirroring compileLoopNamedRowScalars()'s $selfName/$selfIndex - is delegated to compileLoopOuterScopeOnly().
protected
static compileSubLoopDispatchTextSpan(string $name, string $text, string $var, int &$counter[, string|null $selfName = null ][, string|null $selfIndex = null ]) : string
Parameters
- $name : string
- $text : string
- $var : string
- $counter : int
-
running counter (by reference), see compileSubLoopDispatchBody()
- $selfName : string|null = null
- $selfIndex : string|null = null
Tags
Return values
stringcompileSubLoopEntry()
Compile a sub-loop's own per-entry body: [{key}]/[{value}]/[{i}] with a separate, sub-loop-local 1-indexed counter (not the outer loop's), stringability-guarded; any other placeholder falls back to the outer $data scope. Matches Parser's sub-loop entry substitution exactly - a fixed ['[{key}]','[{value}]','[{i}]'] str_replace list, nothing else row-scoped.
protected
static compileSubLoopEntry(string $body, string $subVar) : string
Parameters
- $body : string
- $subVar : string
Return values
stringfindSubLoopTags()
Find every [{tag}]...[{/tag}] pair in a loop body, for the nested-sub-loop dispatch (Feature A).
protected
static findSubLoopTags(string $name, string $body) : array<string|int, mixed>
Uses the same broad detection regex as compileArrays()'s top-level loop scanner. An unclosed tag-looking span is silently skipped (not an error) - same "continue past an unmatched open tag" precedent compileArrays() itself already establishes for the top-level scanner.
Parameters
- $name : string
-
the outer loop's name, for error messages
- $body : string
Tags
Return values
array<string|int, mixed> —list of ['name'=>, 'start'=>, 'end'=>, 'inner'=>, 'full'=>], in document order
parseInLoopIfBlock()
Parse a single '[{if(rowvar)}]...[{else}]...[{/if}]' block starting at $start within $body - the shared parsing core for every in-loop conditional consumer (compileLoopScalarRow()'s always-false scalar-row branch, compileLoopConditionals()'s named-row branch, and compileSubLoopDispatchBody()'s nested-sub-loop-row branch, Finding C2). Extracts and validates the condition variable (and optional array-index form), splits the body on '[{else}]' if present, and locates where the caller's cursor should resume after this block. Throws on an unclosed block or an invalid variable name - structural syntax, same throw-not-fallback policy as every other loop/conditional name in this compiler.
protected
static parseInLoopIfBlock(string $body, int $start) : array{rowVar: string, index: ?string, then: string, else: ?string, nextCursor: int}
Parameters
- $body : string
- $start : int
-
offset of the '[{if(' this block begins at
Tags
Return values
array{rowVar: string, index: ?string, then: string, else: ?string, nextCursor: int}phpEcho()
Safely emit a literal text chunk as an escaped PHP string echo
protected
static phpEcho(string $literal) : string
Parameters
- $literal : string