🥸

50行くらいでvim-qfedit的なquickfix編集機能を作る

2024/05/24に公開

この記事はVim駅伝の2024-05-24の記事です。
前回の記事はkuuoteさんのHとLをいい感じにするマッピングをvimrc読書会で見つけたので魔改造してみたです。
次回の記事はuga-rosaさんのnvim-treesitter は Python のインデントを壊すです。


vim-qfeditというプラグインがあります。
Vimのquickfixリストを編集できるようにするプラグインです。

https://github.com/itchyny/vim-qfedit

quickfixの勉強を兼ねて、自分なりに再実装してみました。
途中からコード短縮も楽しくなってきたのでなるべく短くしました。

qed.vim
let s:qed_setlocal = {-> execute('setlocal modifiable nomodified noswapfile')}
let s:is_loc = {-> get(get(getwininfo(win_getid()), 0, {}), 'loclist', 0)}
let s:setlist = {items -> s:is_loc() ? setloclist(0, items) : setqflist(items)}
let s:delpat = {str, pattern -> substitute(str, pattern, '', '')}
let s:split_two = {str, pattern -> (split(str, pattern) + ['', ''])[:1]}
let s:extract = {str, pattern -> matchlist(str, $'^\({pattern}\v)\s*(.*)')[1:2] ?? ['', str]}

function s:parseline(line) abort
  let [mid, text] = s:extract(a:line->trim('', 1), '^[^|]*|[^|]*|')
  let [filename, mid] = split(mid, '\s*|\s*', v:true)[:1]
  let bufnr = empty(filename) ? 0 : bufnr(filename)
  if bufnr < 1
    unlet bufnr
  endif
  let [lnum_part, mid] = s:extract(mid, '\v\d+%(-\d+)?')
  let [lnum, end_lnum] = s:split_two(lnum_part, '-')
  let [col_part, mid] = s:extract(mid, 'col\s*\v\d+%(-\d+)?')
  let [col, end_col] = s:split_two(s:delpat(col_part, '^col\s*'), '-')
  let [type, nr] = s:split_two(mid, '\s\+')
  unlet mid lnum_part col_part
  return l:
endfunction

function s:on_change() abort
  defer setpos('.', getcurpos())
  let items = []
  for item in getline(1, '$')
    try
      call add(items, s:parseline(item))
    catch
      " skip add() if s:parseline() throws error
    endtry
  endfor
  call s:setlist(items)
  call s:qed_setlocal()
endfunction

function qed#start() abort
  call s:qed_setlocal()
  augroup qed_change
    autocmd! TextChanged <buffer> keepjump call s:on_change()
  augroup END
endfunction

上記のコードをqed.vimとして保存し、runtimepathに登録すると使えるようになります。

こんな感じでautocmdを使ってquickfixウィンドウに入ったときにqed#start()を呼んで実行します。

autocmd BufReadPost quickfix call qed#start()

これで、quickfixリストの項目を削除したり順序を入れ替えたりできるようになります。

コード解説

50行くらいしかないので全部解説します。

構造

このプラグインは以下のように動作します。

  1. quickfixウィンドウに入ると(前述のautocmdで)qed#start()が呼ばれる
  2. 変更に反応してs:on_change()が呼ばれる
  3. 各行がs:parseline()でパースされる
  4. パースされたリストが反映される

ヘルパー関数群

冒頭に定義しているヘルパー関数を紹介します。

quickfixを編集できるよう、オプションを設定する関数↓

let s:qed_setlocal = {-> execute('setlocal modifiable nomodified noswapfile')}

現在開いているのがquickfixリストかlocationリストかを返す関数↓

let s:is_loc = {-> get(get(getwininfo(win_getid()), 0, {}), 'loclist', 0)}

itemをquickfixリスト(またはlocationリスト)へ反映する関数↓

let s:setlist = {items -> s:is_loc() ? setloclist(0, items) : setqflist(items)}

文字列から指定されたパターンを削除する関数(substituteのラッパー)↓

let s:delpat = {str, pattern -> substitute(str, pattern, '', '')}

" example:

echo s:extract('12men', '\d\+')
" 'men'

文字列を指定されたパターンで分割する関数↓

let s:split_two = {str, pattern -> (split(str, pattern) + ['', ''])[:1]}

" example:

echo s:split_two('1-1', '-')
" ['1', '1']

echo s:split_two('1', '-')
" ['1', '']

echo s:split_two('', '-')
" ['', '']

文字列から指定されたパターンにマッチする部分としない部分に分割する関数↓

let s:extract = {str, pattern -> matchlist(str, $'^\({pattern}\v)\s*(.*)')[1:2] ?? ['', str]}

" example:

echo s:extract('12men', '\d\+')
" ['12', 'men']

エントリーポイント

quickfixに入ったときにautocmd経由で実行されるのが以下のqed#start()です。

この関数の内部でローカルオプションを設定し、編集を可能にします。
また、バッファローカルなautocmdで、変更時にs:on_change()を呼び出すよう設定します。

function qed#start() abort
  call s:qed_setlocal()
  augroup qed_change
    autocmd! TextChanged <buffer> keepjump call s:on_change()
  augroup END
endfunction

on_change関数

quickfixのバッファを編集するとこの関数が呼ばれ、各行についてs:parseline()を実行します。
その後、そのリストをquickfixに反映し、再度ローカルオプションを更新します。

function s:on_change() abort
  defer setpos('.', getcurpos())
  let items = []
  for item in getline(1, '$')
    try
      call add(items, s:parseline(item))
    catch
      " skip add() if s:parseline() throws error
    endtry
  endfor
  call s:setlist(items)
  call s:qed_setlocal()
endfunction

なお、パースがエラーになった(編集したことで書式が崩れた)場合、その行はスキップするようにしています。

パース関数

quickfixの行は以下のような形式になっています。

filename |line-endline col-endcol error num| contents

これを直接efmに設定できれば楽だったのですが、どの項目もオプショナルだったので、簡単に設定することはできなさそうでした。
仕方なくひとつひとつパースすることにしたのですが、前述のヘルパーのおかげで少しは短縮できたかなと思います。

function s:parseline(line) abort
  let [mid, text] = s:extract(a:line->trim('', 1), '^[^|]*|[^|]*|')
  let [filename, mid] = split(mid, '\s*|\s*', v:true)[:1]
  let bufnr = empty(filename) ? 0 : bufnr(filename)
  if bufnr < 1
    unlet bufnr
  endif
  let [lnum_part, mid] = s:extract(mid, '\v\d+%(-\d+)?')
  let [lnum, end_lnum] = s:split_two(lnum_part, '-')
  let [col_part, mid] = s:extract(mid, 'col\s*\v\d+%(-\d+)?')
  let [col, end_col] = s:split_two(s:delpat(col_part, '^col\s*'), '-')
  let [type, nr] = s:split_two(mid, '\s\+')
  unlet mid lnum_part col_part
  return l:
endfunction

limitation

s:parseline()はデフォルトのquickfixのフォーマットにあわせています。
通常はこれで問題ないと思われますが、'quickfixtextfunc'によって独自フォーマットが適用されている場合、パースが失敗します。

この値はオプション以外にもsetqflist()で設定可能であり、さらに関数なのでどのようなフォーマットが適用されているかを判断するのは無理そうでした。このため、デフォルト以外のフォーマットに対応するのは諦めています。

ちなみにこのパース失敗は内部エラーを引き起こし、Vimが操作不能になったのでissueを作りました。

https://github.com/vim/vim/issues/13905

Discussion