Aでは動くのにBでは動かないNeovimのデバッグ設定を見直した

VS Codeでも、デバッグを実行してもうまく動かなかったり、ショートカットキーから始まらなかったりすることは、たまにありました。ただ、今回の直接のきっかけはNeovimです。

Geminiにデバッグ設定を作らせ、その時に開いていたプロジェクトでは使えるようになりました。ところが別のプロジェクトへ移ると動かない。設定を見直すと、プロジェクト名や実行ファイルの場所に依存する部分が残っていました。

NeovimではPython、Go、TypeScript、C#をnvim-dapへまとめています。キーを共通化すれば操作は覚えやすくなりますが、それだけではプロジェクトが変わった時に動きません。

今回、設定をWindowsとWSL2の両方で使えるように見直す中で、根本原因になりそうな箇所を洗い出しました。固定パスをなくすだけでは足りません。現在のファイルからプロジェクトルートと実行対象を決めること、.vscodeの設定を読む前に中身を見ることも必要でした。

F5の裏側には三つの設定がある

nvim-dapの公式READMEでは、nvim-dapはNeovim用のDebug Adapter Protocolクライアントと説明されています。nvim-dapだけで各言語をデバッグするわけではありません。

Neovim / nvim-dap
  -> 言語別のデバッグアダプター
    -> 実際に起動するプログラム

私の環境では次の組み合わせです。

言語 アダプター 実行対象の決め方
Python debugpy 現在のファイルと、そのプロジェクトの仮想環境
Go Delve 現在のファイルまたはパッケージ
TypeScript js-debug 現在のファイル、またはlaunch.json
C# NetCoreDbg 現在のソースに最も近いcsprojのDLL

F5が効かない時は、キー設定だけを見ても原因を特定できません。プロジェクトの場所、アダプターの実行ファイル、デバッグ対象の三つを分けて確認する必要があります。

操作キーは言語をまたいで共通にする

操作はFキーと<leader>dへ寄せています。<leader>はSpaceです。

キー 動作
<F5> デバッグ開始・続行
<F10> ステップオーバー
<F11> ステップイン
<F12> ステップアウト
<leader>db ブレークポイントを切り替える
<leader>du 変数、スタック、ブレークポイントのUIを開閉する
<leader>de カーソル下または選択した式を評価する
<leader>dr DAPのREPLを開く
<leader>dl 直前のデバッグ構成をもう一度実行する
<leader>dt デバッグセッションを終了する
<leader>do ビルドなどの事前タスクの出力を見る
<leader>dn 前回とは別の新しいセッションを始める

基本の流れは、止めたい行で<leader>dbF5で開始、止まったらF10F11です。値を詳しく見たい時だけ<leader>deかDAP UIを使います。

ショートカットを増やすより、言語が変わっても同じキーで動く方を優先しました。

最初にプロジェクトルートを確定する

F5を押す前に、現在のファイルからプロジェクトルートへ作業ディレクトリを合わせます。次は私のキー定義の抜粋です。config.projectは、次の節で載せるlua/config/project.luaです。

{
  "<F5>",
  function()
    local project = require("config.project")
    project.use_root()
    require("dap").continue()
  end,
  desc = "Debug: Start/Continue",
}

ルートの目印にはgo.modpackage.jsonpyproject.toml.vscode.gitなどを使います。.NETではファイル名が固定ではないため、拡張子を判定します。

function(name)
  local extension = vim.fs.ext(name)
  return extension == "sln"
    or extension == "slnx"
    or extension == "csproj"
end

以前の設定には"*.csproj""*.sln"をそのまま渡していました。しかしNeovimのvim.fs.root()で文字列のglobは使えません。Neovimの公式Luaドキュメントにも、任意のcsprojを探す例では判定関数が使われています。

見た目は正しそうな設定でも、実際にはルートを検出できず、Neovimを起動した場所へ依存していました。Aでは動き、Bでは動かない状態を生みやすい部分です。

lua/config/project.luaのルート検出とC#実行対象の抜粋を表示する
local M = {}

M.markers = {
  ".vscode", "go.mod", "package.json", "tsconfig.json",
  "pyproject.toml", "requirements.txt", ".git",
  function(name)
    local extension = vim.fs.ext(name)
    return extension == "sln" or extension == "slnx" or extension == "csproj"
  end,
}

local function buffer_path(bufnr)
  local name = vim.api.nvim_buf_get_name(bufnr or 0)
  return name ~= "" and vim.fs.dirname(name) or vim.uv.cwd()
end

function M.root(bufnr)
  return vim.fs.root(buffer_path(bufnr), M.markers) or vim.uv.cwd()
end

function M.use_root(bufnr)
  local root = M.root(bufnr)
  if root ~= vim.fn.getcwd() then
    vim.cmd("lcd " .. vim.fn.fnameescape(root))
  end
  return root
