🎄

2022年の nvim-treesitter の変更・新機能を振り返る

2022/12/25に公開

はじめに

今日はクリスマス。今年も街中のクリスマスツリーが綺麗に彩られています。「ツリー」と「彩り」といえばもちろん、Neovim のバッファを綺麗に彩る nvim-treesitter ですよね。nvim-treesitter を語るに今日ほどふさわしい日はありません。

2022年、nvim-treesitter には様々な変更が入りました。本記事では、その中でも私が「ユーザ影響が大きそうだ」もしくは「便利そうだ」と感じた仕様変更や機能追加を選んで紹介します。

nvim-treesitter とは

nvim-treesitter は Neovim 上で tree-sitter と連携し、構文解析に基づくシンタックスハイライトや折りたたみなどの様々な便利機能を実現するプラグインです。インストール方法や設定手順、具体的な機能については、公式の README や他の入門記事を参照してください。

https://github.com/nvim-treesitter/nvim-treesitter

https://zenn.dev/duglaser/articles/c02d6a937a48df

影響の大きい変更点

ハイライトグループの紐付け方が変わった

tree-sitter では、パースして得た構文木の一部をクエリと呼ばれるパターンにマッチさせ、取り出したノードにキャプチャという属性をつけることができます。クエリを記述することで、「タイトルを表すテキストに @text.title という属性をつける」といった取り決めが可能となるのです。以後、クエリを記述するファイルのことをクエリファイルと呼ぶことにします。

クエリファイルは、nvim-treesitter をカスタマイズする上での中核を担っているといっても過言ではありません。具体的なクエリの書き方については、去年のアドベントカレンダーの記事を参照してください。

https://zenn.dev/monaqa/articles/2021-12-22-vim-nvim-treesitter-highlight

たとえば Markdown の highlights.scm というクエリファイルには以下のようなクエリが記述されています。

markdown/highlights.scm (一部のみ抜粋)
(atx_heading (inline) @text.title)

highlights.scm は主にシンタックスハイライトに用いられるクエリファイルであり、ざっくりいうと「どんな条件を満たすノードにどんな色付けを行うか」という対応関係を定めるものです。上記のクエリは、Markdown の H1〜H6 ヘッダのテキスト (inline) を @text.title という名前でキャプチャする、ということを意味しています。

では、 @text.title でキャプチャされた内容にはどんな色が付くのでしょうか。Neovim のバッファへの色付け規則を定めるのは、 :hi コマンドや vim.api.nvim_set_hl() 関数で定義される ハイライトグループ です。つまり、キャプチャ名とハイライトグループ名との紐付けが必要となるのです。

変更前

従来の nvim-treesitter では、クエリファイルのキャプチャ名に対して nvim-treesitter 専用のハイライトグループ名が紐付いていました。たとえば以下のような具合です。

キャプチャ名 ハイライトグループ名
@text TSText
@text.strong TSStrong
@parameter TSParameter
@parameter.reference TSParameterReference
@punctuation.delimiter TSPunctDelimiter

従来のハイライトグループ名は TS で始まるものとなっていますが、この変換規則には一貫性がありません。たとえば、 @tag.attribute という名前でキャプチャされたグループのハイライトを変更したいとき、何という名前のハイライトグループを変更すればいいでしょうか? TSAttribute でしょうか、TSTagAttribute でしょうか。ちゃんと調べるには Neovim 本体や nvim-treesitter のコードを追う必要がありました [1]

変更後

Neovim 自体に変更が入り、新たに @ はじまりのハイライトグループ名を許容するようになりました。それを契機にキャプチャ名にも @ はじまりのハイライトグループ名がそのまま対応するようになり、 TS* というハイライト名は使われなくなりました。

https://github.com/nvim-treesitter/nvim-treesitter/pull/3656

キャプチャ名 ハイライトグループ名
@text @text
@text.strong @text.strong
@punctuation.delimiter @punctuation.delimiter

こちらのほうが簡潔明瞭ですね。

今後どうすればよいか

