日常に彩りを加える nvim-treesitter の設定術

2021/12/21に公開約11,300字

はじめに

今年は Neovim に大きな変化がありました。 v0.5.0 と v0.6.0 のリリースです。特に v0.5.0 から tree-sitter のライブラリが組み込まれたことは大きな話題を集め、以下をはじめとする様々な記事で紹介されました。

https://lab.mo-t.com/blog/neovim-v05-introduction-new-features-part-2
https://www.soum.co.jp/misc/vim-advanced/6/
https://zenn.dev/duglaser/articles/c02d6a937a48df

これらの記事で紹介されている通り、nvim-treesitter というプラグインを用いると tree-sitter の力を借りられるようになります。高速な構文解析に基づき、シンタックスハイライトやインデントといった様々な処理を正確かつ柔軟に行えるようになるのです。

とはいえ、nvim-treesitter を導入することで必ずしも全てのユーザにとって使いやすくなるとは限りません。特にシンタックスハイライトは tree-sitter に変更することでかなり変わります。従来の色付けに慣れていればいるほど、かえって見づらくなったと感じることもあるでしょう。

そこで本記事では、nvim-treesitter のシンタックスハイライトの挙動をより自分好みにカスタマイズする方法を紹介します。パーサに直接手を加えずとも、クエリ (query) と呼ばれる設定を少し追加するだけで自分の好みに近づけることができるのです。色付けが自分の好みから変わるのが嫌で tree-sitter への移行を躊躇っている、という方はぜひ試してみてください。

カスタマイズの実例

本記事では、カスタマイズの実例としてMarkdown の色付けを用います。Markdown はつい最近(1週間ほど前) Neovim 公式でサポートされ、 :TSInstall markdown でインストールできるようになりました。 tree-sitter-markdown というリポジトリで定義された文法が用いられています。

筆者も Markdown を頻繁に使用するため、Markdown の色付けを自分好みに設定し直してみました。設定前と設定後の様子を見比べてみてください。どこが変化したか分かりますか?

before
設定前

after
設定後

変更したのは以下の6箇所でした。結構沢山ありますね。

  1. 引用全体が灰色になった
  2. H1 ヘッダのテキストに下線が引かれるようになった
  3. ハイパーリンクや画像の記法全体に色がついた
  4. 言語名を指定していない fenced code block [1] 全体に緑色がついた
  5. fenced code block の言語名で python ではなく短縮名の py で指定しても Python のシンタックスハイライトが付くようになった
  6. fenced code block にて python という言語名の後ろに :ファイル名 を指定しても Python のシンタックスハイライトが付くようになった

5 と 6 は今の所特定の言語名にしか対応しておらず、別言語に対応したい場合は別途設定を追加する必要があります。万能ではないものの、個人が使う分には大きく困らないと思います。

nvim-treesitter の動作原理

設定方法の説明の前に、まず nvim-treesitter がどのような手順でシンタックスハイライトを行っているかについて簡単に紹介します。

そもそも tree-sitter とはパーサジェネレータ、つまり「構文解析器を生成するもの」です。 Neovim は tree-sitter から作られた各言語のパーサ (e.g. tree-sitter-rust, tree-sitter-python, tree-sitter-markdown) を利用して開いているテキストの構文木を取得し、シンタックスハイライト等の便利な機能を提供しています。つまり、ざっくりと書けば

  1. 構文解析:テキストを構文解析して構文木を作成する
  2. クエリ検索:予め定義したクエリ(検索パターン)にマッチする箇所を構文木から探す
  3. 色付け:手順2でとらえたマッチ箇所に予め指定された色を付ける

という手順でシンタックスハイライトを提供しているのです。

構文解析

たとえば以下のような Markdown ファイルがあるとします。

example.md
# タイトル

ここが段落。途中で**強調**できる。

- リスト
- **強調**の入ったリスト

tree-sitter-markdown を用いて上のファイルをパースすると、以下のような構文木が得られます。

[Syntax Tree Corresponding to example.md]
atx_heading [0, 0] - [1, 0]
  atx_h1_marker [0, 0] - [0, 1]
  heading_content [0, 1] - [0, 14]
paragraph [2, 0] - [3, 0]
  strong_emphasis [2, 27] - [2, 37]
list [4, 0] - [6, 0]
  list_item [4, 0] - [5, 0]
    list_marker_minus [4, 0] - [4, 2]
    paragraph [4, 2] - [5, 0]
  list_item [5, 0] - [6, 0]
    list_marker_minus [5, 0] - [5, 2]
    paragraph [5, 2] - [6, 0]
      strong_emphasis [5, 2] - [5, 12]

