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