Subversion-Projekte lars-tiefland.laravel_shop

Revision

Revision 688 | Zur aktuellen Revision | Details | Letzte Änderung | Log anzeigen | RSS feed

Revision Autor Zeilennr. Zeile
148 lars 1
<?php declare(strict_types=1);
2
/*
3
 * This file is part of phpunit/php-code-coverage.
4
 *
5
 * (c) Sebastian Bergmann <sebastian@phpunit.de>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
namespace SebastianBergmann\CodeCoverage\Report\Html;
11
 
12
use const ENT_COMPAT;
13
use const ENT_HTML401;
14
use const ENT_SUBSTITUTE;
15
use const T_ABSTRACT;
16
use const T_ARRAY;
17
use const T_AS;
18
use const T_BREAK;
19
use const T_CALLABLE;
20
use const T_CASE;
21
use const T_CATCH;
22
use const T_CLASS;
23
use const T_CLONE;
24
use const T_COMMENT;
25
use const T_CONST;
26
use const T_CONTINUE;
27
use const T_DECLARE;
28
use const T_DEFAULT;
29
use const T_DO;
30
use const T_DOC_COMMENT;
31
use const T_ECHO;
32
use const T_ELSE;
33
use const T_ELSEIF;
34
use const T_EMPTY;
35
use const T_ENDDECLARE;
36
use const T_ENDFOR;
37
use const T_ENDFOREACH;
38
use const T_ENDIF;
39
use const T_ENDSWITCH;
40
use const T_ENDWHILE;
41
use const T_EVAL;
42
use const T_EXIT;
43
use const T_EXTENDS;
44
use const T_FINAL;
45
use const T_FINALLY;
46
use const T_FOR;
47
use const T_FOREACH;
48
use const T_FUNCTION;
49
use const T_GLOBAL;
50
use const T_GOTO;
51
use const T_HALT_COMPILER;
52
use const T_IF;
53
use const T_IMPLEMENTS;
54
use const T_INCLUDE;
55
use const T_INCLUDE_ONCE;
56
use const T_INLINE_HTML;
57
use const T_INSTANCEOF;
58
use const T_INSTEADOF;
59
use const T_INTERFACE;
60
use const T_ISSET;
61
use const T_LIST;
62
use const T_NAMESPACE;
63
use const T_NEW;
64
use const T_PRINT;
65
use const T_PRIVATE;
66
use const T_PROTECTED;
67
use const T_PUBLIC;
68
use const T_REQUIRE;
69
use const T_REQUIRE_ONCE;
70
use const T_RETURN;
71
use const T_STATIC;
72
use const T_SWITCH;
73
use const T_THROW;
74
use const T_TRAIT;
75
use const T_TRY;
76
use const T_UNSET;
77
use const T_USE;
78
use const T_VAR;
79
use const T_WHILE;
80
use const T_YIELD;
81
use const T_YIELD_FROM;
82
use function array_key_exists;
83
use function array_keys;
84
use function array_merge;
85
use function array_pop;
86
use function array_unique;
87
use function constant;
88
use function count;
89
use function defined;
90
use function explode;
91
use function file_get_contents;
92
use function htmlspecialchars;
93
use function is_string;
94
use function ksort;
95
use function range;
96
use function sort;
97
use function sprintf;
98
use function str_replace;
99
use function substr;
100
use function token_get_all;
101
use function trim;
102
use PHPUnit\Runner\BaseTestRunner;
103
use SebastianBergmann\CodeCoverage\Node\File as FileNode;
104
use SebastianBergmann\CodeCoverage\Util\Percentage;
105
use SebastianBergmann\Template\Template;
106
 
107
/**
108
 * @internal This class is not covered by the backward compatibility promise for phpunit/php-code-coverage
109
 */
