@@ -600,70 +600,44 @@ private function PathExpression(PathExpression $expression): string
600600 return $ base ;
601601 }
602602
603- $ miss = $ this ->missValue ($ expression ->original );
604-
605603 // @partial-block as variable: truthy when an active partial block exists
606604 if ($ data && $ depth === 0 && count ($ stringParts ) === 1 && $ stringParts [0 ] === 'partial-block ' ) {
607605 return "\$cx->partialBlock !== null ? true : null " ;
608606 }
609607
608+ $ isLength = end ($ stringParts ) === 'length ' ;
609+ $ varParts = $ isLength ? array_slice ($ stringParts , 0 , -1 ) : $ stringParts ;
610+ $ miss = $ this ->missValue ($ expression ->original );
611+
610612 // Check block params (depth-0, non-data, non-scoped paths only, not SubExpression-headed)
611613 if (!$ hasSubExprHead && !$ data && $ depth === 0 && !self ::scopedId ($ expression )) {
612614 $ bp = $ this ->lookupBlockParam ($ stringParts [0 ]);
613615 if ($ bp !== null ) {
614616 [$ bpDepth , $ bpIndex ] = $ bp ;
615617 $ bpBase = "\$blockParams[ $ bpDepth][ $ bpIndex] " ;
616- $ remaining = self ::buildKeyAccess (array_slice ($ stringParts , 1 ));
617618 // Mark the current compileProgram() level as having a direct $blockParams reference.
618619 if ($ this ->bpRefStack ) {
619620 $ this ->bpRefStack [array_key_last ($ this ->bpRefStack )] = true ;
620621 }
621- return "$ bpBase$ remaining ?? $ miss " ;
622- }
623- }
624-
625- // Build array access string
626- $ n = self ::buildKeyAccess ($ stringParts );
627-
628- // Handle .length special case
629- $ lastPart = end ($ stringParts );
630- if ($ lastPart === 'length ' ) {
631- $ varParts = array_slice ($ stringParts , 0 , -1 );
632- $ p = self ::buildKeyAccess ($ varParts );
633-
634- $ checks = [];
635- if ($ depth > 0 ) {
636- $ checks [] = "isset( $ base) " ;
637- }
638- if ($ p !== '' && $ depth === 0 && !$ hasSubExprHead ) {
639- $ checks [] = "isset( $ base$ p) " ;
640- }
641- $ baseP = "$ base$ p " ;
642- $ checks [] = $ baseP === '$in ' ? '$inary ' : "is_array( $ base$ p) " ;
643-
644- $ cond = implode (' && ' , $ checks );
645- if (count ($ checks ) > 1 ) {
646- $ cond = "( $ cond) " ;
622+ // Skip the block param name ($varParts[0]) since it has been resolved to a $blockParams index.
623+ $ parent = $ bpBase . self ::buildKeyAccess (array_slice ($ varParts , 1 ));
624+ if ($ isLength ) {
625+ return $ this ->buildLookupLength ($ parent );
626+ }
627+ return "$ parent ?? $ miss " ;
647628 }
648- $ lenStart = "$ cond ? count( $ base$ p) : " ;
649-
650- return "$ base$ n ?? ( $ lenStart$ miss) " ;
651629 }
652630
653- // assumeObjects and strict mode for helper arguments both use nullCheck chains.
654- // This mirrors HBS.js: both paths use bare nameLookup (no container.strict wrapping), so
655- // only a null intermediate throws (JS TypeError), while a missing key on a valid array
656- // returns null silently (JS undefined). nullCheck encodes those semantics and includes
657- // the key name in the exception message.
658- if ( $ this -> context -> options -> assumeObjects || ( $ this ->context -> options -> strict && $ this -> compilingHelperArgs )) {
659- return self :: buildCallChain ( ' nullCheck ' , $ base , $ stringParts );
631+ // Handle .length: compile parent path through the normal mode-aware logic, then wrap in
632+ // lookupLength() at runtime. This mirrors HBS.js, where .length is a normal property
633+ // access with no compile-time special casing.
634+ if ( $ isLength ) {
635+ return $ this -> buildLookupLength (
636+ $ this ->compileModeAwareLookup ( $ base , $ varParts , $ expression -> original , ' null ' ),
637+ );
660638 }
661639
662- if ($ this ->context ->options ->strict ) {
663- return self ::buildCallChain ('strictLookup ' , $ base , $ stringParts , self ::quote ($ expression ->original ));
664- }
665-
666- return "$ base$ n ?? $ miss " ;
640+ return $ this ->compileModeAwareLookup ($ base , $ stringParts , $ expression ->original , $ miss );
667641 }
668642
669643 private function StringLiteral (StringLiteral $ literal ): string
@@ -972,6 +946,33 @@ private function compileProgramOrEmpty(?Program $program): string
972946 return $ this ->compileProgram ($ program );
973947 }
974948
949+ private function buildLookupLength (string $ parent ): string
950+ {
951+ $ strict = $ this ->context ->options ->strict || $ this ->context ->options ->assumeObjects ;
952+ return self ::getRuntimeFunc ('lookupLength ' , $ strict ? "$ parent, true " : $ parent );
953+ }
954+
955+ /**
956+ * Compile a mode-aware path access expression for the given base and parts.
957+ * @param string[] $parts
958+ */
959+ private function compileModeAwareLookup (string $ base , array $ parts , string $ original , string $ miss ): string
960+ {
961+ if (!$ parts ) {
962+ return $ base ;
963+ }
964+ if ($ this ->context ->options ->assumeObjects || ($ this ->context ->options ->strict && $ this ->compilingHelperArgs )) {
965+ // Use nullCheck chain for assumeObjects and helper arguments in strict mode.
966+ // This mirrors HBS.js: both paths use bare nameLookup, so only a null intermediate throws
967+ // (JS TypeError), while a missing key on a valid object returns null silently (JS undefined).
968+ return self ::buildCallChain ('nullCheck ' , $ base , $ parts );
969+ }
970+ if ($ this ->context ->options ->strict ) {
971+ return self ::buildCallChain ('strictLookup ' , $ base , $ parts , self ::quote ($ original ));
972+ }
973+ return $ base . self ::buildKeyAccess ($ parts ) . " ?? $ miss " ;
974+ }
975+
975976 private function throwKnownHelpersOnly (string $ helperName ): never
976977 {
977978 throw new \Exception ("You specified knownHelpersOnly, but used the unknown helper $ helperName " );
0 commit comments