Skip to content

Commit 1c47d9c

Browse files
committed
Fix more issues
1 parent 6eea0aa commit 1c47d9c

4 files changed

Lines changed: 90 additions & 57 deletions

File tree

src/Compiler.php

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -532,24 +532,19 @@ private function MustacheStatement(MustacheStatement $mustache): string
532532

533533
if ($helperName !== null && $type === SexprType::Ambiguous && !$this->context->options->strict) {
534534
$escapedKey = self::quote($helperName);
535+
$isData = $path instanceof PathExpression && $path->data;
535536
if ($this->context->options->knownHelpersOnly) {
536-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('cv', "\$in, $escapedKey"));
537+
$lookup = $isData ? "\$cx->data[$escapedKey] ?? null" : self::getRuntimeFunc('cv', "\$in, $escapedKey");
538+
return self::getRuntimeFunc($fn, $lookup);
537539
}
538-
$hvArgs = "\$cx, $escapedKey, \$in" . ($this->context->options->assumeObjects ? ', true' : '');
540+
$scope = $isData ? '$cx->data' : '$in';
541+
$hvArgs = "\$cx, $escapedKey, $scope" . ($this->context->options->assumeObjects ? ', true' : '');
539542
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', $hvArgs));
540543
}
541544

542545
// Simple: direct path lookup
543546
if ($path instanceof PathExpression) {
544-
// Single-segment @data vars use lambda() so closures receive HelperOptions;
545-
// everything else (multi-segment, depth/scoped paths) uses dv().
546-
$varPath = $this->PathExpression($path);
547-
if ($path->data && $path->depth === 0 && count($path->parts) === 1
548-
&& is_string($path->parts[0]) && $path->parts[0] !== 'partial-block') {
549-
$escapedName = self::quote($path->parts[0]);
550-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('lambda', "\$cx, $escapedName, $varPath, \$in"));
551-
}
552-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $varPath));
547+
return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $this->PathExpression($path)));
553548
}
554549