110
final class File extends Renderer
111
{
112
    /**
113
     * @psalm-var array<int,true>
114
     */
115
    private static $keywordTokens = [];
116
 
117
    /**
118
     * @var array
119
     */
120
    private static $formattedSourceCache = [];
121
 
122
    /**
123
     * @var int
124
     */
125
    private $htmlSpecialCharsFlags = ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE;
126
 
127
    public function render(FileNode $node, string $file): void
128
    {
129
        $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_branch.html' : 'file.html');
130
        $template     = new Template($templateName, '{{', '}}');
131
        $this->setCommonTemplateVariables($template, $node);
132
 
133
        $template->setVar(
134
            [
135
                'items'     => $this->renderItems($node),
136
                'lines'     => $this->renderSourceWithLineCoverage($node),
137
                '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>',
138
                'structure' => '',
139
            ]
140
        );
141
 
142
        $template->renderTo($file . '.html');
143
 
144
        if ($this->hasBranchCoverage) {
145
            $template->setVar(
146
                [
147
                    'items'     => $this->renderItems($node),
148
                    'lines'     => $this->renderSourceWithBranchCoverage($node),
149
                    '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>',
150
                    'structure' => $this->renderBranchStructure($node),
151
                ]
152
            );
153
 
154
            $template->renderTo($file . '_branch.html');
155
 
156
            $template->setVar(
157
                [
158
                    'items'     => $this->renderItems($node),
159
                    'lines'     => $this->renderSourceWithPathCoverage($node),
160
                    '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>',
161
                    'structure' => $this->renderPathStructure($node),
162
                ]
163
            );
164
 
165
            $template->renderTo($file . '_path.html');
166
        }
167
    }
168
 
169
    private function renderItems(FileNode $node): string
170
    {
171
        $templateName = $this->templatePath . ($this->hasBranchCoverage ? 'file_item_branch.html' : 'file_item.html');
172
        $template     = new Template($templateName, '{{', '}}');
173
 
174
        $methodTemplateName = $this->templatePath . ($this->hasBranchCoverage ? 'method_item_branch.html' : 'method_item.html');
175
        $methodItemTemplate = new Template(
176
            $methodTemplateName,
177
            '{{',
178
            '}}'
179
        );
180
 
181
        $items = $this->renderItemTemplate(
182
            $template,
183
            [
184
                'name'                            => 'Total',
185
                'numClasses'                      => $node->numberOfClassesAndTraits(),
186
                'numTestedClasses'                => $node->numberOfTestedClassesAndTraits(),
187
                'numMethods'                      => $node->numberOfFunctionsAndMethods(),
188
                'numTestedMethods'                => $node->numberOfTestedFunctionsAndMethods(),
189
                'linesExecutedPercent'            => $node->percentageOfExecutedLines()->asFloat(),
190
                'linesExecutedPercentAsString'    => $node->percentageOfExecutedLines()->asString(),
191
                'numExecutedLines'                => $node->numberOfExecutedLines(),
192
                'numExecutableLines'              => $node->numberOfExecutableLines(),
193
                'branchesExecutedPercent'         => $node->percentageOfExecutedBranches()->asFloat(),
194
                'branchesExecutedPercentAsString' => $node->percentageOfExecutedBranches()->asString(),
195
                'numExecutedBranches'             => $node->numberOfExecutedBranches(),
196
                'numExecutableBranches'           => $node->numberOfExecutableBranches(),
197
                'pathsExecutedPercent'            => $node->percentageOfExecutedPaths()->asFloat(),
198
                'pathsExecutedPercentAsString'    => $node->percentageOfExecutedPaths()->asString(),
199
                'numExecutedPaths'                => $node->numberOfExecutedPaths(),
200
                'numExecutablePaths'              => $node->numberOfExecutablePaths(),
201
                'testedMethodsPercent'            => $node->percentageOfTestedFunctionsAndMethods()->asFloat(),
202
                'testedMethodsPercentAsString'    => $node->percentageOfTestedFunctionsAndMethods()->asString(),
203
                'testedClassesPercent'            => $node->percentageOfTestedClassesAndTraits()->asFloat(),
204
                'testedClassesPercentAsString'    => $node->percentageOfTestedClassesAndTraits()->asString(),
205
                'crap'                            => '<abbr title="Change Risk Anti-Patterns (CRAP) Index">CRAP</abbr>',
206
            ]
207
        );
208
 
209
        $items .= $this->renderFunctionItems(
210
            $node->functions(),
211
            $methodItemTemplate
212
        );
213
 
214
        $items .= $this->renderTraitOrClassItems(
215
            $node->traits(),
216
            $template,
217
            $methodItemTemplate
218
        );
219
 
220
        $items .= $this->renderTraitOrClassItems(
221
            $node->classes(),
222
            $template,
223
            $methodItemTemplate
224
        );
225
 
226
        return $items;
227
    }
228
 
229
    private function renderTraitOrClassItems(array $items, Template $template, Template $methodItemTemplate): string
230
    {
231
        $buffer = '';
232
 
233
        if (empty($items)) {
234
            return $buffer;
235
        }
236
 
237
        foreach ($items as $name => $item) {
238
            $numMethods       = 0;
239
            $numTestedMethods = 0;
240
 
241
            foreach ($item['methods'] as $method) {
242
                if ($method['executableLines'] > 0) {
243
                    $numMethods++;
244
 
245
                    if ($method['executedLines'] === $method['executableLines']) {
246
                        $numTestedMethods++;
247
                    }
248
                }
249
            }
250
 
251
            if ($item['executableLines'] > 0) {
252
                $numClasses                   = 1;
253
                $numTestedClasses             = $numTestedMethods === $numMethods ? 1 : 0;
254
                $linesExecutedPercentAsString = Percentage::fromFractionAndTotal(
255
                    $item['executedLines'],
256
                    $item['executableLines']
257
                )->asString();
258
                $branchesExecutedPercentAsString = Percentage::fromFractionAndTotal(
259
                    $item['executedBranches'],
260
                    $item['executableBranches']
261
                )->asString();
262
                $pathsExecutedPercentAsString = Percentage::fromFractionAndTotal(
263
                    $item['executedPaths'],
264
                    $item['executablePaths']
265
                )->asString();
266
            } else {
267
                $numClasses                      = 0;
268
                $numTestedClasses                = 0;
269
                $linesExecutedPercentAsString    = 'n/a';
270
                $branchesExecutedPercentAsString = 'n/a';
271
                $pathsExecutedPercentAsString    = 'n/a';
272
            }
273
 
274
            $testedMethodsPercentage = Percentage::fromFractionAndTotal(
275
                $numTestedMethods,
276
                $numMethods
277
            );
278
 
279
            $testedClassesPercentage = Percentage::fromFractionAndTotal(
280
                $numTestedMethods === $numMethods ? 1 : 0,
281
                1
282
            );
283
 
284
            $buffer .= $this->renderItemTemplate(
285
                $template,
286
                [
287
                    'name'                            => $this->abbreviateClassName($name),
288
                    'numClasses'                      => $numClasses,
289
                    'numTestedClasses'                => $numTestedClasses,
290
                    'numMethods'                      => $numMethods,
291
                    'numTestedMethods'                => $numTestedMethods,
292
                    'linesExecutedPercent'            => Percentage::fromFractionAndTotal(
293
                        $item['executedLines'],
294
                        $item['executableLines'],
295
                    )->asFloat(),
296
                    'linesExecutedPercentAsString'    => $linesExecutedPercentAsString,
297
                    'numExecutedLines'                => $item['executedLines'],
298
                    'numExecutableLines'              => $item['executableLines'],
299
                    'branchesExecutedPercent'         => Percentage::fromFractionAndTotal(
300
                        $item['executedBranches'],
301
                        $item['executableBranches'],
302
                    )->asFloat(),
303
                    'branchesExecutedPercentAsString' => $branchesExecutedPercentAsString,
304
                    'numExecutedBranches'             => $item['executedBranches'],
305
                    'numExecutableBranches'           => $item['executableBranches'],
306
                    'pathsExecutedPercent'            => Percentage::fromFractionAndTotal(
307
                        $item['executedPaths'],
308
                        $item['executablePaths']
309
                    )->asFloat(),
310
                    'pathsExecutedPercentAsString'    => $pathsExecutedPercentAsString,
311
                    'numExecutedPaths'                => $item['executedPaths'],
312
                    'numExecutablePaths'              => $item['executablePaths'],
313
                    'testedMethodsPercent'            => $testedMethodsPercentage->asFloat(),
314
                    'testedMethodsPercentAsString'    => $testedMethodsPercentage->asString(),
315
                    'testedClassesPercent'            => $testedClassesPercentage->asFloat(),
316
                    'testedClassesPercentAsString'    => $testedClassesPercentage->asString(),
317
                    'crap'                            => $item['crap'],
318
                ]
319
            );
320
 
321
            foreach ($item['methods'] as $method) {
322
                $buffer .= $this->renderFunctionOrMethodItem(
323
                    $methodItemTemplate,
324
                    $method,
325
                    '&nbsp;'
326
                );
327
            }
328
        }
329
 
330
        return $buffer;
331
    }
332
 
333
    private function renderFunctionItems(array $functions, Template $template): string
334
    {
335
        if (empty($functions)) {
336
            return '';
337
        }
338
 
339
        $buffer = '';
340
 
341
        foreach ($functions as $function) {
342
            $buffer .= $this->renderFunctionOrMethodItem(
343
                $template,
344
                $function
345
            );
346
        }
347
 
348
        return $buffer;
349
    }
350
 
351
    private function renderFunctionOrMethodItem(Template $template, array $item, string $indent = ''): string
352
    {
353
        $numMethods       = 0;
354
        $numTestedMethods = 0;
355
 
356
        if ($item['executableLines'] > 0) {
357
            $numMethods = 1;
358
 
359
            if ($item['executedLines'] === $item['executableLines']) {
360
                $numTestedMethods = 1;
361
            }
362
        }
363
 
364
        $executedLinesPercentage = Percentage::fromFractionAndTotal(
365
            $item['executedLines'],
366
            $item['executableLines']
367
        );
368
 
369
        $executedBranchesPercentage = Percentage::fromFractionAndTotal(
370
            $item['executedBranches'],
371
            $item['executableBranches']
372
        );
373
 
374
        $executedPathsPercentage = Percentage::fromFractionAndTotal(
375
            $item['executedPaths'],
376
            $item['executablePaths']
377
        );
378
 
379
        $testedMethodsPercentage = Percentage::fromFractionAndTotal(
380
            $numTestedMethods,
381
            1
382
        );
383
 
384
        return $this->renderItemTemplate(
385
            $template,
386
            [
387
                'name'                            => sprintf(
388
                    '%s<a href="#%d"><abbr title="%s">%s</abbr></a>',
389
                    $indent,
390
                    $item['startLine'],
391
                    htmlspecialchars($item['signature'], $this->htmlSpecialCharsFlags),
392
                    $item['functionName'] ?? $item['methodName']
393
                ),
394
                'numMethods'                      => $numMethods,
395
                'numTestedMethods'                => $numTestedMethods,
396
                'linesExecutedPercent'            => $executedLinesPercentage->asFloat(),
397
                'linesExecutedPercentAsString'    => $executedLinesPercentage->asString(),
398
                'numExecutedLines'                => $item['executedLines'],
399
                'numExecutableLines'              => $item['executableLines'],
400
                'branchesExecutedPercent'         => $executedBranchesPercentage->asFloat(),
401
                'branchesExecutedPercentAsString' => $executedBranchesPercentage->asString(),
402
                'numExecutedBranches'             => $item['executedBranches'],
403
                'numExecutableBranches'           => $item['executableBranches'],
404
                'pathsExecutedPercent'            => $executedPathsPercentage->asFloat(),
405
                'pathsExecutedPercentAsString'    => $executedPathsPercentage->asString(),
406
                'numExecutedPaths'                => $item['executedPaths'],
407
                'numExecutablePaths'              => $item['executablePaths'],
408
                'testedMethodsPercent'            => $testedMethodsPercentage->asFloat(),
409
                'testedMethodsPercentAsString'    => $testedMethodsPercentage->asString(),
410
                'crap'                            => $item['crap'],
411
            ]
412
        );
413
    }
414
 
415
    private function renderSourceWithLineCoverage(FileNode $node): string
416
    {
417
        $linesTemplate      = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
418
        $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
419
 
420
        $coverageData = $node->lineCoverageData();
421
        $testData     = $node->testData();
422
        $codeLines    = $this->loadFile($node->pathAsString());
423
        $lines        = '';
424
        $i            = 1;
425
 
426
        foreach ($codeLines as $line) {
427
            $trClass        = '';
428
            $popoverContent = '';
429
            $popoverTitle   = '';
430
 
431
            if (array_key_exists($i, $coverageData)) {
432
                $numTests = ($coverageData[$i] ? count($coverageData[$i]) : 0);
433
 
434
                if ($coverageData[$i] === null) {
435
                    $trClass = 'warning';
436
                } elseif ($numTests === 0) {
437
                    $trClass = 'danger';
438
                } else {
439
                    if ($numTests > 1) {
440
                        $popoverTitle = $numTests . ' tests cover line ' . $i;
441
                    } else {
442
                        $popoverTitle = '1 test covers line ' . $i;
443
                    }
444
 
445
                    $lineCss        = 'covered-by-large-tests';
446
                    $popoverContent = '<ul>';
447
 
448
                    foreach ($coverageData[$i] as $test) {
449
                        if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') {
450
                            $lineCss = 'covered-by-medium-tests';
451
                        } elseif ($testData[$test]['size'] === 'small') {
452
                            $lineCss = 'covered-by-small-tests';
453
                        }
454
 
455
                        $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
456
                    }
457
 
458
                    $popoverContent .= '</ul>';
459
                    $trClass = $lineCss . ' popin';
460
                }
461
            }
462
 
463
            $popover = '';
464
 
465
            if (!empty($popoverTitle)) {
466
                $popover = sprintf(
467
                    ' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
468
                    $popoverTitle,
469
                    htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
470
                );
471
            }
472
 
473
            $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover);
474
 
475
            $i++;
476
        }
