🧹

Vimから外部コマンドを呼び出してバッファをフォーマットする

2024/08/07に公開

この記事はVim駅伝の2024-08-07の記事です。
前回の記事はPeacockさんのVolar (Vueの言語サーバー)をnvim-lspconfigとNixで使うための設定です。
次回の記事はArc CosineさんのChromeOSでNeovim環境を構築するです。


Vimは標準でフィルタ機能(:h filter)を備えています。標準入出力を扱うシェルコマンドを利用して現在のバッファを書き換える機能です。
たとえば、deno fmtを使うと、:%!deno fmt --ext=json -でjsonファイルをフォーマットすることができます。

複雑な設定が要らず便利なのですが、これの問題点は、フォーマッタが(構文不備などで)エラーを出力した場合、現在のバッファがエラーメッセージで書き換えられてしまうことです。

ということで、エラー対応を加えた関数を作成して使っています。

コード

ほとんどmattn/vim-sqlfmtを参考にしました。

https://github.com/mattn/vim-sqlfmt

fmt.vim
function! fmt#run(cmd) abort
  " フォーマット範囲取得 Visual modeでは`<cmd>`で呼び出される想定
  let [firstline, lastline] = [1, '$']
  if mode() == 'v'
    let [firstline, lastline] = [line('.'), line('v')]
    if firstline > lastline
      let [firstline, lastline] = [lastline, firstline]
    endif
  endif

  " バッファの内容をstdinから渡してフォーマット実行
  let buf = join(getline(firstline, lastline), "\n")
  let lines = systemlist(a:cmd, iconv(buf, &encoding, 'utf-8'))

  " エラーが発生したら表示 バッファは変更せず終了
  if v:shell_error != 0
    echohl WarningMsg
    for line in lines
      " ターミナル用のカラーコードは除去
      echomsg substitute(line, '\e[[0-9]\{-1,}m', '', 'g')
    endfor
    echohl None
    return
  endif

  " バッファの書き換え・カーソル位置の復帰
  defer setpos('.', getcurpos())
  call append(lastline, lines)
  silent! execute $'{firstline},{lastline}delete _'
endfunction

上記のコードをfmt.vimとして保存し、runtimepathに登録すると使えるようになります。
call fmt#run('deno fmt --ext=json -')のように呼ぶとバッファ全体をフォーマットします。

使い方

筆者の設定はこんな感じです。関数の呼び出しを<space>pにマッピングしています。
autocmdでファイルタイプ別のフォーマットコマンドを設定し、コマンドの設定のないファイルでは=を使ってインデントの調整を行っています。

augroup my_fmt
  autocmd Filetype typescript,typescriptreact,javascript,javascriptreact,markdown,json,jsonc
          \  let b:fmt_cmd = $'deno fmt --ext {expand("%:e")} -'
  autocmd Filetype nix let b:fmt_cmd = 'alejandra -q -'
  " ファイルタイプごとの設定が続く…
augroup END

nnoremap <expr> <space>p exists('b:fmt_cmd')
      \ ? '<cmd>call fmt#run(b:fmt_cmd)<cr>'
      \ : 'mzgg=G`z'
xnoremap <expr> <space>p exists('b:fmt_cmd')
      \ ? '<cmd>call fmt#run(b:fmt_cmd)<cr>'
      \ : '=gv'

範囲フォーマットの注意点

範囲指定はマッピング経由で呼ぶことを想定しているので、'<,'>callで使うことはできません。
また、フィルターコマンドからは選択した部分しか見えないので、その範囲だけで構文エラーのない状態になっている必要があります。

関連

deferでカーソル位置を保存しておく手法は以下の記事でも紹介しました。

https://zenn.dev/vim_jp/articles/669aa445a3f20b

<cmd>マッピングでrangeを取得する手法は以下の記事でも紹介しました。当時書いたものとは少し変わっていますが。

https://zenn.dev/kawarimidoll/articles/014973ba5b2169

Discussion