Skip to content

Commit 038007b

Browse files
committed
Test hoisting block program closures
1 parent a93a564 commit 038007b

2 files changed

Lines changed: 77 additions & 7 deletions

File tree

src/Compiler.php

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ final class Compiler
3737
*/
3838
private array $blockParamValues = [];
3939

40+
/**
41+
* Hoisted block program closures: varName => closure string.
42+
* Populated during main-template compilation; emitted before the render closure in composePHPRender
43+
* so they are created once per template() call and reused across all render calls.
44+
* Only populated when $isHoistingEnabled is true (main compiler instance, not partial compilers).
45+
* @var array<string, string>
46+
*/
47+
private array $hoistedPrograms = [];
48+
private int $hoistedProgramIdx = 0;
49+
4050
/**
4151
* Stack of booleans, one per active compileProgram() call.
4252
* Each entry is set to true if that invocation directly emitted a $blockParams reference.
@@ -58,8 +68,13 @@ final class Compiler
5868
*/
5969
private bool $compilingHelperArgs = false;
6070

71+
/**
72+
* @param bool $isHoistingEnabled Only true for the main-template Compiler. Partial compilers use the default
73+
* so their block programs remain inline (partial closures are recreated per render call anyway).
74+
*/
6175
public function __construct(
6276
private readonly Parser $parser,
77+
private readonly bool $isHoistingEnabled = false,
6378
) {}
6479

6580
public function compile(Program $program, Context $context): string
@@ -68,6 +83,8 @@ public function compile(Program $program, Context $context): string
6883
$this->blockParamValues = [];
6984
$this->bpRefStack = [];
7085
$this->lastCompileProgramHadDirectBpRef = false;
86+
$this->hoistedPrograms = [];
87+
$this->hoistedProgramIdx = 0;
7188
return $this->compileProgram($program);
7289
}
7390

@@ -79,8 +96,15 @@ public function compile(Program $program, Context $context): string
7996
public function composePHPRender(string $code): string
8097
{
8198
$partials = implode(",\n", $this->context->partialCode);
82-
$closure = self::templateClosure($code, $partials, "\n \$in = &\$cx->data['root'];");
83-
return "use " . Runtime::class . " as LR;\nreturn $closure;";
99+
$useVars = $this->hoistedPrograms ? implode(', ', array_keys($this->hoistedPrograms)) : '';
100+
$closure = self::templateClosure($code, $partials, "\n \$in = &\$cx->data['root'];", $useVars);
101+
102+
$assignments = '';
103+
foreach ($this->hoistedPrograms as $var => $closureStr) {
104+
$assignments .= "$var = $closureStr;\n";
105+
}
106+
107+
return "use " . Runtime::class . " as LR;\n{$assignments}return $closure;";
84108
}
85109

86110
/**
@@ -257,8 +281,14 @@ private function outerBlockParamsExpr(): string
257281

258282
/**
259283
* Compile a block program, pushing/popping its block params around the compilation.
260-
* Returns a PHP closure string: the signature varies based on whether the program declares or
261-
* inherits block params, and a $sc preamble is added when depths are accessed multiple times.
284+
* Returns either a hoisted variable reference (e.g. '$p0') or an inline closure string.
285+
*
286+
* Closures that do not capture any runtime variable ($declaresBp receive $blockParams as a
287+
* parameter; default programs have no block-param dependency) are hoisted to eval-scope
288+
* variables so they are created once at template() time and reused across render calls.
289+
*
290+
* The one non-hoistable case is $inheritsBp && !$declaresBp: the closure must capture
291+
* $blockParams from its enclosing runtime scope via use(), so it must stay inline.
262292
*/
263293
private function compileProgramWithBlockParams(Program $program): string
264294
{
@@ -283,6 +313,26 @@ private function compileProgramWithBlockParams(Program $program): string
283313
$inheritsBp => "function(\$cx, \$in) use (\$blockParams)",
284314
default => "function(\$cx, \$in)",
285315
};
316+
// Hoist closures that capture no runtime variable. The use($blockParams) case
317+
// ($inheritsBp && !$declaresBp) must remain inline; all others are safe to hoist.
318+
if ($this->isHoistingEnabled && !($inheritsBp && !$declaresBp)) {
319+
$varName = '$p' . $this->hoistedProgramIdx++;
320+
// Inner programs compile before outer ones (depth-first), so any $pN referenced in
321+
// this body was already assigned a lower index and will be emitted first. Add a
322+
// use() clause so the hoisted closure can access those sibling variables.
323+
preg_match_all('/\$p\d+/', $preamble . $body, $matches);
324+
$refs = array_unique($matches[0]);
325+
$useClause = $refs ? ' use (' . implode(', ', $refs) . ')' : '';
326+
$this->hoistedPrograms[$varName] = "$sig$useClause {{$preamble}return $body;}";
327+
return $varName;
328+
}
329+
330+
if ($inheritsBp && !$declaresBp) {
331+
// Non-hoistable: use($blockParams) captures a runtime value, so the closure must stay
332+
// inline. Still extend its use() clause with any hoisted $pN it references.
333+
$extendedUse = $this->extendUseVarsWithHoistedRefs('$blockParams', $preamble . $body);
334+
return "function(\$cx, \$in) use ($extendedUse) {{$preamble}return $body;}";
335+
}
286336
return "$sig {{$preamble}return $body;}";
287337
}
288338