477
 
478
        $linesTemplate->setVar(['lines' => $lines]);
479
 
480
        return $linesTemplate->render();
481
    }
482
 
483
    private function renderSourceWithBranchCoverage(FileNode $node): string
484
    {
485
        $linesTemplate      = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
486
        $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
487
 
488
        $functionCoverageData = $node->functionCoverageData();
489
        $testData             = $node->testData();
490
        $codeLines            = $this->loadFile($node->pathAsString());
491
 
492
        $lineData = [];
493
 
494
        /** @var int $line */
495
        foreach (array_keys($codeLines) as $line) {
496
            $lineData[$line + 1] = [
497
                'includedInBranches'    => 0,
498
                'includedInHitBranches' => 0,
499
                'tests'                 => [],
500
            ];
501
        }
502
 
503
        foreach ($functionCoverageData as $method) {
504
            foreach ($method['branches'] as $branch) {
505
                foreach (range($branch['line_start'], $branch['line_end']) as $line) {
506
                    if (!isset($lineData[$line])) { // blank line at end of file is sometimes included here
507
                        continue;
508
                    }
509
 
510
                    $lineData[$line]['includedInBranches']++;
511
 
512
                    if ($branch['hit']) {
513
                        $lineData[$line]['includedInHitBranches']++;
514
                        $lineData[$line]['tests'] = array_unique(array_merge($lineData[$line]['tests'], $branch['hit']));
515
                    }
516
                }
517
            }
518
        }
519
 
520
        $lines = '';
521
        $i     = 1;
522
 
523
        /** @var string $line */
524
        foreach ($codeLines as $line) {
525
            $trClass = '';
526
            $popover = '';
527
 
528
            if ($lineData[$i]['includedInBranches'] > 0) {
529
                $lineCss = 'success';
530
 
531
                if ($lineData[$i]['includedInHitBranches'] === 0) {
532
                    $lineCss = 'danger';
533
                } elseif ($lineData[$i]['includedInHitBranches'] !== $lineData[$i]['includedInBranches']) {
534
                    $lineCss = 'warning';
535
                }
536
 
537
                $popoverContent = '<ul>';
538
 
539
                if (count($lineData[$i]['tests']) === 1) {
540
                    $popoverTitle = '1 test covers line ' . $i;
541
                } else {
542
                    $popoverTitle = count($lineData[$i]['tests']) . ' tests cover line ' . $i;
543
                }
544
                $popoverTitle .= '. These are covering ' . $lineData[$i]['includedInHitBranches'] . ' out of the ' . $lineData[$i]['includedInBranches'] . ' code branches.';
545
 
546
                foreach ($lineData[$i]['tests'] as $test) {
547
                    $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
548
                }
549
 
550
                $popoverContent .= '</ul>';
551
                $trClass = $lineCss . ' popin';
552
 
553
                $popover = sprintf(
554
                    ' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
555
                    $popoverTitle,
556
                    htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
557
                );
558
            }
559
 
560
            $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover);
561
 
562
            $i++;
563
        }