555550
// Literal in simple/fallthrough position (strict, assumeObjects, or knownHelpersOnly):
@@ -795,8 +790,7 @@ private function getSimpleHelperName(PathExpression|Literal $path): ?string
795790
if ($path instanceof Literal) {
796791
return $this->getLiteralKeyName($path);
797792
}
798-
if ($path->data
799-
|| $path->depth > 0
793+
if ($path->depth > 0
800794
|| self::scopedId($path)
801795
|| count($path->parts) !== 1
802796
|| !is_string($path->parts[0])
@@ -866,43 +860,47 @@ private static function buildHbbchCall(string $helperExpr, string $escapedName,
866860
return self::getRuntimeFunc('hbbch', "\$cx, $helperExpr, $escapedName, $params, \$in, $blockFn, $else$trailingArgs");
867861
}
868862

869-
/**
870-
* Build a resolveHelper + hbch call for dynamic inline helper dispatch.
871-
*/
872-
private function buildResolvedHbchCall(string $escapedName, string $varPath, string $params): string
873-
{
874-
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $escapedName, $varPath");
875-
return self::getRuntimeFunc('hbch', "\$cx, $resolved, $escapedName, $params, \$in");
876-
}
877-
878863
/**
879864
* Compile a helper call for a MustacheStatement (Helper type) or SubExpression.
880865
* @param Expression[] $params
881866
*/
882867
private function compileHelperCall(?string $helperName, Expression $path, array $params, ?Hash $hash): string
883868
{
869+
$compiledParams = $this->compileParams($params, $hash);
870+
884871
if ($helperName !== null) {
885-
$compiledParams = $this->compileParams($params, $hash);
886872
$escapedName = self::quote($helperName);
873+
$isData = $path instanceof PathExpression && $path->data;
887874
if ($this->isKnownHelper($helperName)) {
888875
return self::getRuntimeFunc('hbch', "\$cx, \$cx->helpers[$escapedName], $escapedName, $compiledParams, \$in");
889876
}
890877
if ($this->context->options->knownHelpersOnly) {
891878
$this->throwKnownHelpersOnly($helperName);
892879
}
893-
return $this->buildResolvedHbchCall($escapedName, "\$in[$escapedName] ?? null", $compiledParams);
880+
$fallback = $isData ? "\$cx->data[$escapedName] ?? null" : "\$in[$escapedName] ?? null";
881+
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $escapedName, $fallback");
882+
return self::getRuntimeFunc('hbch', "\$cx, $resolved, $escapedName, $compiledParams, \$in");
894883
}
884+
895885
if ($path instanceof PathExpression) {
896886
$varPath = $this->PathExpression($path);
897887
$stringParts = array_filter($path->parts, 'is_string');
898-
if (!$path->data && $path->depth === 0 && !self::scopedId($path)
899-
&& count($stringParts) === count($path->parts)) {
900-
$logicalName = self::quote(implode('.', $stringParts));
901-
return $this->buildResolvedHbchCall($logicalName, $varPath, $this->compileParams($params, $hash));
888+
if (count($stringParts) === count($path->parts)) {
889+
// All-string parts (foo.bar, ../fn, ./fn, @fn): scoped/depth/data paths resolve
890+
// from context only; normal paths check the helpers hash first via resolveHelper.
891+
$escapedName = self::quote($path->original);
892+
$resolverFn = (!$path->data && $path->depth === 0 && !self::scopedId($path))
893+
? 'resolveHelper'
894+
: 'resolveContextHelper';
895+
} else {
896+
// SubExpression-headed path (e.g. ((helper).prop args)): context-only resolution.
897+
$escapedName = self::quote(implode('.', $stringParts));
898+
$resolverFn = 'resolveContextHelper';
902899
}
903-
$args = array_map(fn($p) => $this->compileExpression($p), $params);
904-
return self::getRuntimeFunc('dv', implode(', ', [$varPath, ...$args]));
900+
$resolved = self::getRuntimeFunc($resolverFn, "\$cx, $escapedName, $varPath");
901+
return self::getRuntimeFunc('hbch', "\$cx, $resolved, $escapedName, $compiledParams, \$in");
905902
}
903+
906904
throw new \Exception('Sub-expression must be a helper call');
907905
}
908906

src/Runtime.php

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

204204
/**
205-
* Invoke $v if it is callable, passing any extra args; otherwise return $v as-is.
206-
* Mirrors HBS.js container.lambda for multi-segment paths and scoped/depth expressions with args.
205+
* Invoke $v if it is a Closure; otherwise return $v as-is.
207206
*/
208-
public static function dv(mixed $v, mixed ...$args): mixed
207+
public static function dv(mixed $v): mixed
209208
{
210-
return $v instanceof Closure ? $v(...$args) : $v;
211-
}
212-
213-
/**
214-
* Mirrors HBS.js container.lambda for single-segment @data variables: routes closures through
215-
* hbch so they receive a HelperOptions argument when their signature expects one.
216-
*/
217-
public static function lambda(RuntimeContext $cx, string $name, mixed $v, mixed &$_this): mixed
218-
{
219-
if ($v instanceof Closure) {
220-
return static::hbch($cx, $v, $name, [], [], $_this);
221-
}
222-
return $v;
209+
return $v instanceof Closure ? $v() : $v;
223210
}
224211

225212
/**
@@ -452,6 +439,21 @@ public static function resolveHelper(RuntimeContext $cx, string $name, mixed $ca
452439
return $cx->helpers['helperMissing'];
453440
}
454441

442+
/**
443+
* Like resolveHelper but skips the helpers hash lookup — for scoped (./), depth (../),
444+
* and data (@) paths which resolve from context only, matching HBS.js behaviour.
445+
*/
446+
public static function resolveContextHelper(RuntimeContext $cx, string $name, mixed $callable): Closure
447+
{
448+
if ($callable instanceof Closure) {
449+
return $callable;
450+
}
451+
if ($callable !== null) {
452+
throw new \Exception("Expected $name to be a function, got " . json_encode($callable));
453+
}
454+
return $cx->helpers['helperMissing'];
455+
}
456+
455457
/**
456458
* Invoke a resolved helper Closure with positional params, hash, and a HelperOptions instance.
457459
* Used for known helpers and resolved helpers (direct hbch calls from generated code),

tests/ErrorTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,11 @@ public static function errorProvider(): array
266266
'options' => new Options(knownHelpersOnly: true),
267267
'expected' => 'You specified knownHelpersOnly, but used the unknown helper ../flag',
268268
],
269+
'knownHelpersOnly rejects @data path with params' => [
270+
'template' => '{{@fn "Hello"}}',
271+
'options' => new Options(knownHelpersOnly: true),
272+
'expected' => 'You specified knownHelpersOnly, but used the unknown helper fn',
273+
],
269274
'unknown decorator throws' => [
270275
'template' => '{{#*help me}}{{/help}}',
271276
'expected' => 'Unknown decorator: "help"',

tests/RegressionTest.php

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2127,6 +2127,12 @@ public static function dataClosuresProvider(): array
21272127
'vars' => ['foo' => fn(HelperOptions $options) => $options->name . ': OK'],
21282128
'expected' => 'foo: OK',
21292129
],
2130+
'registered helper takes priority over @data value' => [
2131+
'template' => '{{@var}}',
2132+
'vars' => ['var' => 'bad'],
2133+
'helpers' => ['var' => fn(HelperOptions $opts) => 'helper:' . $opts->name],
2134+
'expected' => 'helper:var',
2135+
],
21302136
'@data closure at multi-segment path is invoked without arguments' => [
21312137
'template' => '{{@foo.bar}}',
21322138
'vars' => ['foo' => ['bar' => fn(...$args) => count($args) . ' args']],
@@ -2254,10 +2260,30 @@ public static function subexpressionPathProvider(): array
22542260
'expected' => 'got:val',
22552261
],
22562262
'sub-expression as path head: callable' => [
2257-
'template' => '{{((my-helper foo).bar baz)}}',
2258-
'data' => ['foo' => 'x', 'baz' => 'y'],
2259-
'helpers' => ['my-helper' => fn($arg) => ['bar' => fn($x) => "called:$x"]],
2260-
'expected' => 'called:y',
2263+
'template' => '{{((my-helper foo).x baz key=val)}}',
2264+
'data' => ['foo' => 'x', 'baz' => 'y', 'val' => 'hashval'],
2265+
'helpers' => [
2266+
'my-helper' => fn($arg) => [
2267+
$arg => function ($x, ?HelperOptions $opts = null) {
2268+
return 'called:' . $x . ',key=' . ($opts?->hash['key'] ?? 'null');
2269+
},
2270+
],
2271+
],
2272+
'expected' => 'called:y,key=hashval',
2273+
],
2274+
'sub-expression: depth-based callee receives hash in HelperOptions' => [
2275+
'template' => '{{#each items}}{{{identity (../outerFn pos key=val)}}}|{{/each}}',
2276+
'data' => [
2277+
'items' => [
2278+
['pos' => 'p1', 'val' => 'v1'],
2279+
['pos' => 'p2', 'val' => 'v2'],
2280+
],
2281+
'outerFn' => function ($pos, ?HelperOptions $opts = null) {
2282+
return 'pos=' . $pos . ',key=' . ($opts?->hash['key'] ?? 'null');
2283+
},
2284+
],
2285+
'helpers' => ['identity' => fn($x) => $x],
2286+
'expected' => 'pos=p1,key=v1|pos=p2,key=v2|',
22612287
],
22622288
'sub-expression as path head: argument' => [
22632289
'template' => '{{(foo (my-helper bar).baz)}}',
@@ -2279,26 +2305,28 @@ public static function subexpressionPathProvider(): array
22792305
],
22802306

22812307
'sub-expression: multi-segment callee invokes helperMissing when not in context' => [
2282-
'template' => '{{identity (foo.bar baz)}}',
2308+
'template' => '{{{identity (foo.bar baz prop=2)}}}',
22832309
'data' => ['baz' => 'val'],
22842310
'helpers' => [
22852311
'identity' => fn($x) => $x,
2286-
'helperMissing' => fn($x, HelperOptions $opts) => 'missing:' . $opts->name,
2312+
'helperMissing' => function ($baz, HelperOptions $options) {
2313+
return "missing:{$options->name},baz=$baz,prop={$options->hash['prop']}";
2314+
},
22872315
],
2288-
'expected' => 'missing:foo.bar',
2316+
'expected' => 'missing:foo.bar,baz=val,prop=2',
22892317
],
22902318
'sub-expression: multi-segment callee receives HelperOptions' => [
2291-
'template' => '{{{identity (foo.bar baz)}}}',
2319+
'template' => '{{{identity (foo.bar baz prop=2)}}}',
22922320
'data' => [
22932321
'foo' => [
2294-
'bar' => function ($x, ?HelperOptions $opts = null) {
2295-
return 'name=' . ($opts->name ?? 'null') . ',x=' . $x;
2322+
'bar' => function ($baz, HelperOptions $options) {
2323+
return "name={$options->name},baz=$baz,prop={$options->hash['prop']}";
22962324
},
22972325
],
22982326
'baz' => 'val',
22992327
],
23002328
'helpers' => ['identity' => fn($x) => $x],
2301-
'expected' => 'name=foo.bar,x=val',
2329+
'expected' => 'name=foo.bar,baz=val,prop=2',
23022330
],
23032331
];
23042332
}

0 commit comments

Comments
 (0)