Skip to content

Commit fc84799

Browse files
committed
Fix inverted section and else block edge cases
1 parent f64be1a commit fc84799

5 files changed

Lines changed: 81 additions & 91 deletions

File tree

src/Compiler.php

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ private function BlockStatement(BlockStatement $block): string
176176
$var = "\$in[$escapedKey] ?? " . $this->missValue($literalKey);
177177

178178
if ($block->program === null) {
179-
return $this->compileInvertedSection($block, $var, null);
179+
return $this->compileInvertedSection($block, $var, $escapedKey);
180180
}
181181

182182
return $this->compileSection($block, $var, $escapedKey);
@@ -227,14 +227,11 @@ private function compileSection(BlockStatement $block, string $var, string $esca
227227

228228
private function compileInvertedSection(BlockStatement $block, string $var, ?string $escapedName): string
229229
{
230-
$body = $this->compileProgramOrEmpty($block->inverse);
230+
assert($block->inverse !== null);
231231

232-
if ($escapedName !== null) {
233-
$blockFn = self::blockClosure($body, inheritsBp: $this->lastCompileProgramHadDirectBpRef);
234-
return self::getRuntimeFunc('isech', "\$cx, $var, \$in, $blockFn, $escapedName");
235-
}
236-
237-
return "(" . self::getRuntimeFunc('isec', $var) . " ? $body : '')";
232+
$blockFn = $this->compileProgramWithBlockParams($block->inverse);
233+
$name = ($escapedName !== null && !$this->context->options->knownHelpersOnly) ? ", $escapedName" : '';
234+
return self::getRuntimeFunc('sec', "\$cx, $var, \$in, null, $blockFn$name");
238235
}
239236

240237
/** Returns '$blockParams' when inside a block-param scope (for use() capture), '' otherwise. */
@@ -262,6 +259,8 @@ private function outerBlockParamsExpr(): string
262259

263260
/**
264261
* Compile a block program, pushing/popping its block params around the compilation.
262+
* Returns a PHP closure string: the signature varies based on whether the program declares or
263+
* inherits block params, and a $sc preamble is added when depths are accessed multiple times.
265264
*/
266265
private function compileProgramWithBlockParams(Program $program): string
267266
{
@@ -273,7 +272,20 @@ private function compileProgramWithBlockParams(Program $program): string
273272
if ($bp) {
274273
array_shift($this->blockParamValues);
275274
}
276-
return self::blockClosure($body, (bool) $bp, $this->lastCompileProgramHadDirectBpRef);
275+
276+
$declaresBp = (bool) $bp;
277+
$inheritsBp = $this->lastCompileProgramHadDirectBpRef;
278+
$preamble = '';
279+
if (str_contains($body, '$cx->depths[count($cx->depths)-')) {
280+
$preamble = '$sc=count($cx->depths);';
281+
$body = str_replace('$cx->depths[count($cx->depths)-', '$cx->depths[$sc-', $body);
282+
}
283+
$sig = match (true) {
284+
$declaresBp => "function(\$cx, \$in, array \$blockParams = [])",
285+
$inheritsBp => "function(\$cx, \$in) use (\$blockParams)",
286+
default => "function(\$cx, \$in)",
287+
};
288+
return "$sig {{$preamble}return $body;}";
277289
}
278290

279291
private function compileBlockHelper(BlockStatement $block, string $name): string
@@ -359,7 +371,7 @@ private function compileDynamicBlockHelper(BlockStatement $block, string $name,
359371
$params = $this->compileParams($block->params, $block->hash);
360372
$blockFn = $block->program !== null
361373
? $this->compileProgramWithBlockParams($block->program)
362-
: self::blockClosure("''");
374+
: 'null';
363375
$else = $this->compileElseClause($block);
364376
$outerBp = $this->outerBlockParamsExpr();
365377
$helperName = self::quote($name);
@@ -905,26 +917,6 @@ private static function getRuntimeFunc(string $name, string $args): string
905917
return "LR::$name($args)";
906918
}
907919

908-
/**
909-
* @param bool $declaresBp true when this closure receives new block param values as its third argument
910-
* @param bool $inheritsBp true when this closure must capture $blockParams from the enclosing scope
911-
*/
912-
private static function blockClosure(string $body, bool $declaresBp = false, bool $inheritsBp = false): string
913-
{
914-
$preamble = '';
915-
if (str_contains($body, '$cx->depths[count($cx->depths)-')) {
916-
$preamble = '$sc=count($cx->depths);';
917-
$body = str_replace('$cx->depths[count($cx->depths)-', '$cx->depths[$sc-', $body);
918-
}
919-
// Inherits block params from the enclosing closure's $blockParams variable when $inheritsBp.
920-
$sig = match (true) {
921-
$declaresBp => "function(\$cx, \$in, array \$blockParams = [])",
922-
$inheritsBp => "function(\$cx, \$in) use (\$blockParams)",
923-
default => "function(\$cx, \$in)",
924-
};
925-
return "$sig {{$preamble}return $body;}";
926-
}
927-
928920
private static function quote(string $string): string
929921
{
930922
return "'" . addcslashes($string, "'\\") . "'";

src/HelperOptions.php

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,37 +72,34 @@ public function fn(mixed $context = Scope::Use, mixed $data = null): string
7272
return '';
7373
}
7474
$cx = $this->cx;
75-
$scope = $this->scope;
76-
7775
// Save inlinePartials so that any {{#* inline}} partials registered inside the block body
7876
// don't leak out after fn() returns. The spec requires inline partials to be
7977
// block-scoped. PHP copy-on-write makes this assignment cheap when no inline partials are registered.
8078
$savedInlinePartials = $cx->inlinePartials;
81-
82-
// Skip depths push for explicit same-context pass (equivalent to HBS.js options.fn(this))
83-
$skipDepths = $context === $scope;
84-
$resolvedContext = $skipDepths ? $scope : ($context === Scope::Use ? $scope : $context);
85-
$ret = $this->callBlock($this->cb, $resolvedContext, !$skipDepths, $data);
86-
79+
$ret = $this->invokeBlock($this->cb, $context, $data);
8780
$cx->inlinePartials = $savedInlinePartials;
8881
return $ret;
8982
}
9083

