Skip to content

Commit 4e4dcc4

Browse files
committed
Hoist compiled sub-programs to top-level closures
Move fn/inverse closures from inline (allocated per block execution) to hoisted $p0/$p1/... variables at the start of the render function (allocated once per render), mirroring how Handlebars.js stores programs in a GUID-indexed array. All block programs now receive block params explicitly via a parameter rather than a use() statement, which enables hoisting even for programs that inherit but don't declare block params. Partials with sub-programs use an IIFE to scope their definitions.
1 parent ba43dd4 commit 4e4dcc4

3 files changed

Lines changed: 96 additions & 36 deletions

File tree

src/Compiler.php

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,22 @@ final class Compiler
6666
*/
6767
private bool $compilingHelperArgs = false;
6868

69+
private int $nextProgramId = 0;
70+
/** @var string[] */
71+
private array $programDefs = [];
72+
/**
73+
* Stack of dep arrays — one per active compilation level. Each entry tracks
74+
* the $pN variables directly referenced at that level (direct children only;
75+
* transitive deps are captured via the use() chains of inner closures).
76+
* @var string[][]
77+
*/
78+
private array $programDepStack = [];
79+
/**
80+
* Deps for the top-level render closure — set by compile() after compileProgram() returns.
81+
* @var string[]
82+
*/
83+
private array $renderDeps = [];
84+
6985
public function __construct(
7086
private readonly Parser $parser,
7187
) {}
@@ -76,7 +92,12 @@ public function compile(Program $program, Context $context): string
7692
$this->blockParamValues = [];
7793
$this->bpRefStack = [];
7894
$this->lastCompileProgramHadDirectBpRef = false;
79-
return $this->compileProgram($program);
95+
$this->nextProgramId = 0;
96+
$this->programDefs = [];
97+
$this->programDepStack = [[]];
98+
$code = $this->compileProgram($program);
99+
$this->renderDeps = array_pop($this->programDepStack);
100+
return $code;
80101
}
81102

82103
/**
@@ -87,8 +108,10 @@ public function compile(Program $program, Context $context): string
87108
public function composePHPRender(string $code): string
88109
{
89110
$partials = implode(",\n", $this->context->partialCode);
90-
$closure = self::templateClosure($code, $partials, "\n \$in = &\$cx->data['root'];");
91-
return "use " . Runtime::class . " as LR;\nreturn $closure;";
111+
$useVars = implode(', ', $this->renderDeps);
112+
$closure = self::templateClosure($code, $partials, "\n \$in = &\$cx->data['root'];", $useVars);
113+
$defs = $this->programDefs ? implode("\n", $this->programDefs) . "\n" : '';
114+
return "use " . Runtime::class . " as LR;\n{$defs}return $closure;";
92115
}
93116

94117
/**
@@ -197,8 +220,9 @@ private function BlockStatement(BlockStatement $block): string
197220
}
198221
}
199222

200-
$nameArg = !$this->context->options->knownHelpersOnly && (!$inverted || $type === SexprType::Ambiguous) ? ", $escapedName" : '';
201-
return self::getRuntimeFunc('sec', "\$cx, $var, \$in, $fn, $else$nameArg");
223+
$nameArg = !$this->context->options->knownHelpersOnly && (!$inverted || $type === SexprType::Ambiguous) ? ", $escapedName" : ', null';
224+
$outerBpArg = $this->blockParamValues ? ', $blockParams' : '';
225+
return self::getRuntimeFunc('sec', "\$cx, $var, \$in, $fn, $else$nameArg$outerBpArg");
202226
}
203227

204228
private function isKnownHelper(string $helperName): bool
@@ -226,41 +250,62 @@ private function classifySexpr(?string $simpleName, array $params, ?Hash $hash):
226250
return ($params || $hash) ? SexprType::Helper : SexprType::Simple;
227251
}
228252

229-
/** Returns '$blockParams' when inside a block-param scope (for use() capture), '' otherwise. */
230-
private function blockParamsUseVars(): string
253+
/**
254+
* Build the use() clause string for an inline partial body closure.
255+
* Prepends $blockParams when inside a block-param scope (marking bpRefStack),
256+
* then appends any hoisted program deps added since $depsBefore.
257+
*/
258+
private function buildInlineUseClause(int $depsBefore): string
231259
{
232-
return $this->blockParamValues ? '$blockParams' : '';
260+
$bodyDeps = array_slice($this->programDepStack[array_key_last($this->programDepStack)], $depsBefore);
261+
$vars = [];
262+
if ($this->blockParamValues) {
263+
if ($this->bpRefStack) {
264+
$this->bpRefStack[array_key_last($this->bpRefStack)] = true;
265+
}
266+
$vars[] = '$blockParams';
267+
}
268+
return implode(', ', array_merge($vars, $bodyDeps));
233269
}
234270

