Vim scriptでファイル探索

こんにちは、成田です。
どうもネタ切れで何もアイデアが思い浮かばないので、今でも愛用しているVimの記事でも書いてみます。
そうですねぇ、VimScriptでファイル探索をするTipsをまとめることにしましょう。
かなりシンプルな内容なのでVim (というよりVimScript) 初心者向けです。

親ディレクトリを辿ってファイルを見つける

Vimで開いたファイルは、その開いたファイルの位置がVimのカレントディレクトリとして記録されます。vim-rooterというVimプラグインではプロジェクトのルート位置にカレントディレクトリを移動するために.git/などが存在する位置を探索してプロジェクトのルートに移動しています。

vim-rooterと同じように.gitディレクトリが存在する位置を探索するにはどうしたらいいでしょう。
やり方は色々あると思いますが、僕なりに実装すると次のような手順になります。

  1. 現在開いているファイルからカレントディレクトリを取得
  2. カレントディレクトリ内で.git/が存在するか検索
  3. .gitが存在しなかったら上の階層へカレントディレクトリを移す
  4. 2と3を繰り返す
  5. 存在しなかったら開いているファイルが存在するディレクトリに戻しておく

これを率直に実装すると次のようになる。

let s:current_dir   = expand("%:p:h")
let s:file_patterns = [".git"]

" ファイルを指定せずにvimを起動したら処理を抜ける
if @% == ""
  finish
endif

" 探索したいファイル名群とマッチするかどうか判定
function s:IsMatchPattern(file)
  for pattern in s:file_patterns
    return a:file == pattern
  endfor
endfunction

" ファイルの探索処理
function s:FindFile(dir)
  let dir = (a:dir)[1:]
  let normalfiles = split(glob(dir . "/*"), "\n")
  let dotfiles    = split(glob(dir . "/\.*"), "\n") 
  for file in normalfiles + dotfiles
    let filename = fnamemodify(file, ":t")
    if s:IsMatchPattern(filename)
      return file
    endif
  endfor
endfunction


let s:count = 0
function s:Main()
  while s:count < len(split(s:current_dir, "\/"))
    let file = s:FindFile(execute("pwd"))
    if !empty(file)
      break
    else
      execute "lcd ../" 
      let s:count += 1
    endif
  endwhile

  if empty(file)
    cd %:h
  endif
endfunction

call s:Main()

このスクリプトを.vimrcに書き足すか、別ファイルに切り出してsourceで読み込んであげると動きます。
試しに何かgitリポジトリの奥深くのファイルを開いてみて、Vim起動中に:pwdと打つと.gitが存在するリポジトリのルートに移動していることが確認できるはずです。
※ vim8での動作は確認しています

スクリプト上部の次の部分はファイルを指定せずにvimを起動した場合は処理を抜ける部分です。
単に vim だけ起動した場合はここを通ります。

if @% == ""
  finish
endif

次にスクリプト下部のMain関数の実装です。
開いたファイルのカレントディレクトのパスを/で分割しリストに変換しています。
このリストの数だけ繰り返しでs:FindFile関数の実行で探索し、対象のディレクトリ内に.gitが見つからなかったらカレントディレクトリを1階層上に移り再度探索を開始します。
何も見つからなかった場合は、開いていたファイルのカレントディレクトリへ移動して終了します。

ディレクトリを移動する際はcdまたはlcdコマンドが使えます。
cdの場合、vsplitなどでウィンドウ分割しているウィンドウ全てのカレントディレクトリが変更されます。
lcdの場合は実行したウィンドウのみのカレントディレクトリが変更されます。

let s:count = 0
function s:Main()
  while s:count < len(split(s:current_dir, "\/"))
    let file = s:FindFile(execute("pwd"))
    if !empty(file)
      break
    else
      execute "lcd ../" 
      let s:count += 1
    endif
  endwhile

  if empty(file)
    cd %:h
  endif
endfunction

call s:Main()

次にファイルの探索処理の実装を見ていきます。

" ファイルの探索処理
function s:FindFile(dir)
  let dir = (a:dir)[1:]
  let normalfiles = split(glob(dir . "/*"), "\n")
  let dotfiles    = split(glob(dir . "/\.*"), "\n") 
  for file in normalfiles + dotfiles
    let filename = fnamemodify(file, ":t")
    if s:IsMatchPattern(filename)
      return file
    endif
  endfor
endfunction

引数にはMain関数内で渡していたpwdコマンドによりカレントディレクトリのパスが入ってきます。
pwdコマンドはカレントディレクトリの先頭に改行コード(\n)を含んでいるため、次のように文字列の先頭以降を取得しています。

  let dir = (a:dir)[1:]

そして、カレントディレクトリ内の全てのファイルをリストで連結する処理が次のようになります。

  let normalfiles = split(glob(dir . "/*"), "\n")
  let dotfiles    = split(glob(dir . "/\.*"), "\n") 

通常のファイルと隠しファイルをglob関数で展開したものをリストに分割して取得しています。
この2つのリスト変数を連結し最後はfor文によりパターンにマッチするファイルを見つけ出しています。
このs:FindFile関数内に存在する次の部分のfnamemodify関数は第一引数に与えたファイル名を第二引数に与えたファイル修飾子に従ってファイル名を修飾する関数です。
フルパスからファイル名だけを抽出する際は次のようになります。

  let filename = fnamemodify(file, ":t")

最後は's:IsMatchPattern'関数で引数に渡ってくるファイル名と探しているファイルの比較の処理で終わりです。

ファイル/パス情報の取得

fnamemodifyに関しては次の結果を見るとわかりやすいと思います。

let filename = "/Users/naritatakuya/workspace/vim/target.txt"
echo fnamemodify(filename, ":t")
" => target.txt
echo fnamemodify(filename, ":t:r")
" => target
echo fnamemodify(filename, ":e")
" => txt
echo fnamemodify(filename, ":p")
" => /Users/naritatakuya/workspace/vim/target.txt

cd ..
echo fnamemodify("target.txt", ":p")
" => /Users/naritatakuya/workspace/target.txt

最後の行の出力結果はカレントディレクトリを変更した場所に第一引数のファイル名が連結された形になっているので、実在しないファイルになっています。

また、スクリプト中に登場したexpand関数も似た組み込み関数ですが、こちらは現在開いているファイルを対象にパス情報を取得します。

echo expand("%:p")
" => /Users/naritatakuya/workspace/vim/target.txt

echo expand("%:p:h")
" => /Users/naritatakuya/workspace/vim

echo expand("%:p:t")
" => target.txt

echo expand("%:e")
" => txt

cd ..
echo expand("%:p")
/Users/naritatakuya/workspace/vim/target.txt

fnamemodifyと違って開いているファイルが対象なため、おそらく実在しないパスは取得されないはずです。
fnamemodifyはカレントディレクトリとは違う場所に存在するパスを操作する際などに使用できそうです。

Vim script中に出てくるパス関連の修飾子は次のhelpを参考にしてください。

:h filename-modifiers

ということでシンプルな内容でした。
最後まで閲読いただきありがとうございます。