564
 
565
        $linesTemplate->setVar(['lines' => $lines]);
566
 
567
        return $linesTemplate->render();
568
    }
569
 
570
    private function renderSourceWithPathCoverage(FileNode $node): string
571
    {
572
        $linesTemplate      = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
573
        $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
574
 
575
        $functionCoverageData = $node->functionCoverageData();
576
        $testData             = $node->testData();
577
        $codeLines            = $this->loadFile($node->pathAsString());
578
 
579
        $lineData = [];
580
 
581
        /** @var int $line */
582
        foreach (array_keys($codeLines) as $line) {
583
            $lineData[$line + 1] = [
584
                'includedInPaths'    => [],
585
                'includedInHitPaths' => [],
586
                'tests'              => [],
587
            ];
588
        }
589
 
590
        foreach ($functionCoverageData as $method) {
591
            foreach ($method['paths'] as $pathId => $path) {
592
                foreach ($path['path'] as $branchTaken) {
593
                    foreach (range($method['branches'][$branchTaken]['line_start'], $method['branches'][$branchTaken]['line_end']) as $line) {
594
                        if (!isset($lineData[$line])) {
595
                            continue;
596
                        }
597
                        $lineData[$line]['includedInPaths'][] = $pathId;
598
 
599
                        if ($path['hit']) {
600
                            $lineData[$line]['includedInHitPaths'][] = $pathId;
601
                            $lineData[$line]['tests']                = array_unique(array_merge($lineData[$line]['tests'], $path['hit']));
602
                        }
603
                    }
604
                }
605
            }
606
        }
607
 
608
        $lines = '';
609
        $i     = 1;
610
 
611
        /** @var string $line */
612
        foreach ($codeLines as $line) {
613
            $trClass                 = '';
614
            $popover                 = '';
615
            $includedInPathsCount    = count(array_unique($lineData[$i]['includedInPaths']));
616
            $includedInHitPathsCount = count(array_unique($lineData[$i]['includedInHitPaths']));
617
 
618
            if ($includedInPathsCount > 0) {
619
                $lineCss = 'success';
620
 
621
                if ($includedInHitPathsCount === 0) {
622
                    $lineCss = 'danger';
623
                } elseif ($includedInHitPathsCount !== $includedInPathsCount) {
624
                    $lineCss = 'warning';
625
                }
626
 
627
                $popoverContent = '<ul>';
628
 
629
                if (count($lineData[$i]['tests']) === 1) {
630
                    $popoverTitle = '1 test covers line ' . $i;
631
                } else {
632
                    $popoverTitle = count($lineData[$i]['tests']) . ' tests cover line ' . $i;
633
                }
634
                $popoverTitle .= '. These are covering ' . $includedInHitPathsCount . ' out of the ' . $includedInPathsCount . ' code paths.';
635
 
636
                foreach ($lineData[$i]['tests'] as $test) {
637
                    $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
638
                }
639
 
640
                $popoverContent .= '</ul>';
641
                $trClass = $lineCss . ' popin';
642
 
643
                $popover = sprintf(
644
                    ' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
645
                    $popoverTitle,
646
                    htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
647
                );
648
            }
649
 
650
            $lines .= $this->renderLine($singleLineTemplate, $i, $line, $trClass, $popover);
651
 
652
            $i++;
653
        }
654
 
655
        $linesTemplate->setVar(['lines' => $lines]);
656
 
657
        return $linesTemplate->render();
658
    }
