Vimのタグジャンプ ctagsでCtrl-]を使う方法とLSPとの使い分け
関数名の上でCtrl-]を押すと、その関数を定義している別のファイルへ飛べます。これがVimのタグジャンプで、飛び先を教えているのはプロジェクトの直下に置いたtagsというファイルです。Vimは自分でソースを解析しておらず、外部のctagsが作った索引を引いているだけです。
Macでこれを始めると、最初のctags -R .がいきなりillegal optionで落ちます。入っているctagsが別物だからです。ここではUniversal Ctagsでtagsを作り、VimとNeovimで飛べる範囲の違いと、LSPのあるバッファでCtrl-]が何を見るかを、小さなCのプロジェクトで確かめます。
検証環境:macOS(arm64)、Vim 9.1.1752、NVIM v0.12.6、Universal Ctags 6.2.1(Homebrew)、clangd(Xcode同梱)。include/config.h・src/main.c・src/config.c・src/net.c・scripts/build.shの5ファイルを一時ディレクトリに作り、tmuxの中でvimrcを読まないVimとNeovimを起動してキーを送りました。掲載している出力はすべてその実行結果です。
目次(9項目)
| 操作 | これだけ覚える |
|---|---|
| Ctrl-] | カーソル下の名前の定義へ飛ぶ |
| Ctrl-t | 飛ぶ前の位置へ戻る(:popも同じ) |
| g] | 候補を一覧してから選ぶ |
| Ctrl-w ] | 定義を上の分割で開く |
| Ctrl-w } | 定義をプレビューウィンドウで見る |
タグジャンプはtagsファイルを引いて飛ぶ
tagsファイルの中身は、名前・ファイル・その行を探す検索パターンを1行ずつ並べたテキストです。検証用のプロジェクトでctags -R .を実行してできたものを、先頭のヘッダー行を除いてそのまま載せます。
CONFIG_H include/config.h /^#define CONFIG_H$/;" d
compile scripts/build.sh /^compile() {$/;" f
config include/config.h /^struct config { int port; };$/;" s
init src/config.c /^static void init(void) {}$/;" f typeref:typename:void file:
init src/net.c /^static void init(void) {}$/;" f typeref:typename:void file:
main src/main.c /^int main(void)$/;" f typeref:typename:int
open_socket src/net.c /^int open_socket(const struct config *c)$/;" f typeref:typename:int
parse_config src/config.c /^int parse_config(const char *path, struct config *out)$/;" f typeref:typename:int
port include/config.h /^struct config { int port; };$/;" m struct:config typeref:typename:int
release scripts/build.sh /^release() {$/;" f
Ctrl-]はカーソル下の単語でこの表を引き、見つかった行のファイルを開いて検索パターンでカーソルを合わせます。Cの関数もシェルの関数(compileとrelease)も同じ表に入っている点が、あとでLSPと比べるときに効いてきます。逆に、config.hにあるparse_configのプロトタイプ宣言は既定では表に入りません。飛び先が宣言ではなく本体になるのはこのためです。
表は作った瞬間の写しなので、関数名を変えたり足したりしたらctags -R .を打ち直すまで反映されません。
Macの標準ctagsは-Rで落ちる
Homebrewで何も入れていないMacでwhich ctagsを見ると/usr/bin/ctagsが出ます。中身はXcodeに同梱されたBSD系のctagsで、ディレクトリをたどる-Rも、版を出す--versionも知りません。
$ ctags --version
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ctags: illegal option -- -
usage: ctags [-BFTaduwvx] [-f tagsfile] file ...
$ ctags -R .
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ctags: illegal option -- R
usage: ctags [-BFTaduwvx] [-f tagsfile] file ...
解説記事が前提にしているのはExuberant Ctagsの流れをくむctagsで、いま開発が続いているUniversal CtagsをHomebrewで入れます。
brew install universal-ctags
入れたあとは、どちらのctagsが先に見つかるかを確かめます。Apple Silicon のMacでは/opt/homebrew/binがPATHで/usr/binより前にあれば新しいほうが使われます。手元では次のようになりました。
$ which -a ctags
/opt/homebrew/bin/ctags
/usr/bin/ctags
$ ctags --version | head -1
Universal Ctags 6.2.1, Copyright (C) 2015-2025 Universal Ctags Team
tagsを作ってCtrl-]で飛び、Ctrl-tで戻る
プロジェクトの直下でctags -R .を実行すると、その場所にtagsができます。Universal Ctagsは.gitのようなバージョン管理のディレクトリを最初から除外しますが、node_modulesは除外しません。中に置いたJavaScriptの関数もtagsに入ったので、要らなければctags -R --exclude=node_modules .で外します。
あとはプロジェクトの直下でvim src/main.cと開き、8行目のparse_configの上でCtrl-]を押すとsrc/config.cの5行目、関数の本体へ移ります。戻るときはCtrl-tで、元のsrc/main.cの8行目、押したときの桁まで戻りました。飛んだ履歴はタグスタックに積まれていて、:tagsで見られます。
:tags
# TO tag FROM line in file/text
1 1 parse_config 8 src/main.c
>
:popもCtrl-tと同じ働きで、戻る先が残っていないところで打つとE555: At bottom of tag stackが出ます。Ctrl-oでも同じ場所へ戻れます。タグジャンプはジャンプリストにも記録されるからです。ただCtrl-oは検索やGでの移動も含めてさかのぼるので、タグで飛んだ地点だけを戻りたいならCtrl-tです。ジャンプリストの仕組みはCtrl-oで前の位置に戻るジャンプリストで詳しく扱っています。
元のファイルを残したまま定義を見たいときは、分割やプレビューで開きます。Ctrl-w ]は画面を上下に分けて上に定義を開き、カーソルもそちらへ移ります。Ctrl-w }と:ptag 名前はプレビューウィンドウ(ステータス行に[Preview]と出る窓)に開き、カーソルはsrc/main.cの8行目に残ったままでした。:pcloseで閉じます。ファイル名から開くgfの分割・タブでの開き方と同じ感覚です。
同じ名前の定義が複数あるとき(g] / :tselect / :tjump)
検証用のプロジェクトでは、src/config.cとsrc/net.cの両方にstatic void init(void)を置きました。tagsにもinitが2行あります。src/net.cでinitを呼んでいる7行目にカーソルを置いてCtrl-]を押すと、飛んだ先は同じsrc/net.cの3行目でした。tagsのfile:はそのファイルの中だけで有効な名前という印で、Vimはいま開いているファイルのstaticな定義を先に選びます。
2番目の候補へは:tnext、戻すのは:tprevです。:tnextでsrc/config.cの3行目へ移り、もう一度:tnextを打つとE428: Cannot go beyond last matching tagが出ました。候補が多いときは、順に送るより一覧から選ぶほうが早く、それがg]です。
# pri kind tag file
1 FSC f init src/net.c
typeref:typename:void
static void init(void) {}
2 FS f init src/config.c
typeref:typename:void
static void init(void) {}
Type number and <Enter> (q or empty cancels):
pri欄のCは「いま開いているファイルの中」、Sはstatic、Fは名前が完全に一致したことを表します。番号を打ってEnterで飛び、qか空のEnterでやめます。名前を打って同じ一覧を出すのが:tselect initで、src/main.cから打つとCは付かずsrc/config.cが1番になりました。
:tjumpはこの2つの中間で、候補が1つならそのまま飛び、2つ以上なら一覧を出します。:tjump parse_configは即座にsrc/config.cへ移り、:tjump initは上と同じ一覧を出しました。キーで同じことをするのがg Ctrl-]で、毎回確実に正しい定義を選びたいならCtrl-]より事故がありません。
サブディレクトリで飛べないときは'tags'の検索範囲を見る
同じプロジェクトで、今度はcd srcしてからvim main.cと開き、parse_configの上でCtrl-]を押しました。Vimはこう返します。
E433: No tags file
E426: Tag not found: parse_config
tagsはひとつ上の階層にあるのに見つけていません。理由は'tags'の既定値で、Vim 9.1はtags=./tags,tagsです。./tagsは開いたファイルと同じディレクトリ、tagsはカレントディレクトリのtagsを指し、どちらも上の階層へはさかのぼりません。:echo tagfiles()も[]でした。プロジェクトの直下でvim src/main.cと開いたときに飛べたのは、カレントディレクトリにtagsがあったからです。
Neovimの既定値はtags=./tags;,tagsで、./tagsの後ろに;が付いています。;は「見つかるまで親ディレクトリへさかのぼる」という印で、同じようにsrcの中からnvim --clean main.cと開いても、Ctrl-]でsrc/config.cの5行目へ飛べました。
Vimで同じ動きにしたければ、vimrcに次の1行を書きます。
set tags=./tags;,tags
これでsrcの中から開いてもtagfiles()が上のtagsを返し、Ctrl-]がsrc/config.cへ届きました。set tags=./tags;だけでも飛べます。ただし、さかのぼりはプロジェクトの境目で止まりません。一時ディレクトリの3階層下でファイルを開いたところ、3つ上に置いた無関係なtagsをtagfiles()が拾いました。上の階層に古いtagsを置き忘れていると、別のプロジェクトでそれを引くことがあります。
LSPがあるときCtrl-]はどちらを見るか(tagfunc)
Neovimで言語サーバーがバッファにつながると、'tagfunc'がv:lua.vim.lsp.tagfuncに設定されます。'tagfunc'はタグの検索をtagsファイルの代わりに関数へ任せるオプションで、空でないあいだはCtrl-]やCtrl-w ]、:tjumpがその関数を呼びます。clangdをvim.lsp.enableで有効にしてsrc/main.cを開き、確かめた結果です。設定の書き方はNeovimの標準LSP設定のとおりです。
:set tagfunc?
tagfunc=v:lua.vim.lsp.tagfunc
この状態でparse_configの上でCtrl-]を押すと、飛んだ先はinclude/config.hの4行目、プロトタイプ宣言でした。tagsならsrc/config.cの本体へ行く場面です。open_socketも同じで、LSPはsrc/main.cの3行目にある宣言を返し、src/net.cの本体へは行きませんでした。compile_commands.jsonを置かず、clangdにほかの.cを読ませていない構成での結果で、全体を読ませた場合は確かめていません。タグスタックはLSP経由でも積まれ、Ctrl-tで戻れます。
:set tagfunc=で空にすると、同じCtrl-]がtagsファイルを引くようになり、open_socketからsrc/net.cの5行目へ飛びました。言語サーバーのつながっていないscripts/build.shではtagfuncは最初から空で、releaseの中のcompileから2行目の定義へtagsで飛べます。
もう1つ気づいたのは、名前を打つ:tagの挙動です。Neovimのvim.lsp.tagfuncは、Ctrl-]のようにカーソル位置から引くときはtextDocument/definitionを、名前を打ったときはworkspace/symbolを問い合わせ、結果が空のときだけtagsへ戻る作りです(runtime/lua/vim/lsp/_tagfunc.luaで確認)。src/main.cで:tag releaseと打つと、シェルの関数ではなく、clangdが返したXcodeのstdatomic.hの72行目が開きました。LSPが何か1つでも返すと、tagsに正解があってもそちらは見ません。
ctagsとLSPの使い分け
ここまでの結果を並べると、言語ごとに任せる先を分けるのが筋です。
| 場面 | ctags(tags) | LSP(tagfunc) |
|---|---|---|
| 準備 | ctags -R .を打つだけ | 言語ごとにサーバーを入れて設定 |
| 対象 | シェル・設定ファイルも含めて一律 | サーバーのある言語だけ |
| 更新 | 作り直すまで古いまま | 編集に追従する |
| 同名の定義 | 一覧から選ぶ(g]) | 文脈で絞り込まれる |
| 飛び先 | 本体(プロトタイプは既定で入らない) | サーバーの答え次第で宣言のこともある |
LSPがある言語はCtrl-]をLSPに任せれば、定義へ飛ぶためだけに別のキーを覚え直す必要はありません。サーバーを用意していない言語、たとえばシェルスクリプトや古いコード、設定ファイルの集まりでは、'tagfunc'が空のままなので同じCtrl-]がtagsへ落ちます。LSPの答えが宣言止まりのときは、:set tagfunc=で一時的にtagsへ切り替えて本体を見る、という逃げ道もあります。tagsもLSPも無い状況で、同じファイルの中だけ探せばよいなら変数の宣言位置へジャンプするgd/gDで足ります。
検証の最初の1回で、parse_configに合わせたつもりのカーソルが文字列"app.conf"の上にあり、E426: Tag not found: appが出ました。それだけなら打ち直せば済むのですが、続けて:tagsを開くと、見つからなかったappがタグスタックに1段積まれていました。失敗したCtrl-]も履歴に残るので、Ctrl-tで戻る回数が数えた数と合わないことがあります。もう1つ驚いたのが:tag releaseで、シェルの関数を開くつもりがstdatomic.hが出てきたときは、tagsが壊れたのかと思って中身を見直しました。
まとめ
Macでは/usr/bin/ctagsがBSD版なので、brew install universal-ctagsで入れてからctags -R .でtagsを作ります。Ctrl-]で飛んでCtrl-tで戻り、同名が複数ならg]か:tjumpで選びます。サブディレクトリでVimだけ飛べないならset tags=./tags;,tagsです。
NeovimでLSPがつながったバッファのCtrl-]は言語サーバーに問い合わせ、答えが返るとtagsは見ません。LSPのある言語はLSPに、無い言語はtagsに任せれば、同じキーで両方を行き来できます。