packages/sql-catalog/src/Reporter/Html/Source/SourceCode.php
1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Reporter\Html\Source;
6
7use SqlCatalog\Core\Catalog\CatalogEntry;
8use SqlCatalog\Reporter\Html\HtmlText;
9use SqlCatalog\Reporter\Html\ReportSite;
10
11/**
12 * The analyzed PHP source, with numbered lines and the database calls marked.
13 *
14 * @visibility root
15 */
16final class SourceCode
17{
18 /**
19 * The lines shown before and after a call on its statement page.
20 */
21 public const CONTEXT = 8;
22
23 private HtmlText $text;
24
25 /**
26 * Wires source rendering to the escaping used throughout the report.
27 */
28 public function __construct(?HtmlText $text = null)
29 {
30 $this->text = $text ?? new HtmlText();
31 }
32
33 /**
34 * A call's surrounding code, linked to the complete file at the same line.
35 */
36 public function excerpt(ReportSite $site, CatalogEntry $entry): string
37 {
38 $source = $site->catalog()->source($entry->site->file);
39 if ($source === null) {
40 return '';
41 }
42 $first = max(1, $entry->site->line - self::CONTEXT);
43 $last = $entry->site->line + self::CONTEXT;
44 $lines = array_slice($this->split($source), $first - 1, $last - $first + 1);
45 $file = '../' . $site->filePage($entry->site->file);
46
47 return '<section><h2 id="source">Source code</h2><p class="muted">'
48 . $this->text->escape($entry->site->display()) . ' · '
49 . $this->text->link('View full source', $file . '#L' . $entry->site->line) . '</p>'
50 . $this->lines($lines, $first, [$entry->site->line], $file) . '</section>';
51 }
52
53 /**
54 * A file's complete source, including files that could not be parsed.
55 */
56 public function file(ReportSite $site, string $file): string
57 {
58 $source = $site->catalog()->source($file);
59 if ($source === null) {
60 return '';
61 }
62 $calls = [];
63 foreach ($site->index()->byFile()[$file] ?? [] as $entry) {
64 $calls[] = $entry->site->line;
65 }
66
67 return '<section><h2 id="source">Source code</h2><p class="muted">Source captured during analysis. Highlighted lines issue database calls.</p>'
68 . $this->lines($this->split($source), 1, $calls) . '</section>';
69 }
70
71 /**
72 * Source lines with their original indentation, escaped before embedding.
73 *
74 * @param list<string> $lines
75 * @param list<int> $calls The line numbers to highlight
76 * @param string $target The full source page, or empty for links within this page
77 */
78 public function lines(array $lines, int $first, array $calls, string $target = ''): string
79 {
80 $written = [];
81 $highlighted = array_fill_keys($calls, true);
82 foreach ($lines as $offset => $line) {
83 $number = $first + $offset;
84 $written[] = '<span class="code-line' . (isset($highlighted[$number]) ? ' is-target' : '') . '" id="L' . $number . '">'
85 . '<a class="ln" href="' . $this->text->escape($target . '#L' . $number) . '" aria-label="Line ' . $number . '">' . $number . '</a>'
86 . $this->text->escape($line) . '</span>';
87 }
88
89 return '<pre class="code code-scroll" tabindex="0" aria-label="PHP source code"><code>' . implode("\n", $written) . '</code></pre>';
90 }
91
92 /**
93 * Lines split using PHP's newline conventions, retaining a trailing empty line.
94 *
95 * @return list<string>
96 */
97 public function split(string $source): array
98 {
99 return explode("\n", str_replace(["\r\n", "\r"], "\n", $source));
100 }
101}
102