659
 
660
    private function renderBranchStructure(FileNode $node): string
661
    {
662
        $branchesTemplate = new Template($this->templatePath . 'branches.html.dist', '{{', '}}');
663
 
664
        $coverageData = $node->functionCoverageData();
665
        $testData     = $node->testData();
666
        $codeLines    = $this->loadFile($node->pathAsString());
667
        $branches     = '';
668
 
669
        ksort($coverageData);
670
 
671
        foreach ($coverageData as $methodName => $methodData) {
672
            if (!$methodData['branches']) {
673
                continue;
674
            }
675
 
676
            $branchStructure = '';
677
 
678
            foreach ($methodData['branches'] as $branch) {
679
                $branchStructure .= $this->renderBranchLines($branch, $codeLines, $testData);
680
            }
681
 
682
            if ($branchStructure !== '') { // don't show empty branches
683
                $branches .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, $this->htmlSpecialCharsFlags) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
684
                $branches .= $branchStructure;
685
            }
686
        }
687
 
688
        $branchesTemplate->setVar(['branches' => $branches]);
689
 
690
        return $branchesTemplate->render();
691
    }
692
 
693
    private function renderBranchLines(array $branch, array $codeLines, array $testData): string
694
    {
695
        $linesTemplate      = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
696
        $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
697
 
698
        $lines = '';
699
 
700
        $branchLines = range($branch['line_start'], $branch['line_end']);
701
        sort($branchLines); // sometimes end_line < start_line
702
 
703
        /** @var int $line */
704
        foreach ($branchLines as $line) {
705
            if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here
706
                continue;
707
            }
708
 
709
            $popoverContent = '';
710
            $popoverTitle   = '';
711
 
712
            $numTests = count($branch['hit']);
713
 
714
            if ($numTests === 0) {
715
                $trClass = 'danger';
716
            } else {
717
                $lineCss        = 'covered-by-large-tests';
718
                $popoverContent = '<ul>';
719
 
720
                if ($numTests > 1) {
721
                    $popoverTitle = $numTests . ' tests cover this branch';
722
                } else {
723
                    $popoverTitle = '1 test covers this branch';
724
                }
725
 
726
                foreach ($branch['hit'] as $test) {
727
                    if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') {
728
                        $lineCss = 'covered-by-medium-tests';
729
                    } elseif ($testData[$test]['size'] === 'small') {
730
                        $lineCss = 'covered-by-small-tests';
731
                    }
732
 
733
                    $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
734
                }
735
                $trClass = $lineCss . ' popin';
736
            }
