Skip to content

Commit a3176ec

Browse files
committed
Unify literal handling and fix knownHelpersOnly bug
1 parent 5705f08 commit a3176ec

3 files changed

Lines changed: 147 additions & 95 deletions

File tree

src/Compiler.php

Lines changed: 119 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@
2323
use DevTheorem\HandlebarsParser\Ast\UndefinedLiteral;
2424
use DevTheorem\HandlebarsParser\Parser;
2525

26+
/** @internal */
27+
enum SexprType
28+
{
29+
case Helper;
30+
case Ambiguous;
31+
case Simple;
32+
}
33+
2634
/**
2735
* @internal
2836
*/
@@ -147,64 +155,70 @@ private function compileExpression(Expression $expr): string
147155

148156
private function BlockStatement(BlockStatement $block): string
149157
{
158+
// getSimpleHelperName returns the key name for both Literal paths and simple PathExpressions,
159+
// null for complex paths (multi-segment, scoped, data, depth). This mirrors HBS.js
160+
// transformLiteralToPath: literals are treated as single-part path lookups throughout.
150161
$helperName = $this->getSimpleHelperName($block->path);
162+
$type = $this->classifySexpr($helperName, $block->params, $block->hash);
163+
// Logical name for runtime dispatch: literal-normalized (strips source quoting).
164+
// e.g. {{#"foo bar"}} → 'foo bar', not '"foo bar"'. Falls back to path->original for complex paths.
165+
$name = $helperName ?? (string) $block->path->original;
151166

152-
if ($helperName !== null) {
153-
if ($this->isKnownHelper($helperName)) {
167+
if ($type === SexprType::Helper) {
168+
if ($helperName !== null && $this->isKnownHelper($helperName)) {
154169
return $this->compileBlockHelper($block, $helperName);
155170
}
156-
157-
if ($block->params || $block->hash !== null) {
158-
if ($this->context->options->knownHelpersOnly) {
159-
$this->throwKnownHelpersOnly($helperName);
160-
}
161-
return $this->compileDynamicBlockHelper($block, $helperName, "\$in[" . self::quote($helperName) . "] ?? null");
162-
}
163-
}
164-
165-
// Handle literal path in block position (e.g. {{#"foo"}}, {{#12}}, {{#true}})
166-
if ($block->path instanceof Literal) {
167-
$literalKey = $this->getLiteralKeyName($block->path);
168-
169-
if ($this->isKnownHelper($literalKey)) {
170-
return $this->compileBlockHelper($block, $literalKey);
171-
}
172-
173-
$escapedKey = self::quote($literalKey);
174-
$var = $this->compileModeAwareLookup('$in', [$literalKey], $literalKey);
175-
176-
if ($block->program === null) {
177-
return $this->compileInvertedSection($block, $var, $escapedKey);
171+
if ($this->context->options->knownHelpersOnly) {
172+
$this->throwKnownHelpersOnly($name);
178173
}
179-
180-
return $this->compileSection($block, $var, $escapedKey);
174+
// Simple/Literal path: look up the key in context. Complex path: compile the full expression.
175+
$var = $helperName !== null
176+
? $this->compileModeAwareLookup('$in', [$helperName], $helperName)
177+
: $this->compileExpression($block->path);
178+
return $this->compileDynamicBlockHelper($block, $name, $var);
181179
}
182180

183-
$var = $this->compileExpression($block->path);
181+
$var = $helperName !== null
182+
? $this->compileModeAwareLookup('$in', [$helperName], $helperName)
183+
: $this->compileExpression($block->path);
184+
$escapedName = self::quote($name);
184185

185-
// Inverted section: {{^var}}...{{/var}}
186186
if ($block->program === null) {
187-
$escapedName = $helperName !== null ? self::quote($helperName) : null;
188-
return $this->compileInvertedSection($block, $var, $escapedName);
189-
}
190-
191-
// Non-simple path with params or hash: invoke as a dynamic block helper call
192-
if ($block->params || $block->hash !== null) {
193-
if ($this->context->options->knownHelpersOnly) {
194-
$this->throwKnownHelpersOnly((string) $block->path->original);
195-
}
196-
return $this->compileDynamicBlockHelper($block, (string) $block->path->original, $var);
187+
return $this->compileInvertedSection($block, $var, $type === SexprType::Ambiguous ? $escapedName : null);
197188
}
198189

199-
// Regular section: {{#var}}...{{/var}}
200-
return $this->compileSection($block, $var, self::quote($block->path->original));
190+
return $this->compileSection($block, $var, $escapedName);
201191
}
202192

203193
private function isKnownHelper(string $helperName): bool
204194
{
205195
return $this->context->options->knownHelpers[$helperName] ?? false;
206196
}
207197

198+
/**
199+
* Classify a sexpr like HBS.js classifySexpr(), given the pre-computed simple name.
200+
* - Helper: definitely a helper call (has params/hash, or is a known helper)
201+
* - Ambiguous: bare simple name that could be a helper or context value at runtime
202+
* - Simple: complex/scoped/data/depth path, or block param; always a context lookup
203+
* @param Expression[] $params
204+
*/
205+
private function classifySexpr(?string $simpleName, array $params, ?Hash $hash): SexprType
206+
{
207+
if ($simpleName !== null && $this->lookupBlockParam($simpleName) !== null) {
208+
return SexprType::Simple;
209+
}
210+
if ($simpleName !== null && $this->isKnownHelper($simpleName)) {
211+
return SexprType::Helper;
212+
}
213+
if ($params || $hash !== null) {
214+
return SexprType::Helper;
215+
}
216+
if ($simpleName !== null) {
217+
return SexprType::Ambiguous;
218+
}
219+
return SexprType::Simple;
220+
}
221+
208222
private function compileSection(BlockStatement $block, string $var, string $escapedName): string
209223
{
210224
assert($block->program !== null);
@@ -220,8 +234,8 @@ private function compileSection(BlockStatement $block, string $var, string $esca
220234
if ($block->hash !== null || $bp) {
221235
$params = $this->compileParams([], $block->hash);
222236
$outerBp = $this->outerBlockParamsExpr();
223-
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $escapedName, $var");
224-
return self::getRuntimeFunc('hbbch', "\$cx, $resolved, $escapedName, $params, \$in, $blockFn, $else, " . count($bp) . ", $outerBp");
237+
$helperExpr = self::getRuntimeFunc('resolveHelper', "\$cx, $escapedName, $var");
238+
return self::buildHbbchCall($helperExpr, $escapedName, $params, $blockFn, $else, count($bp), $outerBp);
225239
}
226240

227241
return self::getRuntimeFunc('sec', "\$cx, $var, \$in, $blockFn, $else, $escapedName");
@@ -322,8 +336,7 @@ private function compileBlockHelper(BlockStatement $block, string $name): string
322336
$helperName = self::quote($name);
323337
$bpCount = count($fnProgram->blockParams);
324338

325-
$trailingArgs = ($bpCount > 0 || $outerBp !== '[]') ? ", $bpCount, $outerBp" : '';
326-
return self::getRuntimeFunc('hbbch', "\$cx, \$cx->helpers[$helperName], $helperName, $params, \$in, $fn, $else$trailingArgs");
339+
return self::buildHbbchCall("\$cx->helpers[$helperName]", $helperName, $params, $fn, $else, $bpCount, $outerBp);
327340
}
328341

329342
/**
@@ -380,8 +393,8 @@ private function compileDynamicBlockHelper(BlockStatement $block, string $name,
380393
$else = $this->compileProgramOrNull($block->inverse);
381394
$outerBp = $this->outerBlockParamsExpr();
382395
$helperName = self::quote($name);
383-
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $helperName, $varPath");
384-
return self::getRuntimeFunc('hbbch', "\$cx, $resolved, $helperName, $params, \$in, $blockFn, $else, " . count($bp) . ", $outerBp");
396+
$helperExpr = self::getRuntimeFunc('resolveHelper', "\$cx, $helperName, $varPath");
397+
return self::buildHbbchCall($helperExpr, $helperName, $params, $blockFn, $else, count($bp), $outerBp);
385398
}
386399

387400
private function DecoratorBlock(BlockStatement $block): string
@@ -502,43 +515,51 @@ private function MustacheStatement(MustacheStatement $mustache): string
502515
$fn = $raw ? 'raw' : 'encq';
503516
$path = $mustache->path;
504517

505-
if ($path instanceof PathExpression) {
506-
$helperName = $this->getSimpleHelperName($path);
518+
// SubExpression path: {{(path args)}} — always a direct helper call result
519+
if ($path instanceof SubExpression) {
520+
return self::getRuntimeFunc($fn, $this->SubExpression($path));
521+
}
522+
523+
// PathExpression or Literal: getSimpleHelperName returns the key for both,
524+
// null only for complex paths (multi-segment, scoped, data, depth).
525+
$helperName = $this->getSimpleHelperName($path);
526+
$type = $this->classifySexpr($helperName, $mustache->params, $mustache->hash);
507527

508-
if ($helperName !== null && ($this->isKnownHelper($helperName) || $mustache->params || $mustache->hash !== null)) {
528+
if ($type === SexprType::Helper) {
529+
if ($helperName !== null) {
509530
$call = $this->buildInlineHelperCall($helperName, $mustache->params, $mustache->hash);
510531
return self::getRuntimeFunc($fn, $call);
511532
}
512-
513-
if ($mustache->params || $mustache->hash !== null) {
533+
// Complex PathExpression with params: route through resolveHelper+hbch so helperMissing fires,
534+
// or dv() for data/depth/scoped paths where helper dispatch does not apply.
535+
if ($path instanceof PathExpression) {
514536
$varPath = $this->PathExpression($path);
515-
// Multipart context path used as a helper call: route through resolveHelper+hbch so helperMissing fires.
516-
// Data, depth, scoped, and dynamic-segment paths are not helper calls — invoke via dv() instead.
517537
$stringParts = array_filter($path->parts, 'is_string');
518538
if (!$path->data && $path->depth === 0 && !self::scopedId($path)
519539
&& count($stringParts) === count($path->parts)) {
520540
$logicalName = self::quote(implode('.', $stringParts));
521541
$compiledParams = $this->compileParams($mustache->params, $mustache->hash);
522-
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $logicalName, $varPath");
523-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hbch', "\$cx, $resolved, $logicalName, $compiledParams, \$in"));
542+
return self::getRuntimeFunc($fn, $this->buildResolvedHbchCall($logicalName, $varPath, $compiledParams));
524543
}
525544
$args = array_map(fn($p) => $this->compileExpression($p), $mustache->params);
526545
$call = self::getRuntimeFunc('dv', "$varPath, " . implode(', ', $args));
527546
return self::getRuntimeFunc($fn, $call);
528547
}
548+
}
529549

530-
// When not strict/assumeObjects, check runtime helpers for bare identifiers.
531-
if ($helperName !== null && !$this->context->options->strict && !$this->context->options->assumeObjects
532-
&& $this->lookupBlockParam($helperName) === null) {
533-
$escapedKey = self::quote($helperName);
534-
if ($this->context->options->knownHelpersOnly) {
535-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('cv', "\$in, $escapedKey"));
536-
}
537-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', "\$cx, $escapedKey, \$in"));
550+
if ($helperName !== null && $type === SexprType::Ambiguous && !$this->context->options->strict) {
551+
$escapedKey = self::quote($helperName);
552+
if ($this->context->options->knownHelpersOnly) {
553+
return self::getRuntimeFunc($fn, self::getRuntimeFunc('cv', "\$in, $escapedKey"));
538554
}
555+
$hvArgs = "\$cx, $escapedKey, \$in" . ($this->context->options->assumeObjects ? ', true' : '');
556+
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', $hvArgs));
557+
}
539558

540-
// Plain variable. Single-segment @data vars use lambda() so closures receive
541-
// HelperOptions. Everything else (multi-segment, depth/scoped paths) uses dv().
559+
// Simple: direct path lookup
560+
if ($path instanceof PathExpression) {
561+
// Single-segment @data vars use lambda() so closures receive HelperOptions;
562+
// everything else (multi-segment, depth/scoped paths) uses dv().
542563
$varPath = $this->PathExpression($path);
543564
if ($path->data && $path->depth === 0 && count($path->parts) === 1
544565
&& is_string($path->parts[0]) && $path->parts[0] !== 'partial-block') {
@@ -548,25 +569,9 @@ private function MustacheStatement(MustacheStatement $mustache): string
548569
return self::getRuntimeFunc($fn, self::getRuntimeFunc('dv', $varPath));
549570
}
550571

551-
// SubExpression path: {{(path args)}} — compile and render the sub-expression result
552-
if ($path instanceof SubExpression) {
553-
return self::getRuntimeFunc($fn, $this->SubExpression($path));
554-
}
555-
556-
// Literal path — treat as named context lookup or helper call
572+
// Literal in simple/fallthrough position (strict, assumeObjects, or knownHelpersOnly):
573+
// compile as a direct context lookup using the normalized key name.
557574
$literalKey = $this->getLiteralKeyName($path);
558-
559-
if ($this->isKnownHelper($literalKey) || $mustache->params || $mustache->hash !== null) {
560-
$call = $this->buildInlineHelperCall($literalKey, $mustache->params, $mustache->hash);
561-
return self::getRuntimeFunc($fn, $call);
562-
}
563-
564-
$escapedKey = self::quote($literalKey);
565-
566-
if (!$this->context->options->strict && !$this->context->options->knownHelpersOnly) {
567-
return self::getRuntimeFunc($fn, self::getRuntimeFunc('hv', "\$cx, $escapedKey, \$in"));
568-
}
569-
570575
return self::getRuntimeFunc($fn, $this->compileModeAwareLookup('$in', [$literalKey], $literalKey));
571576
}
572577

@@ -575,11 +580,9 @@ private function MustacheStatement(MustacheStatement $mustache): string
575580
private function SubExpression(SubExpression $expression): string
576581
{
577582
$path = $expression->path;
578-
$helperName = match (true) {
579-
$path instanceof Literal => $this->getLiteralKeyName($path),
580-
$path instanceof PathExpression => $this->getSimpleHelperName($path),
581-
default => null,
582-
};
583+
$helperName = ($path instanceof PathExpression || $path instanceof Literal)
584+
? $this->getSimpleHelperName($path)
585+
: null;
583586

584587
if ($helperName === null) {
585588
// Dynamic callable: path rooted at a sub-expression, e.g. ((helper).prop args)
@@ -809,12 +812,17 @@ private static function scopedId(PathExpression $path): bool
809812
}
810813

811814
/**
812-
* Extract simple helper name from a path if it's a single-segment, non-data, depth-0 path.
815+
* Extract simple helper name from a path.
816+
* For Literal paths (e.g. {{#"foo bar"}}, {{#12}}), returns the stringified key name.
817+
* For PathExpression, returns the single part only if depth-0, non-data, non-scoped, single-segment.
818+
* Returns null for complex paths (multi-segment, scoped, data, depth > 0).
813819
*/
814820
private function getSimpleHelperName(PathExpression|Literal $path): ?string
815821
{
816-
if (!$path instanceof PathExpression
817-
|| $path->data
822+
if ($path instanceof Literal) {
823+
return $this->getLiteralKeyName($path);
824+
}
825+
if ($path->data
818826
|| $path->depth > 0
819827
|| self::scopedId($path)
820828
|| count($path->parts) !== 1
@@ -875,6 +883,25 @@ private static function buildKeyAccess(array $parts): string
875883
return $n;
876884
}
877885

886+
/**
887+
* Build an hbbch call with the given helper expression.
888+
* Trailing bpCount/outerBp args are omitted when both are zero/empty.
889+
*/
890+
private static function buildHbbchCall(string $helperExpr, string $escapedName, string $params, string $blockFn, string $else, int $bpCount, string $outerBp): string
891+
{
892+
$trailingArgs = ($bpCount > 0 || $outerBp !== '[]') ? ", $bpCount, $outerBp" : '';
893+
return self::getRuntimeFunc('hbbch', "\$cx, $helperExpr, $escapedName, $params, \$in, $blockFn, $else$trailingArgs");
894+
}
895+
896+
/**
897+
* Build a resolveHelper + hbch call for dynamic inline helper dispatch.
898+
*/
899+
private function buildResolvedHbchCall(string $escapedName, string $varPath, string $params): string
900+
{
901+
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $escapedName, $varPath");
902+
return self::getRuntimeFunc('hbch', "\$cx, $resolved, $escapedName, $params, \$in");
903+
}
904+
878905
/**
879906
* Build runtime function call.
880907
*/
@@ -952,7 +979,6 @@ private function buildInlineHelperCall(string $name, array $params, ?Hash $hash)
952979
if ($this->context->options->knownHelpersOnly) {
953980
$this->throwKnownHelpersOnly($name);
954981
}
955-
$resolved = self::getRuntimeFunc('resolveHelper', "\$cx, $helperName, \$in[$helperName] ?? null");
956-
return self::getRuntimeFunc('hbch', "\$cx, $resolved, $helperName, $compiledParams, \$in");
982+
return $this->buildResolvedHbchCall($helperName, "\$in[$helperName] ?? null", $compiledParams);
957983
}
958984
}

src/Runtime.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,17 @@ public static function cv(mixed &$_this, string $name): mixed
239239
/**
240240
* Helper-or-variable lookup for bare {{identifier}} expressions.
241241
* Checks runtime helpers first, then context value, then helperMissing fallback.
242+
* When $assumeObjects is true, uses nullCheck for context lookup (throws on null context).
242243
*
243244
* @param mixed $_this current rendering context
244245
*/
245-
public static function hv(RuntimeContext $cx, string $name, mixed &$_this): mixed
246+
public static function hv(RuntimeContext $cx, string $name, mixed &$_this, bool $assumeObjects = false): mixed
246247
{
247-
$value = $cx->helpers[$name] ?? $_this[$name] ?? null;
248+
$helper = $cx->helpers[$name] ?? null;
249+
if ($helper !== null) {
250+
return static::hbch($cx, $helper, $name, [], [], $_this);
251+
}
252+
$value = $assumeObjects ? static::nullCheck($_this, $name) : ($_this[$name] ?? null);
248253
if ($value === null || $value instanceof Closure) {
249254
return static::hbch($cx, $value ?? $cx->helpers['helperMissing'], $name, [], [], $_this);
250255
}

tests/RegressionTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public function testLog(): void
6565
#[DataProvider("missingDataProvider")]
6666
#[DataProvider("syntaxProvider")]
6767
#[DataProvider("subexpressionPathProvider")]
68+
#[DataProvider("literalPathProvider")]
6869
public function testIssues(
6970
string $template,
7071
string $expected,
@@ -2279,6 +2280,26 @@ public static function subexpressionPathProvider(): array
22792280
];
22802281
}
22812282

2283+
/** @return array<string, RegIssue> */
2284+
public static function literalPathProvider(): array
2285+
{
2286+
return [
2287+
'knownHelpersOnly: string literal path invokes lambda' => [
2288+
'template' => '{{"foo"}}',
2289+
'data' => ['foo' => fn() => 'lambda-result'],
2290+
'expected' => 'lambda-result',
2291+
'options' => new Options(knownHelpersOnly: true),
2292+
],
2293+
'assumeObjects: string literal path uses helper dispatch, matching ambiguous PathExpression behavior' => [
2294+
'template' => '{{"foo"}}',
2295+
'data' => ['foo' => 'ctx'],
2296+
'helpers' => ['foo' => fn() => 'helper'],
2297+
'expected' => 'helper',
2298+
'options' => new Options(assumeObjects: true),
2299+
],
2300+
];
2301+
}
2302+
22822303
public function testLoadPartialPersistsAcrossFnCalls(): void
22832304
{
22842305
// registerPartial() writes to the persistent $cx->partials array, which fn() does not

0 commit comments

Comments
 (0)