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