Documentation

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
category

Pop

author

Nick Sagona, III nick@popphp.org

copyright

Copyright (c) 2009-2026 Nick Sagona, III

license

https://www.popphp.org/license New BSD License

version
5.0.0

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
throws
Exception
Return values
string

assertNoConditionalSpansLoop()

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
throws
Exception

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
throws
Exception
Return values
string

compileConditionals()

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
throws
Exception
Return values
string

compileDataLookup()

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
string

compileIndexedDataLookup()

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
string

compileLoop()

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
throws
Exception
Return values
string

compileLoopConditionals()

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
throws
Exception
Return values
string

compileLoopNamedRow()

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
throws
Exception
Return values
string

compileLoopNamedRowScalars()

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
string

compileLoopOuterScopeOnly()

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
string

compileLoopScalarRow()

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
throws
Exception
Return values
string

compileLoopScalarRowBody()

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
string

compileLoopSubLoopDispatch()

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
throws
Exception
Return values
string

compileScalars()

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
string

compileSubLoopDispatchBody()

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
throws
Exception
Return values
string

compileSubLoopDispatchTextSpan()

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
throws
Exception
Return values
string

compileSubLoopEntry()

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
string

findSubLoopTags()

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
throws
Exception
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
throws
Exception
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
Return values
string

        
On this page

Search results