end

function M.dotnet_project(bufnr)
  local project_root = vim.fs.root(buffer_path(bufnr), function(name)
    return vim.fs.ext(name) == "csproj"
  end)
  local projects = project_root
    and vim.fn.glob(vim.fs.joinpath(project_root, "*.csproj"), false, true)
    or {}
  table.sort(projects)
  return projects[1]
end

function M.dotnet_target(bufnr)
  local project = M.dotnet_project(bufnr)
  if not project or vim.fn.executable("dotnet") ~= 1 then
    return nil
  end
  local output = vim.fn.system({
    "dotnet", "msbuild", project, "-nologo",
    "-getProperty:TargetPath", "--property:Configuration=Debug",
  })
  if vim.v.shell_error ~= 0 then
    return nil
  end
  local decoder = vim.json and vim.json.decode or vim.fn.json_decode
  local ok, data = pcall(decoder, output)
  local target = ok and data and data.Properties and data.Properties.TargetPath
  if type(target) == "string" and target ~= "" then
    return target
  end
  local plain_target = vim.trim(output)
  return plain_target:match("%.dll$") and plain_target or nil
end

return M

このモジュールには、F5で使う作業ルートの検出と、C#の実行対象に使う近傍csprojの検出をまとめています。ただし、二つは同じ判定ではありません。F5はルート候補のマーカーを上へ探し、C#は現在のファイルから最も近いcsprojを探します。複数のcsprojが同じ階層にある場合は、この記事の設定ではファイル名順で先頭を選びます。実行プロジェクトを選ばせたい場合は、別途選択UIかプロジェクト側の設定を足します。

言語ごとの違いは残し、固定パスだけ外す

F5から始める操作は共通にできても、実行対象の探し方は言語ごとに違います。以前はこの違いまで一つの設定へ押し込み、結果としてプロジェクト名やパスが残っていました。共通化するのは操作、ルート検出、外部コマンドを探す入口までです。言語別の処理は分けて残しました。

C#:プロジェクト名からDLLを組み立てない

C#のコンソールアプリでは、ブレークポイントで止めて標準入力を与えながら値を確認します。API開発でも、リクエストを送って処理の途中で止める使い方をします。

問題は、デバッグするDLLの場所です。プロジェクト名、ターゲットフレームワーク、絶対パスを設定へ書くと、その一つのプロジェクトでしか動きません。

現在は、開いているC#ファイルから上へたどり、近いcsprojを候補にします。同じディレクトリに複数あれば、今の設定ではファイル名順で先頭を選びます。そのうえでMSBuildへTargetPathを問い合わせます。ライブラリの中を開いている時は、近いプロジェクトが実行対象とは限りません。その時は選択UIかプロジェクト側の設定が必要です。

local function nearest_csproj(path)
  local directory = vim.fs.root(path, function(name)
    return vim.fs.ext(name) == "csproj"
  end)
  local projects = directory
    and vim.fn.glob(vim.fs.joinpath(directory, "*.csproj"), false, true)
    or {}
  table.sort(projects)
  return projects[1]
end

local project = nearest_csproj(vim.api.nvim_buf_get_name(0))
if not project then
  error("現在のファイルからcsprojを見つけられません")
end

local output = vim.fn.system({
  "dotnet",
  "msbuild",
  project,
  "-nologo",
  "-getProperty:TargetPath",
  "--property:Configuration=Debug",
})

MicrosoftのMSBuildコマンドラインリファレンスでは、-getPropertyで評価後のプロパティ値を取得できます。この問い合わせだけではビルドされません。実際の設定では、コマンドの終了コードを確認し、出力が単一の値なら前後の空白と改行を除いてDLLのパスとして扱います。ただし、返ってきたパスのファイル存在までは確認していません。ここでは、固定のDLLパスをやめた部分だけを載せました。

既定のC#デバッグ構成にはpreLaunchTask = "dotnet: build"を付けています。F5でこの構成を使い、信頼したtasks.jsonに同名のタスクがある場合はそのタスクを実行します。タスクがない場合、作業ルート直下に*.csproj*.slnがあればdotnet buildを実行し、成功後にNetCoreDbgへ進みます。どちらもないルートでは既定ビルドをせずに進むため、全プロジェクトで自動ビルドされるわけではありません。なお、tasks.jsonのタスクがisBackgroundなら、今の設定は起動から3秒待ってデバッグへ進み、タスクの成功までは確認しません。事前タスクの出力は<leader>doで再表示できます。コンソールはintegratedTerminalにし、標準入力を受け取れるようにしています。

