図書館の通路の様子を写したカバー写真

Natsuki by Neural Integration

なぜCmd+クリックだけで定義元の変数へ飛べるのか

この記事のポイント
  • Cmd+クリックは文字列検索ではなく、スコープに基づいてIdentifierからSymbolを解決する処理です。
  • IdentifierからSymbol、SymbolからDefinition(ファイルと行・列)への3段階で定義位置を特定します。
  • TypeScriptではVS Codeの拡張機能とtsserverが独自プロトコルで連携し、他言語ではLSPが同様の橋渡しを担います。Find ReferencesやRename Symbolも同じ仕組みで支えられています。

関数や変数の上で「Cmd+クリック」(WindowsならCtrl+クリック)する。

別のファイルに書かれた何千行もあるコードベースでも、クリックした瞬間に定義元のファイルが開き、宣言された行へカーソルが吸い付くように移動します。あまりに自然で日常的な操作ですが、ふと「なぜエディタは迷わずそこへ飛べるのか」と疑問に思ったりしたこと、これまでありませんでしたか? 僕は当たり前すぎてなかったです(笑)

仮にエディタが単にその単語をテキストとして検索しているだけなら、同じ名前の変数がプロジェクト内に100個あれば、どのファイルへ飛べばよいか本来分からないはずです。

しかし、IDEは同名の変数がどれだけあっても、今カーソルの下にあるコードが「どの宣言を指しているのか」を正確に把握しています。

ではそれはなぜか?

commiter-cliの開発中に得た知見から IDEがコードを単なる文字列としてではなく「意味」として捉える仕組みについて なるべくわかりやすく解説していきます。

1. Cmd+クリックは文字列検索ではない

まず、単純な具体例から考えてみます。次のTypeScriptコードがあったとします。

const value = 1; // Symbol A

function foo() {
  const value = 2; // Symbol B

  console.log(value); // ← Symbol B を参照
}

文字列として見れば、3か所ともまったく同じ "value" という文字列です。

もしIDEのCmd+クリックが「ファイル内の文字列検索」ならば、console.log(value) の部分でクリックしたとき、1行目の const value = 1 なのか、4行目の const value = 2 なのかを特定できません。

しかし、実際にVSCodeなどのIDEでクリックすると、確実に4行目の const value = 2 へジャンプします。外側の value = 1 に飛ぶことはありません。

これはIDEが、現在のスコープや文脈を踏まえて、コード上のIdentifier(識別子)を Symbol(コード上のどの宣言を指しているかを識別するための意味上の対象)として解決しているからです。

外側の value ──→ Symbol A
内側の value ──→ Symbol B
                     ↑
console.log(value) ──┘

文字列としては同じ value でも、意味のうえでは「外側のvalue(Symbol A)」と「内側のvalue(Symbol B)」という別々の実体として区別されています。

そして console.log(value) に置かれた Identifier は「Symbol B を参照している」と解決できているため、IDEは迷わず内側の宣言位置へ移動できます。

2. IdentifierからSymbol、Symbolから定義へ

シリーズ前回、(コードを1文字変えたとき、IDEの中では何が起きているのか)では、構文木によってコードの文字列が構文上のノードとして認識される仕組みについて解説しました。

構文解析を通すことで、エディタはカーソル位置にあるテキストが単なる文字の並びではなく、Identifier(識別子)という構文要素であると理解できます。

ただし、構文木がわかるのは「構文としての種類と階層」までです。「このIdentifierが、どの宣言と同一の実体なのか」という関係性までは、構文木単体では解決できません。

そこで必要になるのが、Symbol を介した解決です。Cmd+クリックの根本にあるのは、

カーソル位置
  ↓
構文上の Identifier
  ↓
Symbol 解決
  ↓
Definition の位置
  ↓
IDE が対象ファイル・位置を開く

  1. Identifier: 構文木上の名前ノード(構文のレイヤー)
  2. Symbol: スコープや型情報を加味して、どの宣言実体を指しているかを一意に束ねる(コードベースに実態はない)意味上の概念(意味のレイヤー)
  3. Definition: そのSymbolが宣言された実際のファイルパスと範囲(物理座標のレイヤー)