91-
public function inverse(mixed $context = null, mixed $data = null): string
84+
public function inverse(mixed $context = Scope::Use, mixed $data = null): string
9285
{
9386
if ($this->inv === null) {
9487
if ($this->cb === null) {
9588
throw new \Exception('inverse() is not supported for inline helpers');
9689
}
9790
return '';
9891
}
99-
return $this->callBlock($this->inv, $context ?? $this->scope, $context !== null, $data);
92+
return $this->invokeBlock($this->inv, $context, $data);
10093
}
10194

102-
/** @param array<mixed>|null $data */
103-
private function callBlock(\Closure $closure, mixed $context, bool $pushDepths, ?array $data): string
95+
private function invokeBlock(\Closure $closure, mixed $context, mixed $data): string
10496
{
10597
$cx = $this->cx;
98+
$scope = $this->scope;
99+
// Skip depths push when the caller explicitly passes the current scope (equivalent to
100+
// HBS.js options.fn(this) / options.inverse(this)), since the scope level isn't changing.
101+
$pushDepths = $context !== $scope;
102+
$resolvedContext = $pushDepths ? ($context === Scope::Use ? $scope : $context) : $scope;
106103
$savedFrame = null;
107104
$bpStack = null;
108105

@@ -123,9 +120,9 @@ private function callBlock(\Closure $closure, mixed $context, bool $pushDepths,
123120
if ($pushDepths) {
124121
// Push the current scope onto depths so that ../ path expressions inside the block
125122
// body can traverse back up to the caller's context.
126-
$cx->depths[] = $this->scope;
123+
$cx->depths[] = $scope;
127124
}
128-
$ret = $closure($cx, $context, $bpStack);
125+
$ret = $closure($cx, $resolvedContext, $bpStack);
129126
if ($pushDepths) {
130127
array_pop($cx->depths);
131128
}

src/Runtime.php

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -263,29 +263,6 @@ public static function ifvar(mixed $v, bool $zero = false): bool
263263
&& (!is_array($v) || $v);
264264
}
265265

266-
/**
267-
* Returns true if an inverse block {{^var}} should be rendered.
268-
*
269-
* @param array<array<mixed>|string|int>|string|int|bool|null $v value to be tested
270-
*
271-
* @return bool Return true when the value is null or false or empty
272-
*/
273-
public static function isec(mixed $v): bool
274-
{
275-
return $v === null || $v === false || (is_array($v) && !$v);
276-
}
277-
278-
/**
279-
* Inverted section with runtime helper check.
280-
*/
281-
public static function isech(RuntimeContext $cx, mixed $v, mixed $in, \Closure $else, string $helperName): string
282-
{
283-
if (isset($cx->helpers[$helperName])) {
284-
return static::hbbch($cx, $cx->helpers[$helperName], $helperName, [], [], $in, null, $else);
285-
}
286-
return static::hbbch($cx, $cx->helpers['blockHelperMissing'], $helperName, [$v], [], $in, null, $else);
287-
}
288-
289266
/**
290267
* HTML encode {{var}} just like Handlebars.js
291268
*/
@@ -327,28 +304,26 @@ public static function raw(mixed $value): string
327304
}
328305