複数のcsprojを持つ検証用ソリューションも作り、深いディレクトリのC#ファイルから候補を検出できることを確認しました。実際に.NET 10でビルドし、生成されたApp.dllをMSBuildのTargetPathから取得できました。複数候補のうちどれを選ぶかはプロジェクト構成に依存するため、実運用では選択UIや明示設定を加える余地があります。

Python:プロジェクトごとの仮想環境を探す

debugpyを起動するPythonと、対象アプリを実行するPythonは同じとは限りません。nvim-dap-pythonの公式READMEにも、debugpy用の環境とプロジェクトの仮想環境は分けられるとあります。

プロジェクト側のPythonは次の順で探します。

  1. VIRTUAL_ENV
  2. プロジェクト直下の.venvvenvenv.env
  3. PATH上のpython3またはpython

WindowsはScripts/python.exe、WSL2はbin/pythonです。ここを一つの固定パスへすると、仮想環境名やOSが変わっただけでデバッグ対象の依存パッケージを読み込めなくなります。

Go:通常デバッグとTTYを分ける

Goはnvim-dap-goとDelveを使います。nvim-dap-goの公式READMEには、現在のファイル、パッケージ、テスト、実行中プロセスへの接続などの構成があります。

普通のAPIや処理はF5から起動します。一方、ターミナルUIのように/dev/ttyを直接使うアプリは、通常のヘッドレスなDelveでは入力がうまく渡りません。そのため<leader>dTだけは、Neovimの下部ターミナルでDelveを起動してからDAPで接続します。

/dev/ttyはWindowsにはないため、この操作はWSL2専用です。Windowsでは無理に互換処理を入れず、警告を出して止めます。すべてを共通化するのではなく、OS固有の機能を一か所へ隔離しました。

TypeScript:一枚ファイルと実プロジェクトを分ける

TypeScriptはjs-debug-adapterとNode.jsを使います。単純な一枚ファイルなら、現在のNode.jsが持つ型除去機能でそのまま起動できます。

ただしNode.jsのTypeScript公式ドキュメントでは、組み込みの型除去はtsconfig.jsonを読まず、パスエイリアスやJavaScript生成が必要な構文には対応しないと説明されています。

そのため、この設定は小さなスクリプトの確認用です。フレームワークやビルド手順を持つ実プロジェクトでは、プロジェクト側の.vscode/launch.jsonを使います。

launch.jsonとtasks.jsonは信頼してから読む

launch.jsontasks.jsonには、起動するプログラムやコマンドを書けます。リポジトリを開いただけで無条件に実行対象へ入れるのは避けたいので、vim.secure.read()を通すようにしました。

local trusted_content = vim.secure.read(launch_json)
if not trusted_content then
  return {}
end

Neovimの公式ドキュメントによると、許可したファイルは内容のハッシュとともに信頼データベースへ保存されます。内容が変わると保存したハッシュと一致しなくなるため、もう一度確認されます。

拒否した場合は扱いが違います。ファイルのパスが未信頼として保存され、内容を変えただけでは再確認されません。判断をやり直す時は、そのファイルを開いて:trust ++removeで記録を外します。次にvim.secure.read()がそのファイルを読んだ時、許可するか拒否するかを再び確認できます。

リポジトリに入っている設定でも、実行する前に中身を見ることにしました。

動かない時に確認する順番

次からは設定ファイルを手当たり次第に直さず、次の順で確認します。

  1. 現在のファイルから正しいプロジェクトルートを検出できているか
  2. debugpy、Delve、NetCoreDbg、js-debugが実行できるか
  3. Python、Go、.NET SDK、Node.js本体が見つかるか
  4. デバッグ対象のファイルやDLLが存在するか
  5. launch.jsontasks.jsonを信頼したか
  6. 標準入力が必要ならintegrated terminalまたはTTYを使っているか
  7. 事前ビルドが失敗していないか<leader>doで見る

今回の見直しで、WSL2上ではdebugpy、Delve 1.26.3、NetCoreDbg 3.1.3、Node.js 24.18.0を検出できました。これはアダプターとランタイムを見つけられた確認であり、全言語・全プロジェクトでブレークポイント停止まで検証した意味ではありません。Windows側でも、アダプターを配置できただけでは不十分で、言語本体やSDKが必要です。Masonのインストール成功と、プロジェクトを実行できる状態は分けて考える必要があります。

次にF5で動かなかったら、まずルートと実行ファイルの場所を見ます。プロジェクトごとに違う部分だけをlaunch.jsonへ残すと、「なぜこれだけ動かないのか」を追いやすくなりました。

起動時にDAPを読み込ませない設定はLazyVimの起動を見直し、DAPとIME設定を後回しにした、検索からコードを読む流れはNeovimに検索機能を3つ入れたのに、Space sGしか使っていなかったへ分けています。

確認した公式情報

確認日: 2026年9月15日

コメント

タイトルとURLをコピーしました