235271
/**
236272
* Compile a block program, pushing/popping its block params around the compilation.
237-
* Returns a PHP closure string: the signature varies based on whether the program declares or
238-
* inherits block params, and a $sc preamble is added when depths are accessed multiple times.
273+
* Returns a $pN variable name referencing a hoisted closure. The signature uses
274+
* array $blockParams = [] when the program declares or inherits block params.
239275
*/
240276
private function compileProgramWithBlockParams(Program $program): string
241277
{
242278
$bp = $program->blockParams;
243279
if ($bp) {
244280
array_unshift($this->blockParamValues, $bp);
245281
}
282+
$this->programDepStack[] = [];
246283
$body = $this->compileProgram($program);
247284
if ($bp) {
248285
array_shift($this->blockParamValues);
249286
}
250287

251-
$declaresBp = (bool) $bp;
252-
$inheritsBp = $this->lastCompileProgramHadDirectBpRef;
288+
$usesBp = $bp || $this->lastCompileProgramHadDirectBpRef;
253289
$preamble = '';
254290
if (str_contains($body, '$cx->depths[count($cx->depths)-')) {
255291
$preamble = '$sc=count($cx->depths);';
256292
$body = str_replace('$cx->depths[count($cx->depths)-', '$cx->depths[$sc-', $body);
257293
}
258-
$sig = match (true) {
259-
$declaresBp => "function(\$cx, \$in, array \$blockParams = [])",
260-
$inheritsBp => "function(\$cx, \$in) use (\$blockParams)",
261-
default => "function(\$cx, \$in)",
262-
};
263-
return "$sig {{$preamble}return $body;}";
294+
$sig = $usesBp
295+
? "function(\$cx, \$in, array \$blockParams = [])"
296+
: "function(\$cx, \$in)";
297+
298+
$deps = array_pop($this->programDepStack);
299+
if ($deps) {
300+
$sig .= ' use (' . implode(', ', $deps) . ')';
301+
}
302+
303+
$id = $this->nextProgramId++;
304+
$var = "\$p{$id}";
305+
$this->programDefs[] = "{$var} = {$sig} {{$preamble}return {$body};};";
306+
// Propagate this var to the parent dep level so callers can capture it.
307+
$this->programDepStack[array_key_last($this->programDepStack)][] = $var;
308+
return $var;
264309
}
265310

266311
private function compileBlockHelper(BlockStatement $block, string $name): string
@@ -366,14 +411,15 @@ private function DecoratorBlock(BlockStatement $block): string
366411
$partialName = $this->getLiteralKeyName($firstArg);
367412
}
368413

414+
$depsBefore = count($this->programDepStack[array_key_last($this->programDepStack)]);
369415
$body = $this->compileProgramOrEmpty($block->program);
370416

371417
// Register in usedPartial so {{> partialName}} can compile without error.
372418
// Do NOT add to partialCode - `in()` handles runtime registration, keeping inline partials block-scoped.
373419
$this->context->usedPartial[$partialName] = '';
374420