変換ルールが簡潔になったのは歓迎されるべきことですが、一方でこの変更は互換性の破壊を伴ったことから、一部のユーザやカラースキーム開発者に混乱をもたらしました。従来の TS* というハイライトグループを参照しなくなったことから、一部のシンタックスハイライトが効かなくなったのです。この件に対応するには、カラースキーム開発者またはユーザのどちらかがコードを修正する必要があります。

カラースキーム開発者が対応する

手っ取り早いのは、カラースキーム側で @text.title などの名前のハイライトグループを定義してしまうことです。しかしここで大きな問題があります。@ 始まりのハイライトグループ名は新しい Neovim (>= 0.8.0) でしか対応しておらず、Vim または古い Neovim (< 0.8.0) ではエラーになってしまうのです。Vim または古い Neovim との互換性を持たせたいカラースキームでは、 has('nvim-0.8') 関数を用いて処理を分岐させる必要があります。

iceberg.vim というカラースキームにて実際に上記のトラブルに遭遇し、対処したエピソードが別の記事で公開されています。対応予定のあるカラースキーム開発者の方は参考にすると良いでしょう。

https://zenn.dev/arrow2nd/articles/324f3a00c3ca59

ユーザが対応する

すでにメンテナンスされていないカラースキームなどを用いる場合はユーザ側で対応する必要があります。自身の設定に以下の設定を入れることで対応できます。

設定(長いので折り畳んでいます)

こちら から借りてきました。
やっていることとしては単純で、単に @ 始まりの主要なハイライトグループを定義しているだけです。

init.lua
local hl = function(group, opts)
    opts.default = true
    vim.api.nvim_set_hl(0, group, opts)
end

-- Misc {{{
hl('@comment', {link = 'Comment'})
-- hl('@error', {link = 'Error'})
hl('@none', {bg = 'NONE', fg = 'NONE'})
hl('@preproc', {link = 'PreProc'})
hl('@define', {link = 'Define'})
hl('@operator', {link = 'Operator'})
-- }}}

-- Punctuation {{{
hl('@punctuation.delimiter', {link = 'Delimiter'})
hl('@punctuation.bracket', {link = 'Delimiter'})
hl('@punctuation.special', {link = 'Delimiter'})
-- }}}

-- Literals {{{
hl('@string', {link = 'String'})
hl('@string.regex', {link = 'String'})
hl('@string.escape', {link = 'SpecialChar'})
hl('@string.special', {link = 'SpecialChar'})

hl('@character', {link = 'Character'})
hl('@character.special', {link = 'SpecialChar'})

hl('@boolean', {link = 'Boolean'})
hl('@number', {link = 'Number'})
hl('@float', {link = 'Float'})
-- }}}

-- Functions {{{
hl('@function', {link = 'Function'})
hl('@function.call', {link = 'Function'})
hl('@function.builtin', {link = 'Special'})
hl('@function.macro', {link = 'Macro'})

hl('@method', {link = 'Function'})
hl('@method.call', {link = 'Function'})

hl('@constructor', {link = 'Special'})
hl('@parameter', {link = 'Identifier'})
-- }}}

-- Keywords {{{
hl('@keyword', {link = 'Keyword'})
hl('@keyword.function', {link = 'Keyword'})
hl('@keyword.operator', {link = 'Keyword'})
hl('@keyword.return', {link = 'Keyword'})

hl('@conditional', {link = 'Conditional'})
hl('@repeat', {link = 'Repeat'})
hl('@debug', {link = 'Debug'})
hl('@label', {link = 'Label'})
hl('@include', {link = 'Include'})
hl('@exception', {link = 'Exception'})
-- }}}

-- Types {{{
hl('@type', {link = 'Type'})
hl('@type.builtin', {link = 'Type'})
hl('@type.qualifier', {link = 'Type'})
hl('@type.definition', {link = 'Typedef'})

hl('@storageclass', {link = 'StorageClass'})
hl('@attribute', {link = 'PreProc'})
hl('@field', {link = 'Identifier'})
hl('@property', {link = 'Identifier'})
-- }}}

