232 lines · 10.7 KB
Raw Download
1
<?php
2
/**
3
 * Small, regex-based syntax highlighter. Deliberately "basic" — it is not a
4
 * full lexer. Keyword-based languages (php, js, python, java, c, cpp, go, ruby,
5
 * sql, bash) share one master-regex builder; the structural formats css, html,
6
 * json and markdown get tailored regexes. Everything else falls back to plain
7
 * (escape-only) output.
8
 *
9
 * The source is scanned with a single master regex whose alternatives are named
10
 * groups (comment / string / number / variable / keyword / function / tag /
11
 * attribute / selector). Each character is consumed once, so tokens are never
12
 * re-processed and output is always HTML-escaped exactly once.
13
 *
14
 * Note: the file view highlights one line at a time, so patterns are kept
15
 * single-line friendly (block comments only colour within a line).
16
 */
17
18
/** Keyword lists per language. */
19
function highlight_keywords(string $lang): array
20
{
21
    switch ($lang) {
22
        case 'php':
23
            return ['abstract','and','array','as','break','callable','case','catch',
24
                'class','clone','const','continue','declare','default','do','echo','else',
25
                'elseif','empty','enddeclare','endfor','endforeach','endif','endswitch',
26
                'endwhile','enum','extends','final','finally','fn','for','foreach','function',
27
                'global','goto','if','implements','include','include_once','instanceof',
28
                'insteadof','interface','isset','list','match','namespace','new','or','print',
29
                'private','protected','public','readonly','require','require_once','return',
30
                'static','switch','throw','trait','try','unset','use','var','while','xor',
31
                'yield','true','false','null'];
32
        case 'javascript':
33
            return ['await','async','break','case','catch','class','const','continue',
34
                'debugger','default','delete','do','else','export','extends','finally','for',
35
                'function','if','import','in','instanceof','let','new','of','return','super',
36
                'switch','this','throw','try','typeof','var','void','while','with','yield',
37
                'true','false','null','undefined'];
38
        case 'python':
39
            return ['and','as','assert','async','await','break','class','continue','def',
40
                'del','elif','else','except','finally','for','from','global','if','import',
41
                'in','is','lambda','nonlocal','not','or','pass','raise','return','try','while',
42
                'with','yield','True','False','None','self'];
43
        case 'java':
44
            return ['abstract','assert','boolean','break','byte','case','catch','char',
45
                'class','const','continue','default','do','double','else','enum','extends',
46
                'final','finally','float','for','goto','if','implements','import','instanceof',
47
                'int','interface','long','native','new','package','private','protected',
48
                'public','return','short','static','strictfp','super','switch','synchronized',
49
                'this','throw','throws','transient','try','void','volatile','while','var',
50
                'true','false','null'];
51
        case 'c':
52
            return ['auto','break','case','char','const','continue','default','do','double',
53
                'else','enum','extern','float','for','goto','if','inline','int','long',
54
                'register','restrict','return','short','signed','sizeof','static','struct',
55
                'switch','typedef','union','unsigned','void','volatile','while','bool','true',
56
                'false','NULL'];
57
        case 'cpp':
58
            return ['alignas','auto','bool','break','case','catch','char','class','const',
59
                'constexpr','continue','default','delete','do','double','else','enum','explicit',
60
                'extern','false','float','for','friend','goto','if','inline','int','long',
61
                'namespace','new','nullptr','operator','override','private','protected','public',
62
                'return','short','signed','sizeof','static','struct','switch','template','this',
63
                'throw','true','try','typedef','typename','union','unsigned','using','virtual',
64
                'void','volatile','while'];
65
        case 'go':
66
            return ['break','case','chan','const','continue','default','defer','else',
67
                'fallthrough','for','func','go','goto','if','import','interface','map','package',
68
                'range','return','select','struct','switch','type','var','true','false','nil',
69
                'iota','string','int','int64','float64','bool','byte','rune','error'];
70
        case 'ruby':
71
            return ['def','end','if','elsif','else','unless','while','until','for','in','do',
72
                'begin','rescue','ensure','raise','return','yield','class','module','self','nil',
73
                'true','false','and','or','not','then','case','when','break','next','redo',
74
                'retry','super','require','require_relative','attr_accessor','attr_reader',
75
                'attr_writer','puts','new'];
76
        case 'sql':
77
            return ['select','from','where','insert','into','values','update','set','delete',
78
                'create','alter','drop','table','database','view','index','join','left','right',
79
                'inner','outer','full','cross','on','as','and','or','not','null','is','in',
80
                'between','like','order','by','group','having','limit','offset','distinct',
81
                'union','all','asc','desc','primary','key','foreign','references','unique',
82
                'default','constraint','auto_increment','int','integer','varchar','text','char',
83
                'datetime','timestamp','date','boolean','count','sum','avg','min','max','case',
84
                'when','then','else','end','if','exists','begin','commit','rollback'];
85
        case 'bash':
86
            return ['if','then','else','elif','fi','for','while','until','do','done','case',
87
                'esac','function','in','select','return','break','continue','local','export',
88
                'readonly','declare','unset','echo','printf','cd','exit','test','true','false',
89
                'source','alias'];
90
        default:
91
            return [];
92
    }
93
}
94
95
/** Comment pattern for a keyword-based language. */
96
function highlight_comment(string $lang): string
97
{
98
    switch ($lang) {
99
        case 'python':
100
        case 'ruby':
101
        case 'bash':
102
            return '#[^\n]*';
103
        case 'sql':
104
            return '--[^\n]*|\/\*[\s\S]*?\*\/';
105
        default: // php, js, java, c, cpp, go
106
            return '\/\/[^\n]*|\/\*[\s\S]*?\*\/';
107
    }
108
}
109
110
/** Build the master regex for a language, or null for the plain fallback. */
111
function highlight_regex(string $lang): ?string
112
{
113
    // --- Structural formats with tailored token sets. ---
114
    if ($lang === 'css') {
115
        return '/(?<com>\/\*[\s\S]*?\*\/)'
116
            . '|(?<str>"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')'
117
            . '|(?<kw>@[\w-]+|!important)'
118
            . '|(?<fn>[\w-]+(?=\s*\())'
119
            . '|(?<attr>[A-Za-z-]+(?=\s*:))'
120
            . '|(?<num>#[0-9A-Fa-f]{3,8}\b|\b\d+(?:\.\d+)?(?:px|em|rem|ex|ch|vw|vh|vmin|vmax|%|pt|cm|mm|in|deg|s|ms|fr)?\b)'
121
            . '|(?<sel>[.#][A-Za-z_][\w-]*|::?[A-Za-z-]+)/';
122
    }