737
 
738
            $popover = '';
739
 
740
            if (!empty($popoverTitle)) {
741
                $popover = sprintf(
742
                    ' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
743
                    $popoverTitle,
744
                    htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
745
                );
746
            }
747
 
748
            $lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover);
749
        }
750
 
751
        if ($lines === '') {
752
            return '';
753
        }
754
 
755
        $linesTemplate->setVar(['lines' => $lines]);
756
 
757
        return $linesTemplate->render();
758
    }
759
 
760
    private function renderPathStructure(FileNode $node): string
761
    {
762
        $pathsTemplate = new Template($this->templatePath . 'paths.html.dist', '{{', '}}');
763
 
764
        $coverageData = $node->functionCoverageData();
765
        $testData     = $node->testData();
766
        $codeLines    = $this->loadFile($node->pathAsString());
767
        $paths        = '';
768
 
769
        ksort($coverageData);
770
 
771
        foreach ($coverageData as $methodName => $methodData) {
772
            if (!$methodData['paths']) {
773
                continue;
774
            }
775
 
776
            $pathStructure = '';
777
 
778
            if (count($methodData['paths']) > 100) {
779
                $pathStructure .= '<p>' . count($methodData['paths']) . ' is too many paths to sensibly render, consider refactoring your code to bring this number down.</p>';
780
 
781
                continue;
782
            }
783
 
784
            foreach ($methodData['paths'] as $path) {
785
                $pathStructure .= $this->renderPathLines($path, $methodData['branches'], $codeLines, $testData);
786
            }
787
 
788
            if ($pathStructure !== '') {
789
                $paths .= '<h5 class="structure-heading"><a name="' . htmlspecialchars($methodName, $this->htmlSpecialCharsFlags) . '">' . $this->abbreviateMethodName($methodName) . '</a></h5>' . "\n";
790
                $paths .= $pathStructure;
791
            }
792
        }
793
 
794
        $pathsTemplate->setVar(['paths' => $paths]);
795
 
796
        return $pathsTemplate->render();
797
    }
798
 
799
    private function renderPathLines(array $path, array $branches, array $codeLines, array $testData): string
800
    {
801
        $linesTemplate      = new Template($this->templatePath . 'lines.html.dist', '{{', '}}');
802
        $singleLineTemplate = new Template($this->templatePath . 'line.html.dist', '{{', '}}');
803
 
804
        $lines = '';
805
        $first = true;
806
 
807
        foreach ($path['path'] as $branchId) {
808
            if ($first) {
809
                $first = false;
810
            } else {
811
                $lines .= '    <tr><td colspan="2">&nbsp;</td></tr>' . "\n";
812
            }
813
 
814
            $branchLines = range($branches[$branchId]['line_start'], $branches[$branchId]['line_end']);
815
            sort($branchLines); // sometimes end_line < start_line
816
 
817
            /** @var int $line */
818
            foreach ($branchLines as $line) {
819
                if (!isset($codeLines[$line])) { // blank line at end of file is sometimes included here
820
                    continue;
821
                }
822
 
823
                $popoverContent = '';
824
                $popoverTitle   = '';
825
 
826
                $numTests = count($path['hit']);
827
 
828
                if ($numTests === 0) {
829
                    $trClass = 'danger';
830
                } else {
831
                    $lineCss        = 'covered-by-large-tests';
832
                    $popoverContent = '<ul>';
833
 
834
                    if ($numTests > 1) {
835
                        $popoverTitle = $numTests . ' tests cover this path';
836
                    } else {
837
                        $popoverTitle = '1 test covers this path';
838
                    }
839
 
840
                    foreach ($path['hit'] as $test) {
841
                        if ($lineCss === 'covered-by-large-tests' && $testData[$test]['size'] === 'medium') {
842
                            $lineCss = 'covered-by-medium-tests';
843
                        } elseif ($testData[$test]['size'] === 'small') {
844
                            $lineCss = 'covered-by-small-tests';
845
                        }
846
 
847
                        $popoverContent .= $this->createPopoverContentForTest($test, $testData[$test]);
848
                    }
849
 
850
                    $trClass = $lineCss . ' popin';
851
                }
852
 
853
                $popover = '';
854
 
855
                if (!empty($popoverTitle)) {
856
                    $popover = sprintf(
857
                        ' data-title="%s" data-content="%s" data-placement="top" data-html="true"',
858
                        $popoverTitle,
859
                        htmlspecialchars($popoverContent, $this->htmlSpecialCharsFlags)
860
                    );
861
                }
862
 
863
                $lines .= $this->renderLine($singleLineTemplate, $line, $codeLines[$line - 1], $trClass, $popover);
864
            }
865
        }
866
 
867
        if ($lines === '') {
868
            return '';
869
        }
870
 
871
        $linesTemplate->setVar(['lines' => $lines]);
872
 
873
        return $linesTemplate->render();
874
    }
