rust-analyzerにEmacsのlsp-modeを使ってサブディレクトリのCargo.tomlを認識させる

背景

ncaq/konokaは、私が個人的に作って使っているClaude Code用のプラグインマーケットプレイスです。

プラグインはplugins/<name>/に配置される構造になっていて、複数のプラグインを管理しています。

rm-to-trashはRustで実装しています

このうちrm-to-trashプラグインは、 Rustで実装しています。

これはrmコマンドの呼び出しを横取りしてtrashコマンドに置換するフックを提供します。コマンド実行のために毎回一瞬だけ起動してすぐ終了するので、軽量なプログラムである必要があります。

パッケージ定義はplugins/rm-to-trash/Cargo.tomlに存在します。

余談: 他はTypeScriptが多い

他に補助プログラムに使っている言語はTypeScriptが多いです。

GitHubとの連携が重要なプラグインが多く、 octokit.jsの存在を考慮すると、 Node.jsで動かすことを決めがちです。

大体の人の環境やCIランナーなどにNode.jsは入っていますし、導入もしやすいです。

Claude Codeのプラグインはセッションスタート時にgit cloneしてきます。

そこでhooks.jsonで、

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PLUGIN_ROOT}/hooks/build",
            "timeout": 500
          }
        ]
      }
    ]
  }
}

のように最初にビルドしろと定義して、

#!/usr/bin/env bash
set -euo pipefail

# `dist/`が存在すればビルド済みなので何もしません。
# プラグイン更新時は`CLAUDE_PLUGIN_ROOT`ごと入れ替わり`dist/`も消えるため、
# この存在チェックだけで初回・更新後の両方を検知できます。
[ -d "${CLAUDE_PLUGIN_ROOT}/dist" ] && exit 0

# ビルドされていなかったのでビルドとインストールを行います。
echo "Installing dependencies and building kyosei plugin..."

# プロジェクトのディレクトリに移動。
cd "${CLAUDE_PLUGIN_ROOT}"
# 依存関係をインストール。
npm ci
# プログラムをビルド。
npm run build

のようにまだビルドされていない場合はビルドします。

なので例えばHaskellを使う場合は指定したバージョンのGHCが動くことを前提にしないといけないんですよね。もしくはNixが動くことを前提にして、 nix-shellとかで依存関係を指定して起動するか。

.NETはランタイムだけなら割と入れることも要求しやすく、 JITコンパイルのおかげで起動も速いので、 octokit.netなどを、 F#で動かすことも考えました。ですがそんなに細かいロジックを実装しているわけでもなく、バリデーションやエラーメッセージを工夫するだけで、ひたすらAPI呼び出しをするだけになりがちです。 octokit.jsの方がライブラリが成熟してそうなので、結局TypeScriptを選びがちです。

最近TypeScriptではEffectを使いだして、これを使いこなせばちょっとTypeScriptの体験が良くなると思い始めました。コンテキスト処理やエラー処理が楽になり、 The ReaderT Design Pattern にちょっと似た感じで悪くないです。

いやまあ正直TypeScriptは色々とつらいので、プログラムが複雑になってきたらF#やHaskellに移行するかもしれませんが。

問題

私はリポジトリルートを~/Desktop/konoka/に置いて作業しています。

私はEmacsを、 lsp-mode経由で rust-analyzerと連携させて、普段Rustコードを編集しています。

今回、 plugins/rm-to-trash/配下のRustファイルを開くと、以下の警告が出ていてLSPが機能しませんでした。

Consider adding the `Cargo.toml` of the workspace to the linkedProjects setting.

rust-analyzerは起動ディレクトリから上方向にCargo.tomlを探索します。ところがkonokaのリポジトリルートにはRustのワークスペース設定が無いため、 Cargo.tomlを発見できないわけです。

問題の整理

参照: Configuration - rust-analyzer

rust-analyzerにはlinkedProjectsという設定があり、これにCargo.tomlへのパスを書いておけば認識されます。そこでこの値をどう与えるかを検討しました。

検討した3案

案A: .dir-locals.ellsp-rust-analyzer-linked-projectsを設定

最終的に採用した案です。エディタ(Emacs+lsp-mode)依存ではあるものの確実に動きます。

