packages/sql-catalog/src/Reporter/Html/SqlHighlighter.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlCatalog\Reporter\Html;
6
7use SqlCatalog\Core\Catalog\StatementPart;
8
9/**
10 * Renders a reconstructed statement as marked-up SQL.
11 *
12 * The statement is rendered from the pattern rather than from the text it
13 * displays as, so a gap keeps what is known about it. A gap is a claim about
14 * the analysis and not about the statement, and rendering it as an ordinary
15 * `{$}` in the middle of the SQL hides which of the two a reader is looking at.
16 * Here it is marked, tinted by where the value came from, and says so when
17 * pointed at. The tokens are written in the classes doc-ui highlights.
18 *
19 * @visibility root
20 */
21final class SqlHighlighter
22{
23    /**
24     * The words written as keywords.
25     */
26    private const KEYWORDS = 'SELECT|INSERT|UPDATE|DELETE|REPLACE|MERGE|TRUNCATE|CREATE|ALTER|DROP|RENAME|CALL|SHOW|EXPLAIN|DESCRIBE|WITH|RECURSIVE|FROM|WHERE|GROUP|BY|HAVING|ORDER|LIMIT|OFFSET|FETCH|JOIN|INNER|LEFT|RIGHT|FULL|OUTER|CROSS|NATURAL|LATERAL|ON|USING|UNION|INTERSECT|EXCEPT|ALL|DISTINCT|AS|INTO|VALUES|SET|DEFAULT|RETURNING|AND|OR|NOT|IN|IS|NULL|LIKE|ILIKE|RLIKE|REGEXP|BETWEEN|EXISTS|CASE|WHEN|THEN|ELSE|END|ASC|DESC|NULLS|FIRST|LAST|TABLE|VIEW|INDEX|DATABASE|SCHEMA|COLUMN|CONSTRAINT|PRIMARY|FOREIGN|KEY|UNIQUE|REFERENCES|CASCADE|RESTRICT|IF|DUPLICATE|IGNORE|STRAIGHT_JOIN|SQL_CALC_FOUND_ROWS|FOR|SHARE|LOCK|BEGIN|START|TRANSACTION|COMMIT|ROLLBACK|SAVEPOINT|GRANT|REVOKE|ANALYZE|OPTIMIZE|VACUUM|PRAGMA|CAST|CONVERT|COLLATE|INTERVAL|PARTITION|OVER|WINDOW|FILTER|GROUPS|RANGE|ROWS|UNBOUNDED|PRECEDING|FOLLOWING|CURRENT|ROW|TRUE|FALSE|UNSIGNED|BINARY|CHARACTER|CHARSET|ADD|MODIFY|CHANGE|AUTO_INCREMENT|ENGINE|STATUS|TEMPORARY|EXISTS';
27
28    /**
29     * How the tokens of a resolved run are told apart.
30     */
31    public const TOKENS = '/(?<com>--[^\n]*|\#[^\n]*|\/\*.*?\*\/)|(?<str>\'(?:\'\'|\\\\.|[^\'])*\'|"(?:""|\\\\.|[^"])*")|(?<qid>`[^`]*`)|(?<ph>\?|:[A-Za-z_][A-Za-z0-9_]*|\$[0-9]+|%[sdfF]\b)|(?<num>\b[0-9]+(?:\.[0-9]+)?\b)|(?<word>[A-Za-z_][A-Za-z0-9_]*)/s';
32
33    /**
34     * The class each kind of token is written with, as doc-ui names them.
35     */
36    private const TOKEN_CLASSES = ['com' => 'tok-com', 'str' => 'tok-str', 'qid' => 'tok-id', 'ph' => 'tok-var', 'num' => 'tok-num'];
37
38    private HtmlText $text;
39
40    /**
41     * Wires the highlighter to the escaping it writes through.
42     */
43    public function __construct(?HtmlText $text = null)
44    {
45        $this->text = $text ?? new HtmlText();
46    }
47
48    /**
49     * The whole statement, with its known runs highlighted and its gaps marked.
50     *
51     * @param list<StatementPart> $parts
52     */
53    public function render(array $parts): string
54    {
55        $rendered = '';
56        foreach ($parts as $part) {
57            $rendered .= $part->isGap ? $this->hole($part) : $this->highlight($part->text);
58        }
59
60        return $rendered === '' ? '<span class="none">(empty)</span>' : $rendered;
61    }
62
63    /**
64     * The displayed SQL without markup, for navigation and search labels.
65     *
66     * @param list<StatementPart> $parts
67     */
68    public function plain(array $parts): string
69    {
70        return implode('', array_map(fn (StatementPart $part): string => $part->isGap ? $this->holeLabel($part) : $part->text, $parts));
71    }
72
73    /**
74     * The whole statement on one line, for a listing.
75     *
76     * @param list<StatementPart> $parts
77     */
78    public function inline(array $parts): string
79    {
80        $collapsed = [];
81        foreach ($parts as $part) {
82            $collapsed[] = $part->isGap ? $part : new StatementPart((string) preg_replace('/\s+/', ' ', $part->text));
83        }
84
85        return $this->render($collapsed);
86    }
87
88    /**
89     * One resolved run of SQL, with its tokens marked.
90     *
91     * The run is tokenized before it is escaped, not after. Escaping first
92     * turns an apostrophe into an entity, and marking a token inside an entity
93     * breaks it, so what a reader sees is the escaping rather than the SQL.
94     */
95    public function highlight(string $sql): string
96    {
97        if (preg_match_all(self::TOKENS, $sql, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE) === false) {
98            return $this->text->escape($sql);
99        }
100
101        $marked = '';
102        $at = 0;
103        foreach ($matches as $match) {
104            [$whole, $offset] = $match[0];
105            $marked .= $this->text->escape(substr($sql, $at, $offset - $at)) . $this->token($this->captured($match));
106            $at = $offset + strlen($whole);
107        }
108
109        return $marked . $this->text->escape(substr($sql, $at));
110    }
111
112    /**
113     * The named captures of a match, without the offsets they were captured with.
114     *
115     * @param array<array-key, array{string, int}> $match
116     * @return array<string, string>
117     */
118    public function captured(array $match): array
119    {
120        $captured = [];
121        foreach ($match as $name => $capture) {
122            if (is_string($name)) {
123                $captured[$name] = $capture[0];
124            }
125        }
126
127        return $captured;
128    }
129
130    /**
131     * One matched token, wrapped in the class its kind is written with.
132     *
133     * @param array<string, string> $match
134     */
135    public function token(array $match): string
136    {
137        foreach (['com', 'str', 'qid', 'ph', 'num'] as $kind) {
138            if (($match[$kind] ?? '') !== '') {
139                return '<span class="' . self::TOKEN_CLASSES[$kind] . '">'
140                    . $this->text->escape($match[$kind]) . '</span>';
141            }
142        }
143        $word = $match['word'] ?? '';
144
145        return $this->isKeyword($word)
146            ? '<span class="tok-kw">' . $this->text->escape($word) . '</span>'
147            : $this->text->escape($word);
148    }
149
150    /**
151     * Whether a word is one SQL writes as a keyword.
152     */
153    public function isKeyword(string $word): bool
154    {
155        return in_array(strtoupper($word), explode('|', self::KEYWORDS), true);
156    }
157
158    /**
159     * One gap, marked with where the value that fills it comes from.
160     */
161    public function hole(StatementPart $gap): string
162    {
163        $note = 'This is a gap: ' . $gap->reason . ' fills it.';
164        if ($gap->expression !== null) {
165            $note .= ' Written as ' . $gap->expression . '.';
166        }
167
168        return '<span class="hole ' . $this->text->escape($this->holeRole($gap->origin)) . '" title="'
169            . $this->text->escape($note) . '">' . $this->text->escape($this->holeLabel($gap)) . '</span>';
170    }
171
172    /**
173     * A named PHP variable when known, or the anonymous gap marker.
174     */
175    public function holeLabel(StatementPart $gap): string
176    {
177        $variable = $gap->variable ?? $gap->expression;
178
179        return $variable !== null && preg_match('/\A\$[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\z/', $variable) === 1
180            ? '{' . $variable . '}'
181            : '{$}';
182    }
183
184    /**
185     * The tone a gap of that origin is tinted by.
186     *
187     * A value from outside the program is the one thing in a statement worth
188     * a warning colour; a call the analysis never reached is not a claim about
189     * the statement at all and is written in no colour; every other gap is a
190     * caution.
191     */
192    public function holeRole(string $origin): string
193    {
194        return match ($origin) {
195            'external' => 'tone-danger',
196            'unreached' => 'tone-neutral',
197            default => 'tone-warn',
198        };
199    }
200}
201