875
 
876
    private function renderLine(Template $template, int $lineNumber, string $lineContent, string $class, string $popover): string
877
    {
878
        $template->setVar(
879
            [
880
                'lineNumber'  => $lineNumber,
881
                'lineContent' => $lineContent,
882
                'class'       => $class,
883
                'popover'     => $popover,
884
            ]
885
        );
886
 
887
        return $template->render();
888
    }
889
 
890
    private function loadFile(string $file): array
891
    {
892
        if (isset(self::$formattedSourceCache[$file])) {
893
            return self::$formattedSourceCache[$file];
894
        }
895
 
896
        $buffer              = file_get_contents($file);
897
        $tokens              = token_get_all($buffer);
898
        $result              = [''];
899
        $i                   = 0;
900
        $stringFlag          = false;
901
        $fileEndsWithNewLine = substr($buffer, -1) === "\n";
902
 
903
        unset($buffer);
904
 
905
        foreach ($tokens as $j => $token) {
906
            if (is_string($token)) {
907
                if ($token === '"' && $tokens[$j - 1] !== '\\') {
908
                    $result[$i] .= sprintf(
909
                        '<span class="string">%s</span>',
910
                        htmlspecialchars($token, $this->htmlSpecialCharsFlags)
911
                    );
912
 
913
                    $stringFlag = !$stringFlag;
914
                } else {
915
                    $result[$i] .= sprintf(
916
                        '<span class="keyword">%s</span>',
917
                        htmlspecialchars($token, $this->htmlSpecialCharsFlags)
918
                    );
919
                }
920
 
921
                continue;
922
            }
923
 
924
            [$token, $value] = $token;
925
 
926
            $value = str_replace(
927
                ["\t", ' '],
928
                ['&nbsp;&nbsp;&nbsp;&nbsp;', '&nbsp;'],
929
                htmlspecialchars($value, $this->htmlSpecialCharsFlags)
930
            );
931
 
932
            if ($value === "\n") {
933
                $result[++$i] = '';
934
            } else {
935
                $lines = explode("\n", $value);
936
 
937
                foreach ($lines as $jj => $line) {
938
                    $line = trim($line);
939
 
940
                    if ($line !== '') {
941
                        if ($stringFlag) {
942
                            $colour = 'string';
943
                        } else {
944
                            $colour = 'default';
945
 
946
                            if ($this->isInlineHtml($token)) {
947
                                $colour = 'html';
948
                            } elseif ($this->isComment($token)) {
949
                                $colour = 'comment';
950
                            } elseif ($this->isKeyword($token)) {
951
                                $colour = 'keyword';
952
                            }
953
                        }
954
 
955
                        $result[$i] .= sprintf(
956
                            '<span class="%s">%s</span>',
957
                            $colour,
958
                            $line
959
                        );
960
                    }
961
 
962
                    if (isset($lines[$jj + 1])) {
963
                        $result[++$i] = '';
964
                    }
965
                }
966
            }
967
        }
968
 
969
        if ($fileEndsWithNewLine) {
970
            unset($result[count($result) - 1]);
971
        }
972
 
973
        self::$formattedSourceCache[$file] = $result;
974
 
975
        return $result;
976
    }
977
 
978
    private function abbreviateClassName(string $className): string
979
    {
980
        $tmp = explode('\\', $className);
981
 
982
        if (count($tmp) > 1) {
983
            $className = sprintf(
984
                '<abbr title="%s">%s</abbr>',
985
                $className,
986
                array_pop($tmp)
987
            );
988
        }
989
 
990
        return $className;
991
    }
992
 
993
    private function abbreviateMethodName(string $methodName): string
994
    {
995
        $parts = explode('->', $methodName);
996
 
997
        if (count($parts) === 2) {
998
            return $this->abbreviateClassName($parts[0]) . '->' . $parts[1];
999
        }
1000
 
1001
        return $methodName;
1002
    }
1003
 
1004
    private function createPopoverContentForTest(string $test, array $testData): string
