2323use DevTheorem \HandlebarsParser \Ast \UndefinedLiteral ;
2424use 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}
0 commit comments