変数の宣言位置へジャンプする(gd/gD)
LSPを設定していない環境で、変数がどこで宣言されているか確認したくなることがあります。LSPの「定義へ移動」ほど賢くはありませんが、Vim標準にも似たようなコマンドが用意されています。
この記事に書いた挙動は、すべて手元で押して確かめました。使ったのは Vim 9.1(patches 1-1752)と Neovim 0.12.4、macOS 26.4.1 です。設定の影響を受けないように vim -Nu NONE -i NONE と nvim --clean で起動し、カーソルがどこへ移ったかは line('.') と col('.') の値で確認しています。
目次(10項目)
| キー | これだけ覚える |
|---|---|
| gd | ローカル宣言を検索 |
| gD | グローバル宣言を検索 |
| 1gd | 閉じたブロック内の一致を無視 |
| Ctrl-o | ジャンプ前の位置に戻る |
ローカル宣言を探す:gd
gd
カーソル下の単語について、次の順序で宣言らしき箇所を探します。
1. [[ と同じ規則で「関数の始まり」を後方に探す
2. 見つかったら、そこからさらに空行まで戻る(見つからなければ1行目が起点)
3. その位置から前方へ、* と同じ要領でカーソル下の単語を探す
4. コメントらしい行は読み飛ばす(判定は 'comments' オプション)
function calc() {
let total = 0;
return total + 1;
}
function calc() {
let total = 0;
return total + 1;
}
使用箇所にカーソルを置いてgdを実行すると、同じ関数内の宣言行にカーソルが移動します。厳密な構文解析をしているわけではなく、あくまで単語検索の延長にすぎません。そのため、スコープを正しく理解しているわけではなく、同じ名前のグローバル変数にヒットしてしまうこともあります。プログラミング言語やコードの書き方によって精度は変わります。
その分かれ目になるのが1番目の「関数の始まりを探す」です。Vimはこれを構文から判断しているのではなく、[[とまったく同じ規則、つまり「1桁目にある { を後方に探す」という文字の位置だけで決めています。開き波括弧が行頭に来ていない書き方だと関数の始まりは見つからず、起点は問答無用でファイルの1行目まで下がります。
同じJavaScriptでも波括弧の置き方を変えるだけで結果が変わります。次の2つを別々のファイルに保存し、total を使っている行の上でそれぞれgdを押しました。
// A: 開き波括弧が1桁目にある書き方
var total = 999;
function calc(n)
{
let total = 0;
return total + n;
}
// B: 開き波括弧が行末にある書き方
var total = 999;
function calc(n) {
let total = 0;
return total + n;
}
Aでは let total = 0; の行に移りました。Bでは1行目の var total = 999; に移りました。Bの結果は、同じ場所でgDを押したときとまったく同じです。gdがローカル優先に見えるのは、関数の本体を開く波括弧が行頭に置かれるC言語の書式が前提になっているからで、その前提が崩れた瞬間に、何も言わずグローバル検索に化けます。
言語ごとの当たり外れ
起点が波括弧の位置で決まるということは、言語ごとにgdの信頼度がはっきり分かれるということです。関数の外と中に同じ名前がある小さなファイルを言語別に作り、関数内の使用箇所からgdを押した結果が次の表です。Vim 9.1とNeovim 0.12.4で結果は同じでした。
| 言語 | 関数の開き方 | 飛んだ先 |
|---|---|---|
| C | 波括弧を次の行の1桁目に置く | 関数内の宣言(期待どおり) |
| JavaScript | 波括弧を関数名と同じ行の行末に置く | ファイル先頭のグローバル |
| Go | 波括弧を行末に置く(書式が固定) | ファイル先頭のグローバル |
| Python | そもそも波括弧が無い | モジュール先頭の代入 |
Pythonのように波括弧そのものが無い言語では、関数の始まりは原理的に見つかりません。探索は必ず1行目から始まるので、gdとgDはまず同じ動きになります。GoやJavaScriptも、行末に波括弧を置くのが標準の書式なので同じ結果です。Goの total := 0 という短い変数宣言もまったく見ておらず、ファイル先頭の var total = 999 に着きました。
裏を返すと、gdが名前どおりに働くのはCやC++、あるいはJavaやC#を波括弧行頭の書式で書いた場合くらいです。:help gdには「これはCのコードのために作られたもので、他の言語ではうまく働かないかもしれない」と最初から書かれています。実測すると、その但し書きは控えめな表現ではなく、そのままの意味でした。
グローバル宣言を探す:gD
gD
ファイルの先頭から検索し、主にソースコードの上方で宣言されるグローバル変数を探すためのコマンドです。gdがローカルスコープを優先するのに対し、gDは最初から全体を対象にします。
起点が1行目に固定されているぶん、gDはカーソルがどこにあっても同じ場所に着きます。gdは関数の始まりが見つかるかどうかで結果が変わるので、押す前に着地点を予想できるのはgDのほうです。
気になるのは、ファイルの先頭がライセンス表記や説明のコメントで、そこに変数名が出てくる場合です。1行目から順に最初の一致へ飛ぶなら、そのコメントに引っかかりそうに見えます。実際に /* total is the running sum */ を1行目に置いたCのファイルで試したところ、コメント行は読み飛ばされ、2行目の int total = 999; に着きました。読み飛ばしの判定を持っているのは'comments'オプションで、行頭がそのオプションのコメント記号に一致する行が候補から外れます。
1gd と 1gD で閉じたブロックを飛ばす
あまり知られていませんが、数字の1を前置した1gdと1gDがあります。ヘルプの説明は「カーソル位置より手前で閉じている {} ブロックの中の一致を無視する」で、平たく言えば、今いる場所からは見えないスコープの宣言を候補から外すという意味です。
int calc(int x)
{
if (x) {
int total = 1;
}
return total + 2;
}
このファイルの return total + 2; にカーソルを置いてgdを押すと、if の中の int total = 1; に飛びます。ブロックはすでに閉じているので、この行から参照できるはずのない変数です。同じ位置で1gdを押すと、その一致は手前で閉じたブロックの中にあるという理由で捨てられ、カーソルはその場から1文字も動きませんでした。
動かないのは一見すると失敗ですが、無関係な行へ連れて行かれて「宣言はここか」と勘違いするよりは扱いやすい結果です。条件分岐やループの入れ子が深いCのコードで宣言を追うときは、最初から1gdのほうを押しておくと空振りが分かりやすくなります。
誤爆する条件を知っておく
波括弧の位置のほかに、設定と書き方に由来する落とし穴が2つあります。どちらも画面上は普通にジャンプが成功したように見えるので、知らないと気づけません。
1つ目は大文字小文字です。gdが使った検索パターンは検索レジスタに残るので、押したあとに@/の中身を見れば分かります。実際に表示させると \V\<total\> という形でした。\Vは記号を特別扱いしない指定、\<と\>は単語の境界です。ここに大文字小文字を固定する \C が入っていないので、'ignorecase'を有効にしていると別の名前にも当たります。
やっかいなのは'smartcase'を併用しても効かないことです。'smartcase'は自分でタイプした検索パターンにだけ適用される仕組みで、gdが内部で組み立てたパターンは対象外になります。set ignorecase smartcase にしたVim 9.1で、小文字の total の上からgdを押すと int Total = 0; の行へ飛びました。'ignorecase'を常用している人ほど、キャメルケースとスネークケースが混在したコードで別物の宣言を読まされます。
2つ目はコメントと文字列です。ヘルプにはコメント行を読み飛ばすとしか書かれていませんが、実際にはもう少し賢く動きます。行の途中に書かれた /* total */ や、引用符で囲まれた中の total も候補から外れました。printf("total\n"); だけが候補というファイルでgdを押しても、カーソルは動きません。文字列の中の同名語で誤爆する心配は、少なくともC風の引用符を使う言語ではしなくて済みます。
そして見つからなかったときは、本当に何も起きません。エラーメッセージは出ず、v:errmsgも空のままで、カーソルがその場に残るだけです。「押したのに動かない」のは宣言が見つからなかった合図だと覚えておくと、連打して原因を探さずに済みます。例外は、カーソルが単語の上にないときで、このときだけ E349: No identifier under cursor が出ます。
ハイライトと組み合わせて確認する
gdで宣言箇所へ移動すると、同時にその変数名がハイライトされます(hlsearchが有効な場合)。宣言位置にたどり着いたあと、そのままハイライトされた箇所を目で追えば、他にどこでその変数が使われているかもざっと把握できます。宣言ジャンプと使用箇所の確認を1つの操作でまとめて済ませられるのは地味ですが便利な点です。
ハイライトが点くのは、gdが検索レジスタを書き換えているからです。押したあとにv:hlsearchを見ると1になっていて、*で単語検索した直後とまったく同じ状態になっています。ですから消したくなったら:nohlsearchで消えますし、押すたびに消す手間が煩わしければ設定で自動化できます。消し方の選択肢はVimの検索ハイライトを消す設定にまとめてあります。
ヘルプに書かれているとおり、gdのあとのnは前方検索になります。宣言行からnを押すと、さきほど自分がいた使用箇所へ順に進んでいきます。Nで逆順にもたどれるので、宣言を見て、使っている場所を上から順に見て、また宣言へ戻るという往復が、検索コマンドを1文字も打たずに回せます。カーソル下の単語をそのまま検索する*と#の使い方を知っていれば、操作感はほぼ同じです。
LSPとの使い分け
LSPが有効な環境では、定義ジャンプの精度は圧倒的にLSP側が優れています。それでもgd/gDを知っておく価値があるのは、標準機能だけでも最低限の宣言ジャンプができるからです。
| 場面 | 使うべき手段 |
|---|---|
| LSPが有効 | LSPの定義ジャンプ(精度が高い) |
| LSP未設定・未対応の言語 | gd / gD(標準機能だけで完結) |
ここで誤解しやすいのがNeovimの既定のキー割り当てです。Neovimは起動時にLSP向けのマッピングを無条件で作りますが、その名前はgrn(rename)、gra(code action)、grr(references)、gri(implementation)、grt(type definition)、grx(codelens)、gO(document symbol)で、gdは含まれていません。手元の 0.12.4 に付属するヘルプでも一覧はこのとおりでした。つまりLSPを動かしていても、gdを押した瞬間に走るのはここまで説明してきた単語検索のままです。
定義ジャンプ自体は別の入口が用意されています。LSPが付いたバッファでは'tagfunc'がvim.lsp.tagfuncに設定されるので、タグジャンプのCtrl-]がそのままLSPの定義ジャンプになります。gdで定義に飛びたければ自分で割り当てます。
-- LSPが付いたバッファでだけ gd を定義ジャンプに差し替える
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, { buffer = args.buf })
end,
})
バッファローカルに割り当てるのが肝で、こうしておけばLSPが動いていないファイルでは標準のgdがそのまま残ります。設定ファイルの置き場所やサーバの有効化の手順はNeovimの標準LSP設定にまとめてあります。
普段はLSPに任せつつ、いざというときの保険として覚えておくと安心です。
LSPが設定されていない古いシェルスクリプトのプロジェクトで、変数の出どころを探すのにgdを使ってみたら、思ったより正確に該当箇所へ飛んでくれて助かったことがあります。逆に混乱したのがJavaScriptで、関数の中で宣言したはずの変数なのに毎回ファイルの先頭へ飛ばされ、しばらく「壊れているのでは」と疑っていました。原因が波括弧の位置だと分かったのは、同じコードを波括弧行頭の書式に直して押し比べたときです。言語を選ぶコマンドだと納得してからは、外したときに素直にgDへ切り替えるようになりました。
元の位置に戻る
gdで宣言箇所に飛んだあと、元の場所に戻りたければCtrl-oを使います。これはジャンプ履歴をたどる汎用的なコマンドで、gdによる移動もこの履歴に記録されます。定義を確認したらすぐ元の作業に戻る、という一連の流れがスムーズになります。何度も往復するときはCtrl-iで新しい位置へ戻ることもできるので、ブラウザの戻る進むのような感覚で使いこなせるようになります。
履歴に載ることは:jumpsで確かめられます。設定を読まないVimでファイルを開き、jとwだけで5行目の total まで移動してからgdを押すと、履歴に「5行目」の行が1つ増えました。jやwのような通常の移動は履歴に残らないので、増えた1行はgdが積んだものです。
戻り方にはマークを使う手もありますが、着地する桁が違います。5行目の10桁目からgdで3行目に飛んだあと、Ctrl-oは5行目の10桁目に戻り、シングルクォート2つの''は5行目の3桁目、つまり行頭の非空白文字に着きました。桁まで元どおりにしたいならバッククォート2つの``を使います。この使い分けはVimのマーク機能と共通なので、片方を覚えれば両方に効きます。
まとめ
gdは宣言を理解して飛んでいるのではなく、関数の始まりらしき位置を波括弧の桁で当てて、そこから単語を前方検索しているだけです。Cのコードでは狙いどおりに当たり、JavaScriptやGoやPythonではgDと同じ動きに落ちます。閉じたブロックを外したければ1gd、'ignorecase'を有効にしているなら大文字違いの名前に当たることを頭に入れておけば、外れたときに理由が読めます。NeovimでもgdはLSPに取られていないので、この単純さは当分そのままです。