Skip to content

Commit 8270d50

Browse files
committed
Fix block param and literal path lookups in strict mode
1 parent 2ccdd93 commit 8270d50

4 files changed

Lines changed: 44 additions & 34 deletions

File tree

src/Compiler.php

Lines changed: 15 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ private function BlockStatement(BlockStatement $block): string
172172
}
173173

174174
$escapedKey = self::quote($literalKey);
175-
$var = "\$in[$escapedKey] ?? " . $this->missValue($literalKey);
175+
$var = $this->compileModeAwareLookup('$in', [$literalKey], $literalKey);
176176

177177
if ($block->program === null) {
178178
return $this->compileInvertedSection($block, $var, $escapedKey);
@@ -554,8 +554,7 @@ private function MustacheStatement(MustacheStatement $mustache): string
554554
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', "\$cx, $escapedKey, \$in"));
555555
}
556556

557-
$miss = $this->missValue($literalKey);
558-
return self::getRuntimeFunc($fn, "\$in[$escapedKey] ?? $miss");
557+
return self::getRuntimeFunc($fn, $this->compileModeAwareLookup('$in', [$literalKey], $literalKey));
559558
}
560559

561560
// ── Expressions ─────────────────────────────────────────────────
@@ -611,38 +610,36 @@ private function PathExpression(PathExpression $expression): string
611610
}
612611

613612
$isLength = end($stringParts) === 'length';
614-
$varParts = $isLength ? array_slice($stringParts, 0, -1) : $stringParts;
615-
$miss = $this->missValue($expression->original);
616613

617614
// Check block params (depth-0, non-data, non-scoped paths only, not SubExpression-headed)
618615
if (!$hasSubExprHead && !$data && $depth === 0 && !self::scopedId($expression)) {
619-
$bp = $this->lookupBlockParam($stringParts[0]);
616+
$bp = $this->lookupBlockParam($expression->head);
620617
if ($bp !== null) {
621618
[$bpDepth, $bpIndex] = $bp;
622619
$bpBase = "\$blockParams[$bpDepth][$bpIndex]";
623620
// Mark the current compileProgram() level as having a direct $blockParams reference.
624621
if ($this->bpRefStack) {
625622
$this->bpRefStack[array_key_last($this->bpRefStack)] = true;
626623
}
627-
// Skip the block param name ($varParts[0]) since it has been resolved to a $blockParams index.
628-
$parent = $bpBase . self::buildKeyAccess(array_slice($varParts, 1));
629-
if ($isLength) {
630-
return $this->buildLookupLength($parent);
631-
}
632-
return "$parent ?? $miss";
624+
625+
// Skip the block param name since it has been resolved to a $blockParams index.
626+
$keys = $isLength ? array_slice($expression->tail, 0, -1) : $expression->tail;
627+
$lookup = $this->compileModeAwareLookup($bpBase, $keys, $expression->original);
628+
return $isLength ? $this->buildLookupLength($lookup) : $lookup;
633629
}
634630
}
635631

636632
// Handle .length: compile parent path through the normal mode-aware logic, then wrap in
637633
// lookupLength() at runtime. This mirrors HBS.js, where .length is a normal property
638634
// access with no compile-time special casing.
639635
if ($isLength) {
636+
$partsExceptLength = array_slice($stringParts, 0, -1);
640637
return $this->buildLookupLength(
641-
$this->compileModeAwareLookup($base, $varParts, $expression->original, 'null'),
638+
$this->compileModeAwareLookup($base, $partsExceptLength, $expression->original),
642639
);
643640
}
644641

645-
return $this->compileModeAwareLookup($base, $stringParts, $expression->original, $miss);
642+
return $this->compileModeAwareLookup($base, $stringParts, $expression->original);
646643
}
647644

