Lines 59.22% 61 / 103
Methods 33.33% 1 / 3
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 shouldHighlight 100.00% 1 / 1 100.00% 1 / 1 1
 highlightCore 56.38% 53 / 94 0.00% 0 / 1 5.33
 highlight 87.50% 7 / 8 0.00% 0 / 1 3.02
7class SqlHighlighter implements Highlighter
8{
9    /**
10     * Array of valid SQL patterns.
11     * @var string[]
12     */
13    private array $sqlPatterns = [
14        // SELECT <value>
15        '/\bSELECT\b\s+(?!.*\b(FROM|WHERE|GROUP|ORDER|HAVING|LIMIT|OFFSET|JOIN)\b)/i',
16
17        // SELECT ... FROM <table> [[INNER|LEFT|RIGHT|FULL] [OUTER] JOIN .. ON ..] [WHERE ...] [GROUP BY ...] [ORDER BY ...]
18        '/\bSELECT\b[^;]+?\bFROM\b\s+\S+(?:\s+(?:(?:INNER|LEFT|RIGHT|FULL)(?:\s+OUTER)?\s+)?\bJOIN\b\s+\S+\s+\bON\b[^;]+?)*(?:\s+\bWHERE\b[^;]+?)?(?:\s+\bGROUP\s+\bBY\b[^;]+?)?(?:\s+\bORDER\s+\bBY\b[^;]+?)?(?:\s+\bLIMIT\b\s+\d+)?(?:\s+\bOFFSET\b\s+\d+)?/i',
19
20        // INSERT INTO table (...) VALUES (...)
21        '/\bINSERT\b\s+\bINTO\b\s+\S+\s*\([^)]+\)\s+\bVALUES\b\s*\([^)]+?\)/is',
22
23        // UPDATE table SET ... [WHERE ...]
24        '/\bUPDATE\b\s+\S+\s+\bSET\b\s+.+?(?:\s+\bWHERE\b\s+.+)?/is',
25
26        // DELETE FROM table [WHERE ...]
27        '/\bDELETE\b\s+\bFROM\b\s+\S+(?:\s+\bWHERE\b\s+.+)?/is',
28
29        // CREATE TABLE [IF NOT EXISTS] table (...)
30        '/\bCREATE\b\s+\bTABLE\b\s+(?:\bIF\b\s+\bNOT\b\s+\bEXISTS\b\s+)?\S+\s*\((?:[^()]*|\([^()]*\))*\)/is',
31
32        // ALTER TABLE table ...
33        '/\bALTER\b\s+\bTABLE\b\s+\S+.+/is',
34
35        // DROP TABLE/INDEX [IF EXISTS] name
36        '/\bDROP\b\s+(?:\bTABLE\b|\bINDEX\b)\s+(?:\bIF\b\s+\bEXISTS\b\s+)?\S+/is',
37
38        // REPLACE INTO table (...) VALUES (...)
39        '/\bREPLACE\b\s+\bINTO\b\s+\S+\s*\([^)]+\)\s+\bVALUES\b\s*\([^)]+?\)/is',
40
41        // TRUNCATE TABLE ...
42        '/\bTRUNCATE\b\s+\bTABLE\b\s+\S+/is',
43
44        // WITH ... (CTE)
45        '/\bWITH\b\s+.+/is',
46
47        // SHOW ...
48        '/\bSHOW\b\s+.+/is',
49
50        // DESCRIBE table
51        '/\bDESCRIBE\b\s+\S+/is',
52
53        // SET variable = value (not part of column names)
54        '/\bSET\b\s+[^;]+/is',
55
56        // PRAGMA variable = value
57        '/\bPRAGMA\b\s+\S+(?:\s*=\s*[^;]+)?/is',
58    ];
59
60
61    /**
62     * Array of colors to use for highlighting.
63     * @var string[]
64     */
65    private $colors = [
66        'keyword' => "\033[1;34m", // Blue
67        'type' => "\033[1;35m", // Magenta
68        'identifier' => "\033[1;37m", // White
69        'string' => "\033[0;32m", // Green
70        'number' => "\033[0;36m", // Cyan
71        'function' => "\033[1;33m", // Yellow
72        'operator' => "\033[1;37m", // White
73        'comment' => "\033[0;90m", // Dark Grey
74        'placeholder' => "\033[1;37m", // White
75        'reset' => "\033[0m",
76    ];
77
78    /**
79     * Array of valid SQL keywords.
80     * @var string[]
81     */
82    private array $keywords = [
83        'SELECT',
84        'FROM',
85        'WHERE',
86        'INSERT',
87        'INTO',
88        'VALUES',
89        'UPDATE',
90        'SET',
91        'PRAGMA',
92        'DELETE',
93        'CREATE',
94        'TABLE',
95        'DESCRIBE',
96        'SHOW',
97        'ALTER',
98        'DROP',
99        'JOIN',
100        'LEFT',
101        'RIGHT',
102        'INNER',
103        'OUTER',
104        'FULL',
105        'ON',
106        'AS',
107        'AND',
108        'OR',
109        'NOT',
110        'NULL',
111        'DISTINCT',
112        'GROUP',
113        'BY',
114        'ORDER',
115        'LIMIT',
116        'OFFSET',
117        'HAVING',
118        'CASE',
119        'WHEN',
120        'THEN',
121        'ELSE',
122        'END',
123        'IN',
124        'IS',
125        'LIKE',
126        'UNION',
127        'ALL',
128        'DESC',
129        'ASC',
130        'IF',
131        'EXISTS',
132        'PRIMARY',
133        'KEY',
134        'DEFAULT',
135        'CHECK',
136        'CONSTRAINT',
137        'AUTO_INCREMENT',
138        'AUTOINCREMENT'
139    ];
140
141    /**
142     * Array of valid SQL types.
143     * @var string[]
144     */
145    private $types = [
146        'INT',
147        'INTEGER',
148        'SMALLINT',
149        'TINYINT',
150        'BIGINT',
151        'DECIMAL',
152        'NUMERIC',
153        'FLOAT',
154        'REAL',
155        'DOUBLE',
156        'CHAR',
157        'VARCHAR',
158        'BINARY',
159        'TEXT',
160        'MEDIUMTEXT',
161        'LONGTEXT',
162        'BLOB',
163        'DATE',
164        'TIME',
165        'DATETIME',
166        'TIMESTAMP',
167        'BOOLEAN',
168        'BOOL',
169        'JSON',
170        'UUID',
171        'ENUM'
172    ];
173
174    /**
175     * Array of valid SQL functions.
176     * @var string[]
177     */
178    private $functions = [
179        'COUNT',
180        'SUM',
181        'AVG',
182        'MIN',
183        'MAX',
184        'NOW',
185        'CURRENT_TIMESTAMP',
186        'UPPER',
187        'LOWER',
188        'LENGTH',
189        'ABS',
190        'ROUND',
191        'RANDOM'
192    ];
193
194    public function shouldHighlight(string $level, string $line): bool
195    {
196        return array_any($this->sqlPatterns, fn($pattern) => preg_match($pattern, $line));
197
198    }
199
200    private function highlightCore(string $sql): string
201    {
202        // Protect existing ANSI codes
203        $placeholders = [];
204        $sql = preg_replace_callback('/\033\[[0-9;]*m/', function ($m) use (&$placeholders) {
205            $key = "%%ANSI" . count($placeholders) . "%%";
206            $placeholders[$key] = $m[0];
207            return $key;
208        }, $sql);
209
210        // Comments
211        $sql = preg_replace_callback('/\/\*.*?\*\//s', fn($m) => $this->colors['comment'] . $m[0] . $this->colors['reset'], $sql);
212        $sql = preg_replace_callback('/--.*$/m', fn($m) => $this->colors['comment'] . $m[0] . $this->colors['reset'], $sql);
213
214        // Strings (protected from other stages)
215        $sql = preg_replace_callback(
216            '/(\'[^\']*\'|"[^"]*")/',
217            function ($m) use (&$placeholders) {
218                $key = "%%STR" . count($placeholders) . "%%";
219                $placeholders[$key] = $this->colors['string'] . $m[0] . $this->colors['reset'];
220                return $key;
221            },
222            $sql
223        );
224
225        // Identifiers (protected from other stages)
226        $sql = preg_replace_callback(
227            '/[`"]([^`"]+)[`"]/',
228            function ($m) use (&$placeholders) {
229                $key = "%%IDENT" . count($placeholders) . "%%";
230                $placeholders[$key] = $this->colors['identifier'] . $m[0] . $this->colors['reset'];
231                return $key;
232            },
233            $sql
234        );
235
236        // Special handling for SET and PRAGMA
237        foreach (['SET', 'PRAGMA'] as $cmd) {
238            if (preg_match("/^\s*$cmd\s+/i", $sql)) {
239                $sql = preg_replace_callback(
240                    "/$cmd\s+(.*)/i",
241                    function ($m) use ($cmd) {
242                        $assignments = $m[1];
243                        // Highlight numbers
244                        $assignments = preg_replace(
245                            '/\b\d+(\.\d+)?\b/',
246                            $this->colors['number'] . '$0' . $this->colors['reset'],
247                            $assignments
248                        );
249                        // Highlight variables
250                        $assignments = preg_replace(
251                            '/(\b[A-Z_][A-Z0-9_]*\b|@[A-Za-z0-9_]+)/i',
252                            $this->colors['identifier'] . '$1' . $this->colors['reset'],
253                            $assignments
254                        );
255                        // Highlight ON/OFF/TRUE/FALSE for PRAGMA
256                        if ($cmd === 'PRAGMA') {
257                            $assignments = preg_replace(
258                                '/\b(ON|OFF|TRUE|FALSE)\b/i',
259                                $this->colors['keyword'] . '$0' . $this->colors['reset'],
260                                $assignments
261                            );
262                        }
263                        // Highlight operators
264                        $assignments = preg_replace(
265                            '/=/',
266                            $this->colors['operator'] . '=' . $this->colors['reset'],
267                            $assignments
268                        );
269                        return $this->colors['keyword'] . $cmd . $this->colors['reset'] . ' ' . $assignments;
270                    },
271                    $sql
272                );
273            }
274        }
275
276        // Functions
277        $sql = preg_replace_callback(
278            '/\b(' . implode('|', $this->functions) . ')\s*(?=\()/i',
279            fn($m) => $this->colors['function'] . strtoupper($m[1]) . $this->colors['reset'],
280            $sql
281        );
282
283        // Types
284        $sql = preg_replace_callback(
285            '/\b(' . implode('|', $this->types) . ')\b/i',
286            fn($m) => $this->colors['type'] . strtoupper($m[1]) . $this->colors['reset'],
287            $sql
288        );
289
290        // Keywords
291        $sql = preg_replace_callback(
292            '/\b(' . implode('|', $this->keywords) . ')\b/i',
293            fn($m) => $this->colors['keyword'] . strtoupper($m[1]) . $this->colors['reset'],
294            $sql
295        );
296
297        // Operators
298        $sql = preg_replace(
299            '/(\=|<|>|\!|\+|\-|\*|\/|\(|\)|,)/',
300            $this->colors['operator'] . '$1' . $this->colors['reset'],
301            $sql
302        );
303
304        // Protect ANSI codes before number pass.
305        $sql = preg_replace_callback('/\033\[[0-9;]*m/', function ($m) use (&$placeholders) {
306            $key = "%%ANSI" . count($placeholders) . "%%";
307            $placeholders[$key] = $m[0];
308            return $key;
309        }, $sql);
310
311        // Numbers
312        $sql = preg_replace(
313            '/\b\d+(\.\d+)?\b/',
314            $this->colors['number'] . '$0' . $this->colors['reset'],
315            $sql
316        );
317
318        // Placeholders
319        $sql = preg_replace(
320            '/\?/',
321            $this->colors['placeholder'] . '?' . $this->colors['reset'],
322            $sql
323        );
324
325        // Restore ANSI codes
326        $sql = strtr($sql, $placeholders);
327
328        return $sql;
329    }
330
331    public function highlight(string $level, string $line): string
332    {
333        // Highlight against the FIRST matching pattern only. Running every
334        // pattern over the whole line is mostly harmless because the ANSI
335        // codes inserted by highlightCore() contain ';' which breaks the
336        // [^;]+ quantifiers in most patterns. However, patterns using .+
337        // (WITH, SHOW, ALTER) could still produce nested codes, so we
338        // stop at the first match for safety.
339        foreach ($this->sqlPatterns as $pattern) {
340            if (preg_match($pattern, $line)) {
341                return preg_replace_callback(
342                    $pattern,
343                    fn($matches) => $this->highlightCore($matches[0]),
344                    $line
345                );
346            }
347        }
348
349        return $line;
350    }
351}