Skip to content

Commit 6efd9e3

Browse files
committed
Fix length lookup for block params and strict mode
1 parent 5c80640 commit 6efd9e3

4 files changed

Lines changed: 104 additions & 44 deletions

File tree

src/Compiler.php

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -600,70 +600,44 @@ private function PathExpression(PathExpression $expression): string
600600
return $base;
601601
}
602602

603-
$miss = $this->missValue($expression->original);
604-
605603
// @partial-block as variable: truthy when an active partial block exists
606604
if ($data && $depth === 0 && count($stringParts) === 1 && $stringParts[0] === 'partial-block') {
607605
return "\$cx->partialBlock !== null ? true : null";
608606
}
609607

608+
$isLength = end($stringParts) === 'length';
609+
$varParts = $isLength ? array_slice($stringParts, 0, -1) : $stringParts;
610+
$miss = $this->missValue($expression->original);
611+
610612
// Check block params (depth-0, non-data, non-scoped paths only, not SubExpression-headed)
611613
if (!$hasSubExprHead && !$data && $depth === 0 && !self::scopedId($expression)) {
612614
$bp = $this->lookupBlockParam($stringParts[0]);
613615
if ($bp !== null) {
614616
[$bpDepth, $bpIndex] = $bp;
615617
$bpBase = "\$blockParams[$bpDepth][$bpIndex]";
616-
$remaining = self::buildKeyAccess(array_slice($stringParts, 1));
617618
// Mark the current compileProgram() level as having a direct $blockParams reference.
618619
if ($this->bpRefStack) {
619620
$this->bpRefStack[array_key_last($this->bpRefStack)] = true;
620621
}
621-
return "$bpBase$remaining ?? $miss";
622-
}
623-
}
624-
625-
// Build array access string
626-
$n = self::buildKeyAccess($stringParts);
627-
628-
// Handle .length special case
629-
$lastPart = end($stringParts);
630-
if ($lastPart === 'length') {
631-
$varParts = array_slice($stringParts, 0, -1);
632-
$p = self::buildKeyAccess($varParts);
633-
634-
$checks = [];
635-
if ($depth > 0) {
636-
$checks[] = "isset($base)";
637-
}
638-
if ($p !== '' && $depth === 0 && !$hasSubExprHead) {
639-
$checks[] = "isset($base$p)";
640-
}
641-
$baseP = "$base$p";
642-
$checks[] = $baseP === '$in' ? '$inary' : "is_array($base$p)";
643-
644-
$cond = implode(' && ', $checks);
645-
if (count($checks) > 1) {
646-
$cond = "($cond)";
622+
// Skip the block param name ($varParts[0]) since it has been resolved to a $blockParams index.
623+
$parent = $bpBase . self::buildKeyAccess(array_slice($varParts, 1));
624+
if ($isLength) {
625+
return $this->buildLookupLength($parent);
626+
}
627+
return "$parent ?? $miss";
647628
}
648-
$lenStart = "$cond ? count($base$p) : ";
649-
650-
return "$base$n ?? ($lenStart$miss)";
651629
}
652630

653-
// assumeObjects and strict mode for helper arguments both use nullCheck chains.
654-
// This mirrors HBS.js: both paths use bare nameLookup (no container.strict wrapping), so
655-
// only a null intermediate throws (JS TypeError), while a missing key on a valid array
656-
// returns null silently (JS undefined). nullCheck encodes those semantics and includes
657-
// the key name in the exception message.
658-
if ($this->context->options->assumeObjects || ($this->context->options->strict && $this->compilingHelperArgs)) {
659-
return self::buildCallChain('nullCheck', $base, $stringParts);
631+
// Handle .length: compile parent path through the normal mode-aware logic, then wrap in
632+
// lookupLength() at runtime. This mirrors HBS.js, where .length is a normal property
633+
// access with no compile-time special casing.
634+
if ($isLength) {
635+
return $this->buildLookupLength(
636+
$this->compileModeAwareLookup($base, $varParts, $expression->original, 'null'),
637+
);
660638
}
661639

662-
if ($this->context->options->strict) {
663-
return self::buildCallChain('strictLookup', $base, $stringParts, self::quote($expression->original));
664-
}
665-
666-
return "$base$n ?? $miss";
640+
return $this->compileModeAwareLookup($base, $stringParts, $expression->original, $miss);
667641
}
668642

669643
private function StringLiteral(StringLiteral $literal): string
@@ -972,6 +946,33 @@ private function compileProgramOrEmpty(?Program $program): string
972946
return $this->compileProgram($program);
973947
}
974948