123
    if ($lang === 'html') {
124
        return '/(?<com><!--[\s\S]*?-->)'
125
            . '|(?<str>"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')'
126
            . '|(?<tag><\/?[A-Za-z][\w:-]*|\/?>)'
127
            . '|(?<attr>\s[A-Za-z_:][\w:.-]*(?=\s*=))/';
128
    }
129
    if ($lang === 'json') {
130
        return '/(?<attr>"(?:\\\\.|[^"\\\\])*"(?=\s*:))'
131
            . '|(?<str>"(?:\\\\.|[^"\\\\])*")'
132
            . '|(?<kw>\b(?:true|false|null)\b)'
133
            . '|(?<num>-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/';
134
    }
135
    if ($lang === 'markdown') {
136
        return '/(?<tag>^\s{0,3}#{1,6}\s.*$|^\s*[-*+]\s|^\s*>\s)'
137
            . '|(?<str>`[^`]+`)'
138
            . '|(?<kw>\*\*[^*]+\*\*|__[^_]+__)'
139
            . '|(?<fn>\[[^\]]+\]\([^)]+\))/m';
140
    }
141
142
    // --- Keyword-based languages share one builder. ---
143
    $keywords = highlight_keywords($lang);
144
    if (!$keywords) {
145
        return null;
146
    }
147
148
    $comment = highlight_comment($lang);
149
150
    // String styles (single, double; backticks for js/bash).
151
    $string = '"(?:\\\\.|[^"\\\\])*"' . '|' . "'(?:\\\\.|[^'\\\\])*'";
152
    if ($lang === 'javascript' || $lang === 'bash') {
153
        $string .= '|`(?:\\\\.|[^`\\\\])*`';
154
    }
155
156
    $number = '\b\d+(?:\.\d+)?\b';
157
    if ($lang === 'php' || $lang === 'bash') {
158
        $variable = '\$\{[^}]+\}|\$[A-Za-z_]\w*';
159
    } else {
160
        $variable = '(?!)'; // never matches
161
    }
162
    $kw   = '\b(?:' . implode('|', array_map('preg_quote', $keywords)) . ')\b';
163
    $func = '[A-Za-z_]\w*(?=\s*\()';
164
165
    $flags = ($lang === 'sql') ? 'i' : '';
166
167
    return '/(?<com>' . $comment . ')'
168
        . '|(?<str>' . $string . ')'
169
        . '|(?<num>' . $number . ')'
170
        . '|(?<var>' . $variable . ')'
171
        . '|(?<kw>' . $kw . ')'
172
        . '|(?<fn>' . $func . ')/' . $flags;
173
}
174
175
/**
176
 * Return HTML for the given source code with token <span>s. The result is safe
177
 * to embed directly (all literal text is HTML-escaped).
178
 */
179
function highlight_code(string $code, string $lang): string
180
{
181
    $lang  = strtolower($lang);
182
    $regex = highlight_regex($lang);
183
184
    // Unknown language: escape and return as-is.
185
    if ($regex === null) {
186
        return htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
187
    }
188
189
    // Every group name that may appear in any language's regex.
190
    $groups = ['com', 'str', 'num', 'var', 'kw', 'fn', 'tag', 'attr', 'sel'];
191
    $out    = '';
192
    $offset = 0;
193
194
    if (preg_match_all($regex, $code, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER)) {
195
        foreach ($matches as $m) {
196
            $full = $m[0][0];
197
            $pos  = $m[0][1];
198
199
            if ($pos > $offset) {
200
                $out .= htmlspecialchars(substr($code, $offset, $pos - $offset), ENT_QUOTES, 'UTF-8');
201
            }
202
203
            $class = null;
204
            foreach ($groups as $g) {
205
                if (isset($m[$g]) && $m[$g][1] !== -1 && $m[$g][0] !== '') {
206
                    $class = $g;
207
                    break;
208
                }
209
            }
210
211
            // Preserve any leading whitespace captured by a token (e.g. HTML
212
            // attributes, markdown list markers) outside the coloured span.
213
            $lead = '';
214
            if ($class !== null && ($class === 'attr' || $class === 'tag') && preg_match('/^\s+/', $full, $ws)) {
215
                $lead = $ws[0];
216
                $full = substr($full, strlen($lead));
217
            }
218
219
            $esc = htmlspecialchars($full, ENT_QUOTES, 'UTF-8');
220
            $out .= $lead . ($class ? '<span class="tok-' . $class . '">' . $esc . '</span>' : $esc);
221
222
            $offset = $pos + strlen($m[0][0]);
223
        }
224
    }
225
226
    if ($offset < strlen($code)) {
227
        $out .= htmlspecialchars(substr($code, $offset), ENT_QUOTES, 'UTF-8');
228
    }
229
230
    return $out;
231
}
232