atx_heading といった文字列はノードの名前を、 [0, 0] - [1, 0] などはその要素のある場所を示しています[2]。「タイトル (atx_heading)」や「強調 (strong_emphasis)」、「箇条書き (list)」といった構造が取れていることが分かります。

クエリ検索

tree-sitter におけるクエリとは、構文木から所望のノードを探すための検索パターンのようなものです。たとえば markdownhighlights.scm というファイルには以下のようなクエリが定義されています。

[Example Query 1]
(strong_emphasis) @text.strong

これは「strong_emphasis という名前のノードを @text.strong でキャプチャ[3]せよ」という意味になります。先程の syntax tree の例では、 [2, 27] - [2, 37]strong_emphasis[5, 2] - [5, 12] の2箇所にマッチします。

もう少し複雑な例も見てみましょう。 highlights.scm には以下のようなクエリも定義されています。

[Example Query 2]
(atx_heading
 (heading_content) @text.title
 )

こちらは「atx_heading という名前のノードがあり、その直下に heading_content という子ノードがあるとき、heading_content@text.title という名前でキャプチャせよ」という意味になります。先程の syntax tree の例では、 [0, 0] - [1, 0] にある atx_heading[0, 1] - [0, 14]heading_content を持っているため、この1箇所にマッチします。

色付け

先程の例では1番目のクエリにマッチする箇所が2つ、2番目のクエリにマッチする箇所が1つあることが分かりました。nvim-treesitter では、キャプチャした場所に合わせて以下のような Neovim のシンタックスハイライトを追加します。

  • @text.strongTSStrong
  • @text.titleTSTitle

これによって Neovim バッファの中にある2箇所の strong_emphasis と 1 箇所の heading_content に相当する箇所に色付けが行われます。

ハイライトのカスタマイズ手順

ここからが本題の「nvim-treesitter をどのようにカスタマイズするか」の話です。いろいろ試した結果、以下のような手順を踏むとスムーズにカスタマイズできそうです。

  1. 変更対象の言語で書かれたサンプルファイルを用意する(変更したい色付けが入った内容にしておくこと)
  2. 該当箇所の構文木を見て変更したいノード周辺の構造を把握する
  3. 狙ったノードの色が変更されるようクエリを追加・変更する
  4. サンプルファイルを読み込み直し、変更後のルールが反映されているか確認する

具体例:H1 ヘッダに下線を付ける設定

ここでは比較的単純な例として、H1 ヘッダにのみ下線を付ける設定を上記手順に沿って入れてみます。

nvim-treesitter-playground のインストール

まずは nvim-treesitter の設定をいじるのに欠かせない nvim-treesitter-playground というプラグインをインストールします。

https://github.com/nvim-treesitter/playground#setup

これで以下の2つのコマンドが使えるようになります。

  • :TSPlaygroundToggle: 与えられたバッファの構文木を表示
  • :TSHighlightCapturesUnderCursor: カーソル下のシンタックスハイライトグループを表示

サンプルファイルの用意

以下のようなサンプルファイルを用意します。

sample.md
# tree-sitter-markdown の例

## リンク記法