375-
// Capture $blockParams if we're inside a block-param scope so the inline partial body can access them.
376-
$useVars = $this->blockParamsUseVars();
421+
// Capture $blockParams and any hoisted program vars so the inline partial body can access them.
422+
$useVars = $this->buildInlineUseClause($depsBefore);
377423
$escapedName = self::quote($partialName);
378424
return self::getRuntimeFunc('in', "\$cx, $escapedName, " . self::templateClosure($body, useVars: $useVars));
379425
}
@@ -424,6 +470,7 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
424470
}
425471

426472
$name = $statement->name;
473+
$depsBefore = count($this->programDepStack[array_key_last($this->programDepStack)]);
427474
$body = $this->compileProgram($statement->program);
428475
$partialName = null;
429476
$found = false;
@@ -450,8 +497,8 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
450497

451498
$vars = $this->compilePartialParams($statement->params, $statement->hash);
452499

453-
// Capture $blockParams if we're inside a block-param scope so the partial block body can access them.
454-
$useVars = $this->blockParamsUseVars();
500+
// Capture $blockParams and any hoisted program vars so the partial block body can access them.
501+
$useVars = $this->buildInlineUseClause($depsBefore);
455502
$bodyClosure = self::templateClosure($body, useVars: $useVars);
456503

457504
if ($partialName !== null && !$found) {
@@ -673,9 +720,16 @@ private function compilePartialTemplate(string $name, string $template): void
673720
}
674721

675722
$program = $this->parser->parse($template, $this->context->options->ignoreStandalone);
676-
$code = (new Compiler($this->parser))->compile($program, $this->context);
723+
$partialCompiler = new self($this->parser);
724+
$code = $partialCompiler->compile($program, $this->context);
725+
$closureExpr = self::templateClosure($code, useVars: implode(', ', $partialCompiler->renderDeps));
726+
727+
if ($partialCompiler->programDefs) {
728+
$defs = implode("\n", $partialCompiler->programDefs) . "\n";
729+
$closureExpr = "(static function() {\n{$defs}return {$closureExpr};\n})()";
730+
}
677731

678-
$this->context->partialCode[$name] = self::quote($name) . ' => ' . self::templateClosure($code);
732+
$this->context->partialCode[$name] = self::quote($name) . ' => ' . $closureExpr;
679733
}
680734

681735
public function handleDynamicPartials(): void
@@ -814,7 +868,7 @@ private static function buildKeyAccess(array $parts): string
814868

815869
private function buildBlockHelperCall(string $helperExpr, string $escapedName, BlockStatement $block, string $fn, string $else): string
816870
{
817-
// Mark the enclosing bp-declaring closure as needing to capture $blockParams via use().
871+
// Mark the enclosing closure as needing $blockParams in its signature.
818872
if ($this->blockParamValues && $this->bpRefStack) {
819873
$this->bpRefStack[array_key_last($this->bpRefStack)] = true;
820874
}
@@ -823,7 +877,7 @@ private function buildBlockHelperCall(string $helperExpr, string $escapedName, B
823877
$params = $this->compileParams($block->params, $block->hash);
824878

825879
// omit trailing bpCount/outerBp args when both are zero/empty
826-
$trailingArgs = ($bpCount > 0 || $outerBp !== '[]') ? ", $bpCount, $outerBp" : '';
880+
$trailingArgs = ($bpCount > 0 || $outerBp !== '[]') ? ", $outerBp, $bpCount" : '';
827881
$args = "\$cx, $helperExpr, $escapedName, $params, \$in, $fn, $else";
828882
return self::getRuntimeFunc('hbbch', $args . $trailingArgs);
829883
}

src/HelperOptions.php

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ private function invokeBlock(Closure $closure, mixed $context, mixed $data): str
116116
// body can traverse back up to the caller's context.
117117
$cx->depths[] = $scope;
118118
}
119-
$ret = $closure($cx, $resolvedContext, $bpStack);
119+
$ret = $closure($cx, $resolvedContext, $bpStack ?? $this->outerBlockParams);
120120
if ($pushDepths) {
121121
array_pop($cx->depths);
122122
}
@@ -156,10 +156,13 @@ public function iterate(array $items): string
156156
$ret = '';
157157
$i = 0;
158158
$outerFrame = $cx->data;
159-
// Pre-allocate bpStack once; mutate [0][0] and [0][1] per iteration.
160-
// PHP COW ensures the inner array's refcount returns to 1 after $cb() returns,
159+
$hasBp = $this->blockParams > 0;
160+
// When block params are declared, pre-allocate a slot at depth 0 and mutate it each
161+
// iteration. PHP COW ensures the inner array's refcount returns to 1 after $cb() returns,
161162
// so the next iteration's assignment is an in-place mutation, not a copy.
162-
$bpStack = [[null, null], ...$this->outerBlockParams];
163+
// When no block params are declared, pass outerBlockParams directly — prepending a slot
164+
// would shift all compiled depth indices by 1.
165+
$bpStack = $hasBp ? [[null, null], ...$this->outerBlockParams] : $this->outerBlockParams;
163166
$data = Handlebars::createFrame($outerFrame);
164167
$data['first'] = true;
165168