1005
    {
1006
        $testCSS = '';
1007
 
1008
        if ($testData['fromTestcase']) {
1009
            switch ($testData['status']) {
1010
                case BaseTestRunner::STATUS_PASSED:
1011
                    switch ($testData['size']) {
1012
                        case 'small':
1013
                            $testCSS = ' class="covered-by-small-tests"';
1014
 
1015
                            break;
1016
 
1017
                        case 'medium':
1018
                            $testCSS = ' class="covered-by-medium-tests"';
1019
 
1020
                            break;
1021
 
1022
                        default:
1023
                            $testCSS = ' class="covered-by-large-tests"';
1024
 
1025
                            break;
1026
                    }
1027
 
1028
                    break;
1029
 
1030
                case BaseTestRunner::STATUS_SKIPPED:
1031
                case BaseTestRunner::STATUS_INCOMPLETE:
1032
                case BaseTestRunner::STATUS_RISKY:
1033
                case BaseTestRunner::STATUS_WARNING:
1034
                    $testCSS = ' class="warning"';
1035
 
1036
                    break;
1037
 
1038
                case BaseTestRunner::STATUS_FAILURE:
1039
                case BaseTestRunner::STATUS_ERROR:
1040
                    $testCSS = ' class="danger"';
1041
 
1042
                    break;
1043
            }
1044
        }
1045
 
1046
        return sprintf(
1047
            '<li%s>%s</li>',
1048
            $testCSS,
1049
            htmlspecialchars($test, $this->htmlSpecialCharsFlags)
1050
        );
1051
    }
1052
 
1053
    private function isComment(int $token): bool
1054
    {
1055
        return $token === T_COMMENT || $token === T_DOC_COMMENT;
1056
    }
1057
 
1058
    private function isInlineHtml(int $token): bool
1059
    {
1060
        return $token === T_INLINE_HTML;
1061
    }
1062
 
1063
    private function isKeyword(int $token): bool
1064
    {
1065
        return isset(self::keywordTokens()[$token]);
1066
    }
1067
 
1068
    /**
1069
     * @psalm-return array<int,true>
1070
     */
1071
    private static function keywordTokens(): array
1072
    {
1073
        if (self::$keywordTokens !== []) {
1074
            return self::$keywordTokens;
1075
        }
1076
 
1077
        self::$keywordTokens = [
1078
            T_ABSTRACT      => true,
1079
            T_ARRAY         => true,
1080
            T_AS            => true,
1081
            T_BREAK         => true,
1082
            T_CALLABLE      => true,
1083
            T_CASE          => true,
1084
            T_CATCH         => true,
1085
            T_CLASS         => true,
1086
            T_CLONE         => true,
1087
            T_CONST         => true,
1088
            T_CONTINUE      => true,
1089
            T_DECLARE       => true,
1090
            T_DEFAULT       => true,
1091
            T_DO            => true,
1092
            T_ECHO          => true,
1093
            T_ELSE          => true,
1094
            T_ELSEIF        => true,
1095
            T_EMPTY         => true,
1096
            T_ENDDECLARE    => true,
1097
            T_ENDFOR        => true,
1098
            T_ENDFOREACH    => true,
1099
            T_ENDIF         => true,
1100
            T_ENDSWITCH     => true,
1101
            T_ENDWHILE      => true,
1102
            T_EVAL          => true,
1103
            T_EXIT          => true,
1104
            T_EXTENDS       => true,
1105
            T_FINAL         => true,
1106
            T_FINALLY       => true,
1107
            T_FOR           => true,
1108
            T_FOREACH       => true,
1109
            T_FUNCTION      => true,
1110
            T_GLOBAL        => true,
1111
            T_GOTO          => true,
1112
            T_HALT_COMPILER => true,
1113
            T_IF            => true,
1114
            T_IMPLEMENTS    => true,
1115
            T_INCLUDE       => true,
1116
            T_INCLUDE_ONCE  => true,
1117
            T_INSTANCEOF    => true,
1118
            T_INSTEADOF     => true,
1119
            T_INTERFACE     => true,
1120
            T_ISSET         => true,
1121
            T_LIST          => true,
1122
            T_NAMESPACE     => true,
1123
            T_NEW           => true,
1124
            T_PRINT         => true,
1125
            T_PRIVATE       => true,
1126
            T_PROTECTED     => true,
1127
            T_PUBLIC        => true,
1128
            T_REQUIRE       => true,
1129
            T_REQUIRE_ONCE  => true,
1130
            T_RETURN        => true,
1131
            T_STATIC        => true,
1132
            T_SWITCH        => true,
1133
            T_THROW         => true,
1134
            T_TRAIT         => true,
1135
            T_TRY           => true,
1136
            T_UNSET         => true,
1137
            T_USE           => true,
1138
            T_VAR           => true,
1139
            T_WHILE         => true,
1140
            T_YIELD         => true,
1141
            T_YIELD_FROM    => true,
1142
        ];
1143
 
1144
        if (defined('T_FN')) {
1145
            self::$keywordTokens[constant('T_FN')] = true;
1146
        }
1147
 
1148
        if (defined('T_MATCH')) {
1149
            self::$keywordTokens[constant('T_MATCH')] = true;
1150
        }
1151
 
1152
        if (defined('T_ENUM')) {
1153
            self::$keywordTokens[constant('T_ENUM')] = true;
1154
        }
1155
 
1156
        if (defined('T_READONLY')) {
1157
            self::$keywordTokens[constant('T_READONLY')] = true;
1158
        }
1159
 
1160
        return self::$keywordTokens;
1161
    }
1162
}