2022年の nvim-treesitter の変更・新機能を振り返る
はじめに
今日はクリスマス。今年も街中のクリスマスツリーが綺麗に彩られています。「ツリー」と「彩り」といえばもちろん、Neovim のバッファを綺麗に彩る nvim-treesitter ですよね。nvim-treesitter を語るに今日ほどふさわしい日はありません。
2022年、nvim-treesitter には様々な変更が入りました。本記事では、その中でも私が「ユーザ影響が大きそうだ」もしくは「便利そうだ」と感じた仕様変更や機能追加を選んで紹介します。
nvim-treesitter とは
nvim-treesitter は Neovim 上で tree-sitter と連携し、構文解析に基づくシンタックスハイライトや折りたたみなどの様々な便利機能を実現するプラグインです。インストール方法や設定手順、具体的な機能については、公式の README や他の入門記事を参照してください。
影響の大きい変更点
ハイライトグループの紐付け方が変わった
tree-sitter では、パースして得た構文木の一部をクエリと呼ばれるパターンにマッチさせ、取り出したノードにキャプチャという属性をつけることができます。クエリを記述することで、「タイトルを表すテキストに @text.title
という属性をつける」といった取り決めが可能となるのです。以後、クエリを記述するファイルのことをクエリファイルと呼ぶことにします。
クエリファイルは、nvim-treesitter をカスタマイズする上での中核を担っているといっても過言ではありません。具体的なクエリの書き方については、去年のアドベントカレンダーの記事を参照してください。
たとえば 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*
というハイライト名は使われなくなりました。
キャプチャ名 | ハイライトグループ名 |
---|---|
@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 というカラースキームにて実際に上記のトラブルに遭遇し、対処したエピソードが別の記事で公開されています。対応予定のあるカラースキーム開発者の方は参考にすると良いでしょう。
ユーザが対応する
すでにメンテナンスされていないカラースキームなどを用いる場合はユーザ側で対応する必要があります。自身の設定に以下の設定を入れることで対応できます。
設定(長いので折り畳んでいます)
こちら から借りてきました。
やっていることとしては単純で、単に @
始まりの主要なハイライトグループを定義しているだけです。
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 内部でもハイライトグループを定義している箇所があるみたいなのですが、クエリファイルで用いられているもの全てはなぜか網羅されていません。
上のリストも完全ではない可能性があるため、必要に応じて設定を追加してください。
クエリファイルの上書き・拡張が選べるようになった
nvim-treesitter の主要な機能を使うにはクエリファイルがほぼ不可欠です。クエリファイルがなければシンタックスハイライトも行われません。しかし実際には、特にユーザ自身の手でクエリファイルを自分で書かなくとも便利な機能をほとんど使うことができます。それは主要な言語のクエリファイルが nvim-treesitter プラグイン内に既に入っているからです。実際、nvim-treesitter/queries
ディレクトリは以下のような構造となっています(*.scm
ファイル1つ1つがクエリファイルです)。
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種類あると考えられます。
-
$XDG_CONFIG_HOME/nvim/queries
- ユーザの設定を記述する場所。
-
/path/to/install-dir-of/nvim-treesitter/queries
- nvim-treesitter プラグインをインストールした場所。
- これはデフォルトのクエリファイルのため、編集しないことが推奨される。
-
$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 の良いところですね。
; コードブロックの中身をスペルチェック対象から外す
(code_fence_content) @nospell
(code_span) @nospell ; インラインのコードリテラルをスペルチェック対象から外す
(uri_autolink) @nospell ; URL をスペルチェック対象から外す
(link_destination) @nospell ; ハイパーリンクの URL をスペルチェック対象から外す
disable
フィールドに関数を指定可能になった
設定の nvim-treesitter の抱える課題の一つに「大きいサイズのファイルを開くのに時間がかかる」というものがあります。構文解析を行う以上、最初にバッファをハイライトする際にはどれだけ頑張ってもバッファのサイズに対し線形オーダーの処理時間がかかります。正規表現ベースの方法では一部分のみハイライトの処理を行うこともできますが、構文解析ではそうもいきません。とはいえ現実問題として、サイズの大きいファイルで操作がブロックされるのは避けたいところです。
この課題の解決策の一つとして、ファイルサイズが大きい場合はハイライトを無効化するよう設定できるようになりました。 具体的には、setup()
メソッドに渡すテーブルの highlight.disable
フィールドに関数を渡すことで、条件に合致するときハイライトを無効化できるようになりました。 以下は公式の README に記載されている設定例の一部です。
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 が追加されました。
nvim-treesitter の HEAD 版を使っている方は、この issue を subscribe しておくことを強くおすすめします。
おわりに
nvim-treesitter をめぐる今年の新機能や変更点をまとめました。こうしてみると、1年の間に実に様々な機能追加があったことが分かりますね。互換性が壊れて困ることもたまにあるものの、この開発の活発さ、どんどん便利になっていく感覚はやはり魅力的です。これからも nvim-treesitter の動向に目が離せません!
そして本日は Vim advent calendar 2022 の最終日でした。2022/12/25 時点で登録数は55、実際に公開された記事数も 50 以上あります。これは Qiita に登録されている他のテキストエディタのものと比較しても、圧倒的に多い数字です。衰えを知らない Vim ユーザと Vim コミュニティの勢いを裏付けていると感じます。
Vim というテキストエディタが生まれて30年ほど経ちますが、Vim は今もなお多くのユーザを魅了し、記事執筆へと駆り立てているのです。今年の残りも、そしてもちろん来年以降も、Vim を使ってアクティブに開発していきましょう!
Discussion