メリットは以下の通りです。

  • リポジトリ構造を変えない
  • プラグインの自己完結性を保てる
  • cargoの挙動に影響しない

デメリットはエディタ依存になることです。以下のような他のエディタを使う場合は別途設定が必要になってしまうことでしょう。

それに最近はEmacs公式のLSPクライアントは、どちらかと言うと eglot の方になっている気がします。

私の環境に限れば、このデメリットは実質無視できるのですが、そういったアドホックな方法は私の好むところではありません。

しかし他に良い方法がなかったので最終的にこれを採用することになりました。

もしコントリビュートしたい人がいて、別の環境を使っているのであれば、遠慮なくそのエディタ用の設定を追加してもらえると嬉しいです。

Emacs側の設定

そのままだと毎回Emacs側にローカル変数の警告が出るので、編集して良い変数だと教えてあげる必要があります。

例えばrusticの設定を以下のようにします。

(leaf
 rustic
 :ensure t
 :mode "\\.rs\\'"
 :config
 (put 'lsp-rust-analyzer-linked-projects 'safe-local-variable #'vectorp))

案B: ルートにworkspace用Cargo.tomlを作成

エディタ非依存でcargoの標準機能を使う案です。

[workspace]
members = ["plugins/rm-to-trash"]
resolver = "3"

しかしこれは破綻するため却下しました。

破綻理由はplugins/rm-to-trash/hooks/buildの構造にあります。

cd "${CLAUDE_PLUGIN_ROOT}"
cargo build --release

そしてフック設定は${CLAUDE_PLUGIN_ROOT}/target/release/rm-to-trashを参照しています。ルートにworkspace Cargo.tomlを置くと以下のように壊れます。

  1. cargoは上方向に親Cargo.tomlを探索しルートのworkspaceを発見する
  2. workspaceモードでビルドしてバイナリは<workspace_root>/target/release/に出力される
  3. しかしフックは${CLAUDE_PLUGIN_ROOT}/target/release/(プラグインローカル)を見る
  4. バイナリが見つからずフック起動失敗

回避策としてhooks/build--target-dirを渡すか、 .cargo/config.tomltarget-dirを書く方法もあります。

しかしそうするとプラグインの自己完結性が損なわれて、 konokaリポジトリ外で単独で扱いにくくなるため避けました。

もっと真面目にやるなら、 Claude Code Pluginの単独環境でも動くことを検証した上で、この案を採用するべきかもしれません。

案C: rust-analyzer.toml(ratoml)

リポジトリルートにrust-analyzer.tomlを置けば、 rust-analyzerが直接読み込みエディタ非依存で動くはずでした。

linkedProjects = ["plugins/rm-to-trash/Cargo.toml"]

実際のmonorepoでも使われているように見えるパターンで、 madesroches/micromegasを2026-01時点で確認しています。

しかしこれは現状動かないため却下しました。

却下の決定打はrust-analyzerメンテナのVeykril氏のコメントです。 rust-lang/rust-analyzer#13529で2025-01-16に投稿されています。

Headsup on the status of this, the feature is currently pretty broken ... the reasons for that are architectural which unfortunately means we need to rewrite a decent part

ratomlはアーキテクチャ的に書き直しが必要な状態と書かれています。特にlinkedProjectsは鶏と卵の問題があります。 rust-analyzerがworkspaceを発見しないとratomlを読めず、 ratomlを読まないとlinkedProjectsを取得できないというデッドロックです。

私の手元で使っているrust-analyzerは2025-10-28リリースのもの(nixpkgs由来)です。公式ドキュメントにも以下の記述があります。

(Work in progress:) It is also possible to place configuration in a rust-analyzer.toml file... This is a work in progress, many configuration options aren't supported yet.

将来安定化したら案Cに戻す価値はあると思います。

結論

.dir-locals.ellsp-rust-analyzer-linked-projectsを設定します。値はリストではなくベクターで書く必要があります。これが最大のハマりポイントでした。

((nil . ((lsp-format-buffer-on-save . t)))
 (rustic-mode
  . ((lsp-rust-analyzer-linked-projects
      . ["plugins/rm-to-trash/Cargo.toml"]))))

lsp-rust-analyzer-linked-projectsの型定義はlsp-string-vectorです。コンスリスト(...)で渡すとJSON配列として正しくシリアライズされず、 rust-analyzerはlinkedProjectsの値を空とみなして動作しません。ベクター[...]で渡すとJSON配列として送信され機能します。

切り分けの過程(失敗の記録)

ここまで一直線に書いていますが、正解にたどり着くまでにかなり試行錯誤しました。

試行1: ratoml(案C)

rust-analyzer.tomlをリポジトリルートに作成してコミットしました。 LSPを再起動しても同じエラーが消えませんでした。

その時点では「相対パスの解決が変なのか?」と疑ったのですが、 GitHub Issueを掘った結果ratoml機能自体が機能していないと判明しました。

試行2: dir-localsにリスト形式で設定(案A)

(rustic-mode . ((lsp-rust-analyzer-linked-projects . ("plugins/rm-to-trash/Cargo.toml"))))

エラーメッセージは消えましたが、 LSPが意味不明な動作になりました。 M-x lsp-rust-analyzer-statusを実行すると以下のエラーが出ます。

expected initialize request, got Request { id: RequestId(I32(52)), method: "rust-analyzer/analyzerStatus", ... }

LSPトレースを有効化(M-x lsp-toggle-trace-io)してログを確認すると、レスポンスのJSONはこのようになっていました。

{
  "code": -32002,
  "message": "expected initialize request, got Request..."
}

エラーコード-32002はLSPプロトコルのServerNotInitializedです。 initializeリクエスト前に他のリクエストを送るとサーバが返すエラーです。

試行3: (eval ...)形式で動的に絶対パスを構築

((rustic-mode
  . ((eval
      . (setq-local
         lsp-rust-analyzer-linked-projects
         (list (expand-file-name
                "plugins/rm-to-trash/Cargo.toml"
                (locate-dominating-file
                 default-directory ".dir-locals.el"))))))))

これも動きませんでした。

試行4: 絶対パスをハードコード

/home/ncaq/Desktop/konoka/plugins/rm-to-trash/Cargo.tomlをリスト形式で直書きしました。これも動きません。値はdescribe-variableで正しく反映されているのにLSPが認識しない、という不可解な状態でした。

ブレイクスルー: 型を再確認

lsp-rust-analyzer-linked-projectsのlsp-mode側の型定義を確認すると、 lsp-string-vector(JSON配列に対応するベクター型)でした。

コンスリスト(...)はJSONオブジェクトに化けるか、あるいはlsp-modeのシリアライザがリストを認識せず空として扱うため、 rust-analyzerに送られるlinkedProjectsは実質空配列だった可能性が高いです。

ベクター記法に変えて即解決しました。

(rustic-mode . ((lsp-rust-analyzer-linked-projects . ["plugins/rm-to-trash/Cargo.toml"])))

教訓

lsp-modeのカスタム変数は型をきちんと確認すること。 lsp-string-vector(または類する)のものは、ベクター[...]で渡すこと。

コンスリストは静かに無視される。 Lispだからリストだろうと思い込むのは危険。

describe-variableで値が反映されているように見えても、シリアライズ時に正しくJSON配列にならないなら意味がない。

LSPプロトコルエラー-32002 ServerNotInitializedは、 linkedProjectsが無効値で初期化シーケンスがまともに走れない時にも発生しうる。

rust-analyzerのrust-analyzer.tomlは2026-05時点でWIPで、特にlinkedProjectsは機能しない。実用するならエディタ側で設定する。

monorepo + Cargo workspaceは、フック等がtarget/の出力位置を前提にしている場合に破綻することがある。プラグイン自己完結性とworkspaceメリットは両立しないことがある。

感想

rust-analyzerのratomlがまだ実用にならない状況は意外でした。公式ドキュメントには「Work in progress」と書いてあるのですが、それなりに広まっているパターンだと思っていたので、普通に動くものだと油断していました。

そしてElispのリストとベクターの違いがJSONシリアライズで効いてくるというのは、たまたま変数定義を見て気が付いただけで、本当に偶然でしかなかったため、もっと深くハマる可能性はありました。