Vimのカレントディレクトリを変える(:cd / :lcd / :tcd と autochdir)
Vimで:cdを打ったのに、隣のウィンドウでは:eの補完が前の場所のまま、ということがあります。原因はたいてい、その窓にだけ:lcdの指定が残っていることです。カレントディレクトリは全体・タブ・ウィンドウの3つの単位で持たれ、狭い単位が勝ちます。Neovim 0.13ではバッファ単位が加わります。
この記事では、:cd :tcd :lcdがどこに効き、どれが優先されるかをgetcwd()とhaslocaldir()の値で確かめます。同じgetcwd(-1)がVimとNeovimで別の単位を返す、という違いも見つかりました。
検証環境:Vim 9.1.1752、NVIM v0.12.4、Neovimの公式nightlyビルド NVIM v0.13.0-dev-1944+g7b87ce822e(いずれもmacOS)。一時ディレクトリにg t w xなどのディレクトリと小さなプロジェクトを作り、-u NONEで起動した3つに同じ手順を流して値を記録しました(autochdirとDirChangedはtmuxで対話起動)。掲載した値は実行結果で、パスは末尾のディレクトリ名だけにしてあります。0.13は開発中なので、正式版で変わる可能性があります。
目次(10項目)
| コマンド | 効く範囲 |
|---|---|
:cd {dir} | 全体。今のウィンドウとタブの指定も外す |
:tcd {dir} | 今のタブだけ。今のウィンドウの:lcdは外れる |
:lcd {dir} | 今のウィンドウだけ |
:bcd {dir} | 今のバッファだけ(Neovim 0.13〜) |
:verbose pwd | 今の場所と、どの単位で決まったか |
:lcd %:h | 開いているファイルの場所へ、このウィンドウだけ移る |
カレントディレクトリは4つの単位で持たれる
カレントディレクトリは、:e src/main.cのような相対パス、:!lsや:make、:terminalの起動場所、:vimgrepのglobの基準になります。起動したシェルの場所が全体のディレクトリになり、:cdはこれを変えます。
これとは別に、タブごとに:tcd、ウィンドウごとに:lcdで個別の場所を持たせられます。ウィンドウとタブの関係はVimの「バッファ」「ウィンドウ」「タブ」の違いで整理しました。ディレクトリは「狭い単位に指定があればそれを使い、無ければ1つ外側を見る」という規則で動きます。
| 単位 | 設定するコマンド | 使える版 | 新しく開いたものに引き継がれるか |
|---|---|---|---|
| バッファ | :bcd | Neovim 0.13〜 | 引き継がれない |
| ウィンドウ | :lcd | Vim / Neovim | :split :new :tabnewで引き継がれる |
| タブ | :tcd | Vim / Neovim | :tabnewで引き継がれる |
| 全体 | :cd | Vim / Neovim | 常に1つ |
上ほど優先されます。引き継ぎの列は実測で、:lcd wした窓から:split :new :tabnewのどれで開いても新しい窓はwにいました(3つの版で同じ)。
:cd / :tcd / :lcd の使い分けと、どれが勝つか
3つを重ねたときの動きを見るため、次の順で打ちました。全体をgにし、2つ目のタブをtにし、そのタブを縦に割って左のウィンドウだけwにします。
:cd g
:tabnew
:tcd ../t
:vsplit
:lcd ../w
それぞれの場所で:verbose pwdを打つと、どの単位で決まったかが角括弧で出ます。
| 場所 | :pwd | :verbose pwdの角括弧 |
|---|---|---|
タブ2の左(:lcdした) | w | [window] |
| タブ2の右 | t | [tabpage] |
| タブ1 | g | [global] |
ここまでVimとNeovimに差はありません。左の窓(:lcd済み)で:tcd ../xを打つと:lcdは外れて左右ともxになり、左で:cdを打つと窓とタブの指定がどちらも外れて右の窓まで全体の値に揃いました。上の単位を設定すると、今いる窓とタブにある下の単位は消えます。
消えるのは今いるウィンドウとタブの分だけです。タブ1で:cd ../yを打っても、タブ2の:tcdは残り、タブ2に戻るとxのままでした。タブの開き方や移り方はVimのタブ操作にまとめました。
プロジェクトを丸ごと切り替えるなら:tcd、1つの窓でライブラリのソースを覗くような寄り道なら:lcd、全体の起点を変えたいときだけ:cd、と分けると迷いません。
今どのディレクトリにいるかを確かめる(:pwd / getcwd() / haslocaldir())
手で確かめるなら:verbose pwdで足ります。:pwdはパスしか出しませんが、verboseを付けると[global] [tabpage] [window]のどれかが前に付きます。後で触れるautochdirが効いているときは[autochdir]、0.13の:bcdなら[buffer]です。
関数で取るときはgetcwd()を使いますが、引数の意味がVimとNeovimで食い違います。上と同じ配置で、タブ2の左のウィンドウ(:lcdでw、タブはt、全体はg)から呼んだ結果です。
| 呼び方 | Vim 9.1 | Neovim 0.12.4 / 0.13 |
|---|---|---|
getcwd() | w | w |
getcwd(-1) | g(全体) | t(タブ) |
getcwd(-1, 0) | t | t |
getcwd(-1, -1) | 空文字 | g(全体) |
Vimのgetcwd(-1)は全体、Neovimではタブです。Vimにgetcwd(-1, -1)を渡すとエラーにならず空文字が返ります(Neovimのヘルプにも注記があります)。両方で動かすなら、タブは共通のgetcwd(-1, 0)で取り、全体だけhas('nvim')で分けます。
haslocaldir()も同じように違います。
| 呼んだ場所 | Vim 9.1 | Neovim 0.12.4 / 0.13 |
|---|---|---|
:lcdしたウィンドウ | 1 | 1 |
:tcdだけのタブのウィンドウ | 2 | 0 |
| 指定なし | 0 | 0 |
haslocaldir(-1, 0)(:tcdしたタブ) | 2 | 1 |
Vimは引数なしでもタブの指定を2として返しますが、Neovimの引数なしはウィンドウの指定しか見ず、タブの指定はhaslocaldir(-1)で1として返します。Vimのヘルプにあるhaslocaldir() == 2の分岐は、Neovimでは入りません。
「:cd したのに変わらない」原因と外し方
冒頭の症状を再現します。ウィンドウを左右に割り、左で:lcd ../w、右へ移って:cd ../xを打ちました。
右のウィンドウ :verbose pwd → [global] .../x
左のウィンドウ :verbose pwd → [window] .../w haslocaldir() = 1
右の:cdは全体を変えましたが、左には[window]の指定があるのでそちらが勝ちます。:cdは他の窓の:lcdにも別のタブの:tcdにも触りません。疑わしい窓で:verbose pwdを打ち、[window]か[tabpage]が出たら原因はこれです。
Vim 9.1とNeovim 0.12には、窓の指定だけを外して外側に戻すコマンドがありません。その窓へ移って:cd {dir}で全体ごと上書きするか、タブの指定を残したいなら:tcd {dir}で:lcdだけを外します。
気を付けたいのが:lcd!です。0.13で「指定を外す」意味が付きましたが、Vim 9.1とNeovim 0.12.4でもエラーにならず、引数なしの:lcdと同じくホームへ移りました。:verbose pwdは[window]のままで、指定はホームに付け替わります。
:tcd tのタブで:lcd wの後に:lcd! | 移った先 | :verbose pwd |
|---|---|---|
| Vim 9.1 / Neovim 0.12.4 | ホーム | [window] |
| Neovim 0.13-dev | t | [tabpage] |
開いたファイルの場所へ移る(:lcd %:h と autochdir の落とし穴)
開いているファイルのある場所で作業したいときは、:lcd %:hが手軽です。proj/src/sub/a.txtを開いて打つと、この窓だけがproj/src/subに移りました(haslocaldir()は1)。一度移るだけで、別のファイルを開いてもついては来ません。
毎回自動でやるのが:set autochdirです。ファイルを開くたび、ウィンドウを移るたびに、そのファイルの場所へ移ります。projで起動して試した結果です。
| 操作 | 移った先 |
|---|---|
:e src/sub/a.txt | proj/src/sub |
:split ../../Makefile | proj |
:wincmd jでa.txtの窓へ | proj/src/sub |
:verbose pwdは[autochdir]を出し、getcwd(-1)までproj/src/subになっていました。autochdirは:lcdのようにウィンドウへ指定を足すのではなく、全体のディレクトリを書き換えます(haslocaldir()は0)。
困るのは、相対パスの基準がファイルを移るたびに動くことです。a.txtを開いた状態で次の3つを試すと、どれもnoautochdirのときと違う結果になりました。
:e src/b.txt:既存のproj/src/b.txtではなく、存在しないproj/src/sub/src/b.txtを新規ファイルとして開く:vimgrep /needle/ src/**/*.txt:E480: No match: needle。どちらのファイルにもneedleと書いてあるのに見つからない:set path=.,,**での:find b.txt:noautochdirならsrc/b.txtが見つかるのに、findfile('b.txt')が空になる
1つ目はエラーにならず空の新規バッファが開くだけなので気づきにくいです。:findはpathの**をカレントディレクトリから数えるため、探す範囲が開いたファイルの下だけに縮みます。:findでファイル名からファイルを探す使い方をしているなら、autochdirは相性が悪い組み合わせです。
ファイルの場所で:!を打ちたいだけなら、autochdirは切って必要なときだけ:lcd %:hで移るほうが、基準が勝手に動かず安全です。
Neovim でプロジェクトルートへ自動で移る(vim.fs.root と autocmd)
欲しいのがファイルの場所ではなくプロジェクトのルートなら、Neovimのvim.fs.root()が使えます。バッファ番号かパスと目印のファイル名を渡すと、親をたどって最初に目印が見つかったディレクトリを返し、無ければnilを返します。これをBufEnterから:lcdに渡せば、vim-rooterを入れずにルートへ移れます。
-- init.lua
vim.api.nvim_create_autocmd('BufEnter', {
callback = function(ev)
local root = vim.fs.root(ev.buf, { '.git', 'Makefile' })
if root and root ~= vim.fn.getcwd(0) then
vim.cmd.lcd(root)
end
end,
})
proj/.gitとproj/Makefileがあるプロジェクトで、ホームから起動して試しました。0.12.4と0.13で結果は同じです。
| 操作 | 結果 |
|---|---|
:e proj/src/sub/a.txt | projへ移る。haslocaldir()は1 |
続けて:e src/b.txt | proj/src/b.txtが開く(ルート基準) |
:terminal pwd | projと出る |
目印の無いother/x.txtを:splitで開く | projのまま |
autochdirと違ってサブディレクトリのファイルを開いても基準はルートに留まるので、:eも:vimgrepもルートからのパスで書けます。:terminalもそのウィンドウのディレクトリで起動しました。
最後の行は意図どおりとは限りません。vim.fs.root()がnilを返すので何もせず、:splitが元の窓の:lcdを引き継ぐため、目印の無いファイルの窓も前のプロジェクトのルートにいます。:lcdを:tcdにすればタブ単位で揃い、0.13なら:bcdも選べます。プロジェクトごとの設定を読ませたいなら.nvim.luaとexrcと組み合わせられます。
Neovim 0.13 の :bcd と :lcd! で何が変わるか
0.13ではバッファ単位の:bcdが入りました。0.12.4ではexists(':bcd')が0で、nightlyでは2です。0.13のヘルプは優先順位を次のように書いています。
buffer > window > tab > global
nightlyで、全体をgにしてb.txtを開き、:bcd ../xから順に打った結果です。getcwd()は実際に使われる場所、getcwd(0)はウィンドウ以上の単位で決まる場所、getcwd(-1, -1, 0)は今のバッファの場所です。
| 操作 | getcwd() | getcwd(0) | :verbose pwd |
|---|---|---|---|
:bcd ../x | x | g | [buffer] |
:vsplit(同じバッファ) | x | g | [buffer] |
その窓で:enew | g | g | [global] |
b.txtに戻って:lcd ../w | x | w | [buffer] |
:lcd! | x | g | [buffer] |
:cd ../y | x | y | [buffer] |
:bcd! | y | y | [global] |
:bcdはバッファに付くので、同じバッファを別の窓に出しても付いてきますが、同じ窓で別のバッファを開くと効かなくなります(ヘルプの言う「sticky ではない」)。:lcdを重ねてもバッファが勝ち、:cdでも消えず、外せるのは:bcd!か次の:bcdだけです。haslocaldir(-1, -1, 0)は:bcdがあるあいだ1、:bcd!の後は0でした。
:tcd!も同じで、全体のyに戻りました。:cd!は外す先が無いので:cdと同じ扱いになり、ホームへ移りました。0.13のほかの変更はNeovim 0.13の新機能にまとめています。
ディレクトリが変わったときに処理を走らせる(DirChanged)
ディレクトリが変わったら何かしたい場合はDirChangedを使います。どの単位が変わったかは、Vimではパターン(<amatch>)で、Neovimではそれに加えてv:event.scopeで分かります。次の自動コマンドで記録を取りました。
let g:x = []
autocmd DirChanged * call add(g:x, expand("<amatch>") . "/" . get(v:event, "scope", "-") . "/" . get(v:event, "changed_window", "-"))
| 操作 | Vim 9.1 <amatch> | Neovim <amatch> | Neovim v:event.scope |
|---|---|---|---|
:cd | global | global | global |
:lcd | window | window | window |
:tcd | tabpage | tabpage | tabpage |
| 場所の違う窓へ移る | 発火しない | 移った先の単位(試した例ではtabpage) | 同じ。v:event.changed_windowがv:true |
autochdir | auto | auto | window |
:bcd(0.13) | なし | buffer | buffer |
Vimのv:eventにはscopeというキーが無く、v:event.scopeと書くとE716: Key not present in Dictionary: "scope"になりました。両方で使うならexpand('<amatch>')で分けます。Neovimのautochdirはパターンがautoでもv:event.scopeはwindowなので、autochdirだけを拾うときもパターンで絞ります。
窓の移動にも差があります。Neovimは:lcdした窓から:tcdだけの窓へ移っただけでv:event.changed_window付きで発火しましたが、Vimは:cd系とautochdirでしか発火しません。今の場所を表示する用途でDirChangedだけに頼ると、Vimでは窓を移っても更新されません。
検証で最初につまずいたのは自分の確認用スクリプトでした。getcwd(-1, -1)を全体の値として記録する関数をNeovim向けに書き、同じものをVimで流したら、その列だけが空でした。壊れていたのは私の前提のほうで、気づくまでしばらく出力を眺めていました。0.13のヘルプを読んだ直後に0.12の窓で:lcd!を打ち、黙ってホームへ飛ばされたときも、:verbose pwdを打つまで何が起きたのか分かりませんでした。
まとめ
カレントディレクトリは全体・タブ・ウィンドウ(0.13ではバッファも)で持たれ、狭い単位が勝ちます。:cdが効かない窓では:verbose pwdの角括弧で残っている指定が分かり、Vimと0.12ではその窓で:cdか:tcdを打ち直し、0.13なら:lcd!で外せます。古い版の:lcd!はホームへ移るだけです。
関数ではgetcwd(-1)とhaslocaldir()の意味がVimとNeovimで違う前提で書きます。自動で移るなら、基準が動き回るautochdirより、vim.fs.root()でルートへ:lcdする数行のほうが:eや:vimgrepと噛み合います。