-- Identifiers {{{
hl('@variable', {link = 'Normal'})
hl('@variable.builtin', {link = 'Special'})

hl('@constant', {link = 'Constant'})
hl('@constant.builtin', {link = 'Special'})
hl('@constant.macro', {link = 'Define'})

hl('@namespace', {link = 'Include'})
hl('@symbol', {link = 'Identifier'})
-- }}}

-- Text {{{
hl('@text', {link = 'Normal'})
hl('@text.strong', {bold = true})
hl('@text.emphasis', {italic = true})
hl('@text.underline', {underline = true})
hl('@text.strike', {strikethrough = true})
hl('@text.title', {link = 'Title'})
hl('@text.literal', {link = 'String'})
hl('@text.uri', {link = 'Underlined'})
hl('@text.math', {link = 'Special'})
hl('@text.environment', {link = 'Macro'})
hl('@text.environment.name', {link = 'Type'})
hl('@text.reference', {link = 'Constant'})

hl('@text.todo', {link = 'Todo'})
hl('@text.note', {link = 'SpecialComment'})
hl('@text.warning', {link = 'WarningMsg'})
hl('@text.danger', {link = 'ErrorMsg'})
-- }}}

-- Tags {{{
hl('@tag', {link = 'Tag'})
hl('@tag.attribute', {link = 'Identifier'})
hl('@tag.delimiter', {link = 'Delimiter'})
-- }}}

実は Neovim 内部でもハイライトグループを定義している箇所があるみたいなのですが、クエリファイルで用いられているもの全てはなぜか網羅されていません。
上のリストも完全ではない可能性があるため、必要に応じて設定を追加してください。

https://github.com/neovim/neovim/blob/9b14ad5fd9e15718aa938f7a426dddcc2edab4e3/src/nvim/highlight_group.c#L213-L277

クエリファイルの上書き・拡張が選べるようになった

nvim-treesitter の主要な機能を使うにはクエリファイルがほぼ不可欠です。クエリファイルがなければシンタックスハイライトも行われません。しかし実際には、特にユーザ自身の手でクエリファイルを自分で書かなくとも便利な機能をほとんど使うことができます。それは主要な言語のクエリファイルが nvim-treesitter プラグイン内に既に入っているからです。実際、nvim-treesitter/queries ディレクトリは以下のような構造となっています(*.scm ファイル1つ1つがクエリファイルです)。

https://github.com/nvim-treesitter/nvim-treesitter/tree/master/queries

nvim-treesitter/
 │ queries/
 │  │ json/
 │  │  │ folds.scm        // 折りたたみ
 │  │  │ highlights.scm   // シンタックスハイライト
 │  │  │ indents.scm      // インデント
 │  │  └ locals.scm       // キーワード定義ジャンプ機能?に用いられるらしい
 │  │ rust/
 │  │  │ folds.scm
 │  │  │ highlights.scm
 │  │  │ indents.scm
 │  │  │ injections.scm   // 他の構文木の注入(正規表現リテラルに tree-sitter-regex をあてるなど)
 │  │  └ locals.scm
 └  └  ...                // 上には2言語の例しかないが、本当はこれが言語ごとにいっぱいある

しかし、そうは言ってもクエリファイルは nvim-treesitter のカスタマイズの中核。好きなクエリを追加・削除したくなることもあります。実は、Neovim ではクエリファイルを新たに自分で作成し、読み込ませることもできるのです。

では、たとえば rust/highlights.scm に相当するファイルがデフォルトとユーザ定義の2種類存在していたら、どちらが優先的に読み込まれるのでしょうか?片方は無視されるのでしょうか、それとも両方とも適用されるのでしょうか。デフォルトのクエリを無視させるにはどうすればよいのでしょうか。

変更前

これまでは、複数クエリファイルがあったとしても全て順に読み込まれ、マージされるようになっていました。デフォルトの設定を手軽に拡張できる一方、nvim-treesitter に存在するデフォルトのクエリを無効化することはできませんでした [2]

変更後

