Skip to content

Commit 1983c29

Browse files
committed
Fix ambiguous helper calls in strict mode
1 parent fc143fc commit 1983c29

3 files changed

Lines changed: 82 additions & 56 deletions

File tree

src/Compiler.php

Lines changed: 48 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -217,19 +217,15 @@ private function isKnownHelper(string $helperName): bool
217217
*/
218218
private function classifySexpr(?string $simpleName, array $params, ?Hash $hash): SexprType
219219
{
220-
if ($simpleName !== null && $this->lookupBlockParam($simpleName) !== null) {
221-
return SexprType::Simple;
222-
}
223-
if ($simpleName !== null && $this->isKnownHelper($simpleName)) {
224-
return SexprType::Helper;
225-
}
226-
if ($params || $hash !== null) {
227-
return SexprType::Helper;
228-
}
229220
if ($simpleName !== null) {
230-
return SexprType::Ambiguous;
221+
if ($this->lookupBlockParam($simpleName) !== null) {
222+
return SexprType::Simple;
223+
} elseif ($params || $hash || $this->isKnownHelper($simpleName)) {
224+
return SexprType::Helper;
225+
}
226+
return $this->context->options->knownHelpersOnly ? SexprType::Simple : SexprType::Ambiguous;
231227
}
232-
return SexprType::Simple;
228+
return ($params || $hash) ? SexprType::Helper : SexprType::Simple;
233229
}
234230

235231
/** Returns '$blockParams' when inside a block-param scope (for use() capture), '' otherwise. */
@@ -485,16 +481,16 @@ private function PartialBlockStatement(PartialBlockStatement $statement): string
485481

486482
if ($partialName !== null && !$found) {
487483
// Register the block body as a fallback partial only if no runtime partial with this name exists yet.
488-
$parts[] = "(isset(\$cx->inlinePartials[$p]) || isset(\$cx->partials[$p]) ? '' : " . self::getRuntimeFunc('in', "\$cx, $p, $bodyClosure") . ')';
484+
$parts[] = "(isset(\$cx->inlinePartials[$p]) || isset(\$cx->partials[$p]) ? '' : "
485+
. self::getRuntimeFunc('in', "\$cx, $p, $bodyClosure") . ')';
489486
}
490487
$parts[] = self::getRuntimeFunc('p', "\$cx, $p, $vars, '', $bodyClosure");
491488
return implode('.', $parts);
492489
}
493490

494491
private function MustacheStatement(MustacheStatement $mustache): string
495492
{
496-
$raw = !$mustache->escaped || $this->context->options->noEscape;
497-
$fn = $raw ? 'raw' : 'encq';
493+
$fn = (!$mustache->escaped || $this->context->options->noEscape) ? 'raw' : 'encq';
498494
$path = $mustache->path;
499495

500496
// SubExpression path: {{(path args)}} — always a direct helper call result
@@ -512,27 +508,38 @@ private function MustacheStatement(MustacheStatement $mustache): string
512508
return self::getRuntimeFunc($fn, $call);
513509
}
514510

515-
if ($helperName !== null && $type === SexprType::Ambiguous && !$this->context->options->strict) {
511+
if ($type === SexprType::Ambiguous) {
512+
assert($helperName !== null);
516513
$escapedKey = self::quote($helperName);
517514
$isData = $path instanceof PathExpression && $path->data;
518-
if ($this->context->options->knownHelpersOnly) {
519-
$lookup = $isData ? "\$cx->data[$escapedKey] ?? null" : self::getRuntimeFunc('cv', "\$in, $escapedKey");
520-
return self::getRuntimeFunc($fn, $lookup);
521-
}
522515
$scope = $isData ? '$cx->data' : '$in';
523-
$hvArgs = "\$cx, $escapedKey, $scope" . ($this->context->options->assumeObjects ? ', true' : '');
516+
$hvArgs = "\$cx, $escapedKey, $scope";
517+
if ($this->context->options->assumeObjects) {
518+
$hvArgs .= ', true';
519+
} elseif ($this->context->options->strict) {
520+
$hvArgs .= ', false, true';
521+
}
524522
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', $hvArgs));
525523
}
526524

527-
// Simple: direct path lookup
525+
// Simple: direct path lookup with lambda resolution.
526+
// For knownHelpersOnly bare identifiers (single-segment, non-data): use cv() to pass the
527+
// current context to any Closure, mirroring JS fn.call(context) where context is `this`.
528+
// For all other simple paths (multi-segment, scoped, depth, data): use dv() with zero args,
529+
// matching HBS.js container.lambda which also passes no positional arguments.
528530
if ($path instanceof PathExpression) {
529-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $this->PathExpression($path)));
531+
if ($helperName !== null && !$path->data && $this->context->options->knownHelpersOnly) {
532+
$cvArgs = '$in, ' . self::quote($helperName) . ($this->context->options->strict ? ', true' : '');
533+
return self::getRuntimeFunc($fn, self::getRuntimeFunc('cv', $cvArgs));
534+
}
535+
$expression = $this->PathExpression($path);
536+
} else {
537+
// Literal in simple position: same lambda resolution as PathExpression above.
538+
$literalKey = $this->getLiteralKeyName($path);
539+
$expression = $this->compileModeAwareLookup('$in', [$literalKey], $literalKey);
530540
}
531541

