Neovimビルトインのインデントの挙動とLSP経由でフォーマットしたときの挙動の違いがずっと気持ち悪かったので、対策を練った。


はじめに

vim(neovim)にはデフォルトでファイルのインデントを行う機能が付いている。 vim始めたての頃「gg=Gでファイルをインデントしようねー」と教わった方も多いかもしれない。

最近では、MicrosoftがLSPを作ってくれたり、NeovimでTreesitterがサポートされたりしたおかげで、 vimでの開発体験もIDE並に向上して、標準のインデント機能のみでコードを書くvimmerは少なくなったのではないかなと感じる。

それでもビルトインのインデント機能を完全に意識しないという瞬間は少なく、たとえば oで挿入モードに入るとき、適当な位置にカーソルを持っていってくれるのはこのビルトインのインデント機能のおかげなのだが、 近頃この機能によるインデント結果と、LSPでフォーマットをかけた時の結果の違いが気になるようになってきた。

例えば以下のコードのハイライトされた行でoを入力すると、嬉しくない位置にカーソルが移動してしまったり。

sample.js
...
  switch (key) {
    case value:
      foo();
    ▏← この位置にカーソルが来る
      break;
  
    default:
      bar();
      break;
  }
...

ファイル保存時に自動でフォーマットかければええやんというツッコミが飛んできそうだが、 そもそも自分がわざわざNeovimを好んで使っている理由の1つとして、ソフトウェアに余計なお節介を焼かれる(自分の把握していないところで勝手に何かされる)のが気持ち悪くて毎回マニュアル保存しているというのもあるので、それは嫌だった。

vimのインデントの仕組みもあまり良くわかっていなかったので、その調査も兼ねて自分が行った対応をログとして残していこうと思う。

前提

vimビルトインのインデントは、LSPと違ってプロジェクト固有のインデント方法を記述したファイル1を読み込んでインデント幅を変える、みたいな器用なことはできない。

そのため、ここでは何もオプションを与えない場合のLSPのインデントの挙動と、vimビルトインのインデントの挙動をできるかぎり揃えることを目的にする。

もし、グローバル設定と異なるインデント幅を用いる場合はプロジェクトルートないし(プロジェクトに.vimrcを含められない場合)その前段にローカルなvimrcを2用意すれば対応可能だ。

この記事ではLSPクライアントとしてcoc.nvimの利用を想定している。

対応

不要なプラグイン削除

副作用をなくすため、インデントに影響を与えるプラグインを必要最低限にして、LSPとTreesitterにインデント機能を集約する。

自分はtwigファイルのハイライトのためにvim-polyglotといういろんな言語のハイライト・インデントをまとめたプラグインを入れていたが、Treesitterがtwigに対応していたので、削除することにした。

yioneko/nvim-yati導入

いつもぶっ壊れているTreesitterのインデントをなんとかしてくれるやつ。

.vimrc設定

.vimrc
set expandtab " タブ入力を複数の空白入力に置き換える
set tabstop=2 " 画面上でタブ文字が占める幅(この値のみ変更)
set cindent
set shiftwidth=0 " smartindentで増減する幅(0の場合tabstopに従う)
set softtabstop=-1 " 連続した空白に対してタブキーやバックスペースキーでカーソルが動く幅(負の場合shiftwidthに従う)

augroup indent
    autocmd!
    au FileType go set noexpandtab tabstop=2
    au FileType python set tabstop=4
augroup END

ビルトインのインデント周りの設定を行う。ポイントはshiftwidth=0softtabstop=-1の部分。

こうしておくと、各言語の設定が

  • インデント幅(tabstop)
  • ソフト/ハードタブ(expandtab/noexpandtab)

だけで済むようになる。

ここまでで殆どのインデントの問題が解消されるが、一部素直に言うことを聞いてくれないやつがいる。

たとえば、上記.vimrcで10行目を以下のように書いても、Pythonのインデント幅は2にならない。

.vimrc
    au FileType python set tabstop=2

これはデフォルトで以下のランタイムが動くことに起因する。

/usr/share/nvim/runtime/ftplugin/markdown.vim
...
  setlocal expandtab shiftwidth=4 softtabstop=4 tabstop=8
...

マニュアル3通り.vimrcに以下のような記載をすることでインデント幅の変更が反映される。

.vimrc
let g:python_recommended_style = 0

これ以外にも/usr/share/nvim/runtime配下を調べた限り、以下の言語はランタイムが意図しないインデントの挙動の原因になりそうだった。

  • ruby
  • meson
  • rust
  • yaml
  • markdown

必要に応じてlet g:{言語名}_recommended_style=0してやる必要がありそう。

C/C++のswitch

cのswitchはcaseをインデントするかしないかで流儀が分かれる。

indent.c
...
  switch (foo) {
    case 1:
      bar();
      break;
    default:
      baz();
  }
...
no_indent.c
...
  switch (foo) {
  case 1:
    bar();
    break;
  default:
    baz();
  }
...

ビルトインのインデントは前者、clangdのオプションなし/設定ファイルなし時のデフォルトの挙動は後者だった。

これを前者に統一しようと思い、.clang-formatが存在しない時のデフォルトオプションを指定する設定をcoc-settings.jsonに追記したが動作しなかった。

coc-setting.json
...
  "clangd.arguments": ["--fallback-style='{ IndentCaseLabels: true }'"],
...

ログを見てもオプションはきちんと渡っているようだったので、疑問に思い調べてみると、 どうやらこれはclangd本体のバグ4らしく、仕方なく後者のcaseでインデントしない挙動に統一することにした。

.vimrcに以下を追記

.vimrc
set cinoptions+=:0 "switch内のcaseのインデント幅

cinoptionsの細かい設定の仕方は:h cinoptions-valuesで確認できる。

また、nvim-yatiが上記設定を上書いてしまうので、ccppを無視する設定を追記する。

.vimrc
require'nvim-treesitter.configs'.setup {
  ...
  yati = {
    enable = true,
    disable = { "c","cpp","python" },
    default_lazy = true,
    default_fallback = "auto"
  },
  indent = {
    enable = false,
  },
  ...
}

おわりに

ひとまずこれでインデントの不一致は気にならないレベルになった。

それでもまだ改善の余地はありそうなので、新しい発見があり次第追記していきます。

参考文献


  1. Cなら.clangd-formatみたいな ↩︎

  2. 自分はhttps://github.com/embear/vim-localvimrcというプラグインを利用している。 ↩︎

  3. :h ft-python-plugin ↩︎

  4. https://github.com/clangd/clangd/issues/362 ↩︎