今年から仕様が変わり、以下のようになりました。

  • runtimepath ディレクトリを順に走査し、最初に発見した queries ファイルを読み込む。
  • 2番目以降に発見した queries ファイルは基本的に無視する。
  • ただし、クエリファイルの冒頭に ;; extends と書かれたクエリファイルだけは読み込み、それまでに読み込まれているものと連結する。

今後どうすればよいか

現在、無難なクエリファイルの置き場所は3種類あると考えられます。

  1. $XDG_CONFIG_HOME/nvim/queries
    • ユーザの設定を記述する場所。
  2. /path/to/install-dir-of/nvim-treesitter/queries
    • nvim-treesitter プラグインをインストールした場所。
    • これはデフォルトのクエリファイルのため、編集しないことが推奨される。
  3. $XDG_CONFIG_HOME/nvim/after/queries
    • ユーザの設定を記述する場所。 after/ が付いているため、最後の方に読み込まれる。

重要なのは、これらのディレクトリがどの順番で読み込まれるか、つまり 'runtimepath' がどの順番になっているか、です。特殊な設定 [3] を行わない限りは 1, 2, 3 の順番になると思われるため、以下その前提で説明します。

  • デフォルトのクエリファイルを維持したまま拡張したいときは、 $XDG_CONFIG_HOME/nvim/after/queries 以下にクエリファイルを作成し、ファイル冒頭に ;; extends をつける。
    • $XDG_CONFIG_HOME/nvim/after/queries にあるクエリファイルはデフォルトのそれより後に読み込まれるため、デフォルトのクエリファイルをベースに拡張を行うことができます。
    • :TSEditQueryUserAfter コマンドを使って作成すれば、基本的にデフォルトでこのパスに作られるはずです。
    • ;; extends を付けなければユーザ設定は無視されます。注意しましょう。
  • デフォルトのクエリファイルを無効化し、自分が書いたクエリだけ読み込みたいときは、$XDG_CONFIG_HOME/nvim/queries 以下にクエリファイルを作成する。
    • $XDG_CONFIG_HOME/nvim/queries にあるクエリファイルはデフォルトのそれより前に読み込まれるため、まずユーザ定義ファイルが読み込まれ、デフォルトのクエリファイルは無視されます。
    • 今の所、コマンドからこのパスにクエリファイルを作る方法はなさそうです。

クエリファイルの置き場所は上で示した場所以外でも問題ないはずですが、'runtimepath' の読み込み順番には気をつけましょう。

嬉しい新機能

スペルチェック範囲を @spell / @nospell で制御可能になった

英語の文書を記述するとき、誤ったスペルをハイライトしてくれるスペルチェック機能は非常に便利です。一方、スペルチェックは往々にして「スペルチェックの対象外としたいところにもチェックが入ってしまい、バッファが朱だらけになる」という問題を抱えがちです。それを避ける方法として、特定の構文要素に対してのみスペルチェックを行うことが考えられます。たとえば

  • 通常のプログラミング言語では、コメントの中だけをスペルチェックの対象とする
  • Markdown で書かれた技術文書などでは、コードブロックの中身をスペルチェックの対象から外す

といった要領でスペルチェックの適用範囲を制御すれば、意図しない場所での朱はかなり避けられるようになるでしょう。

nvim-treesitter を使うことで、このような器用なスペルチェックが簡単に実現できるようになりました。@spell / @nospell というキャプチャを指定することで、スペルチェックの有無を制御できるようになったのです。

キャプチャ名 効果
@spell キャプチャされたノードはスペルチェックの対象となる
@nospell キャプチャされたノードはスペルチェックの対象から外れる

nvim-treesitter のデフォルトクエリ (highlights.scm) には既に @spell などのキャプチャが入っているため、tree-sitter 対象のバッファ上で 'spell' オプションを有効化しさえすれば、その恩恵を受けることができるはずです。

とはいえ、細かいカスタマイズを行いたければ自分でクエリを設定するのがおすすめです。たとえば私は以下のように設定することで、マークダウンの中のコードブロックをスペルチェックの対象から除外しています。どこに @spell / @nospell を入れるかは個人の好みによって変わってくるため、このような設定が自由にできるのは nvim-treesitter の良いところですね。