949+
private function buildLookupLength(string $parent): string
950+
{
951+
$strict = $this->context->options->strict || $this->context->options->assumeObjects;
952+
return self::getRuntimeFunc('lookupLength', $strict ? "$parent, true" : $parent);
953+
}
954+
955+
/**
956+
* Compile a mode-aware path access expression for the given base and parts.
957+
* @param string[] $parts
958+
*/
959+
private function compileModeAwareLookup(string $base, array $parts, string $original, string $miss): string
960+
{
961+
if (!$parts) {
962+
return $base;
963+
}
964+
if ($this->context->options->assumeObjects || ($this->context->options->strict && $this->compilingHelperArgs)) {
965+
// Use nullCheck chain for assumeObjects and helper arguments in strict mode.
966+
// This mirrors HBS.js: both paths use bare nameLookup, so only a null intermediate throws
967+
// (JS TypeError), while a missing key on a valid object returns null silently (JS undefined).
968+
return self::buildCallChain('nullCheck', $base, $parts);
969+
}
970+
if ($this->context->options->strict) {
971+
return self::buildCallChain('strictLookup', $base, $parts, self::quote($original));
972+
}
973+
return $base . self::buildKeyAccess($parts) . " ?? $miss";
974+
}
975+
975976
private function throwKnownHelpersOnly(string $helperName): never
976977
{
977978
throw new \Exception("You specified knownHelpersOnly, but used the unknown helper $helperName");

src/Runtime.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,29 @@ public static function nullCheck(mixed $base, string $key): mixed
145145
return is_array($base) ? ($base[$key] ?? null) : null;
146146
}
147147

148+
/**
149+
* Terminal .length lookup: returns count() for arrays (since PHP arrays have no native .length
150+
* property), an explicit 'length' key if present, or null for non-array bases.
151+
* When $strict is true, throws for any non-array base, mirroring HBS.js strict-mode behaviour.
152+
*/
153+
public static function lookupLength(mixed $base, bool $strict = false): mixed
154+
{
155+
if (is_array($base)) {
156+
return array_key_exists('length', $base) ? $base['length'] : count($base);
157+
}
158+
if ($strict) {
159+
$desc = match (true) {
160+
$base === null => 'null',
161+
is_bool($base) => $base ? 'true' : 'false',
162+
is_int($base) || is_float($base) => (string) $base,
163+
is_string($base) => "\"$base\"",
164+
default => get_debug_type($base),
165+
};
166+
throw new \Exception("\"length\" not defined in $desc");
167+
}
168+
return null;
169+
}
170+
148171
/**
149172
* Build a RuntimeContext from raw render options and compile-time partial closures.
150173
*

tests/ErrorTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,30 @@ public static function renderErrorProvider(): array
6969
'data' => ['foo' => []],
7070
'expected' => '"foo.bar" not defined',
7171
],
72+
[
73+
'template' => '{{foo.bar.length}}',
74+
'options' => new Options(strict: true),
75+
'data' => ['foo' => ['bar' => null]],
76+
'expected' => '"length" not defined in null',
77+
],
78+
[
79+
'template' => '{{foo.length}}',
80+
'options' => new Options(strict: true),
81+
'data' => ['foo' => false],
82+
'expected' => '"length" not defined in false',
83+
],
84+
[
85+
'template' => '{{foo.length}}',
86+
'options' => new Options(strict: true),
87+
'data' => ['foo' => 'hello'],
88+
'expected' => '"length" not defined in "hello"',
89+
],
90+
[
91+
'template' => '{{foo.length}}',
92+
'options' => new Options(strict: true),
93+
'data' => ['foo' => 42],
94+
'expected' => '"length" not defined in 42',
95+
],
7296
[
7397
'template' => '{{#if foo.bar}}bad{{else}}OK{{/if}}',
7498
'options' => new Options(strict: true),

tests/RegressionTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,6 +2036,18 @@ public static function arrayLengthProvider(): array
20362036
'data' => ['items' => [1, 2, 3]],
20372037
'expected' => '3',
20382038
],
2039+
[
2040+
'desc' => 'length in block params',
2041+
'template' => '{{#each items as |item|}}{{item.length}}{{/each}}',
2042+
'data' => ['items' => [[1, 2, 3]]],
2043+
'expected' => '3',
2044+
],
2045+
[
2046+
'desc' => 'length in block params with nested path',
2047+
'template' => '{{#each items as |item|}}{{item.nested.length}}{{/each}}',
2048+
'data' => ['items' => [['nested' => [1, 2]]]],
2049+
'expected' => '2',
2050+
],
20392051
];
20402052
}
20412053

0 commit comments

Comments
 (0)