はじめに
先週、エージェントが書いた200行のコードを開いた。move |ctx| { ... } というクロージャがあった。この ctx は何をキャプチャしているのか。所有権は移動しているのか、借用なのか。コードを読んでも分からない。コンパイルしてエラーが出るまで待つか、エージェントに「このクロージャは何をキャプチャしてる?」と聞くか。どちらも面倒だった。
エージェントにすべてを任せれば楽になる、と思っていた時期がある。しかし1ヶ月ほど使って気づいた。大きな変更はエージェントが得意だ。ファイルを跨いだリファクタリング、新機能の実装、テストの追加。これらは確かにエージェントに任せた方が速い。しかし、生成されたコードの一部だけを直したいとき、エージェントに再度依頼するのは効率が悪い。「この unwrap() を ? に変えたい」「このクロージャの引数名を直したい」「この変数を別のスコープに移動したい」といった微修正は、手で直した方が速い。
私は1日の開発時間のうち、7割をエージェントとの対話に、3割をNeovimでの直接作業に使っている。この3割のほとんどはコードリーディングとコードベースの理解で、実際に手で修正するのは5%程度だ。それでも、コードを「読む」環境が貧弱だと、全体の生産性が落ちる。
Neovimを使う理由は、思考のスピードで編集できるからだ。ciw で単語を置換し、. で繰り返し、/ で検索して n で次へ。この一連の操作が指に染み付いていると、「直したい」と思った瞬間に直せる。エージェントに依頼を書いて、結果を待って、差分を確認する時間がない。
私は Claude Code も Neovim から起動している。ターミナルで claude を叩き、エージェントと対話し、生成されたコードを Neovim で開いて確認する。すべてが同じ環境で完結する。エージェントが書いたコードを「読む」環境と、細部を「直す」環境。この両方が揃って初めて、エージェント時代の開発は快適になる。
この記事では、Neovim 0.11+ と Rust の開発環境を見直した結果を紹介する。冒頭で困っていた「クロージャのキャプチャが分からない」問題を解決する Inlay Hints と、素早い修正を可能にするキーマップに重点を置いている。
構成の概要
私の開発環境全体については以下の記事で紹介している。本記事では Rust 関連の設定に絞って解説する。
私の Neovim 環境は NvChad をベースにしている。プラグイン管理には lazy.nvim を使い、Rust 関連は以下の構成だ。
| コンポーネント | 役割 |
|---|---|
| rust-analyzer | LSP(言語サーバー)、nvim-lspconfig 経由で設定 |
| rustaceanvim | Rust専用の拡張機能(テスト実行、マクロ展開など) |
| crates.nvim | Cargo.toml の依存関係管理 |
| nvim-dap | デバッグサポート(LLDB連携) |
| conform.nvim | フォーマッタ(rustfmt) |
| mason.nvim | LSP/DAP のインストール管理 |
エージェント連携として claudecode.nvim も入れている。Neovim から Claude Code を起動し、生成されたコードをそのまま編集できる。
以下では、この構成を「コードを読む」→「テスト・実行する」→「細部を直す」の順で紹介する。rust-analyzer と Inlay Hints でコードを理解し、rustaceanvim でテストを回し、conform.nvim でフォーマットを整える。この流れが、エージェントが書いたコードを確認・修正するワークフローと対応している。
rust-analyzer の設定
rust-analyzer は Rust の公式 LSP(Language Server Protocol)実装だ。LSP とは、エディタに補完、定義ジャンプ、エラー表示などの機能を提供するプロトコルで、VSCode でも Neovim でも同じ言語サーバーを使える。エージェントが生成したコードを読む際、型やライフタイム(Rust特有のメモリ管理の仕組みで、参照がいつまで有効かを示す)の情報がインラインで表示されると理解が格段に速くなる。
私は nvim-lspconfig 経由で設定している。Neovim 0.11+ では vim.lsp.config API が使えるようになり、設定がシンプルになった。
-- lua/configs/lspconfig.lua rust_analyzer = { settings = { ["rust-analyzer"] = { checkOnSave = { command = "clippy", extraArgs = { "--all", "--", "-W", "clippy::all" }, }, cargo = { allFeatures = true, loadOutDirsFromCheck = true, buildScripts = { enable = true }, }, procMacro = { enable = true, attributes = { enable = true }, }, inlayHints = { enable = true, chainingHints = { enable = true }, typeHints = { enable = true, hideClosureInitialization = true }, parameterHints = { enable = true }, closureReturnTypeHints = { enable = "with_block" }, lifetimeElisionHints = { enable = "skip_trivial", useParameterNames = true }, maxLength = 25, bindingModeHints = { enable = true }, closureCaptureHints = { enable = true }, discriminantHints = { enable = "fieldless" }, expressionAdjustmentHints = { enable = "reborrow" }, rangeExclusiveHints = { enable = true }, }, completion = { autoimport = { enable = true }, postfix = { enable = true }, callable = { snippets = "fill_arguments" }, fullFunctionSignatures = { enable = true }, privateEditable = { enable = true }, }, imports = { granularity = { group = "module" }, prefix = "self", }, diagnostics = { enable = true, experimental = { enable = true }, styleLints = { enable = true }, }, semanticHighlighting = { operator = { specialization = { enable = true } }, punctuation = { enable = true, specialization = { enable = true } }, strings = { enable = true }, }, hover = { actions = { enable = true, references = { enable = true }, run = { enable = true }, debug = { enable = true }, gotoTypeDef = { enable = true }, implementations = { enable = true }, }, documentation = { enable = true, keywords = { enable = true } }, links = { enable = true }, }, typing = { autoClosingAngleBrackets = { enable = true }, }, lens = { enable = true, references = { enable = true, adt = { enable = true }, enumVariant = { enable = true }, method = { enable = true }, trait = { enable = true } }, implementations = { enable = true }, run = { enable = true }, debug = { enable = true }, }, workspace = { symbol = { search = { kind = "all_symbols" } }, }, }, }, } -- Neovim 0.11+ の新しい API を使用 vim.lsp.config("rust_analyzer", config) vim.lsp.enable("rust_analyzer")
主な設定項目
inlayHints - エージェントが生成したコードを読むとき、最も役立つのがこれだ。エディタ内にインラインで型情報やパラメータ名が表示される。特に closureCaptureHints は重宝している。Rustではクロージャ(|x| x + 1 のような無名関数)が外部の変数を使うとき、その変数を「キャプチャ」する。move |data| { ... } と書くと、data の所有権がクロージャに移動(ムーブ)する。この「何がキャプチャされているか」がエディタ上に [move: data] と表示されるようになる。エージェントが書いたクロージャを理解するのに、コンパイルエラーを待つ必要がなくなった。rangeExclusiveHints も地味に便利で、0..len が排他的(len を含まない)であることを .. の横に明示してくれる。
diagnostics.experimental - 実験的な診断機能を有効化する。styleLints を有効にすると、Clippy のスタイル系リントも保存時に表示される。エージェントが生成したコードは動くが、慣用的でないことがある。この設定で「動くけど直したほうがいい」箇所が分かる。
semanticHighlighting - 演算子や句読点に対してセマンティックハイライトを適用する。*self.data のような式で * が参照外しとして色付けされると、複雑な式の構造が視覚的に分かる。ただし、色数を増やしすぎるとノイズになるので、私は operator と punctuation のみ有効にしている。
hover.actions - ホバー時に「Run」「Debug」「Go to Type Definition」などのアクションを表示する。エージェントが追加したテストを実行したいとき、テスト関数にカーソルを合わせて K を押すだけで実行できる。
typing.autoClosingAngleBrackets - Vec< と入力すると自動的に > が補完される。エージェントが書いた型を微修正するとき、> の数を数えなくて済む。
rustaceanvim の設定
rust-analyzer がコードを「読む」ための機能を提供するのに対し、rustaceanvim は「テスト・実行・デバッグ」のための機能を提供する。両者は補完関係にあり、rust-analyzer の LSP 機能に加えて、Rust 特有の操作(マクロ展開、テスト実行など)を追加する。
rustaceanvim は rust-tools.nvim の後継だが、単なるメンテナンス引き継ぎではない。最大の違いは遅延読み込みへの対応で、.rs ファイルを開くまで何も読み込まない。私の環境では Neovim の起動時間が 120ms から 45ms に短縮された。
エージェント時代に rustaceanvim が重要な理由は、「素早い確認と修正」のワークフローを支えることにある。エージェントがコードを生成したら、テストを実行し、エラーがあれば修正し、また実行する。このサイクルを <leader>rt(テスト実行)と <leader>re(エラー説明)で高速に回せる。
-- lua/plugins/lang.lua { "mrcjkb/rustaceanvim", version = "^5", lazy = false, init = function() vim.g.rustaceanvim = { tools = { hover_actions = { replace_builtin_hover = false }, float_win_config = { border = "rounded" }, inlay_hints = { auto = true }, code_actions = { ui_select_fallback = true }, }, server = { on_attach = function(_, bufnr) local opts = { silent = true, buffer = bufnr } vim.keymap.set("n", "<leader>ra", function() vim.cmd.RustLsp "codeAction" end, vim.tbl_extend("force", opts, { desc = "Rust code action" })) vim.keymap.set("n", "<leader>rd", function() vim.cmd.RustLsp "debuggables" end, vim.tbl_extend("force", opts, { desc = "Rust debuggables" })) vim.keymap.set("n", "<leader>rr", function() vim.cmd.RustLsp "runnables" end, vim.tbl_extend("force", opts, { desc = "Rust runnables" })) vim.keymap.set("n", "<leader>rt", function() vim.cmd.RustLsp "testables" end, vim.tbl_extend("force", opts, { desc = "Rust testables" })) vim.keymap.set("n", "<leader>rm", function() vim.cmd.RustLsp "expandMacro" end, vim.tbl_extend("force", opts, { desc = "Expand macro" })) vim.keymap.set("n", "<leader>rc", function() vim.cmd.RustLsp "openCargo" end, vim.tbl_extend("force", opts, { desc = "Open Cargo.toml" })) vim.keymap.set("n", "<leader>rp", function() vim.cmd.RustLsp "parentModule" end, vim.tbl_extend("force", opts, { desc = "Parent module" })) vim.keymap.set("n", "<leader>rj", function() vim.cmd.RustLsp "joinLines" end, vim.tbl_extend("force", opts, { desc = "Join lines" })) vim.keymap.set("n", "<leader>rs", function() vim.cmd.RustLsp "ssr" end, vim.tbl_extend("force", opts, { desc = "Structural search replace" })) vim.keymap.set("n", "<leader>re", function() vim.cmd.RustLsp "explainError" end, vim.tbl_extend("force", opts, { desc = "Explain error" })) vim.keymap.set("n", "<leader>rD", function() vim.cmd.RustLsp "renderDiagnostic" end, vim.tbl_extend("force", opts, { desc = "Render diagnostic" })) vim.keymap.set("n", "K", function() vim.cmd.RustLsp { "hover", "actions" } end, vim.tbl_extend("force", opts, { desc = "Rust hover actions" })) end, default_settings = { ["rust-analyzer"] = { cargo = { allFeatures = true }, checkOnSave = { command = "clippy" }, }, }, }, dap = { adapter = { type = "executable", command = "lldb-dap", name = "rt_lldb", }, }, } end, },
キーマップ一覧
| キー | 機能 |
|---|---|
<leader>ra |
コードアクション |
<leader>rr |
実行可能ターゲットを選択して実行 |
<leader>rt |
テストを選択して実行 |
<leader>rd |
デバッグ実行 |
<leader>rm |
カーソル位置のマクロを展開 |
<leader>re |
エラーの詳細説明を表示 |
<leader>rD |
診断をレンダリング |
<leader>rs |
構造的検索置換(SSR) |
<leader>rp |
親モジュールに移動 |
<leader>rj |
行を結合 |
<leader>rc |
Cargo.toml を開く |
K |
ホバーアクション付きドキュメント |
crates.nvim の設定
Rustでは Cargo.toml というファイルで依存ライブラリ(クレートと呼ぶ)を管理する。Node.js の package.json、Python の requirements.txt に相当するものだ。クレートには「フィーチャー」という機能のオン・オフがあり、必要な機能だけを有効にしてビルドサイズを抑えることができる。
エージェントに「tokio を追加して」と頼むと、だいたい最新版を入れてくれる。しかし、フィーチャーの選択は雑なことが多い。tokio = { version = "1", features = ["full"] } と書かれていて、「full はオーバーキルだな、macros と rt-multi-thread だけでいいのに」と思うことがある。
crates.nvim があれば、Cargo.toml 上でクレートにカーソルを合わせて <leader>cf を押すだけで、フィーチャー一覧がポップアップする。必要なものだけ選んで、不要なものは外す。この微調整はエージェントに頼むより、手でやった方が速い。
-- lua/plugins/lang.lua { "saecki/crates.nvim", tag = "stable", event = { "BufRead Cargo.toml" }, dependencies = { "nvim-lua/plenary.nvim" }, config = function() local crates = require "crates" crates.setup { completion = { cmp = { enabled = true }, crates = { enabled = true, max_results = 8, min_chars = 3 }, }, lsp = { enabled = true, on_attach = function(_, bufnr) local opts = { silent = true, buffer = bufnr } vim.keymap.set("n", "<leader>ct", crates.toggle, vim.tbl_extend("force", opts, { desc = "Toggle crates" })) vim.keymap.set("n", "<leader>cr", crates.reload, vim.tbl_extend("force", opts, { desc = "Reload crates" })) vim.keymap.set("n", "<leader>cv", crates.show_versions_popup, vim.tbl_extend("force", opts, { desc = "Show versions" })) vim.keymap.set("n", "<leader>cf", crates.show_features_popup, vim.tbl_extend("force", opts, { desc = "Show features" })) vim.keymap.set("n", "<leader>cd", crates.show_dependencies_popup, vim.tbl_extend("force", opts, { desc = "Show dependencies" })) vim.keymap.set("n", "<leader>cu", crates.update_crate, vim.tbl_extend("force", opts, { desc = "Update crate" })) vim.keymap.set("v", "<leader>cu", crates.update_crates, vim.tbl_extend("force", opts, { desc = "Update crates" })) vim.keymap.set("n", "<leader>cU", crates.upgrade_crate, vim.tbl_extend("force", opts, { desc = "Upgrade crate" })) vim.keymap.set("v", "<leader>cU", crates.upgrade_crates, vim.tbl_extend("force", opts, { desc = "Upgrade crates" })) vim.keymap.set("n", "<leader>cA", crates.upgrade_all_crates, vim.tbl_extend("force", opts, { desc = "Upgrade all crates" })) vim.keymap.set("n", "<leader>cH", crates.open_homepage, vim.tbl_extend("force", opts, { desc = "Open homepage" })) vim.keymap.set("n", "<leader>cR", crates.open_repository, vim.tbl_extend("force", opts, { desc = "Open repository" })) vim.keymap.set("n", "<leader>cD", crates.open_documentation, vim.tbl_extend("force", opts, { desc = "Open docs.rs" })) vim.keymap.set("n", "<leader>cC", crates.open_crates_io, vim.tbl_extend("force", opts, { desc = "Open crates.io" })) end, actions = true, completion = true, hover = true, }, popup = { border = "rounded", show_version_date = true, max_height = 30, min_width = 20, }, } end, },
キーマップ一覧
| キー | 機能 |
|---|---|
<leader>ct |
crates.nvim の表示切り替え |
<leader>cr |
クレート情報を再読み込み |
<leader>cv |
バージョン一覧をポップアップ表示 |
<leader>cf |
フィーチャー一覧を表示 |
<leader>cd |
依存関係を表示 |
<leader>cu |
クレートを最新パッチバージョンに更新(ビジュアルモードで複数選択可) |
<leader>cU |
クレートを最新バージョンにアップグレード |
<leader>cA |
すべてのクレートをアップグレード |
<leader>cH |
クレートのホームページを開く |
<leader>cR |
クレートの GitHub リポジトリを開く |
<leader>cD |
docs.rs を開く |
<leader>cC |
crates.io を開く |
nvim-dap によるデバッグ
エージェントが生成したコードで「なぜこの値になるのか分からない」という場面がある。println デバッグで済むこともあるが、複雑なロジックでは変数の変化を追いたくなる。
Rust のデバッグには LLDB を使う。LLDB は C/C++/Rust などのコンパイル言語向けのデバッガで、ブレークポイント(プログラムを一時停止する地点)を設定し、変数の中身を確認しながらステップ実行できる。macOS の場合は Homebrew で LLVM をインストールし、その中に含まれる lldb-dap(DAP = Debug Adapter Protocol)を使う。
-- lua/plugins/lang.lua { "mfussenegger/nvim-dap", lazy = true, dependencies = { "rcarriga/nvim-dap-ui", "nvim-neotest/nvim-nio", "theHamsta/nvim-dap-virtual-text", }, keys = { { "<leader>db", function() require("dap").toggle_breakpoint() end, desc = "Toggle breakpoint" }, { "<leader>dB", function() require("dap").set_breakpoint(vim.fn.input "Breakpoint condition: ") end, desc = "Conditional breakpoint" }, { "<leader>dc", function() require("dap").continue() end, desc = "Continue" }, { "<leader>dC", function() require("dap").run_to_cursor() end, desc = "Run to cursor" }, { "<leader>di", function() require("dap").step_into() end, desc = "Step into" }, { "<leader>do", function() require("dap").step_over() end, desc = "Step over" }, { "<leader>dO", function() require("dap").step_out() end, desc = "Step out" }, { "<leader>dp", function() require("dap").pause() end, desc = "Pause" }, { "<leader>dr", function() require("dap").repl.toggle() end, desc = "Toggle REPL" }, { "<leader>dt", function() require("dap").terminate() end, desc = "Terminate" }, { "<leader>du", function() require("dapui").toggle() end, desc = "Toggle DAP UI" }, { "<leader>de", function() require("dapui").eval() end, desc = "Eval", mode = { "n", "v" } }, }, config = function() local dap = require "dap" local dapui = require "dapui" -- DAP UI setup with custom layout dapui.setup { icons = { expanded = "▾", collapsed = "▸", current_frame = "▸" }, layouts = { { elements = { { id = "scopes", size = 0.25 }, { id = "breakpoints", size = 0.25 }, { id = "stacks", size = 0.25 }, { id = "watches", size = 0.25 }, }, size = 40, position = "left", }, { elements = { { id = "repl", size = 0.5 }, { id = "console", size = 0.5 }, }, size = 10, position = "bottom", }, }, } -- Virtual text for debugging require("nvim-dap-virtual-text").setup { enabled = true, commented = true } -- LLDB adapter dap.adapters.lldb = { type = "executable", command = "/opt/homebrew/opt/llvm/bin/lldb-dap", name = "lldb", } dap.configurations.rust = { { name = "Launch", type = "lldb", request = "launch", program = function() return vim.fn.input("Path to executable: ", vim.fn.getcwd() .. "/target/debug/", "file") end, cwd = "${workspaceFolder}", stopOnEntry = false, args = {}, runInTerminal = false, }, } -- Auto open/close DAP UI dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end -- Signs vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DapBreakpoint" }) vim.fn.sign_define("DapBreakpointCondition", { text = "●", texthl = "DapBreakpointCondition" }) vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DapStopped", linehl = "DapStoppedLine" }) end, },
キーマップ一覧
| キー | 機能 |
|---|---|
<leader>db |
ブレークポイントの切り替え |
<leader>dB |
条件付きブレークポイント |
<leader>dc |
続行 |
<leader>dC |
カーソル位置まで実行 |
<leader>di |
ステップイン |
<leader>do |
ステップオーバー |
<leader>dO |
ステップアウト |
<leader>dp |
一時停止 |
<leader>dr |
REPL の切り替え |
<leader>dt |
終了 |
<leader>du |
DAP UI の切り替え |
<leader>de |
カーソル位置の式を評価(ビジュアルモードでも使用可) |
conform.nvim でのフォーマット
保存時に rustfmt を自動実行する。エージェントが生成したコードはフォーマットが崩れていることがあるので、保存するだけで整形されるのは便利だ。
-- lua/plugins/lsp.lua { "stevearc/conform.nvim", event = "BufWritePre", config = function() require("conform").setup { formatters_by_ft = { rust = { "rustfmt", lsp_format = "fallback" }, -- 他の言語も設定可能 }, format_on_save = { timeout_ms = 500, lsp_fallback = true }, } end, },
ここまでで「コードを読む」「テスト・実行する」「細部を直す」の設定が揃った。最後に、これらのツール自体をどうインストールするかを紹介する。
Mason でのツールインストール
LSP サーバーやデバッグアダプタは Mason で管理する。:MasonInstall で個別にインストールすることもできるが、ensure_installed に書いておけば自動でインストールされる。
-- lua/plugins/lsp.lua { "williamboman/mason.nvim", opts = { ensure_installed = { "rust-analyzer", "codelldb", -- DAP adapter -- 他の言語のツールも同様に追加 }, }, },
まとめ
この設定を入れた翌日、またエージェントが書いたコードを開いた。move |ctx| { ... } というクロージャがある。今度は違った。クロージャの横に [move: ctx, config] と表示されている。何がキャプチャされているか、一目で分かる。コンパイルを待つ必要も、エージェントに聞く必要もない。
エージェントに任せる7割と、自分で読む3割。この3割を Neovim で快適にすることが、全体の生産性につながる。rust-analyzer の Inlay Hints でコードを読み、rustaceanvim のキーマップでテストを回し、思考のスピードで細部を直す。エージェント時代だからこそ、手元のエディタは大事だ。