markdown/highlights.scm
; コードブロックの中身をスペルチェック対象から外す
(code_fence_content) @nospell
markdown_inline/highlights.scm
(code_span) @nospell         ; インラインのコードリテラルをスペルチェック対象から外す
(uri_autolink) @nospell      ; URL をスペルチェック対象から外す
(link_destination) @nospell  ; ハイパーリンクの URL をスペルチェック対象から外す

設定の disable フィールドに関数を指定可能になった

nvim-treesitter の抱える課題の一つに「大きいサイズのファイルを開くのに時間がかかる」というものがあります。構文解析を行う以上、最初にバッファをハイライトする際にはどれだけ頑張ってもバッファのサイズに対し線形オーダーの処理時間がかかります。正規表現ベースの方法では一部分のみハイライトの処理を行うこともできますが、構文解析ではそうもいきません。とはいえ現実問題として、サイズの大きいファイルで操作がブロックされるのは避けたいところです。

この課題の解決策の一つとして、ファイルサイズが大きい場合はハイライトを無効化するよう設定できるようになりました。 具体的には、setup() メソッドに渡すテーブルの highlight.disable フィールドに関数を渡すことで、条件に合致するときハイライトを無効化できるようになりました。 以下は公式の README に記載されている設定例の一部です。

init.lua
require'nvim-treesitter.configs'.setup {
  highlight = {
    enable = true,
    disable = function(lang, buf)
        -- 100 KB 以下のファイルでは tree-sitter によるシンタックスハイライトを行わない
        local max_filesize = 100 * 1024
        local ok, stats = pcall(vim.loop.fs_stat, vim.api.nvim_buf_get_name(buf))
        if ok and stats and stats.size > max_filesize then
            return true
        end
    end,
  },
}

Breaking Changes の通知用 Issue が追加された

2022年12月現在、tree-sitter と nvim-treesitter は未だ experimental な機能という位置づけです。そのためか、規模の小さなものから大きなものまで、破壊的な変更が結構な頻度で発生します。
しかし、なんの通知もなく互換性が破壊されては困るという人も多いでしょう。実は今年のはじめに、nvim-treesitter リポジトリに互換性を破壊するような変更を通知する issue が追加されました。

https://github.com/nvim-treesitter/nvim-treesitter/issues/2293

nvim-treesitter の HEAD 版を使っている方は、この issue を subscribe しておくことを強くおすすめします。

おわりに

nvim-treesitter をめぐる今年の新機能や変更点をまとめました。こうしてみると、1年の間に実に様々な機能追加があったことが分かりますね。互換性が壊れて困ることもたまにあるものの、この開発の活発さ、どんどん便利になっていく感覚はやはり魅力的です。これからも nvim-treesitter の動向に目が離せません!

そして本日は Vim advent calendar 2022 の最終日でした。2022/12/25 時点で登録数は55、実際に公開された記事数も 50 以上あります。これは Qiita に登録されている他のテキストエディタのものと比較しても、圧倒的に多い数字です。衰えを知らない Vim ユーザと Vim コミュニティの勢いを裏付けていると感じます。

https://qiita.com/advent-calendar/2022/vim

Vim というテキストエディタが生まれて30年ほど経ちますが、Vim は今もなお多くのユーザを魅了し、記事執筆へと駆り立てているのです。今年の残りも、そしてもちろん来年以降も、Vim を使ってアクティブに開発していきましょう!

脚注
  1. 当時の記憶では、ヘルプにも特に記載がなかったような…気がします。違ったらすみません。過去の話なので許してください。 ↩︎

  2. 実は特殊な設定を記述することで無効化することもできたのですが、いずれにせよ設定ファイル (init.lua) に少し面倒な記述を入れる必要がありました。 ↩︎

  3. プラグインの遅延読み込みや 'runtimepath' のカスタマイズなど。 ↩︎

Discussion