奇妙な Lexical Syntax 構造
(justine.lol)- llamafile 向けの新しい 構文ハイライター を作るために 42 言語を扱ったところ、単純なレキシングでさえ言語ごとの例外や古い文法がびっしり潜んでいることが明らかになった
- 実装では C++ と GNU gperf を使ってキーワード検索を高速化し、文字列・コメント・キーワード中心の処理は
forループとswitchベースの有限状態機械で解決している - C の trigraph、JavaScript の
u2028・u2029行終端子、Shell・Perl・Ruby の heredoc、Kotlin・Scala・TypeScript・Swift の文字列補間は レキシングだけで扱うのが難しい事例 として何度も登場する - コード行数ベースでは FORTH は 125 行、Ruby は 1042 行まで差が開き、Ruby は
<<演算子・heredoc・backquote が衝突するため パースなしのハイライト が特に難しい - llamafile の新しいハイライターは Windows 10 上で Meta LLaMA 3.2 3B Instruct により実演され、macOS・Linux・FreeBSD・NetBSD でも動作可能で、構文ハイライトのない ollama と差別化されている
llamafile ハイライターを作ったきっかけ
- llamafile 向けの新しい 構文ハイライター を作るため、1 か月かけて 42 のプログラミング言語を学習した
- 対応対象は Ada, Assembly, BASIC, C, C#, C++, COBOL, CSS, D, FORTH, FORTRAN, Go, Haskell, HTML, Java, JavaScript, Julia, JSON, Kotlin, ld, LISP, Lua, m4, Make, Markdown, MATLAB, Pascal, Perl, PHP, Python, R, Ruby, Rust, Scala, Shell, SQL, Swift, Tcl, TeX, TXT, TypeScript, Zig である
- この一覧は TIOBE Index の大半を含むが、Scratch はテキストではなくブロックを使うため、ハイライト対象から外れている
実装方法: gperf と有限状態機械
- 基本的な構文ハイライターで最大のボトルネックは、トークンが キーワードかどうかを判定 する際に繰り返し文字列比較が増える点にある
- C++ と GNU gperf を用いて 完全ハッシュテーブル を生成した
- 例の
gperf入力では、true、false、nullのような Java 定数をキーワードとして定義している gperfが生成した C ファイルは、衝突のない検索のために単一文字だけを考慮するハッシュ関数を作れる
- 例の
- C のハイライトは、約 4,000 個のキーワードを定義しても gperf のおかげで 35 MB/s の速度で処理できる
- 残りの処理の大半は 有限状態機械 で十分である
flex、bison、ragelなしでも、forループとswitchだけで基本的なハイライターを作れる- 文字列、コメント、キーワードに集中すれば、おおむねレキシングのレベルで処理できる
- C の関数名のような要素までハイライトするには、実際のパースが必要になることもある
- highlight_ada.cpp が実装例として使われている
llamafile のデモと利用環境
- 新しいハイライターとチャットボットインターフェースにより llamafile の使い勝手が向上し、ollama にはない 構文ハイライト が差別化要素になっている
- デモは Windows 10 上で Meta LLaMA 3.2 3B Instruct モデルを使って実行された
- この llamafile は macOS、Linux、FreeBSD、NetBSD でも実行可能である
- gemma 27b it のようなオープンウェイトモデルの品質が向上するにつれ、Claude を使う動機は薄れている
C: 単純な言語という印象とは異なるレキシング例外
- C は単純だという印象に反して、字句要素はかなり独特である
- trigraph は
#,[,\\,^,{,|,},~のような文字を??=,??(,??/,??),??',??<,??!,??>,??-で置き換えられる- C23 標準で削除されたが、レガシーソフトウェアのためにコンパイラは今後も対応し続けると見られる
- 優れた構文ハイライターなら、この文法も処理すべきである
- C の universal character は、
int \\uFEB2 = 1;のように ASCII ソースコード内で Unicode 識別子を表現できる- GCC は、標準委員会が許可した特定の Unicode plane でなければエラーを出す
- C の単一行コメントは、行末バックスラッシュを使うと複数行にまたがることがある
- Perl、Ruby、Shell のようにソースコード中でバックスラッシュエスケープを許す言語でも、この C 風の動作はサポートしていない
- Tcl と GNU Make はこの動作をサポートしていると見られる
- Emacs や Pygments はこれを誤って処理する場合があり、Vim はバックスラッシュ処理を常に正しく行っているようだ
- C には null preprocessor directive もある
- 初期の v6 ソースコードの
.cファイルは#だけの行で始まることが多い - 今でも有効なコードであり、
cc -C -Eでコメント保持を要求しても、プリプロセッサに特定のコメントを削除させる用途に使える
- 初期の v6 ソースコードの
コメント文法: Haskell と D
- C では複数行コメントの中にさらに複数行コメントをネストできない
- Haskell は
{- ... {- ... -} ... -}という形で ネストコメント をサポートする - D は C の
//,/* ... */コメントをそのまま採用し、別途/+ ... +/という再帰コメント文法を導入している - D の字句文法ドキュメントは形式的かつ詳細で、実装に必要な情報を十分に提供している
- D lexical syntax 文書 では hex 文字列や heredoc 文字列のような細部も扱っている
- D には
\"...\"、backtick 文字列、r\"...\"、q\"...\"、x\"...\"など多様な文字列形式がある
Tcl と JavaScript: クォートと見えない行終端子
- Tcl では識別子の中にクォートを含められる
puts a\"bはa\"bを出力する- 変数名にもクォートを入れられるが、参照時には
$a\"bではなく${a\"b}と書かなければならない
- JavaScript は正規表現のための組み込み字句文法を持っている
/[/]/gのように文字集合の角括弧内にある/はエスケープ不要である- 閉じスラッシュだけを単純に走査する方法は、minified コードでは誤ることがある
- ECMAScript は
u2028LINE SEPARATOR とu2029PARAGRAPH SEPARATOR を 行終端子 と定義している- これらの文字は実質的に
\\nのように振る舞う - Trojan Source 文字なので、Emacs ではそれぞれ
↵、¶と表示する設定にしている - 多くのソフトウェアはこの文字を認識できず、疑問符としてレンダリングすることがある
- D を除くと、こうした扱いをする他言語は知らないという
- これらの文字は実質的に
- この特性を利用して、SectorLISP では C と JavaScript の polyglot を作れた
- lisp.js はブラウザで実行でき、GCC でコンパイルしてローカルでも実行できる
- llamafile はこのようなコードを正しくハイライトできるが、他のハイライターでは同じ処理を見つけられなかったという
Shell と heredoc の境界事例
- Shell の heredoc は
cat <<EOF ... EOFの形で複数行文字列を書く - quoted heredoc 文法
cat <<'END'は変数置換を無効にする - heredoc マーカーを空文字列にすると、次の空行で heredoc が終わる
- 例のプログラムは
helloとworldを 2 行で出力する
- 例のプログラムは
- Shell、Ruby、Perl のように heredoc をサポートする言語では、同じ行に複数の heredoc を置ける
- Shell は Tcl と同様、
#が常にコメント開始とは限らない${x#hi-}では#は接頭辞の削除に使われ、例ではthereを出力する
文字列補間: 状態スタックが必要な言語たち
- Kotlin の文字列は
\"で始まっていても、文字列補間のために{で別の状態に入ることがある - TypeScript、Swift、Kotlin、Scala は、文字列の中に実際のコードを入れる方向で文字列補間を強くサポートしている
- Kotlin、Scala、TypeScript をハイライトするには、中括弧を数えて パーサー状態スタック を維持する必要がある
- TypeScript は比較的単純で、有限状態機械にいくつか状態を追加するだけで済む
- Kotlin と Scala は double quote と triple quote の両方で補間をサポートするため、文字列レキシングだけで約 13 個の独立状態が必要だった
- Swift は
\"\\(var)\"補間構文と triple quote をサポートするが、実装には 10 個の状態が必要だった
Swift, C#, FORTH: 文字列を囲む方法の違い
- Swift は
\"...\"、\"\"\"...\"\"\"、/regex/文字列を任意個の#で囲む文法をサポートしている- 両側の
#の数は一致していなければならない - この方法は文字列内にクォートや正規表現の区切りを入れる問題を解決する
- 両側の
- C# は Python の triple quote 複数行文字列に似ているが、開始と終了により多くのクォート数を対応させて使える
- 左側に置いたクォート数が右側の終了条件になる
- 任意個のクォートを許すことで、古典的な Python triple quote より妥当性ルールが減り、有限状態機械でより単純にデコードできるとみなしている
- FORTH は空白境界ですべてをトークン化する
- 文字列開始構文
c\"も 1 つのトークンである c\" hello world\"は他言語の\"hello world\"と同じ意味である
- 文字列開始構文
FORTRAN と COBOL: 固定カラム規則
- llamafile は、FORTRAN と COBOL のプログラマが引退した後でも銀行システム保守に役立つユースケースとして提示されている
- エアギャップ環境で制御可能な AI として Gemma 27b に COBOL や FORTRAN のコードを依頼できるという
- FORTRAN の 固定カラム規則 は次のとおり
- 1 列目に
*、c、Cを置くとその行はコメントになる - 6 列目に空白以外の文字を置くと、80 文字を超える行を継続できる
- 1〜5 列目に数字を置くとラベルになる
- 1 列目に
- COBOL の規則は次のとおり
- 7 列目に
*を置くとコメントになる - 7 列目に
-を置くと、80 文字を超える行を継続できる - 1〜6 列目には行番号が入る
- 7 列目に
Zig と Lua: 複数行文字列への別解
- Zig は 2 本のバックスラッシュで始まる 複数行文字列 文法を持つ
- この文法により、Python の triple quote 文字列で
textwrap.dedent()を呼ぶ必要がなくなる - 欠点としてセミコロンが見栄えよくないとしている
- Go、Scala、Python のようにセミコロン不要の言語が検討する価値のある文字列文法として提案している
- この文法により、Python の triple quote 文字列で
- Lua の複数行文字列は
[[...]]をベースにし、角括弧の間に任意個の=を入れられる[==[ ... ]==]のように開始と終了の=の数を一致させる- 同じ方式をコメントにも使える
--[[ ... ]]と--[==[ ... ]==]のどちらも可能である
Assembly: 方言とプリプロセッサの組み合わせ
- Assembly は方言が多岐に分かれており、ハイライトが難しい言語の 1 つである
- llamafile は AT&T、nasm など複数の構文を妥当に扱うことを目指している
- キーワードは、行の最初の識別子であり、かつコロンの後ろにない項目として扱えば、大半の assembly コードをそれらしく見せられる
- コメント文法も単純ではない
- 元の UNIX コメントは単一の
/だけでよかった - GNU as は今でも行頭位置でのみこのコメントをサポートしている
- Clang は fixed comment をサポートしないため、オープンソースコードでは実用的に使いにくいという
- 元の UNIX コメントは単一の
- 元の UNIX assembler は文字リテラルに閉じクォートを使わなかった
'xは文字xの値0x78を表す- GNU as はこれを今もサポートするが、LLVM はサポートしない
- 既存コードにこの文法があるので、良いハイライターなら対応すべきである
- GNU assembler は引用付き識別子を許すため、シンボルにほぼあらゆる文字を入れられる
- Assembly は C プリプロセッサや m4 と一緒に使われることが多い
dnl、m4_dnl、Cで始まる行もコメントとして処理する必要がある
Ada と BASIC: 小さな文法がレキシングを揺さぶる例
- Ada はレキシング自体はかなり単純だが、シングルクォートの使い方が独特である
- C のように
'x'という文字リテラルを持てる Foo'Sizeのように属性参照でもシングルクォートを使うCharacter'(')')'Imageは文字を宣言し、Image関数で文字列表現へ変換する
- C のように
- Commodore BASIC の例は、構文ハイライトのさまざまな前提を崩す
- 文字列は行末で閉じクォートを省略できる
- 変数名には
$のような sigil が付く gotoのようなキーワードは識別子の中でも積極的にレキシングされる
- Visual BASIC には
#1/1/2024#のような 日付リテラル 文法がある - Visual BASIC は
#If DEBUG Then、#Else、#End Ifのようなプリプロセッサディレクティブも持ち、レキシングが厄介である
Perl: Shell とプログラミング言語の間の複雑さ
- Perl は Shell とプログラミング言語の中間にあり、両方の複雑さを受け継いでいる
- 正規表現を言語の第一級要素にし、その影響は Python など他言語にも及んだ
- Perl の置換構文は
s/hello/Perl/iのように sed に似ている/の代わりに任意の句読点文字を区切りに使えるs!hello!Perl!iのようにスラッシュが正規表現内に入るとき便利であるs{hello}{Perl}iのように対応する文字を使う場合は追加文字が必要になる
- Perl には、文字列のようにハイライトすべき魔法の接頭辞が多い
m、s、y、qr、qw、qq、qxなどがさまざまな区切りとともに使われる
y/x/y/を除算と誤判定しないには文脈を見る必要がある- Perl の変数はスカラー
$、配列@、ハッシュ%のような sigil を持つため、文法全体をパースしなくても区別の助けになる
- Perl の変数はスカラー
- Perl では、ソースコード内に man page 用の POD 文書を入れる慣習がある
- 行頭の
=wordが POD 文書の開始となり、=cutで終わる
- 行頭の
Ruby: 最も難しかったレキシング対象
- Ruby は前述の言語群の和集合のように見え、文法が形式的に十分文書化されていないと評価されている
- Ruby マニュアルの syntax 文書 は細部の説明が軽めだと見ている
- Ruby は backquote 文法をサポートする一方で、backquote をメソッド名のようにも使えるため、ハイライターがそれを文字列と見るべきかどうか判定しにくい
- Ruby には
<<演算子と heredoc の両方がある- 実際のコードには
options[:includes] <<arg; trueのような形が存在する - これは heredoc のように見えることがあり、Emacs も誤って処理する
- 実際のコードには
puts \"This is #{<<HERE.strip} evil\"のように、文字列補間の中に heredoc が入るコードも有効である- 42 言語の中で Ruby が最も衝撃的だったとされ、Ruby はパースなしにはレキシングできないかもしれず、パースしても解釈可能かどうか確信しにくいと見ている
実装コード行数で見た複雑さ
- 各言語ハイライター実装のコード行数で見ると、FORTH が最も単純で Ruby が最も複雑である
- 最も短い実装は highlight_forth.cpp の 125 行である
- 比較的短い実装には m4 132 行、Ada 149 行、LISP 160 行、MATLAB 166 行、COBOL 186 行、BASIC 199 行、FORTRAN 200 行などがある
- 中規模の実装には JavaScript 337 行、TypeScript 371 行、Kotlin 387 行、Scala 387 行、Assembly 447 行、C 449 行、Swift 455 行、D 521 行が含まれる
- 最も長い実装は Shell 570 行、Perl 583 行、highlight_ruby.cpp 1042 行である
1件のコメント
Hacker Newsの意見
Cのトライグラフの中でいちばん気に入っているのは、
do_action() ??!??! handle_error()みたいな形特殊なエラー処理構文のように見えるが、実は
??!??!が||の論理 OR に変換され、短絡評価の規則によってdo_action()が 0 以外の値を返したときにhandle_error()を実行する構造なので満足感がある??!は|に変換されるので、??!??!は||、つまり “or” になる||の代わりに古いCのトライグラフを選んだのだろうか?面白く読んだが、むしろ Lisp 側の観点により共感するようになった
私の理解では、構文は言語の中でそれほど重要な部分ではなく、助けになるより障害になりやすいので、他のことに集中できるようできるだけ単純で均一であるべきだという立場だ
ただ、Lisp で構造的編集を学ぶことは、今のところ助けより障害に近く、いつかは報われるのだろうと思う
Lisp のように単純化すると、構文についての議論が意味論の問題へ押しやられるだけで、階層が変わるにすぎない
複雑な意味論を持つ単純な構文より、複雑な構文のほうが読み書きしやすいと考える。構文エラーにはすぐフィードバックがあるが、意味エラーは実行時まで隠れていることもある
たとえば https://pyret.org/ のようなものもある。実際、Lisp が単純だったり、必ずしも均一だったりするとまでは言いにくい
parinferを使い、slurp、barf、raiseの3つのコマンドのキーバインドだけ覚えればよいこの4つだけでもpareditの利点を複雑さなしに 95% ほど享受でき、残りのコツは慣れてから学べばよい
「誰が C を毎秒 35MB でシンタックスハイライトしたがるのかは分からないが、今はそれが可能だ」というくだりがあったが、速いとはいえ
tccはかなり古いコンピュータでも C を毎秒 29MB コンパイルしてバイナリコードにする: https://bellard.org/tcc/#speedもっと速くすることもできるだろうが、おそらく必要ではない
筆者が見落としている点がいくつかある
TypeScript、Swift、Kotlin、Scala だけが文字列補間に実際のコードを入れる極端なところまで行ったわけではなく、C#、Python、JavaScript、Ruby、Shell、Make も同様のことをサポートしている
Tcl はコードとデータの区別が難しいため、
{ }は事実上特殊な文字列区切りであり、xyzzy {#hello world}における#hello worldがコメントなのか文字列なのかは、評価時点になるまで分からないPostgreSQL には便利なドル引用文字列があり、https://www.postgresql.org/docs/current/sql-syntax-lexical.h... のように
'Dianne''s horse'、$$Dianne's horse$$、$SomeTag$Dianne's horse$SomeTag$は同じ意味になる\"I have $foo $bar's: @{[$bar x $foo]}\"のような文字列はI have 5 x's: xxxxxを出力できる@{[...]}構文は、スカラーだけでなく配列補間も可能な Perl の特性を利用したもので、内側の[...]は配列参照を作り、外側の@{...}がそれを逆参照する。Perl インタプリタはその内側の式に任意のコードを許可するそうすれば
\"foo { toUpper(\"bar { x + y } bar\") } foo\"のようにネストでき、+が文字列連結なら\"foo \" + toUpper(\"bar \" + (x + y) + \" bar\") + \" foo\"と実質的に同じになる実際にそう動く言語があるのかは分からない
Ruby は好きだが、文字列補間と heredoc を混ぜた
puts \"This is #{<のような有効な Ruby を擁護するのは難しいまた Ruby では空白も引用文字になりうる。左辺オペランドのない文脈では
%が引用文字列の開始となり、次の文字が引用の種類を示すため、% helloはhelloを含む文字列になる。%(this is a string)や%{this is a string}はよいが、空白を使うものは実際に見たこともなく、irbでも正しく扱えないので、削除されてもよいと思うval s"${a} + ${b}" = "1 + 2"を使うと、aは1、bは2になる以前は、ホットループ内の
log.trace($\"Entering iteration {i} for customer {c.ID} [{c.ShortName}]\");のようなコードが、ロガーが早期リターンする前であっても毎回string.Concatを呼んでいたため、多くのロガーが独自の補間を実装していたC# では
DefaultInterpolatedStringHandlerやカスタムハンドラーパターンを受け取るオーバーロードを宣言でき、このオーバーロードが優先されることで、実際にログが必要かを確認してから文字列生成を遅らせられるここで触れられていない、ほとんどのシンタックスハイライタを壊す別の文法上の特異点がある
Java では Unicode エスケープ が文字列の中だけでなくどこにでも現れうる
たとえば
class Foo\u007b}は有効なクラスで、//コメント内の\u000Aが実際の改行として扱われうるため、assertの例も予想と違う動作になるUnicode エスケープでブロックコメントを終了できるので、Java ソースファイル内のコメントに悪意あるコードを隠すには、コメント内に Unicode エスケープのブロックがありそうな言い訳を作ればよい
この特性を知らない開発者は、コメントアウトされているものとして見過ごしてしまう可能性が高い
「Ruby は文法を理解しようとするあらゆる試みをすり抜ける言語」という一節があるが、任意に再プログラム可能なレキサ を持つ TeX から見ればかわいいものだ
「すべての C プログラマは複数行コメントの中に複数行コメントを入れられないことを知っている」とあるが、Standard ML プログラマには意外な制約かもしれない
(* (* Nested (**) *) comment *)のような ネストしたコメント は有効で、その後に続くval _ = print "hello, world\n"も正常に実行されるC が登場した当時は表現力のある言語だと見なされていたことを考えると、ネストコメントが言語に入らなかったのは興味深い
ML には 1 行コメントがなかったので、それも同じくらい意外な制約だし、C を「表現力がある」と呼ぶのは初めて聞いたが、1972 年にアセンブリと比べるならそうだったのかもしれない
そしてコメント文法が言語の表現力とどう関係するのかは分からず、定義上まったく関係ないと思う
伝統的に字句解析は正規表現のような有限状態オートマトンだけを使う領域と考えられていたので、そうした機能はレキシングの範囲外に見えていた
#if 0を使えばコメントをネストする方法が 1 つある#if 0 ... #if 0 ... #endif ... #endifのように囲めるjoe のシンタックスハイライト実装を再利用可能な形で見てみたい
https://joe-editor.sf.net/ の形式は、Python f-string を正しくハイライトできるほど強力だ
関連する文書と例は https://github.com/cmur2/joe-syntax/blob/joe-4.4/misc/HowItW...、https://gist.github.com/irdc/6188f11b1e699d615ce2520f03f1d0d... にある
状態と遷移の文法は標準ツールよりずっと理解しやすかった
欠点は規則集合が冗長になり、理想的な構造化がやや難しい点だが、コードからすべての生成規則を推論しやすくなるという意味ではむしろ利点だと思う
筆者は TeX のシンタックスハイライトを試したことがないようだ
精神衛生のためにはよいことかもしれないが、TeX はたいてい 解釈なしでは完全なシンタックスハイライトが不可能 だ
パースだけでも不十分で、各文字の役割を再定義できるため、「これから K は
{で、C は}だ」のようなことまでできる実際、arXiv の論文の中にもこの呪われた機能を使っているものがある
.texファイルについては壊れず、かなりうまくハイライトできている目標は現実世界の利用の 99.9% をカバーすることだ。そうすれば LLM が出力しそうなものも大半はカバーできる可能性が高い
難解な文法でも、文字列やコメントがファイル末尾まで続いて残りのソースコードを覆ってしまいさえしなければ、たいてい大きな問題にはならない
\makeatletterが「何かを at 文字にする」という意味ではなく、パース時に@文字を文字として扱う という意味だと知っても、なかなか信じがたかったVimのような優れた構文ハイライトエンジンを書くのは簡単ではないと思う
構文ハイライトには、特定の形でネストされた内容にそれぞれ異なる規則を適用する文脈処理が必要になる
Vimの構文ハイライタでは
matchとregionの2種類の項目を宣言でき、matchは単純な字句規則で、regionは開始・終了・中間をそれぞれマッチする式を持つ項目は特定の領域内にあるときだけ有効になるよう宣言でき、包含関係も相互に宣言できる
この基本的な意味構造の上に、さまざまな特殊ケース向けの機能が追加されている。Justineでも面接の場でその場ですぐ作るのは難しく、一晩課題くらいにはなると思う
manpageのHTML変換結果を受け取り、TXRマニュアルのHTMLに整える
genmanスクリプトがここにある: https://www.kylheku.com/cgit/txr/tree/genman.txr白く見える部分はリテラルテンプレートで、Lispコードは
@(do ...)のようなディレクティブの中に入る。TXRキーワードは紫、TXR Lispキーワードは緑で表示され、同じ単語でも文脈によって異なる準文字列にもネストした文法を含めることができ、その中にさらに埋め込みコードや準文字列を入れられる。
txr.vimとtl.vimの構文定義ファイルはどちらも https://www.kylheku.com/cgit/txr/tree/genvim.txr から生成されるそうしないのは効率の問題なのだろう。たとえば、画面に見えている部分だけをハイライトする代わりに、ファイル全体をパースする必要があり、コストが大きくなるかもしれない