で囲む。 * 色はstyle-code.cssで定義する。 * * 使い方: * if (Syntax::isSupported($lang)) $html = (new Syntax($lang))->colorize($code); */ class Syntax { private const SUPPORTED = [ 'asm' => Asm::class, 'asm-6502' => Asm6502::class, 'asm-68000' => Asm68000::class, 'asm-aarch32' => AsmAarch32::class, 'asm-aarch64' => AsmAarch64::class, 'asm-mips' => AsmMips::class, 'asm-power' => AsmPower::class, 'asm-riscv' => AsmRiscv::class, 'asm-x8664' => AsmX8664::class, 'asm-z80' => AsmZ80::class, 'c' => C::class, 'cpp' => Cpp::class, 'csharp' => CSharp::class, 'glsl' => Glsl::class, 'go' => Go::class, 'hlsl' => Hlsl::class, 'objc' => ObjC::class, 'slang' => Slang::class, 'swift' => Swift::class, ]; private const ALIASES = [ 'asm-6502' => ['6502', 'asm6502', 'asm-65c02'], 'asm-68000' => ['68000', '68k', 'm68k', 'asm-68k', 'asm-m68k'], 'asm-aarch32' => ['asm-arm', 'asm-arm32', 'arm', 'arm32', 'aarch32', 'asm-thumb'], 'asm-aarch64' => ['asm-arm64', 'arm64', 'aarch64'], 'asm-mips' => ['mips', 'asm-mips64'], 'asm-power' => ['asm-ppc', 'asm-powerpc', 'asm-ppc64', 'ppc', 'powerpc', 'power'], 'asm-riscv' => ['riscv', 'asm-riscv64', 'asm-rv64', 'rv64', 'risc-v'], 'asm-x8664' => ['asm-x86', 'asm-x64', 'asm-x86-64', 'asm-amd64', 'x86', 'x64', 'x86-64', 'nasm'], 'asm-z80' => ['z80', 'asm-gbz80', 'gbz80'], 'c' => ['h', 'c99', 'c11', 'c23'], 'cpp' => ['c++', 'cc', 'cxx', 'hpp', 'hh', 'hxx', 'h++'], 'csharp' => ['c#', 'cs'], 'glsl' => ['vert', 'frag', 'geom', 'comp', 'tesc', 'tese'], 'go' => ['golang'], 'hlsl' => ['fx', 'fxh', 'hlsli'], 'objc' => ['objective-c', 'obj-c', 'objectivec', 'm', 'mm', 'objc++'], 'slang' => [], 'swift' => [], ]; private string $lang; private SyntaxInterface $handler; /** @var array 規則の正規表現をキャッシュ(言語 => [行頭用, 通常用, 種類一覧]) */ private static array $compiled = []; public function __construct(string $lang) { $resolved = self::resolve($lang); if ($resolved === null) throw new \InvalidArgumentException("未対応の言語: {$lang}"); $this->lang = $resolved; $class = self::SUPPORTED[$resolved]; $this->handler = new $class(); } /** * 言語が対応しているか * * @param string $lang 言語名又は別名 * @return bool */ public static function isSupported(string $lang): bool { return self::resolve($lang) !== null; } /** * 別名を正式な言語名にする * * @param string $lang 言語名又は別名 * @return string|null 正式な言語名、未対応ならnull */ public static function resolve(string $lang): ?string { $lang = strtolower(trim($lang)); if (isset(self::SUPPORTED[$lang])) return $lang; foreach (self::ALIASES as $k => $v) if (in_array($lang, $v, true)) return $k; return null; } public function getLanguage(): string { return $this->lang; } /** * コードをハイライトしたHTMLにする(HTMLエスケープ済み) * 失敗した場合はエスケープしただけのコードを返す * * @param string $code 生のコード * @return string HTML */ public function colorize(string $code): string { $code = str_replace(["\r\n", "\r"], "\n", $code); if ($code === '' || preg_match('//u', $code) !== 1) return self::escape($code); try { return $this->tokenize($code); } catch (\Throwable $e) { return self::escape($code); } } /////////////// private function tokenize(string $code): string { $h = $this->handler; $h->prepare($code); [$reBol, $reAny, $classes] = $this->compile(); $nested = $h->getNestedComment(); $headers = $h->getHeaderDirectives(); $out = ''; $plain = ''; $pos = 0; $len = strlen($code); $bol = true; // 行頭から空白しか無い $first = true; // この行でまだ単語が無い(アセンブリの命令判定用) $prev = ''; // 直前の空白以外の文字 $prevWord = ''; // この行の直前の単語 $lineWord = ''; // この行の最初の単語 $expectHeader = false; $emit = function(string $text, ?string $class) use (&$out, &$plain): void { if ($class === null) { $plain .= $text; return; } if ($plain !== '') { $out .= self::escape($plain); $plain = ''; } $out .= ''.self::escape($text).''; }; while ($pos < $len) { $ch = $code[$pos]; if ($ch === "\n") { $plain .= "\n"; $pos++; $bol = true; $first = true; $prevWord = ''; $lineWord = ''; $expectHeader = false; continue; } if ($ch === ' ' || $ch === "\t") { $plain .= $ch; $pos++; continue; } // 入れ子のコメント(/* /* */ */) if ($nested && substr_compare($code, $nested[0], $pos, strlen($nested[0])) === 0) { $end = $this->scanNested($code, $pos, $nested[0], $nested[1]); $text = substr($code, $pos, $end - $pos); $emit($text, 'comment'); $pos = $end; $bol = false; if (str_contains($text, "\n")) $first = false; continue; } // #include のファイル名 if ($expectHeader && $ch === '<' && preg_match('~\G<[^>\n]*>~', $code, $m, 0, $pos)) { $emit($m[0], 'string'); $pos += strlen($m[0]); $bol = false; $expectHeader = false; continue; } if (preg_match($bol ? $reBol : $reAny, $code, $m, PREG_UNMATCHED_AS_NULL, $pos) === 1 && $m[0] !== '') { $text = $m[0]; $class = null; foreach ($classes as $i => $c) { if (isset($m["r{$i}"])) { $class = $c; break; } } $after = $pos + strlen($text); if ($class === 'ident') { $class = $h->classify($text, $this->context($code, $after, $bol, $first, $prev, $prevWord, $lineWord)); if ($class !== 'label') { if ($first) $lineWord = $text; $first = false; } $prevWord = $text; } else if ($class === 'preprocessor') { $name = strtolower(ltrim(substr($text, 1), " \t")); if (in_array($name, $headers, true)) $expectHeader = true; } $emit($text, $class); $pos = $after; $bol = false; $prev = substr($text, -1); if (str_contains($text, "\n")) $first = false; continue; } // どの規則にも合わない文字(演算子等) preg_match('~\G.~su', $code, $m, 0, $pos); $c = $m[0] ?? $ch; $plain .= $c; $pos += strlen($c); $bol = false; $prev = $c; } if ($plain !== '') $out .= self::escape($plain); return $out; } /** * 規則を2つの正規表現にまとめる(行頭用と通常用) * * @return array [行頭用, 通常用, 番号 => 種類] */ private function compile(): array { if (isset(self::$compiled[$this->lang])) return self::$compiled[$this->lang]; $rules = $this->handler->getRules(); $bol = []; $any = []; $classes = []; foreach ($rules as $i => $r) { $classes[$i] = $r['class']; $part = "(?{$r['re']})"; $bol[] = $part; if (empty($r['bol'])) $any[] = $part; } $mk = fn(array $parts) => '~\G(?:'.implode('|', $parts).')~su'; return self::$compiled[$this->lang] = [$mk($bol), $mk($any), $classes]; } /** * 識別子の分類に使う文脈を作る * * @param string $code コード全体 * @param int $after 識別子の直後の位置 * @param bool $bol 識別子が行頭か * @param bool $first 行の最初の単語か * @param string $prev 直前の空白以外の文字 * @param string $prevWord この行の直前の単語 * @param string $lineWord この行の最初の単語(ラベルを除く) * @return array */ private function context(string $code, int $after, bool $bol, bool $first, string $prev, string $prevWord, string $lineWord): array { preg_match('~\G[ \t]*([^ \t\n]?)~u', $code, $m, 0, $after); $next = $m[1] ?? ''; preg_match('~\G.~su', $code, $r, 0, $after); preg_match('~\G[ \t]*:?[ \t]*([^\s,;:()]*)~u', $code, $w, 0, $after); return [ 'bol' => $bol, 'first' => $first, 'prev' => $prev, 'prevWord' => $prevWord, 'lineWord' => $lineWord, 'next' => $next, 'nextRaw' => $r[0] ?? '', 'nextWord' => $w[1] ?? '', ]; } /** * 入れ子のコメントの終わりを探す * * @return int コメントの直後の位置(閉じていなければコードの最後) */ private function scanNested(string $code, int $pos, string $start, string $end): int { $depth = 0; $len = strlen($code); $ls = strlen($start); $le = strlen($end); while ($pos < $len) { if (substr_compare($code, $start, $pos, $ls) === 0) { $depth++; $pos += $ls; } else if (substr_compare($code, $end, $pos, $le) === 0) { $depth--; $pos += $le; if ($depth === 0) return $pos; } else { $pos++; } } return $len; } private static function escape(string $s): string { return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } }