| 1 | <?php |
| 2 | namespace Std\Lib; |
| 3 | |
| 4 | use Std\Lib\Syntax\SyntaxInterface; |
| 5 | use Std\Lib\Syntax\Asm; |
| 6 | use Std\Lib\Syntax\Asm6502; |
| 7 | use Std\Lib\Syntax\Asm68000; |
| 8 | use Std\Lib\Syntax\AsmAarch32; |
| 9 | use Std\Lib\Syntax\AsmAarch64; |
| 10 | use Std\Lib\Syntax\AsmMips; |
| 11 | use Std\Lib\Syntax\AsmPower; |
| 12 | use Std\Lib\Syntax\AsmRiscv; |
| 13 | use Std\Lib\Syntax\AsmX8664; |
| 14 | use Std\Lib\Syntax\AsmZ80; |
| 15 | use Std\Lib\Syntax\C; |
| 16 | use Std\Lib\Syntax\Cpp; |
| 17 | use Std\Lib\Syntax\CSharp; |
| 18 | use Std\Lib\Syntax\Glsl; |
| 19 | use Std\Lib\Syntax\Go; |
| 20 | use Std\Lib\Syntax\Hlsl; |
| 21 | use Std\Lib\Syntax\ObjC; |
| 22 | use Std\Lib\Syntax\Slang; |
| 23 | use 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 | */ |
| 34 | class 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 | } |