ブログやスライドで輝くGoコードへ: prettygo をご紹介
この記事は、Finatext Advent Calendar 2025 の11日目の記事です。
私は以前から、ブログやGitHubなどでよく見かける、以下のようなマークダウンのコードブロックに使われるシンタックスハイライト(コードハイライト)をあまり好んでいませんでした。
```go
//var x string = "asd"
x := struct {
A int
B string
}{
A: 1,
B: "test",
}
```
正規表現 (RegEx) で色をつけているため、100%正確にコードの文法を認識するのは不可能です。そもそもコードの前後関係(コンテキスト)があまりなく、パッケージ名なのか、変数なのか、引数なのかを判断することも不可能な場合が多いです。結果的に上記のような「何かが微妙に違う」ハイライトになってしまいます。
そこでGoのコードを適切なCSS classが付与されたHTMLに変換するCLIツールを作りました。
以下にいくつかサンプルをご紹介します。ぜひHTMLとCSSもチェックしてみてください。
カスタム CSS
VS Code の Dark Plus
こちらはちなみに、対応するカッコの色付けも対応しています。
CSSなので、より柔軟にコードのハイライトも実現できます。
highlight.js も対応
実はマークダウンにおけるコードブロックは、ほとんどのウェブサイトでは highlight.js でレンダリングされるようになっています。highlight.js はあらゆる言語に対応しており、選べるカラースキーム(テンプレート)も豊富です。
「自分でCSSを書きたくない、人気のあのテンプレートを使いたい」という方もいらっしゃるかと思い、highlight.jsで使われるCSS classも対応しています。もちろん、highlight.js はRegExでの認識を前提に作られているため、設けられているclassが少なく、すごくきれいになるというわけでもないですが、カスタムCSSで拡張も可能です。
以下は定番の monokai です。
テンプレートのリストは以下から参照できます。
作り方
ここからは、シンタックスハイライターをどのように作ったのかを簡単にご紹介したいと思います。
スタートはGoの公式LSP、goplsでした。LSPの役割はシンタックスハイライト(というか、トークン化)でもあるので、goplsのどこかに実装があるはずだ、と思いました。
しばらく調べてみると gopls semtok というサブコマンドを発見しました。
以下のプログラムに対して実行してみると
このようなアウトプットがプリントされました。
❯ gopls semtok ./main.go
/*⇒7,keyword,[]*/package /*⇒4,namespace,[]*/main
/*⇒6,keyword,[]*/import "fmt"/*⇐3,namespace,[]*/
/*⇒4,keyword,[]*/func /*⇒4,function,[definition signature]*/main() {
/*⇒3,namespace,[]*/fmt./*⇒7,function,[signature]*/Println(/*⇒15,string,[]*/"Hello, World!")
}
シンタックスはあくまでもプリント用で若干分かりにくいですが、/*⇒7,keyword,[]*/というコメントの意味は次の通りです:コメントのあと(2つ目の/のポジション)から右へ、7文字分、keywordというトークンがあるということです。[]はモディファイアを示していて、この場合はモディファイアがありません。もう少し下(mainの前)にfunction,[definition signature]があるので、関数の「定義」だとわかります。
さて、このロジックを使えばシンタックスハイライターを自由に作れると思いました。
問題その1: internal
残念ながらgoplsのほとんどのコードはinternalパッケージにあるため、外部パッケージとして利用することはできません。
仕方なくsemantic tokenizationに関するコードを全て切り出し、整理し、動く形にできました。幸い、依存パッケージがあまり多くなかったので、ファイル数はそれほどでもありませんでした。
問題その2:パーサーが諦めるの早い!
上記でgoplsを試したときは完全で正しいGoファイルでしたが、コードブロックに貼るようなコードは部分的に切り出されたものがほとんどだと思います。一行だけだったり、if文だけだったりします。
初めて切り出したコードをテストしてみたときに、ひとまず str := "test" のようなコードで試してみましたが、謎のエラーになっていました。
色々調べてみると、GoのLSPは完全で正しいGoファイルしか対応していないようでした!
まずはパーサーのコードを探ってみて以下のところを見つけました。
最初のトークン(つまりpackageというキーワード)でエラーが発生した場合、「おそらくGoのファイルではないから処理を中止しよう」という判断になっているようです。
パーサーのロジックだけがinternalではなく、パッケージとして使えていたのに、こちらも結局丸ごとコピーし、修正することになりました……
他にも何箇所か修正が必要なところがありました。今回のユースケースはそもそもGoファイルしかインプットされないのは前提だったので、どんな壊れたコードが入ってきてもできる限りパースできるように、パッケージを調整しました。
修正完了後、str := "test"もハイライトされるようになりました。
問題その3:改行
私はコードハイライトのテストに使っていた元のコードには、改行を含んだ長いstringが入っていました(` で囲んで)。Linuxではそのstringが問題なくハイライトされていましたが、Windowsで試してみると途中からハイライトが切れ、色が白になってしまいました。
しばらく調査したら、改行が原因だとわかりました。Linuxでの改行が\nになっているのに対し、Windowsでは\r\nが使われます。ただし、Goのパーサーがコードをパースする際、全ての改行を\nに変換します。
問題わかりましたか?30秒考えてみてくださいw。
stringの中の改行が置き換わることによって、string tokenの長さが変わるのです!ちなみに gopls semtokでも発生していたので、GitHub上でissueを立てて、astパッケージをフォークしないで済みました。
ちなみにバグは修正されましたが、現時点ではまだリリースされていません。
おまけ
トークン化がようやく動くようになりました。最後のHTMLとしての出力は比較的スムーズに終わりました。それぞれのトークンをspanで囲んで、トークン化で得た情報をclassとして追加するような実装になっています。直接styleでCSSを生成する機能も作りました。もちろん3つの出力を同時に使うことも可能です。
ただ、もう一つやりたかったことがありました。それはカッコのハイライトです。VS Codeなどのエディターでは対応するカッコが青くなったり、紫になったりしますよね?それを再現してみたかったです。しかし、LSPのsemantic tokenとして、カッコが存在しなくて、goplsでも特に認識されていませんでした(上記のアウトプットでも{}などにはコメントが書いていません)。
そこでゼロから作るしかないと思って実装を進めていきました。
Goにはあらゆるカッコがあります。関数を囲むカッコ、ジェネリックスのカッコ、タイプアサートのカッコ、スコープを自由に切るカッコ、if文で使える任意カッコなどなど。これらを全て抽象構文木(AST)上で認識し、ペアのポジションをトラッキングするようにしました。カッコのレベルは実は、後でソートすればわかるのでトラッキングする必要はありませんでした。
こちらの機能も特に大きな問題なく実装できました。
CLIツールに
最後に cobra や viper で様々なオプションを設けてCLIツールを用意しました。もちろん、ライブラリとして利用することも可能です(goplsと違って)。
ぜひブログやスライドに載せるGoコードをprettyにしましょう!
今後
マークダウンしか使えないところではやはり少し使いづらいかもしれませんが、この記事のようにjsfiddleをエンベッドすれば問題ないと思います。今後はテキストがコピペ可能なSVGにできないか試してみようと思います。
Discussion