😺

:terminal から親の Vim でファイルを開く(bash/zsh編)

2023/10/11に公開

※今回の話は 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 と表示されるはずです。

Vim script
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 コマンド経由で呼び出すコマンドの定義をする。

以下実装です。

.vimrc など
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
.bashrc など
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 ユーザーの方はこちらもどうぞ。

脚注
  1. Terminal API としての drop コマンドは「引数で与えられたファイルを開いているウインドウがあればそのウインドウに移り、なければウインドウを分割してファイルを開く」というコマンドなのに対し、Vim のコマンドとしての :drop コマンドは「引数で与えられたファイルを開いているウインドウがあればそこに移動し、なければ現在のウインドウでファイルを開く」というコマンドなので、実際は少し挙動が異なります。 ↩︎

GitHubで編集を提案

Discussion