Skip to content

Commit 7ead2b1

Browse files
committed
Test hoisting block program closures
1 parent eb0abe0 commit 7ead2b1

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
/**
@@ -258,8 +282,14 @@ private function outerBlockParamsExpr(): string
258282

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

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

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

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

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

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

src/Handlebars.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public static function precompile(string $template, Options $options = new Optio
2727
$context = new Context($options);
2828
$parser = (new ParserFactory())->create($options->ignoreStandalone);
2929
$program = $parser->parse($template);
30-
$compiler = new Compiler($parser);
30+
$compiler = new Compiler($parser, isHoistingEnabled: true);
3131
$code = $compiler->compile($program, $context);
3232
$compiler->handleDynamicPartials();
3333

0 commit comments

Comments
 (0)