master
ブランチ master
LittleBeast / src / Std / Lib / Syntax.php
10.6 KB、333行 生 Blame 履歴
1<?php
2namespace Std\Lib;
3
4use Std\Lib\Syntax\SyntaxInterface;
5use Std\Lib\Syntax\Asm;
6use Std\Lib\Syntax\Asm6502;
7use Std\Lib\Syntax\Asm68000;
8use Std\Lib\Syntax\AsmAarch32;
9use Std\Lib\Syntax\AsmAarch64;
10use Std\Lib\Syntax\AsmMips;
11use Std\Lib\Syntax\AsmPower;
12use Std\Lib\Syntax\AsmRiscv;
13use Std\Lib\Syntax\AsmX8664;
14use Std\Lib\Syntax\AsmZ80;
15use Std\Lib\Syntax\C;
16use Std\Lib\Syntax\Cpp;
17use Std\Lib\Syntax\CSharp;
18use Std\Lib\Syntax\Glsl;
19use Std\Lib\Syntax\Go;
20use Std\Lib\Syntax\Hlsl;
21use Std\Lib\Syntax\ObjC;
22use Std\Lib\Syntax\Slang;
23use Std\Lib\Syntax\Swift;
24
25/**
26 * コードブロックのシンタックスハイライト
27 *
28 * コードを先頭から1回だけ走査し、トークン毎に<span class="hl-種類">で囲む。
29 * 色はstyle-code.cssで定義する。
30 *
31 * 使い方:
32 * if (Syntax::isSupported($lang)) $html = (new Syntax($lang))->colorize($code);
33 */
34class Syntax {
35 private const SUPPORTED = [
36 'asm' => Asm::class,
37 'asm-6502' => Asm6502::class,
38 'asm-68000' => Asm68000::class,
39 'asm-aarch32' => AsmAarch32::class,
40 'asm-aarch64' => AsmAarch64::class,
41 'asm-mips' => AsmMips::class,
42 'asm-power' => AsmPower::class,
43 'asm-riscv' => AsmRiscv::class,
44 'asm-x8664' => AsmX8664::class,
45 'asm-z80' => AsmZ80::class,
46 'c' => C::class,
47 'cpp' => Cpp::class,
48 'csharp' => CSharp::class,
49 'glsl' => Glsl::class,
50 'go' => Go::class,
51 'hlsl' => Hlsl::class,
52 'objc' => ObjC::class,
53 'slang' => Slang::class,
54 'swift' => Swift::class,
55 ];
56
57 private const ALIASES = [
58 'asm-6502' => ['6502', 'asm6502', 'asm-65c02'],
59 'asm-68000' => ['68000', '68k', 'm68k', 'asm-68k', 'asm-m68k'],
60 'asm-aarch32' => ['asm-arm', 'asm-arm32', 'arm', 'arm32', 'aarch32', 'asm-thumb'],
61 'asm-aarch64' => ['asm-arm64', 'arm64', 'aarch64'],
62 'asm-mips' => ['mips', 'asm-mips64'],
63 'asm-power' => ['asm-ppc', 'asm-powerpc', 'asm-ppc64', 'ppc', 'powerpc', 'power'],
64 'asm-riscv' => ['riscv', 'asm-riscv64', 'asm-rv64', 'rv64', 'risc-v'],
65 'asm-x8664' => ['asm-x86', 'asm-x64', 'asm-x86-64', 'asm-amd64', 'x86', 'x64', 'x86-64', 'nasm'],
66 'asm-z80' => ['z80', 'asm-gbz80', 'gbz80'],
67 'c' => ['h', 'c99', 'c11', 'c23'],
68 'cpp' => ['c++', 'cc', 'cxx', 'hpp', 'hh', 'hxx', 'h++'],
69 'csharp' => ['c#', 'cs'],
70 'glsl' => ['vert', 'frag', 'geom', 'comp', 'tesc', 'tese'],
71 'go' => ['golang'],
72 'hlsl' => ['fx', 'fxh', 'hlsli'],
73 'objc' => ['objective-c', 'obj-c', 'objectivec', 'm', 'mm', 'objc++'],
74 'slang' => [],
75 'swift' => [],
76 ];
77
78 private string $lang;
79 private SyntaxInterface $handler;
80
81 /** @var array 規則の正規表現をキャッシュ(言語 => [行頭用, 通常用, 種類一覧]) */
82 private static array $compiled = [];
83
84 public function __construct(string $lang) {
85 $resolved = self::resolve($lang);
86 if ($resolved === null) throw new \InvalidArgumentException("未対応の言語: {$lang}");
87 $this->lang = $resolved;
88 $class = self::SUPPORTED[$resolved];
89 $this->handler = new $class();
90 }
91
92 /**
93 * 言語が対応しているか
94 *
95 * @param string $lang 言語名又は別名
96 * @return bool
97 */
98 public static function isSupported(string $lang): bool {
99 return self::resolve($lang) !== null;
100 }
101
102 /**
103 * 別名を正式な言語名にする
104 *
105 * @param string $lang 言語名又は別名
106 * @return string|null 正式な言語名、未対応ならnull
107 */
108 public static function resolve(string $lang): ?string {
109 $lang = strtolower(trim($lang));
110 if (isset(self::SUPPORTED[$lang])) return $lang;
111 foreach (self::ALIASES as $k => $v) if (in_array($lang, $v, true)) return $k;
112 return null;
113 }
114
115 public function getLanguage(): string {
116 return $this->lang;
117 }
118
119 /**
120 * コードをハイライトしたHTMLにする(HTMLエスケープ済み)
121 * 失敗した場合はエスケープしただけのコードを返す
122 *
123 * @param string $code 生のコード
124 * @return string HTML
125 */
126 public function colorize(string $code): string {
127 $code = str_replace(["\r\n", "\r"], "\n", $code);
128 if ($code === '' || preg_match('//u', $code) !== 1) return self::escape($code);
129
130 try {
131 return $this->tokenize($code);
132 } catch (\Throwable $e) {
133 return self::escape($code);
134 }
135 }
136
137 ///////////////
138
139 private function tokenize(string $code): string {
140 $h = $this->handler;
141 $h->prepare($code);
142 [$reBol, $reAny, $classes] = $this->compile();
143 $nested = $h->getNestedComment();
144 $headers = $h->getHeaderDirectives();
145
146 $out = '';
147 $plain = '';
148 $pos = 0;
149 $len = strlen($code);
150 $bol = true; // 行頭から空白しか無い
151 $first = true; // この行でまだ単語が無い(アセンブリの命令判定用)
152 $prev = ''; // 直前の空白以外の文字
153 $prevWord = ''; // この行の直前の単語
154 $lineWord = ''; // この行の最初の単語
155 $expectHeader = false;
156
157 $emit = function(string $text, ?string $class) use (&$out, &$plain): void {
158 if ($class === null) {
159 $plain .= $text;
160 return;
161 }
162 if ($plain !== '') {
163 $out .= self::escape($plain);
164 $plain = '';
165 }
166 $out .= '<span class="hl-'.$class.'">'.self::escape($text).'</span>';
167 };
168
169 while ($pos < $len) {
170 $ch = $code[$pos];
171
172 if ($ch === "\n") {
173 $plain .= "\n";
174 $pos++;
175 $bol = true;
176 $first = true;
177 $prevWord = '';
178 $lineWord = '';
179 $expectHeader = false;
180 continue;
181 }
182
183 if ($ch === ' ' || $ch === "\t") {
184 $plain .= $ch;
185 $pos++;
186 continue;
187 }
188
189 // 入れ子のコメント(/* /* */ */)
190 if ($nested && substr_compare($code, $nested[0], $pos, strlen($nested[0])) === 0) {
191 $end = $this->scanNested($code, $pos, $nested[0], $nested[1]);
192 $text = substr($code, $pos, $end - $pos);
193 $emit($text, 'comment');
194 $pos = $end;
195 $bol = false;
196 if (str_contains($text, "\n")) $first = false;
197 continue;
198 }
199
200 // #include <stdio.h> のファイル名
201 if ($expectHeader && $ch === '<' && preg_match('~\G<[^>\n]*>~', $code, $m, 0, $pos)) {
202 $emit($m[0], 'string');
203 $pos += strlen($m[0]);
204 $bol = false;
205 $expectHeader = false;
206 continue;
207 }
208
209 if (preg_match($bol ? $reBol : $reAny, $code, $m, PREG_UNMATCHED_AS_NULL, $pos) === 1 && $m[0] !== '') {
210 $text = $m[0];
211 $class = null;
212 foreach ($classes as $i => $c) {
213 if (isset($m["r{$i}"])) {
214 $class = $c;
215 break;
216 }
217 }
218
219 $after = $pos + strlen($text);
220 if ($class === 'ident') {
221 $class = $h->classify($text, $this->context($code, $after, $bol, $first, $prev, $prevWord, $lineWord));
222 if ($class !== 'label') {
223 if ($first) $lineWord = $text;
224 $first = false;
225 }
226 $prevWord = $text;
227 } else if ($class === 'preprocessor') {
228 $name = strtolower(ltrim(substr($text, 1), " \t"));
229 if (in_array($name, $headers, true)) $expectHeader = true;
230 }
231
232 $emit($text, $class);
233 $pos = $after;
234 $bol = false;
235 $prev = substr($text, -1);
236 if (str_contains($text, "\n")) $first = false;
237 continue;
238 }
239
240 // どの規則にも合わない文字(演算子等)
241 preg_match('~\G.~su', $code, $m, 0, $pos);
242 $c = $m[0] ?? $ch;
243 $plain .= $c;
244 $pos += strlen($c);
245 $bol = false;
246 $prev = $c;
247 }
248
249 if ($plain !== '') $out .= self::escape($plain);
250 return $out;
251 }
252
253 /**
254 * 規則を2つの正規表現にまとめる(行頭用と通常用)
255 *
256 * @return array [行頭用, 通常用, 番号 => 種類]
257 */
258 private function compile(): array {
259 if (isset(self::$compiled[$this->lang])) return self::$compiled[$this->lang];
260
261 $rules = $this->handler->getRules();
262 $bol = [];
263 $any = [];
264 $classes = [];
265 foreach ($rules as $i => $r) {
266 $classes[$i] = $r['class'];
267 $part = "(?<r{$i}>{$r['re']})";
268 $bol[] = $part;
269 if (empty($r['bol'])) $any[] = $part;
270 }
271
272 $mk = fn(array $parts) => '~\G(?:'.implode('|', $parts).')~su';
273 return self::$compiled[$this->lang] = [$mk($bol), $mk($any), $classes];
274 }
275
276 /**
277 * 識別子の分類に使う文脈を作る
278 *
279 * @param string $code コード全体
280 * @param int $after 識別子の直後の位置
281 * @param bool $bol 識別子が行頭か
282 * @param bool $first 行の最初の単語か
283 * @param string $prev 直前の空白以外の文字
284 * @param string $prevWord この行の直前の単語
285 * @param string $lineWord この行の最初の単語(ラベルを除く)
286 * @return array
287 */
288 private function context(string $code, int $after, bool $bol, bool $first, string $prev, string $prevWord, string $lineWord): array {
289 preg_match('~\G[ \t]*([^ \t\n]?)~u', $code, $m, 0, $after);
290 $next = $m[1] ?? '';
291 preg_match('~\G.~su', $code, $r, 0, $after);
292 preg_match('~\G[ \t]*:?[ \t]*([^\s,;:()]*)~u', $code, $w, 0, $after);
293 return [
294 'bol' => $bol,
295 'first' => $first,
296 'prev' => $prev,
297 'prevWord' => $prevWord,
298 'lineWord' => $lineWord,
299 'next' => $next,
300 'nextRaw' => $r[0] ?? '',
301 'nextWord' => $w[1] ?? '',
302 ];
303 }
304
305 /**
306 * 入れ子のコメントの終わりを探す
307 *
308 * @return int コメントの直後の位置(閉じていなければコードの最後)
309 */
310 private function scanNested(string $code, int $pos, string $start, string $end): int {
311 $depth = 0;
312 $len = strlen($code);
313 $ls = strlen($start);
314 $le = strlen($end);
315 while ($pos < $len) {
316 if (substr_compare($code, $start, $pos, $ls) === 0) {
317 $depth++;
318 $pos += $ls;
319 } else if (substr_compare($code, $end, $pos, $le) === 0) {
320 $depth--;
321 $pos += $le;
322 if ($depth === 0) return $pos;
323 } else {
324 $pos++;
325 }
326 }
327 return $len;
328 }
329
330 private static function escape(string $s): string {
331 return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
332 }
333}