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