Featured image of post Claude Codeにclangdを繋いでみた — grep探索とLSPの精度・速度を実測で比べる

Claude Codeにclangdを繋いでみた — grep探索とLSPの精度・速度を実測で比べる

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するとこうなる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ grep -rn "init_session" --include=*.c --include=*.h .
log.c:3:    /* init_session appears here only in a comment ... */
log.c:6:    /* TODO: rewrite init_session to be non-blocking ... */
log.c:8:        printf("init_session failed: timeout\n");
session.c:5:int init_session(Connection *conn, const char *host) {
worker.c:5:static int init_session(int worker_id) {
worker.c:12:    init_session(id);
session.h:10:int init_session(Connection *conn, const char *host);
main.c:8:    init_session(&conn, "example.com");
...   (全10行)

10行ヒットして、そのうち「main.c が呼んでいる init_session の定義」は session.c:5 の1行だけ。残りはコメント・画面表示の文字列・宣言、そして名前が同じなだけの別物(worker.c の関数)だ。人間なら読み分けられるが、grepの結果をそのまま渡されたエージェントは、worker.c の別物と取り違えてもおかしくない。

handle_session に至ってはもっと悪い。

1
2
3
4
5
$ grep -rn "handle_session" --include=*.c --include=*.h .
main.c:3:int handle_session(void);   /* 「こういう関数がある」という宣言だけ */
main.c:10:    handle_session();
handlers.c:5:  /* ...説明コメント中に handle_session という字が出るだけ... */
handlers.h:6:  /* ...同上... */

「呼び出し」と「宣言」は見つかるが、関数の中身(定義)は1行も出てこない。さきほどの仕掛けで handle_session をくっつけて作った名前なので、ソースのどこを探しても handle_session という字はそのまま書かれていない。だからgrepは定義に辿り着けない。

clangdをsudoなしで入れる

clangdはコンパイラのClang一族に含まれるツールで、ソースを「コンパイラと同じ目線」で読んで、関数や変数の正体を把握している。Ubuntuなら apt install clangd-18 が早いが、今回は管理者権限(sudo)に触らず、LLVM公式が配っている単体で動くバイナリを使った。

1
2
3
4
5
curl -sL -o clangd.zip \
  https://github.com/clangd/clangd/releases/download/22.1.0/clangd-linux-22.1.0.zip
unzip -q clangd.zip
ln -sf "$PWD/clangd_22.1.0/bin/clangd" ~/.local/bin/clangd
clangd --version   # clangd version 22.1.0

プロジェクト直下に compile_flags.txt を置けば、ビルドシステムがなくても最低限の解析は動く。

1
2
-std=c11
-I.

本格的に使うなら 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での実測値)。

質問grepclangd(LSP)応答
main.cinit_session呼び出しの定義は?候補10行session.c:5(グローバル版)2 ms
worker.cinit_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頼みの一括置換(文字列を機械的に置き換えるやり方)だと、こんな行も巻き込む。

1
printf("connected to %s on fd %d\n", host, conn->fd);

この行には fd が2回ある。後ろの conn->fd(項目への参照)は直したいが、前のほうの on fd %d画面に出すメッセージの一部なので触ってはいけない。grepには文字列とコードの区別がつかないので両方書き換わり、メッセージが on socket_fd %d に化ける。

clangdの textDocument/rename はこうだった。

1
2
rename Connection::fd -> socket_fd : 5 edits across 3 files
  session.h:5   session.c:6   session.c:8(col 54)   session.c:9   main.c:11

session.c:8 で直したのは54桁目の conn->fd だけ。同じ行のメッセージ内の fd には手を付けていない。clangdは「これは Connectionfd という項目を指している」と分かったうえで、その参照だけを選んでいるからだ。

26万行のファイルでも速いか

小さいプロジェクトで速いのは当たり前なので、現実的な規模も測った。素材はSQLiteが公式に配っている「全ソースを1つにまとめた版」(sqlite3.c257,679行 / 9.1MB の単一ファイル、バージョン 3.46.1)。リポジトリの scripts/bench_sqlite.py がダウンロードから計測まで一括でやる。

1
2
3
[parse]       didOpen後にエラー診断が返るまで : 1.30 秒
[definition]  sqlite3VdbeExec 呼び出しの定義   : 40 ms  -> sqlite3.c:93917
[references]  sqlite3VdbeExec の参照            : 19 ms  -> 4 件

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)。

書いたサーバーを登録して接続を確認する。

1
2
3
4
5
claude mcp add clangd-lsp --scope user -- \
  python3 scripts/lsp_mcp_server.py /path/to/project

claude mcp list
# clangd-lsp: python3 scripts/lsp_mcp_server.py ... - ✓ Connected

✓ Connected が出れば、Claude Codeはgrepの代わりに「この場所の関数の定義はどこ?」をclangd経由で聞けるようになる。ふだんは自分で書かず、公開されているLSP連携のMCPサーバーを使うほうが楽だが、中身がこれだけ単純だと分かると安心して任せられる。

「使える」と「優先する」は別物

ここが一番ハマるところなので最後に書いておく。MCPを登録しても、それは道具を持たせただけだ。どの道具を使うかは毎回エージェントが判断するので、放っておくと結局grepで済ませることも多い。「導入したのに結局grepされる」が典型的なつまずきだ。clangdを優先させるには、別途そう指示する必要がある。レバーは3つ。

1. その場で頼む。init_session の定義、grepじゃなくてclangd(lsp_definition)で辿って」と言えば、その場ではそのとおりにする。一番確実で即効。

2. CLAUDE.md に方針を書く。 プロジェクト直下の CLAUDE.md は毎セッション読み込まれる。ここに方針を置くと、デフォルトの傾向が変わる。

1
2
3
4
5
6
7
## コード探索のルール(C/C++)
- 関数・型・変数の「定義 / 参照 / リネーム」は grep ではなく clangd-lsp を使う:
  定義 → lsp_definition / 参照 → lsp_references / 改名 → lsp_rename
- grep は、コメント・ログ文字列・ビルド設定など
  「コードの構造に関係ないテキスト」を探すときだけ使う。
- 同名関数・static・マクロ生成の名前が絡む箇所は、
  必ず clangd で確認してから編集する。

3. 登録スコープをリポジトリに合わせる。 さきほどのMCPサーバーは「プロジェクトのルートパス」を引数で受ける。チームで使うなら --scope project にすると .mcp.json がリポジトリに入り、全員で共有できる。

1
2
claude mcp add clangd-lsp --scope project -- \
  python3 /path/scripts/lsp_mcp_server.py /path/to/対象リポジトリ

ただし、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 を出すところまでやれば、定義ジャンプとリネームの正確さははっきり変わる。

参考

この記事は Claude Opus 4.8 が執筆しました。

Next Action

おすすめリンク

この記事に合わせて、関連アイテムを探しやすいリンクをまとめています。

Affiliate Links

AIエージェント設計を深掘りする

AIエージェントや開発まわりを、もう少し詳しく学びたい人向けです。

AIエージェント設計の本を探す Claude、LLM、エージェント設計を深掘りしたい時向け
AI開発・Python本を探す API連携や実装まで踏み込みたい時向け
生成AIの本を探す 入門書、活用本、プロンプト本向け

外部ストアへのアフィリエイトリンクです。気になるものだけ開けば十分です。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。
B!