@@ -169,8 +172,10 @@ public function iterate(array $items): string
169172
$data['last'] = $i === $last;
170173
$cx->data = $data;
171174

172-
$bpStack[0][0] = $value;
173-
$bpStack[0][1] = $index;
175+
if ($hasBp) {
176+
$bpStack[0][0] = $value;
177+
$bpStack[0][1] = $index;
178+
}
174179
$ret .= $cb($cx, $value, $bpStack);
175180
$data['first'] = false;
176181
$i++;

src/Runtime.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,12 +310,13 @@ public static function raw(mixed $value): string
310310
* @param mixed $in input data with current scope
311311
* @param Closure|null $cb callback function to render child context; null for inverted sections
312312
* @param Closure|null $else callback function to render child context when {{else}}
313+
* @param array<mixed> $outerBlockParams outer block param stack, threaded into helper dispatch
313314
*/
314-
public static function sec(RuntimeContext $cx, mixed $value, mixed $in, ?Closure $cb, ?Closure $else = null, ?string $helperName = null): string
315+
public static function sec(RuntimeContext $cx, mixed $value, mixed $in, ?Closure $cb, ?Closure $else, ?string $helperName, array $outerBlockParams = []): string
315316
{
316317
$helper = $helperName !== null ? ($cx->helpers[$helperName] ?? null) : null;
317318
if ($helper !== null) {
318-
return static::hbbch($cx, $helper, $helperName, [], [], $in, $cb, $else);
319+
return static::hbbch($cx, $helper, $helperName, [], [], $in, $cb, $else, $outerBlockParams);
319320
}
320321

321322
// Lambda functions in block position: simple-path identifiers ($helperName set) receive
@@ -328,7 +329,7 @@ public static function sec(RuntimeContext $cx, mixed $value, mixed $in, ?Closure
328329
return static::resolveBlockResult($cx, $result, $in, $cb, $else);
329330
}
330331

331-
return static::hbbch($cx, $cx->helpers['blockHelperMissing'], $helperName ?? '', [$value], [], $in, $cb, $else);
332+
return static::hbbch($cx, $cx->helpers['blockHelperMissing'], $helperName ?? '', [$value], [], $in, $cb, $else, $outerBlockParams);
332333
}
333334

334335
/**
@@ -493,7 +494,7 @@ public static function hbch(RuntimeContext $cx, Closure $helper, string $name, a
493494
* @param Closure|null $else callback function to render child context when {{else}}
494495
* @param array<mixed> $outerBlockParams outer block param stack for block params declared by the template
495496
*/
496-
public static function hbbch(RuntimeContext $cx, Closure $helper, string $name, array $positional, array $hash, mixed &$_this, ?Closure $cb, ?Closure $else, int $blockParamCount = 0, array $outerBlockParams = []): string
497+
public static function hbbch(RuntimeContext $cx, Closure $helper, string $name, array $positional, array $hash, mixed &$_this, ?Closure $cb, ?Closure $else, array $outerBlockParams = [], int $blockParamCount = 0): string
497498
{
498499
$positional[] = new HelperOptions(
499500
scope: $_this,

0 commit comments

Comments
 (0)