@@ -402,7 +452,8 @@ private function DecoratorBlock(BlockStatement $block): string
402452
$this->context->usedPartial[$partialName] = '';
403453

404454
// Capture $blockParams if we're inside a block-param scope so the inline partial body can access them.
405-
$useVars = $this->blockParamsUseVars();
455+
// Also capture any hoisted $pN programs referenced in the body.
456+
$useVars = $this->extendUseVarsWithHoistedRefs($this->blockParamsUseVars(), $body);
406457
$escapedName = self::quote($partialName);
407458
return self::getRuntimeFunc('in', "\$cx, $escapedName, " . self::templateClosure($body, useVars: $useVars));
408459
}
@@ -480,7 +531,8 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
480531
$vars = $this->compilePartialParams($statement->params, $statement->hash);
481532

482533
// Capture $blockParams if we're inside a block-param scope so the partial block body can access them.
483-
$useVars = $this->blockParamsUseVars();
534+
// Also capture any hoisted $pN programs referenced in the body.
535+
$useVars = $this->extendUseVarsWithHoistedRefs($this->blockParamsUseVars(), $body);
484536
$bodyClosure = self::templateClosure($body, useVars: $useVars);
485537

486538
if ($partialName !== null && !$found) {
@@ -924,6 +976,24 @@ private function throwKnownHelpersOnly(string $helperName): never
924976
throw new \Exception("You specified knownHelpersOnly, but used the unknown helper $helperName");
925977
}
926978

979+
/**
980+
* Extends $useVars with any $pN hoisted program references found in $code.
981+
* Called before templateClosure() and for the non-hoistable use($blockParams) closure case,
982+
* so that all closures can see the hoisted program variables they reference.
983+
*/
984+
private function extendUseVarsWithHoistedRefs(string $useVars, string $code): string
985+
{
986+
if (!$this->hoistedPrograms) {
987+
return $useVars;
988+
}
989+
preg_match_all('/\$p\d+/', $code, $matches);
990+
$refs = array_unique($matches[0]);
991+
if (!$refs) {
992+
return $useVars;
993+
}
994+
return $useVars ? $useVars . ', ' . implode(', ', $refs) : implode(', ', $refs);
995+
}
996+
927997
/**
928998
* Build an hbch (known) or dynhbch (unknown) inline helper call string.
929999
* @param Expression[] $params

src/Handlebars.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static function compile(string $template, Options $options = new Options(
3030
public static function precompile(string $template, Options $options = new Options()): string
3131
{
3232
self::$parser ??= (new ParserFactory())->create();
33-
self::$compiler ??= new Compiler(self::$parser);
33+
self::$compiler ??= new Compiler(self::$parser, isHoistingEnabled: true);
3434

3535
$context = new Context($options);
3636
$program = self::$parser->parse($template, $options->ignoreStandalone);

0 commit comments

Comments
 (0)