@@ -106,9 +106,10 @@ public function render(FileNode $node, string $file): void
106106
107107 $ template ->setVar (
108108 [
109- 'items ' => $ this ->renderItems ($ node ),
110- 'lines ' => $ this ->renderSourceWithLineCoverage ($ node ),
111- 'legend ' => '<p><span class="success"><strong>Executed</strong></span><span class="danger"><strong>Not Executed</strong></span><span class="warning"><strong>Dead Code</strong></span></p> ' ,
109+ 'items ' => $ this ->renderItems ($ node ),
110+ 'lines ' => $ this ->renderSourceWithLineCoverage ($ node ),
111+ 'legend ' => '<p><span class="success"><strong>Executed</strong></span><span class="danger"><strong>Not Executed</strong></span><span class="warning"><strong>Dead Code</strong></span></p> ' ,
112+ 'structure ' => '' ,
112113 ]
113114 );
114115
@@ -117,19 +118,21 @@ public function render(FileNode $node, string $file): void
117118 if ($ this ->hasBranchCoverage ) {
118119 $ template ->setVar (
119120 [
120- 'items ' => $ this ->renderItems ($ node ),
121- 'lines ' => $ this ->renderSourceWithBranchCoverage ($ node ),
122- 'legend ' => '<p><span class="success"><strong>Fully covered</strong></span><span class="warning"><strong>Partially covered</strong></span><span class="danger"><strong>Not covered</strong></span></p> ' ,
121+ 'items ' => $ this ->renderItems ($ node ),
122+ 'lines ' => $ this ->renderSourceWithBranchCoverage ($ node ),
123+ 'legend ' => '<p><span class="success"><strong>Fully covered</strong></span><span class="warning"><strong>Partially covered</strong></span><span class="danger"><strong>Not covered</strong></span></p> ' ,
124+ 'structure ' => $ this ->renderBranchStructure ($ node ),
123125 ]
124126 );
125127
126128 $ template ->renderTo ($ file . '_branch.html ' );
127129
128130 $ template ->setVar (
129131 [
130- 'items ' => $ this ->renderItems ($ node ),
131- 'lines ' => $ this ->renderSourceWithPathCoverage ($ node ),
132- 'legend ' => '<p><span class="success"><strong>Fully covered</strong></span><span class="warning"><strong>Partially covered</strong></span><span class="danger"><strong>Not covered</strong></span></p> ' ,
132+ 'items ' => $ this ->renderItems ($ node ),
133+ 'lines ' => $ this ->renderSourceWithPathCoverage ($ node ),
134+ 'legend ' => '<p><span class="success"><strong>Fully covered</strong></span><span class="warning"><strong>Partially covered</strong></span><span class="danger"><strong>Not covered</strong></span></p> ' ,
135+ 'structure ' => $ this ->renderPathStructure ($ node ),
133136 ]
134137 );
135138
@@ -622,6 +625,194 @@ private function renderSourceWithPathCoverage(FileNode $node): string
622625 return $ linesTemplate ->render ();
623626 }
624627
628+ private function renderBranchStructure (FileNode $ node ): string
629+ {
630+ $ branchesTemplate = new Template ($ this ->templatePath . 'branches.html.dist ' , '{{ ' , '}} ' );
631+
632+ $ coverageData = $ node ->functionCoverageData ();
633+ $ testData = $ node ->testData ();
634+ $ codeLines = $ this ->loadFile ($ node ->pathAsString ());
635+ $ branches = '' ;
636+
637+ ksort ($ coverageData );
638+
639+ foreach ($ coverageData as $ methodName => $ methodData ) {
640+ if (!$ methodData ['branches ' ]) {
641+ continue ;
642+ }
643+
644+ $ branches .= '<h5 class="structure-heading"><a name=" ' . htmlspecialchars ('branches_ ' . $ methodName , $ this ->htmlSpecialCharsFlags ) . '"> ' . $ this ->abbreviateMethodName ($ methodName ) . '</a></h5> ' . "\n" ;
645+
646+ foreach ($ methodData ['branches ' ] as $ branch ) {
647+ $ branches .= $ this ->renderBranchLines ($ branch , $ codeLines , $ testData );
648+ }
649+ }
650+
651+ $ branchesTemplate ->setVar (['branches ' => $ branches ]);
652+
653+ return $ branchesTemplate ->render ();
654+ }
655+
656+ private function renderBranchLines (array $ branch , array $ codeLines , array $ testData ): string
657+ {
658+ $ linesTemplate = new Template ($ this ->templatePath . 'lines.html.dist ' , '{{ ' , '}} ' );
659+ $ singleLineTemplate = new Template ($ this ->templatePath . 'line.html.dist ' , '{{ ' , '}} ' );
660+
661+ $ lines = '' ;
662+
663+ $ branchLines = range ($ branch ['line_start ' ], $ branch ['line_end ' ]);
664+ sort ($ branchLines ); // sometimes end_line < start_line
665+
666+ foreach ($ branchLines as $ line ) {
667+ if (!isset ($ codeLines [$ line ])) { // blank line at end of file is sometimes included here
668+ continue ;
669+ }
670+
671+ $ popoverContent = '' ;
672+ $ popoverTitle = '' ;
673+
674+ $ numTests = count ($ branch ['hit ' ]);
675+
676+ if ($ numTests === 0 ) {
677+ $ trClass = 'danger ' ;
678+ } else {
679+ $ lineCss = 'covered-by-large-tests ' ;
680+ $ popoverContent = '<ul> ' ;
681+
682+ if ($ numTests > 1 ) {
683+ $ popoverTitle = $ numTests . ' tests cover this branch ' ;
684+ } else {
685+ $ popoverTitle = '1 test covers this branch ' ;
686+ }
687+
688+ foreach ($ branch ['hit ' ] as $ test ) {
689+ if ($ lineCss === 'covered-by-large-tests ' && $ testData [$ test ]['size ' ] === 'medium ' ) {
690+ $ lineCss = 'covered-by-medium-tests ' ;
691+ } elseif ($ testData [$ test ]['size ' ] === 'small ' ) {
692+ $ lineCss = 'covered-by-small-tests ' ;
693+ }
694+
695+ $ popoverContent .= $ this ->createPopoverContentForTest ($ test , $ testData [$ test ]);
696+ }
697+ $ trClass = $ lineCss . ' popin ' ;
698+ }
699+
700+ $ popover = '' ;
701+
702+ if (!empty ($ popoverTitle )) {
703+ $ popover = sprintf (
704+ ' data-title="%s" data-content="%s" data-placement="top" data-html="true" ' ,
705+ $ popoverTitle ,
706+ htmlspecialchars ($ popoverContent , $ this ->htmlSpecialCharsFlags )
707+ );
708+ }
709+
710+ $ lines .= $ this ->renderLine ($ singleLineTemplate , $ line , $ codeLines [$ line - 1 ], $ trClass , $ popover );
711+ }
712+
713+ $ linesTemplate ->setVar (['lines ' => $ lines ]);
714+
715+ return $ linesTemplate ->render ();
716+ }
717+
718+ private function renderPathStructure (FileNode $ node ): string
719+ {
720+ $ pathsTemplate = new Template ($ this ->templatePath . 'paths.html.dist ' , '{{ ' , '}} ' );
721+
722+ $ coverageData = $ node ->functionCoverageData ();
723+ $ testData = $ node ->testData ();
724+ $ codeLines = $ this ->loadFile ($ node ->pathAsString ());
725+ $ paths = '' ;
726+
727+ ksort ($ coverageData );
728+
729+ foreach ($ coverageData as $ methodName => $ methodData ) {
730+ if (!$ methodData ['paths ' ]) {
731+ continue ;
732+ }
733+
734+ $ paths .= '<h5 class="structure-heading"><a name=" ' . htmlspecialchars ('paths_ ' . $ methodName , $ this ->htmlSpecialCharsFlags ) . '"> ' . $ this ->abbreviateMethodName ($ methodName ) . '</a></h5> ' . "\n" ;
735+
736+ if (count ($ methodData ['paths ' ]) > 250 ) {
737+ $ paths .= '<p> ' . count ($ methodData ['paths ' ]) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.</p> ' ;
738+
739+ continue ;
740+ }
741+
742+ foreach ($ methodData ['paths ' ] as $ path ) {
743+ $ paths .= $ this ->renderPathLines ($ path , $ methodData ['branches ' ], $ codeLines , $ testData );
744+ }
745+ }
746+
747+ $ pathsTemplate ->setVar (['paths ' => $ paths ]);
748+
749+ return $ pathsTemplate ->render ();
750+ }
751+
752+ private function renderPathLines (array $ path , array $ branches , array $ codeLines , array $ testData ): string
753+ {
754+ $ linesTemplate = new Template ($ this ->templatePath . 'lines.html.dist ' , '{{ ' , '}} ' );
755+ $ singleLineTemplate = new Template ($ this ->templatePath . 'line.html.dist ' , '{{ ' , '}} ' );
756+
757+ $ lines = '' ;
758+
759+ foreach ($ path ['path ' ] as $ branchId ) {
760+ $ branchLines = range ($ branches [$ branchId ]['line_start ' ], $ branches [$ branchId ]['line_end ' ]);
761+ sort ($ branchLines ); // sometimes end_line < start_line
762+
763+ foreach ($ branchLines as $ line ) {
764+ if (!isset ($ codeLines [$ line ])) { // blank line at end of file is sometimes included here
765+ continue ;
766+ }
767+
768+ $ popoverContent = '' ;
769+ $ popoverTitle = '' ;
770+
771+ $ numTests = count ($ path ['hit ' ]);
772+
773+ if ($ numTests === 0 ) {
774+ $ trClass = 'danger ' ;
775+ } else {
776+ $ lineCss = 'covered-by-large-tests ' ;
777+ $ popoverContent = '<ul> ' ;
778+
779+ if ($ numTests > 1 ) {
780+ $ popoverTitle = $ numTests . ' tests cover this path ' ;
781+ } else {
782+ $ popoverTitle = '1 test covers this path ' ;
783+ }
784+
785+ foreach ($ path ['hit ' ] as $ test ) {
786+ if ($ lineCss === 'covered-by-large-tests ' && $ testData [$ test ]['size ' ] === 'medium ' ) {
787+ $ lineCss = 'covered-by-medium-tests ' ;
788+ } elseif ($ testData [$ test ]['size ' ] === 'small ' ) {
789+ $ lineCss = 'covered-by-small-tests ' ;
790+ }
791+
792+ $ popoverContent .= $ this ->createPopoverContentForTest ($ test , $ testData [$ test ]);
793+ }
794+ $ trClass = $ lineCss . ' popin ' ;
795+ }
796+
797+ $ popover = '' ;
798+
799+ if (!empty ($ popoverTitle )) {
800+ $ popover = sprintf (
801+ ' data-title="%s" data-content="%s" data-placement="top" data-html="true" ' ,
802+ $ popoverTitle ,
803+ htmlspecialchars ($ popoverContent , $ this ->htmlSpecialCharsFlags )
804+ );
805+ }
806+
807+ $ lines .= $ this ->renderLine ($ singleLineTemplate , $ line , $ codeLines [$ line - 1 ], $ trClass , $ popover );
808+ }
809+ }
810+
811+ $ linesTemplate ->setVar (['lines ' => $ lines ]);
812+
813+ return $ linesTemplate ->render ();
814+ }
815+
625816 private function renderLine (Template $ template , int $ lineNumber , string $ lineContent , string $ class , string $ popover ): string
626817 {
627818 $ template ->setVar (
@@ -802,6 +993,17 @@ private function abbreviateClassName(string $className): string
802993 return $ className ;
803994 }
804995
996+ private function abbreviateMethodName (string $ methodName ): string
997+ {
998+ $ parts = explode ('-> ' , $ methodName );
999+
1000+ if (count ($ parts ) === 2 ) {
1001+ return $ this ->abbreviateClassName ($ parts [0 ]) . '-> ' . $ parts [1 ];
1002+ }
1003+
1004+ return $ methodName ;
1005+ }
1006+
8051007 private function createPopoverContentForTest (string $ test , array $ testData ): string
8061008 {
8071009 switch ($ testData ['status ' ]) {
0 commit comments