Anthropicの大規模コードベース向けベストプラクティス(前回の記事で紹介した)では、C/C++にClaude Codeを入れる前提として「LSPの整備」が名指しされていた。まず用語を3つだけ。
- grep:文字列の一致でファイルを探す検索。Claude Codeは標準でこれを使ってコードを読む。「
init_sessionという字が出てくる行を全部出す」ことはできるが、それが関数なのかコメントなのかは区別できない。 - LSP(Language Server Protocol):エディタが「この関数の定義はどこ?」「使われている場所を全部出して」と問い合わせるための共通の仕組み。文字列ではなく、コードの構造(どれが関数でどれが変数か)を理解した上で答える。
- clangd:そのLSPの問い合わせに答える、C/C++用の常駐プログラム(言語サーバー)。今回これを使う。
#include・マクロ・型が絡むC/C++は、grepの文字列検索だけではまともに辿れない、というのがAnthropicの主張だ。前回はこの話を紹介しただけだったので、今回は手元のWSL2にclangdを入れ、Claude Codeから問い合わせられるよう繋ぐところまで実際にやって、grepとどれだけ差が出るかを測った。
結論から書くと、同じ名前の関数を呼び出し位置ごとに正しく区別し、マクロが組み立てた関数の定義まで一発で当て、26万行のファイルでも定義ジャンプは数十ミリ秒で返ってきた。grepでは原理的に無理な範囲だ。
ここで使ったCプロジェクトとスクリプト一式は GitHub に置いた。git clone して bash run-demo.sh を叩けば、以下の比較が手元でそのまま再現できる(clangd の用意も含めて自動)。会社のコードベースに同じ手順で入れたい人もこれをたたき台にできる。
grepは「同じ名前」で詰まる
まず素のClaude Codeがやるテキスト検索の限界を確認する。次のような小さなCプロジェクトを用意した。狙いは、grepが引っかかる罠を意図的に仕込むこと。
init_sessionという関数をsession.cに置き、main.cから呼ぶ(プロジェクト全体で使う本物)worker.cにも同じ名前init_sessionの別物の関数を置く。引数が違うし、「このファイルの中だけで使う関数」(C言語のstatic)なので、名前が同じでも衝突せず共存できる。grepにはどちらも同じ字にしか見えないlog.cのコメントと、画面に出すメッセージの文字列の中にもinit_sessionという字を入れておく(コードではないが、grepは拾う)handlers.cでは、handle_と渡した名前をくっつけて関数名を作る仕掛けを使う。C言語のマクロ(コンパイル前に行う機械的な置き換え)で、handle_##nameの##が文字どうしを連結する。DEFINE_HANDLER(session)と書くとhandle_sessionという関数ができあがる。ソースにはhandle_sessionという字はどこにも書かれていない
この状態で init_session をgrepするとこうなる。
| |
10行ヒットして、そのうち「main.c が呼んでいる init_session の定義」は session.c:5 の1行だけ。残りはコメント・画面表示の文字列・宣言、そして名前が同じなだけの別物(worker.c の関数)だ。人間なら読み分けられるが、grepの結果をそのまま渡されたエージェントは、worker.c の別物と取り違えてもおかしくない。
handle_session に至ってはもっと悪い。
| |
「呼び出し」と「宣言」は見つかるが、関数の中身(定義)は1行も出てこない。さきほどの仕掛けで handle_ と session をくっつけて作った名前なので、ソースのどこを探しても handle_session という字はそのまま書かれていない。だからgrepは定義に辿り着けない。
clangdをsudoなしで入れる
clangdはコンパイラのClang一族に含まれるツールで、ソースを「コンパイラと同じ目線」で読んで、関数や変数の正体を把握している。Ubuntuなら apt install clangd-18 が早いが、今回は管理者権限(sudo)に触らず、LLVM公式が配っている単体で動くバイナリを使った。
| |
プロジェクト直下に compile_flags.txt を置けば、ビルドシステムがなくても最低限の解析は動く。
| |
本格的に使うなら compile_commands.json(コンパイルデータベース)を用意したほうが正確になる。CMakeなら -DCMAKE_EXPORT_COMPILE_COMMANDS=ON、Makefileなら bear -- make で生成できる。
LSPに直接聞くと、何が返るか
clangdは、決まった形式のメッセージを送ると答えを返してくれるプログラムだ(その「決まった形式」がLSP)。Claude Codeに繋ぐ前に、まずclangd単体の実力を見ておきたい。そこで、clangdにメッセージを送って返事をそのまま表示するだけの小さなプログラム(リポジトリの scripts/lsp_probe.py、標準ライブラリだけの200行ほど)を用意し、grepと同じ質問をぶつけてみた。結果が以下(応答時間はマシンで前後する。手元のWSL2での実測値)。
| 質問 | grep | clangd(LSP) | 応答 |
|---|---|---|---|
main.cのinit_session呼び出しの定義は? | 候補10行 | session.c:5(グローバル版) | 2 ms |
worker.cのinit_session呼び出しの定義は? | 候補10行(区別不能) | worker.c:5(ローカルstatic版) | 4 ms |
handle_sessionの定義は? | 0行 | handlers.c:7(マクロ展開先) | 2 ms |
同じ init_session という呼び出しでも、clangdは呼ぶ場所のスコープを見て、main.c からはグローバル定義、worker.c からはファイルローカルのstatic関数へと、別々の定義に飛ばす。マクロが組み立てた handle_session も、展開後の定義位置を正確に返す。grepが0行だった質問に、数ミリ秒で答えが返る。
リネームは文字列を壊さない
精度の差がいちばんはっきり出るのはリネーム(名前の一括変更)だ。Connection というデータのかたまりが持つ fd という項目を、socket_fd に改名したい。grep頼みの一括置換(文字列を機械的に置き換えるやり方)だと、こんな行も巻き込む。
| |
この行には fd が2回ある。後ろの conn->fd(項目への参照)は直したいが、前のほうの on fd %d は画面に出すメッセージの一部なので触ってはいけない。grepには文字列とコードの区別がつかないので両方書き換わり、メッセージが on socket_fd %d に化ける。
clangdの textDocument/rename はこうだった。
| |
session.c:8 で直したのは54桁目の conn->fd だけ。同じ行のメッセージ内の fd には手を付けていない。clangdは「これは Connection の fd という項目を指している」と分かったうえで、その参照だけを選んでいるからだ。
26万行のファイルでも速いか
小さいプロジェクトで速いのは当たり前なので、現実的な規模も測った。素材はSQLiteが公式に配っている「全ソースを1つにまとめた版」(sqlite3.c、257,679行 / 9.1MB の単一ファイル、バージョン 3.46.1)。リポジトリの scripts/bench_sqlite.py がダウンロードから計測まで一括でやる。
| |
26万行を最初に読み込む解析が1.3秒。その後の定義ジャンプは数十ミリ秒、参照検索は20ミリ秒弱で返る。一度パースしてしまえば、ファイルサイズはほぼ関係ない。
参照検索の中身も見ておく。sqlite3VdbeExec をgrepすると8行ヒットするが、内訳は宣言1・定義1・呼び出し2・コメント4だ。半分がノイズ。clangdが返した4件は宣言・定義・呼び出し2件ちょうどで、コメント4行はきれいに落ちている。
Claude CodeにMCPで繋ぐ
LSPの値打ちは分かった。あとはこれをClaude Codeから使えるようにする。間に挟むのがMCP(Model Context Protocol)サーバー、つまりClaude Codeに外部ツールを追加するための仲介役だ。今回は「定義を調べる」「参照を調べる」「改名する」の3つの道具をClaude Codeに見せ、Claude Codeがその道具を使うたびに、裏でclangdへ問い合わせて結果を返すだけの小さな仲介役を、Pythonの標準ライブラリだけで書いた(リポジトリの scripts/lsp_mcp_server.py。サーバー名 clangd-lsp-bridge)。
書いたサーバーを登録して接続を確認する。
| |
✓ Connected が出れば、Claude Codeはgrepの代わりに「この場所の関数の定義はどこ?」をclangd経由で聞けるようになる。ふだんは自分で書かず、公開されているLSP連携のMCPサーバーを使うほうが楽だが、中身がこれだけ単純だと分かると安心して任せられる。
「使える」と「優先する」は別物
ここが一番ハマるところなので最後に書いておく。MCPを登録しても、それは道具を持たせただけだ。どの道具を使うかは毎回エージェントが判断するので、放っておくと結局grepで済ませることも多い。「導入したのに結局grepされる」が典型的なつまずきだ。clangdを優先させるには、別途そう指示する必要がある。レバーは3つ。
1. その場で頼む。 「init_session の定義、grepじゃなくてclangd(lsp_definition)で辿って」と言えば、その場ではそのとおりにする。一番確実で即効。
2. CLAUDE.md に方針を書く。 プロジェクト直下の CLAUDE.md は毎セッション読み込まれる。ここに方針を置くと、デフォルトの傾向が変わる。
| |
3. 登録スコープをリポジトリに合わせる。 さきほどのMCPサーバーは「プロジェクトのルートパス」を引数で受ける。チームで使うなら --scope project にすると .mcp.json がリポジトリに入り、全員で共有できる。
| |
ただし、CLAUDE.md に書いても100%は強制できない。「ここはgrepで十分」とエージェントが判断する余地は残る。確実に通したいその一回は、明示で「clangdで」と言うのが速い。3つを揃えると、C/C++のコード探索は体感かなりclangd側に寄る。
Rust・TypeScriptでも同じか
今回はclangdで測ったが、LSPという共通の仕組みに乗っているだけなので、言語サーバーを取り替えれば他の言語でも同じことができる。Rustなら rust-analyzer、TypeScriptなら typescript-language-server だ。grepが苦手なのは「同じ名前が別の意味で使われる」「マクロなどで名前を組み立てる」「同じ名前でも場所によって別物になる」コードで、複雑な言語ほどこれが増える。逆に、名前がそのまま素直に並んでいるコードなら、grepでも十分間に合う。LSPを入れる手間が見合うかは、言語とコードの複雑さしだいだ。
C/C++で「Claude Codeがコードを取り違える」のが気になっているなら、clangdの導入は1日かからない。compile_commands.json を出すところまでやれば、定義ジャンプとリネームの正確さははっきり変わる。
参考
- hide10/clangd-lsp-vs-grep — この記事の再現用リポジトリ(デモCプロジェクト・LSPプローブ・MCPサーバー・SQLite計測スクリプト)
- How Claude Code works in large codebases: Best practices and where to start | Anthropic
- clangd — Getting started
- Language Server Protocol Specification
- Model Context Protocol
この記事は Claude Opus 4.8 が執筆しました。
