Skip to content

Commit deca117

Browse files
committed
Allow SubExpressions to be PathExpression roots
This was a feature added in v2.1.0 of the Handlebars.js parser.
1 parent c8eecf0 commit deca117

File tree

4 files changed

+75
-16
lines changed

4 files changed

+75
-16
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
],
2020
"require": {
2121
"php": ">=8.2",
22-
"devtheorem/php-handlebars-parser": "^1.1.0"
22+
"devtheorem/php-handlebars-parser": "^1.1.1"
2323
},
2424
"require-dev": {
2525
"friendsofphp/php-cs-fixer": "^3.94",

composer.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Compiler.php

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,11 @@ private function MustacheStatement(MustacheStatement $mustache): string
534534
return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $varPath));
535535
}
536536

537+
// SubExpression path: {{(path args)}} — compile and render the sub-expression result
538+
if ($path instanceof SubExpression) {
539+
return self::getRuntimeFunc($fn, $this->SubExpression($path));
540+
}
541+
537542
// Literal path — treat as named context lookup or helper call
538543
$literalKey = $this->getLiteralKeyName($path);
539544

@@ -574,6 +579,12 @@ private function SubExpression(SubExpression $expression): string
574579
};
575580

576581
if ($helperName === null) {
582+
// Dynamic callable: path rooted at a sub-expression, e.g. ((helper).prop args)
583+
if ($path instanceof PathExpression) {
584+
$varPath = $this->PathExpression($path);
585+
$args = array_map(fn($p) => $this->compileExpression($p), $expression->params);
586+
return self::getRuntimeFunc('dv', implode(', ', [$varPath, ...$args]));
587+
}
577588
throw new \Exception('Sub-expression must be a helper call');
578589
}
579590

@@ -586,10 +597,16 @@ private function PathExpression(PathExpression $expression): string
586597
$depth = $expression->depth;
587598
$parts = $expression->parts;
588599

589-
$base = $this->buildBasePath($data, $depth);
590-
591-
// Filter out SubExpression parts for string-only operations
592-
$stringParts = self::stringPartsOf($parts);
600+
// When the path head is a SubExpression (e.g. (helper).foo.bar), compile the
601+
// sub-expression as the base and use the string tail as the remaining key accesses.
602+
$hasSubExprHead = $expression->head instanceof SubExpression;
603+
if ($hasSubExprHead) {
604+
$base = '(' . $this->SubExpression($expression->head) . ')';
605+
$stringParts = $expression->tail;
606+
} else {
607+
$base = $this->buildBasePath($data, $depth);
608+
$stringParts = self::stringPartsOf($parts);
609+
}
593610

594611
// `this` with no parts or empty parts
595612
if (($expression->this_ && !$parts) || !$stringParts) {
@@ -603,8 +620,8 @@ private function PathExpression(PathExpression $expression): string
603620
return "isset(\$cx->partials['@partial-block' . \$cx->partialId]) ? true : null";
604621
}
605622

606-
// Check block params (depth-0, non-data, non-scoped paths only)
607-
if (!$data && $depth === 0 && !self::scopedId($expression)) {
623+
// Check block params (depth-0, non-data, non-scoped paths only, not SubExpression-headed)
624+
if (!$hasSubExprHead && !$data && $depth === 0 && !self::scopedId($expression)) {
608625
$bp = $this->lookupBlockParam($stringParts[0]);
609626
if ($bp !== null) {
610627
[$bpDepth, $bpIndex] = $bp;
@@ -631,7 +648,7 @@ private function PathExpression(PathExpression $expression): string
631648
if ($depth > 0) {
632649
$checks[] = "isset($base)";
633650
}
634-
if ($p !== '' && $depth === 0) {
651+
if ($p !== '' && $depth === 0 && !$hasSubExprHead) {
635652
$checks[] = "isset($base$p)";
636653
}
637654
$baseP = "$base$p";

tests/RegressionTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ public function testRuntimePartials(): void
9696
#[DataProvider("dataClosuresProvider")]
9797
#[DataProvider("missingDataProvider")]
9898
#[DataProvider("syntaxProvider")]
99+
#[DataProvider("subexpressionPathProvider")]
99100
public function testIssues(string $template, string $expected, string $desc = '', mixed $data = null, ?Options $options = null, array $helpers = []): void
100101
{
101102
$templateSpec = Handlebars::precompile($template, $options ?? new Options());
@@ -2123,4 +2124,45 @@ public static function syntaxProvider(): array
21232124
],
21242125
];
21252126
}
2127+
2128+
/** @return list<RegIssue> */
2129+
public static function subexpressionPathProvider(): array
2130+
{
2131+
return [
2132+
[
2133+
'desc' => 'sub-expression as path head: standalone mustache',
2134+
'template' => '{{(my-helper foo).bar}}',
2135+
'data' => ['foo' => 'val'],
2136+
'helpers' => ['my-helper' => fn($arg) => ['bar' => "got:$arg"]],
2137+
'expected' => 'got:val',
2138+
],
2139+
[
2140+
'desc' => 'sub-expression as path head: callable',
2141+
'template' => '{{((my-helper foo).bar baz)}}',
2142+
'data' => ['foo' => 'x', 'baz' => 'y'],
2143+
'helpers' => ['my-helper' => fn($arg) => ['bar' => fn($x) => "called:$x"]],
2144+
'expected' => 'called:y',
2145+
],
2146+
[
2147+
'desc' => 'sub-expression as path head: argument',
2148+
'template' => '{{(foo (my-helper bar).baz)}}',
2149+
'data' => ['bar' => 'hello'],
2150+
'helpers' => [
2151+
'my-helper' => fn($arg) => ['baz' => strtoupper($arg)],
2152+
'foo' => fn($val) => "foo:$val",
2153+
],
2154+
'expected' => 'foo:HELLO',
2155+
],
2156+
[
2157+
'desc' => 'sub-expression as path head: named argument',
2158+
'template' => '{{(foo bar=(my-helper baz).qux)}}',
2159+
'data' => ['baz' => 'world'],
2160+
'helpers' => [
2161+
'my-helper' => fn($arg) => ['qux' => strtoupper($arg)],
2162+
'foo' => fn(HelperOptions $options) => $options->hash['bar'],
2163+
],
2164+
'expected' => 'WORLD',
2165+
],
2166+
];
2167+
}
21262168
}

0 commit comments

Comments
 (0)