648645
/**
@@ -841,9 +838,9 @@ private function resolvePartialName(PathExpression|StringLiteral|NumberLiteral $
841838
* An optional $extraArg is appended to every call's argument list.
842839
* @param string[] $parts
843840
*/
844-
private static function buildCallChain(string $fn, string $base, array $parts, string $extraArg = ''): string
841+
private static function buildCallChain(string $fn, string $base, array $parts, ?string $extraArg = null): string
845842
{
846-
$extra = $extraArg !== '' ? ", $extraArg" : '';
843+
$extra = $extraArg !== null ? ", $extraArg" : '';
847844
$expr = $base;
848845
foreach ($parts as $part) {
849846
$expr = self::getRuntimeFunc($fn, "$expr, " . self::quote($part) . $extra);
@@ -878,13 +875,6 @@ private static function quote(string $string): string
878875
return "'" . addcslashes($string, "'\\") . "'";
879876
}
880877

881-
private function missValue(string $key): string
882-
{
883-
return ($this->context->options->strict && !$this->compilingHelperArgs)
884-
? self::getRuntimeFunc('miss', self::quote($key))
885-
: 'null';
886-
}
887-
888878
private function compileProgramOrNull(?Program $program): string
889879
{
890880
if (!$program) {
@@ -913,7 +903,7 @@ private function buildLookupLength(string $parent): string
913903
* Compile a mode-aware path access expression for the given base and parts.
914904
* @param string[] $parts
915905
*/
916-
private function compileModeAwareLookup(string $base, array $parts, string $original, string $miss): string
906+
private function compileModeAwareLookup(string $base, array $parts, string $original): string
917907
{
918908
if (!$parts) {
919909
return $base;
@@ -927,7 +917,7 @@ private function compileModeAwareLookup(string $base, array $parts, string $orig
927917
if ($this->context->options->strict) {
928918
return self::buildCallChain('strictLookup', $base, $parts, self::quote($original));
929919
}
930-
return $base . self::buildKeyAccess($parts) . " ?? $miss";
920+
return $base . self::buildKeyAccess($parts) . ' ?? null';
931921
}
932922

933923
private function throwKnownHelpersOnly(string $helperName): never

src/Runtime.php

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,22 +111,14 @@ public static function defaultHelpers(): array
111111
];
112112
}
113113

114-
/**
115-
* Throw exception for missing expression. Only used in strict mode.
116-
*/
117-
public static function miss(string $v): void
118-
{
119-
throw new \Exception('"' . $v . '" not defined');
120-
}
121-
122114
/**
123115
* Strict-mode key lookup: throw if $base is not an array or $key is absent.
124116
* Unlike the null-coalescing pattern, this allows null values when the key exists.
125117
*/
126118
public static function strictLookup(mixed $base, string $key, string $original): mixed
127119
{
128120
if (!is_array($base) || !array_key_exists($key, $base)) {
129-
self::miss($original);
121+
throw new \Exception('"' . $original . '" not defined');
130122
}
131123
return $base[$key];
132124
}

tests/ErrorTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ public static function renderErrorProvider(): array
192192
'template' => '{{#each}}OK!{{/each}}',
193193
'expected' => 'Must pass iterator to #each',
194194
],
195+
[
196+
'desc' => 'assumeObjects mode should throw on null block param intermediate',
197+
'template' => '{{#each items as |item|}}{{item.nested.val}}{{/each}}',
198+
'options' => new Options(assumeObjects: true),
199+
'data' => ['items' => [['nested' => null]]],
200+
'expected' => 'Cannot access property "val" on null',
201+
],
195202
[
196203
'desc' => 'strict mode should throw for missing block param property',
197204
'template' => '{{#each items as |item|}}{{item.missing}}{{/each}}',

tests/RegressionTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2228,6 +2228,27 @@ public static function missingDataProvider(): array
22282228
'data' => ['foo' => []],
22292229
'expected' => '',
22302230
],
2231+
[
2232+
'desc' => 'strict mode should not throw for null block param property value',
2233+
'template' => '{{#each items as |item|}}{{item.val}}{{/each}}',
2234+
'options' => new Options(strict: true),
2235+
'data' => ['items' => [['val' => null]]],
2236+
'expected' => '',
2237+
],
2238+
[
2239+
'desc' => 'strict mode should not throw for explicit null value at literal mustache path',
2240+
'template' => '{{"foo"}}',
2241+
'options' => new Options(strict: true),
2242+
'data' => ['foo' => null],
2243+
'expected' => '',
2244+
],
2245+
[
2246+
'desc' => 'strict mode should not throw for explicit null value at literal block section path',
2247+
'template' => '{{#"foo"}}YES{{/"foo"}}',
2248+
'options' => new Options(strict: true),
2249+
'data' => ['foo' => null],
2250+
'expected' => '',
2251+
],
22312252
];
22322253
}
22332254

0 commit comments

Comments
 (0)