の流れです。 IDEは3段階に橋渡しを行うことで、「構文上のトークン」を「物理的なファイル位置」へと変換しています。

3. IDEと言語サービスの間で何が起きるのか

図:IDEとLanguage Server

実際にユーザーがエディタ上でクリックした瞬間、IDEの内部でどのように連携してこの位置を割り出しているのでしょうか。 TypeScriptを例に挙げます、処理経路はこんな感じです(図:IDEとLanguage Server)。

VS Code内部では、まずエディタ側がカーソルのドキュメント位置(行番号と列番号)を取得し、言語拡張機能の DefinitionProvider を呼び出します。

ここから先は、言語解析を専門に担当する仕組みへ処理が渡ります。TypeScriptの場合は tsserver、他の多くの言語ではLSP(Language Server Protocol)に準拠したLanguage Serverがこの役割を担います。

TypeScriptの場合、tsserver に対して definitionAndBoundSpan というリクエストが送られます。リクエストを受け取ったサーバー側は、内部で保持している構文木と型検査・スコープ情報(TypeCheckerやBinder)を参照し、カーソル位置のIdentifierが指しているSymbolを特定します。

そして tsserver は、見つけた宣言について、ファイルパス(file)と開始・終了位置(start / end)を含む定義情報を返します。TypeScript Extension はそれを VS Code API の Location / Range に変換してIDEへ渡します。

最後にIDEがそのレスポンスを受け取り、指定されたファイルをエディタのタブで開き、該当する範囲へカーソルをジャンプさせます。

探索のロジックそのものは言語ごとのコンパイラやLanguage Serverの中にあります。TypeScriptでは VS Code の TypeScript Extension が tsserver とTypeScript独自のプロトコルで通信し、他の多くの言語ではIDEや拡張機能がLanguage ServerとLSPを介して同様の解析結果を受け取り、画面に反映します。

Language Serverとは?

IDEは画面とカーソルを持ち、言語ごとの構文や型、名前がどの宣言を指すかという解析は言語側のサービスが担当します。TypeScriptでは VS Code の TypeScript Extension が tsserver と独自プロトコルで通信します。一方、他の多くの言語ではIDEや拡張機能とLanguage ServerのやりとりをLSP(Language Server Protocol)に揃えています。定義へ飛ぶとき、IDE側は自分で文字列検索するのではなく、この解析機構に「この位置の名前はどこで宣言されているか」と問い合わせ、宣言位置を受け取ります。

4. 同じ仕組みがReferenceやRenameを支えている

「IdentifierからSymbolを特定する」という仕組みが IDEで日常的に使う多くの高度な支援機能の同じ土台になっています。

  • 参照の検索(Find References): 対象のIdentifierからSymbolを特定し、プロジェクト全体の中で「同じSymbolを参照しているすべてのIdentifier」を逆引きして一覧化する
  • シンボルのリネーム(Rename Symbol): 単なる文字列の一括置換ではなく、同一のSymbolを参照している箇所だけをピンポイントで書き換える(スコープ外の同名変数はそのまま残る)
  • 型やドキュメントの表示(Hover): カーソル位置のIdentifierからSymbolを解決し、そのSymbolに結びついた型情報やJSDocを取り出してツールチップに表示する

5. まとめ

何気なく使っているであろうCmd+クリックの裏側では、単なる文字列検索とはまったく異なる技術(シンボル解決)で支えられています。

  • 構文の認識: 構文解析によって、カーソル位置の文字が「Identifier」という構文ノードとして切り出される
  • 意味の解決: スコープや型情報に基づき、そのIdentifierが「どのSymbol」を指しているかが特定される
  • 座標の特定: tsserver や各言語のLanguage Serverが宣言位置を割り出し、IDEがその位置を開く
  • 派生機能への応用: このSymbolの追跡があるからこそ、Find Referencesや安全なRename Symbolが破綻せずに成立する