:terminal から親の Vim でファイルを開く(bash/zsh編)
※今回の話は Vim 限定のお話です。Neovim では使えませんがご容赦ください。
やること
:terminal
上の bash や zsh などから親の Vim でファイルを開く drop
コマンドの紹介をします。
$ drop ./a-text-file # ./a-text-file を開く
$ drop ~/path/to/file # ~/path/to/file を開く
$ drop /absolute/path/to/file # /absolute/path/to/file を開く
このような使い方のできるコマンドになります。
実装で使う部品のお話
drop
コマンドを作るにあたって使ったものについて、幾らか簡単に説明をしておきます。
Terminal API
Vim では、:terminal
で開いたシェルから特殊なエスケープシーケンスを使用して、親の Vim に対して JSON メッセージを送って特定のコマンドを実行してもらう Terminal API という機能があります。メッセージの書式は <ESC>]51;JSON メッセージ<07>
という形式です。なお、この JSON メッセージ本体は常にリストになるので、より具体的に書くなら <ESC>]51;[適当なコマンド]<07>
のようになります。
ヘルプは :h terminal-api
から見ることができます。
call コマンド
この Terminal API で使えるコマンドの一つとして、特定の Vim script の関数を引数付きで呼び出すことのできる call
コマンドというものがあります。JSON メッセージの書式は ["call", "関数名", [引数リスト]]
のようになります。また、呼び出される関数の引数は、第一引数が :terminal
のバッファ番号で、第二引数が JSON メッセージで指定された引数のリストになります。なお、少し注意として、この call
コマンドが呼び出すことのできる関数は名前が Tapi_
から始まるグローバル関数に制限されます。
こちらのヘルプも Terminal API と同じく :h terminal-api
から見ることができます。
あまり大層な例ではないですが、Hello world 的なサンプルをひとつまみ置いておきます。
次の Vim script の関数を定義した上でシェルからコマンドを叩くと、Vim のコマンドライン領域に Hello, world
と表示されるはずです。
function Tapi_greet(buf, to) abort
echo 'Hello,' a:to[0]
endfunction
$ echo "\e]51;[\"call\", \"Tapi_greet\", [\"world\"]]\07"
実行例。ちゃんとコマンドラインエリアに "Hello, world" と出てます。
$VIM_TERMINAL
環境変数
シェルが :terminal
上で動いている時にのみ Vim が自動で設定する環境変数で、Vim のバージョンに関する情報が格納されてます。この環境変数の値が設定されているかを調べることでシェルが :terminal
で起動されたものなのかどうかを判定することができます。
実装
以上の部品を使いつつ drop
コマンドを実装します。方針は以下のような感じです。
-
Vim script 側
引数でシェルのカレントディレクトリとファイル名を受け取ってファイルを開く関数Tapi_drop()
を定義する。- ファイル名が相対パスの場合は、引数でもらったシェルのカレントディレクトリの情報を使って絶対パスに変換する。
- シェルと Vim のカレントディレクトリが一緒とは限らないので相対パスのままだと意図しないファイルが開かれる可能性があります。
- ファイルがすでに開かれていればそのウインドウに移り、開かれていなければウインドウを分割して開く。
- ファイル名が相対パスの場合は、引数でもらったシェルのカレントディレクトリの情報を使って絶対パスに変換する。
-
シェルスクリプト側
Tapi_drop()
関数を Terminal API のcall
コマンド経由で呼び出すコマンドの定義をする。
以下実装です。
function! Tapi_drop(bufnr, arglist) abort
let cwd = a:arglist[0]
let filepath = a:arglist[1]
if filepath !~# '\v^(/|\~|\a:)'
" 絶対パスでない時は絶対パスに変換する
let filepath = fnamemodify(cwd, ':p') . filepath
endif
" ファイルがすでに開かれていればそのウインドウに移動する
let opencmd = 'drop'
if bufwinnr(bufnr(filepath)) == -1
" ファイルがすでに開かれている場合でなければウインドウを分割して開く
let opencmd = 'split'
endif
execute opencmd fnameescape(filepath)
endfunction
if [ -n "$VIM_TERMINAL" ]; then
function drop() {
echo "\e]51;[\"call\", \"Tapi_drop\", [\"$(pwd)\", \"$1\"]]\x07"
}
fi
Tapi_drop()
関数内で、与えられたファイルがフルパスかどうかの判定を filepath !~# '\v^(/|\~|\a:)'
という式で行っていますが、少し条件として甘い部分がなくはないので、気になる方はイイカンジに調整してください。新しめの Vim を使っている方なら組み込み関数の isabsolutepath()
関数を使ったり、vital.vim ユーザーの方であれば System.Filepath
モジュールの is_absolute()
関数を使ったりしても良いんじゃないかと思います。
drop コマンドの簡単な動作デモ
余談:drop コマンド
call
コマンドの他に Terminal API で使えるコマンドの一つとして "drop" コマンドがあります。このコマンドは ["drop", "fileA.txt"]
のように drop
に続けてファイル名を与えてメッセージを投げることで、Vim が :drop fileA.txt
相当[1]のコマンドを実行してファイルを開いてくれる、というものになります。
求めていたものはまさにこれだったんじゃないか、という気がするのですが、drop
コマンドへの引数に与えるファイル名を相対パスで与えた場合は、そのパスの基準となるディレクトリがシェルのカレントディレクトリではなく Vim のカレントディレクトリになるため想定と違うファイルが開かれる場合があります。例えば、Vim のカレントディレクトリが ~/A
で、シェルのカレントディレクトリが ~/B
だったとして、この時にシェルから Terminal API の drop
コマンドで ./fileA.txt
を開こうとしたとき、Vim で開かれるファイルは ~/B/fileA.txt
ではなく ~/A/fileA.txt
になるという話です。
当たり前と言われれば当たり前の挙動ではあるのですが、コマンドを叩く側の気持ちからすると少し非直感的な挙動になります。解決策として、シェル側でファイルパスを絶対パスに変換した上で drop
コマンドを使うという手もあったのですが、そちらよりも call
コマンドで Vim script の関数を呼び出し、絶対パスへの変換を Vim script 側で行う本記事のやり方のほうが移植性が高くて手っ取り早いだろうという判断で call
を使うやり方を採用しました。
おわりに
これめっちゃつかう。
追記
続編として「:terminal から親の Vim でファイルを開く(cmd.exe 編)」を書きました。Windows ユーザーの方はこちらもどうぞ。
-
Terminal API としての
drop
コマンドは「引数で与えられたファイルを開いているウインドウがあればそのウインドウに移り、なければウインドウを分割してファイルを開く」というコマンドなのに対し、Vim のコマンドとしての:drop
コマンドは「引数で与えられたファイルを開いているウインドウがあればそこに移動し、なければ現在のウインドウでファイルを開く」というコマンドなので、実際は少し挙動が異なります。 ↩︎
Discussion