532-
// Literal in simple/fallthrough position (strict, assumeObjects, or knownHelpersOnly):
533-
// compile as a direct context lookup using the normalized key name.
534-
$literalKey = $this->getLiteralKeyName($path);
535-
return self::getRuntimeFunc($fn, $this->compileModeAwareLookup('$in', [$literalKey], $literalKey));
542+
return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $expression));
536543
}
537544

538545
// ── Expressions ─────────────────────────────────────────────────
@@ -547,26 +554,24 @@ private function SubExpression(SubExpression $expression): string
547554
return $this->compileHelperCall($helperName, $path, $expression->params, $expression->hash);
548555
}
549556

550-
private function PathExpression(PathExpression $expression): string
557+
private function PathExpression(PathExpression $path): string
551558
{
552-
$data = $expression->data;
553-
$depth = $expression->depth;
554-
$parts = $expression->parts;
559+
$data = $path->data;
560+
$depth = $path->depth;
555561

556562
// When the path head is a SubExpression (e.g. (helper).foo.bar), compile the
557563
// sub-expression as the base and use the string tail as the remaining key accesses.
558-
$hasSubExprHead = $expression->head instanceof SubExpression;
564+
$hasSubExprHead = $path->head instanceof SubExpression;
559565
if ($hasSubExprHead) {
560-
$base = '(' . $this->SubExpression($expression->head) . ')';
561-
$stringParts = $expression->tail;
566+
$base = '(' . $this->SubExpression($path->head) . ')';
567+
$stringParts = $path->tail;
562568
} else {
563569
$base = $this->buildBasePath($data, $depth);
564-
/** @var string[] $parts */
565-
$stringParts = $parts;
570+
/** @var string[] $stringParts */
571+
$stringParts = $path->parts;
566572
}
567573

568-
// `this` with no parts or empty parts
569-
if (($expression->this_ && !$parts) || !$stringParts) {
574+
if (!$stringParts) {
570575
return $base;
571576
}
572577

@@ -578,8 +583,8 @@ private function PathExpression(PathExpression $expression): string
578583
$isLength = end($stringParts) === 'length';
579584

580585
// Check block params (depth-0, non-data, non-scoped paths only, not SubExpression-headed)
581-
if (!$hasSubExprHead && !$data && $depth === 0 && !self::scopedId($expression)) {
582-
$bp = $this->lookupBlockParam($expression->head);
586+
if (!$hasSubExprHead && !$data && $depth === 0 && !self::scopedId($path)) {
587+
$bp = $this->lookupBlockParam($path->head);
583588
if ($bp !== null) {
584589
[$bpDepth, $bpIndex] = $bp;
585590
$bpBase = "\$blockParams[$bpDepth][$bpIndex]";
@@ -589,8 +594,8 @@ private function PathExpression(PathExpression $expression): string
589594
}
590595

591596
// Skip the block param name since it has been resolved to a $blockParams index.
592-
$keys = $isLength ? array_slice($expression->tail, 0, -1) : $expression->tail;
593-
$lookup = $this->compileModeAwareLookup($bpBase, $keys, $expression->original);
597+
$keys = $isLength ? array_slice($path->tail, 0, -1) : $path->tail;
598+
$lookup = $this->compileModeAwareLookup($bpBase, $keys, $path->original);
594599
return $isLength ? $this->buildLookupLength($lookup) : $lookup;
595600
}
596601
}
@@ -601,11 +606,11 @@ private function PathExpression(PathExpression $expression): string
601606
if ($isLength) {
602607
$partsExceptLength = array_slice($stringParts, 0, -1);
603608
return $this->buildLookupLength(
604-
$this->compileModeAwareLookup($base, $partsExceptLength, $expression->original),
609+
$this->compileModeAwareLookup($base, $partsExceptLength, $path->original),
605610
);
606611
}
607612

608-
return $this->compileModeAwareLookup($base, $stringParts, $expression->original);
613+
return $this->compileModeAwareLookup($base, $stringParts, $path->original);
609614
}
610615

611616
/**

src/Runtime.php

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ public static function createContext(mixed $context, array $options, array $comp
202202
}
203203

204204
/**
205-
* Invoke $v if it is a Closure; otherwise return $v as-is.
205+
* Invoke $v without arguments if it is a Closure; otherwise return $v as-is.
206206
*/
207207
public static function dv(mixed $v): mixed
208208
{
@@ -211,36 +211,42 @@ public static function dv(mixed $v): mixed
211211

212212
/**
213213
* Context variable lookup without helper dispatch.
214-
* Looks up $name in $_this; if the value is a Closure, invokes it with $_this as context.
215-
* Used when helper dispatch is unnecessary: knownHelpersOnly mode (the compiler has already
216-
* ruled out known helpers), and inlined if/unless conditions on single-segment paths.
214+
* Looks up $name in $_this; if the value is a Closure, invokes it with $_this as a positional arg
215+
* (PHP equivalent of JS fn.call(context), where context binds as `this` with no positional args).
216+
* When $strict is true, throws for missing keys.
217217
*
218218
* @param mixed $_this current rendering context
219219
*/
220-
public static function cv(mixed &$_this, string $name): mixed
220+
public static function cv(mixed &$_this, string $name, bool $strict = false): mixed
221221
{
222-
$v = $_this[$name] ?? null;
222+
$v = $strict ? static::strictLookup($_this, $name, $name) : ($_this[$name] ?? null);
223223
return $v instanceof Closure ? $v($_this) : $v;
224224
}
225225

226226
/**
227227
* Helper-or-variable lookup for bare {{identifier}} expressions.
228-
* Checks runtime helpers first, then context value, then helperMissing fallback.
228+
* Checks runtime helpers first, then context value.
229+
* In non-strict mode: falls back to helperMissing when the context value is null.
229230
* When $assumeObjects is true, uses nullCheck for context lookup (throws on null context).
231+
* When $strict is true, uses strictLookup after the helper check (throws for missing keys; no helperMissing fallback).
230232
*
231233
* @param mixed $_this current rendering context
232234
*/
233-
public static function hv(RuntimeContext $cx, string $name, mixed &$_this, bool $assumeObjects = false): mixed
235+
public static function hv(RuntimeContext $cx, string $name, mixed &$_this, bool $assumeObjects = false, bool $strict = false): mixed
234236
{
235237
$helper = $cx->helpers[$name] ?? null;
236238
if ($helper !== null) {
237239
return static::hbch($cx, $helper, $name, [], [], $_this);
238240
}
239-
$value = $assumeObjects ? static::nullCheck($_this, $name) : ($_this[$name] ?? null);
240-
if ($value === null || $value instanceof Closure) {
241-
return static::hbch($cx, $value ?? $cx->helpers['helperMissing'], $name, [], [], $_this);
241+
if ($strict) {
242+
$value = static::strictLookup($_this, $name, $name);
243+
} else {
244+
$value = $assumeObjects ? static::nullCheck($_this, $name) : ($_this[$name] ?? null);
245+
if ($value === null) {
246+
return static::hbch($cx, $cx->helpers['helperMissing'], $name, [], [], $_this);
247+
}
242248
}
243-
return $value;
249+
return $value instanceof Closure ? static::hbch($cx, $value, $name, [], [], $_this) : $value;
244250
}
245251

246252
/**

tests/RegressionTest.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1359,12 +1359,20 @@ public static function builtInProvider(): array
13591359
'expected' => 'EMPTY',
13601360
],
13611361

1362-
'ensure that block parameters are correctly escaped' => [
1362+
'block parameters are correctly escaped' => [
13631363
'template' => "{{#each items as |[it\\'s] item|}}{{item}}{{/each}}",
13641364
'data' => ['items' => ['one', 'two']],
13651365
'expected' => '01',
13661366
],
13671367

1368+
'block parameters take precedence over known helpers' => [
1369+
'template' => "{{#each items as |item id|}}{{id}}: {{item}}\n{{/each}}{{item}}",
1370+
'data' => ['items' => ['a', 'b']],
1371+
'options' => new Options(knownHelpers: ['item' => true]),
1372+
'helpers' => ['item' => fn() => 'Helper!'],
1373+
'expected' => "0: a\n1: b\nHelper!",
1374+
],
1375+
13681376
'each over array with @key' => [
13691377
'template' => '{{#each foo}}{{@key}}: {{.}},{{/each}}',
13701378
'data' => ['foo' => [1, 'a' => 'b', 5]],
@@ -2233,6 +2241,13 @@ public static function missingDataProvider(): array
22332241
'data' => ['foo' => null],
22342242
'expected' => '',
22352243
],
2244+
2245+
'strict mode should invoke ambiguous helper not present in context data' => [
2246+
'template' => '{{greeting}}',
2247+
'options' => new Options(strict: true),
2248+
'helpers' => ['greeting' => fn() => 'Hello'],
2249+
'expected' => 'Hello',
2250+
],
22362251
];
22372252
}
22382253

0 commit comments

Comments
 (0)