で囲む。
* 色は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');
}
}