329306
/**
330-
* For {{#var}} sections.
307+
* For {{#var}} and {{^var}} sections.
308+
* Pass null for $cb when compiling an inverted section ({{^var}}) — blockHelperMissing will call inverse().
331309
*
332310
* @param mixed $in input data with current scope
333-
* @param \Closure $cb callback function to render child context
311+
* @param \Closure|null $cb callback function to render child context; null for inverted sections
334312
* @param \Closure|null $else callback function to render child context when {{else}}
335313
*/
336-
public static function sec(RuntimeContext $cx, mixed $value, mixed $in, \Closure $cb, ?\Closure $else = null, ?string $helperName = null): string
314+
public static function sec(RuntimeContext $cx, mixed $value, mixed $in, ?\Closure $cb, ?\Closure $else = null, ?string $helperName = null): string
337315
{
338316
if ($helperName !== null && isset($cx->helpers[$helperName])) {
339317
return static::hbbch($cx, $cx->helpers[$helperName], $helperName, [], [], $in, $cb, $else);
340318
}
341319

342-
// Lambda functions in block position receive HelperOptions directly.
343-
// This must be checked before blockHelperMissing routing.
320+
// Lambda functions in block position: simple-path identifiers ($helperName set) receive
321+
// HelperOptions so they can render fn/inverse; complex paths ($helperName null) are called
322+
// with no arguments, mirroring HBS.js which does not treat them as helper calls.
344323
if ($value instanceof \Closure) {
345-
$result = $value(new HelperOptions(
346-
scope: $in,
347-
data: $cx->frame,
348-
cx: $cx,
349-
cb: $cb,
350-
inv: $else,
351-
));
324+
$result = $helperName !== null
325+
? $value(new HelperOptions(scope: $in, data: $cx->frame, cx: $cx, cb: $cb, inv: $else))
326+
: $value();
352327
return static::resolveBlockResult($cx, $result, $in, $cb, $else);
353328
}
354329

@@ -549,7 +524,7 @@ public static function hbbch(RuntimeContext $cx, \Closure $helper, string $name,
549524
* @param \Closure|null $else callback function to render child context when {{else}}
550525
* @param array<mixed> $outerBlockParams outer block param stack for block params declared by the template
551526
*/
552-
public static function dynhbbch(RuntimeContext $cx, string $name, mixed $callable, array $positional, array $hash, mixed &$_this, \Closure $cb, ?\Closure $else, int $blockParamCount, array $outerBlockParams): mixed
527+
public static function dynhbbch(RuntimeContext $cx, string $name, mixed $callable, array $positional, array $hash, mixed &$_this, ?\Closure $cb, ?\Closure $else, int $blockParamCount, array $outerBlockParams): mixed
553528
{
554529
$helper = $cx->helpers[$name] ?? null;
555530
if ($helper !== null) {

tests/RegressionTest.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,12 +782,21 @@ public static function helperProvider(): array
782782
'template' => '{{^helper items as |foo bar baz|}}{{foo}}{{bar}}{{baz}}{{/helper}}',
783783
'helpers' => [
784784
'helper' => function (array $items, HelperOptions $options) {
785-
return $options->inverse(null, ['blockParams' => [1, 2, 3]]);
785+
return $options->inverse($options->scope, ['blockParams' => [1, 2, 3]]);
786786
},
787787
],
788788
'data' => ['items' => []],
789789
'expected' => '123',
790790
],
791+
[
792+
'desc' => 'inverse() called with no args at top level: ../ in else body resolves to current scope',
793+
'template' => '{{^myHelper}}{{../parent}}{{/myHelper}}',
794+
'data' => ['parent' => 'value'],
795+
'helpers' => [
796+
'myHelper' => fn(HelperOptions $options) => $options->inverse(),
797+
],
798+
'expected' => 'value',
799+
],
791800
[
792801
'desc' => 'inverted block helper returning truthy non-string: stringified like JS',
793802
'template' => '{{^helper}}block{{/helper}}',
@@ -822,6 +831,15 @@ public static function helperProvider(): array
822831
'helpers' => ["it's" => fn(HelperOptions $options) => $options->fn()],
823832
'expected' => 'YES',
824833
],
834+
[
835+
'desc' => 'inverted literal block path routes through blockHelperMissing',
836+
'template' => '{{^"foo"}}EMPTY{{/"foo"}}',
837+
'data' => ['foo' => false],
838+
'helpers' => [
839+
'blockHelperMissing' => fn(mixed $ctx, HelperOptions $opts) => 'BHM:' . $opts->inverse(),
840+
],
841+
'expected' => 'BHM:EMPTY',
842+
],
825843

826844
[
827845
'template' => '{{#myif foo}}YES{{else}}NO{{/myif}}',
@@ -1968,6 +1986,14 @@ public static function sectionProvider(): array
19681986
'data' => ['items' => ['a', 'b']],
19691987
'expected' => '0: a, 1: blast!',
19701988
],
1989+
[
1990+
'desc' => 'knownHelpersOnly: inverted section skips dispatch to unregistered helpers',
1991+
'template' => '{{^items}}EMPTY{{/items}}',
1992+
'options' => new Options(knownHelpersOnly: true),
1993+
'data' => ['items' => false],
1994+
'helpers' => ['items' => fn() => 'HELPER_CALLED'],
1995+
'expected' => 'EMPTY',
1996+
],
19711997
[
19721998
'desc' => 'knownHelpersOnly: blockHelperMissing is called for inverted sections',
19731999
'template' => '{{^items}}EMPTY{{/items}}',
@@ -1998,6 +2024,16 @@ public static function sectionProvider(): array
19982024
],
19992025
'expected' => 'x:BODY',
20002026
],
2027+
[
2028+
'desc' => 'lambda at complex path in inverted block is called with no arguments',
2029+
'template' => '{{^obj.fn}}BODY{{/obj.fn}}',
2030+
'data' => [
2031+
'obj' => [
2032+
'fn' => fn(mixed ...$args) => count($args) . ' arguments',
2033+
],
2034+
],
2035+
'expected' => '0 arguments',
2036+
],
20012037
[
20022038
'desc' => 'forward block with no else: isset($options->inverse) is true and inverse() returns empty string',
20032039
'template' => '{{#checkInv}}BODY{{/checkInv}}',

tests/RuntimeTest.php

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,6 @@ public function testIfVar(): void
2424
$this->assertTrue(Runtime::ifvar(self::createStringable('0')));
2525
}
2626

27-
public function testIsec(): void
28-
{
29-
$this->assertTrue(Runtime::isec(null));
30-
$this->assertFalse(Runtime::isec(0));
31-
$this->assertTrue(Runtime::isec(false));
32-
$this->assertFalse(Runtime::isec('false'));
33-
$this->assertTrue(Runtime::isec([]));
34-
$this->assertFalse(Runtime::isec(['1']));
35-
}
36-
3727
private static function createStringable(string $value): \Stringable
3828
{
3929
return new class ($value) implements \Stringable {

0 commit comments

Comments
 (0)