[Vim advent calendar 2021](https://qiita.com/advent-calendar/2021/vim) をよろしく。

該当箇所の構文木から色付けを変更したいノードを決定する

サンプルファイルを開いた状態で :TSPlaygroundToggle を実行すると、今いるウィンドウに対応する構文木を見ることが出来ます。


:TSPlaygroundToggle の実行結果

# tree-sitter-markdown の例 というテキストに対して、以下のような構文木が対応していることが分かります。

  • ats_heading (# tree-sitter-markdown の例)
    • ats_h1_marker (#)
    • heading_content ( tree-sitter-markdown の例)

ちなみに、## リンク記法 というテキストに対しては以下の構文木が対応することも分かりますね。

  • ats_heading (## リンク記法)
    • ats_h2_marker (##)
    • heading_content ( リンク記法)

H1ヘッダでは ats_h1_marker が、 H2ヘッダでは ats_h2_marker が用いられています。ここがヘッダのレベルを区別する鍵となります。

クエリを追加する

上で眺めた構文木に基づいて今回やりたいことを言語化すると、「ats_h2_marker を子要素に持つ ats_heading の子要素 heading_content にのみ下線を追加したい」ということになります。これを実現するため、クエリファイルに設定を追加してみましょう。

クエリファイルに設定を追加するには :TSEditQueryUserAfter コマンドを用いて以下のように打ちます。

:TSEditQueryUserAfter highlights markdown

これでシンタックスハイライトに関するユーザ設定を記述するためのファイルが開かれます[4]

開かれたバッファに以下のようなクエリを書き、保存してみましょう。

highlights.scm
(atx_heading
 (atx_h1_marker)
 (heading_content)  @text.underline
 )

慣れるまでは意味が取りにくいと思いますが、S 式で表された構文木に対するパターンマッチングだと考えると少し馴染みやすいかもしれません。

  • 名前が ats_heading であり、子要素に atx_h1_marker ノードと heading_content ノードを持つときにマッチせよ
  • マッチした箇所の heading_content ノードを @text.underline でキャプチャせよ

という指示を表しています。 @text.underline でキャプチャされる箇所には TSUnderline というハイライトグループで色付けが行われることになっており、この設定を書くだけで「H1ヘッダのタイトルにのみ下線を引く」ことが実現できます。

変更後のハイライトルールが反映されているか確認する

元の Markdown ファイルのバッファに戻ってから :e で読み込み直すと、変更内容が反映される状態で改めて色付け処理が走ります。:TSHighlightCapturesUnderCursor コマンドはカーソル下の色付けがどのように行われているか確認するのに便利です。

TSUnderline による色付けが適切に行われていることが確認できました。見た目も期待通りですね!

最終的なクエリ設定

長い説明もそろそろ終わりにして、最終的な設定ファイルを載せます。

highlights.scm の設定

以下のクエリを highlights.scm に加えると、冒頭で示した変更のうち 1 から 4 が実現できます。

highlights.scm
; ハイパーリンクや画像を TSAttribute で色付けする
[
 (inline_link)
 (image)
] @attribute

; 引用箇所全体をコメントと同じ色にする
(block_quote) @comment

; H1 ヘッダのタイトルに下線を引く
(atx_heading
 (atx_h1_marker)
 (heading_content) @text.underline
 )

; 言語名指定のない fenced code block の中身を TSLiteral で色付け
(fenced_code_block
 .
 (code_fence_content) @text.literal)

injections.scm の設定

tree-sitter では「Markdown のコードブロックの中身を Python の文法で色付けする」といったことが可能です。実際、 :TSPlaygroundToggle で構文木を覗いてみると、 Markdown ファイルを見ているにもかかわらず Python の構文木が中に埋め込まれています。これは tree-sitter の language injection という機能を用いています。 Markdown ファイルの中ですが、 Python の色付け用のクエリを借りているわけですね。


Markdown ファイルの構文木に Python の構文木が入っている様子

「Markdown ファイルのどこで language injection を行うか」の設定を行うのが injections.scm であり、以下のコマンドを実行することで開くことが出来ます。

:TSEditQueryUserAfter injections markdown

ここに以下のクエリを追加すると、 冒頭で示した変更のうち 5 と 6 が実現できます。

injections.scm
(fenced_code_block
 (info_string) @lang
 (code_fence_content) @content
 (#vim-match? @lang "^(py|python)(:.*)?$")
 (#set! language "python")
)

info_string の文字列自体が ^(py|python)(:.*)?$ という正規表現にマッチする場合に限り Python の文法で色付けする、という設定となっています。正規表現のパターンや言語名を変更すれば Python 以外の言語にも対応可能です。

おわりに

tree-sitter のクエリをいじって自分好みのシンタックスハイライトを実現する方法について説明しました。パーサそのものをいじるわけではないため「何でも」というわけにはいきませんが、構文木ベースである程度自由度の高い設定を行うことができます。
皆さんも是非、nvim-treesitter を使ってモノクロの毎日に彩りを加えていきましょう!

参考資料

脚注
  1. バッククオート3つで囲まれた、コードを記述するためのマークアップのことを指します。 ↩︎

  2. インデックスは行・列ともに0始まりなのが少しややこしいですね。 ↩︎

  3. tree-sitter ドキュメント原文では @text.strong などに "capture" という名前があてがわれています。日本語としては「捕捉」と言ったほうがよいかもしれません。 ↩︎

  4. ファイルパスは (Neovim の config path)/after/queries/markdown/highlights.scm のようです。 ↩︎

Discussion

ログインするとコメントできます