🫣

Vimで名前が前方一致するファイルが複数あるときに勢い余って一致部分だけのファイルを開いちゃったときの対策をする

に公開

タイトルが分かりづらくてすみません。

さいきん、Nixの設定を行っているのですが:

https://zenn.dev/kawarimidoll/articles/9c44ce8b60726f

flake.nixをメインのファイルとしています。また、flake.lockというロックファイルが作られます。

.
├─ flake.lock
└─ flake.nix

さて、筆者はVimでファイルを開くとき、Zshのファイル名補完を使って「ファイルの最初の数文字を打ち込んでTAB補完して開く」ということが多いです。
この癖でvim fla<tab>すると、vim flake.までが補完され、拡張子が入力されていない状態になります。このまま勢い余って確定してしまうと、flake.という存在しないファイルが開かれてしまうわけです。
これはNixのみならず、ロックファイルが存在するプロジェクトでは発生しうると思います。

このミスを何度かやってしまい、「flake.のように存在しない名前のファイルを開いたとき、flake.nixflake.lockという名前が前方一致するファイルが存在するのなら、そのうちのいずれかを開こうとしたのではないか?と確認する機能」があればいいんじゃないかと思いました。

ということでこれに対応するautocmd関数を作成しました。
以下のコードをvimrcに追加してください。

コード全体像
function! s:check_prefix_match_files() abort
  " 現在のファイル名とディレクトリを取得
  " autocmdによって呼び出されるのでファイル名は <afile> で取得
  " まあこの関数の場合は普通に現在のバッファ名を使っても良い
  " modifierは :h filename-modifiers を参照
  let fname = expand('<afile>')
  let dname = fnamemodify(fname, ':h')

  " 当該ディレクトリ内のファイル一覧を取得
  " カレントディレクトリの場合は全てに ./ がついてしまうと煩わしいため単に * を使用
  " glob() の結果は改行区切りの文字列なので split() を使ってリストに変換
  let files_in_dir = split(glob(dname == '.' ? '*' : $'{dname}/*'), "\n")

  " ファイル名が前方一致するものを抽出
  " 検索文字列に \V をつけることで正規表現を無効化
  let possible = filter(copy(files_in_dir), $'v:val =~# "^\\V{fname}"')

  " 候補がなければ終了
  if empty(possible)
    return
  endif

  " 候補のリストを confirm() を使って表示
  " 数字キーを使って選択できるよう、 配列のインデックス+1 を名前の先頭に追加
  " confirm() はリストではなく改行区切りの文字列を要求するため join() で連結
  " 選択されなかったときのデフォルト値を 0 に設定
  let choice = confirm($'"{fname}" does not exist. Do you want to open file below?',
        \ possible->copy()->map({k, v -> $'&{k+1} {v}'})->join("\n"), 0)

  " 選択されなければ終了
  if choice == 0
    return
  endif

  " 選択されたファイルを開き、最初に開いていたバッファは削除
  " タイマーを使う理由は後述
  call timer_start(0, {->execute($'edit {possible[choice - 1]} | bwipeout! #')})
endfunction

" 存在しないファイルが開かれたときに起動
augroup check_prefix_match_files
  autocmd BufNewFile * call s:check_prefix_match_files()
augroup END

存在しないファイルを開いたとき、以下のように候補が表示されます。


vim flake.でファイルを開いたときの表示例

この場合はキーボードで2を押下するとflake.nixが開きます。<cr>するとキャンセルになります。

関数の最後で、選択されたファイルを開くためにタイマーを使っています。これは、関数の終了後、すなわちautocmdの終了後にファイルを開きたいためです。
autocmdは連鎖しないため、この関数内で普通にファイルを開くとBufReadなどのautocmdが実行されず、編集に支障をきたす可能性があります。
したがって、以下のいずれかの対応が必要です。

  1. タイマーで実行を遅延させる
  2. autocmdの呼び出しを++nestedにする

個人的には困ったらタイマーを使いまくってしまう派です。timer_startの第一引数がタイマーの待機時間ですが、これはVimがメインループに戻ってからの時間なので、「autocmdを出たら即実行」の場合は0を指定して問題ありません(参考::help timer_start())。

これでも動く
 function! s:check_prefix_match_files() abort
   " 略...

-  call timer_start(......)
+  execute 'edit' possible[choice - 1]
 endfunction

 augroup check_prefix_match_files
-  autocmd BufNewFile * call s:check_prefix_match_files()
+  autocmd BufNewFile * ++nested call s:check_prefix_match_files()
 augroup END

ちなみに、confirm()が最初の文字を使って選択する以上、候補が10個以上になるとうまく選択できません。
この設定を利用する際は前方一致のファイルが10個もあるようなディレクトリを作らないようにしてください。


2025/03追記

しばらくこの設定を使っていたのですが、lockファイルを選択するユースケースがなかったので、「lockファイルを除いて」「最も名前の短いファイル名」を質問無しで開くように変更しました。
たとえば、flake.nixflake-backup.nixflake.lockがあったらflake.nixが選択されます。長いファイル名を選びたい場合は開くときにちゃんと入力するだろうという推量です。

check_prefix_match_files()の変更点
 function! s:check_prefix_match_files() abort
   let fname = expand('<afile>')
   let dname = fnamemodify(fname, ':h')

   let files_in_dir = split(glob(dname == '.' ? '*' : $'{dname}/*'), "\n")

   " ファイル名が前方一致するものを抽出
+  " かつ、`lock`を名前に含むものは除く
-  let possible = filter(copy(files_in_dir), $'v:val =~# "^\\V{fname}"')
+  let possible = copy(files_in_dir)
+        \ ->filter($'v:val =~# "^\\V{fname}" && v:val !~# "lock"')

   " 候補がなければ終了
   if empty(possible)
     return
   endif

-  " confirm()による選択は削除
-  let choice = confirm($'"{fname}" does not exist. Do you want to open file below?',
-        \ possible->copy()->map({k, v -> $'&{k+1} {v}'})->join("\n"), 0)
-  if choice == 0
-    return
-  endif
-  call timer_start(0, {->execute($'edit {possible[choice - 1]} | bwipeout! #')})
+  " 文字数でソートし、最初(最短)のものを質問なしで開く
+  call sort(possible, {a, b -> strlen(a) - strlen(b)})
+  call timer_start(0, {->execute($'edit {possible[0]} | bwipeout! #')})
 endfunction

Discussion