NeovimでPythonをデバッグする nvim-dapとdebugpyの設定
NeovimでPythonをデバッグするには、nvim-dapというプラグインと、Python側のデバッガであるdebugpyの2つを用意します。設定例を写して:DapContinueを打ったとき、最初につまずくのはたいていNo configuration found for `python`かConfig references missing adapter `python`のどちらかです。2つは設定の別々の半分が欠けていることを示していて、どちらが出たかで直す場所が決まります。
この記事では、nvim-dapの設定を手で書いてPythonのスクリプトを実際に止め、ステップ実行と変数の確認まで進めます。後半では「ブレークポイントを置いたのに素通りする」状況を手元で3通り再現し、それぞれ画面に何が出たかを並べます。
検証環境:NVIM v0.12.4(macOS)、Python 3.14.7、debugpy 1.8.22。プラグインはvim.packで入れたnvim-dap(0.10.0以降のコミットcfa2d58)、nvim-dap-view(v1.2.1以降のe766b3d)、nvim-dap-ui(v4.0.0以降のcc9dd33)とnvim-nio。一時ディレクトリにHOMEとvenvを作り、tmuxの中でNeovimを起動して操作しました。エラーメッセージ・画面・REPLの出力は実行結果をそのまま載せています(長い一時ディレクトリのパスだけ短くしてあります)。WindowsとLinuxでは確かめていません。
目次(9項目)
| コマンド | やること |
|---|---|
:DapToggleBreakpoint | カーソル行にブレークポイントを置く・外す |
:DapContinue | デバッグを始める。止まっているときは次まで進める |
:DapStepOver | 1行進む。関数の中には入らない |
:DapStepInto | 呼んでいる関数の中に入る |
:DapStepOut | 今の関数を抜けて呼び出し元へ戻る |
:DapToggleRepl | 式を打って値を見るREPLを開く・閉じる |
:DapTerminate | デバッグを終える |
DAPとnvim-dapは何をしているのか
DAP(Debug Adapter Protocol)は、エディタとデバッガのあいだでやり取りする形式を決めた取り決めです。VS Codeのために作られ、いまは言語ごとに「DAPを話すデバッガ」が用意されています。エディタは「この行で止めて」「変数の一覧をくれ」とJSONで頼み、デバッガ側がPythonやCのプロセスを実際に止めて答えを返します。
nvim-dapはこのうちエディタ側だけを受け持つプラグインです。デバッガそのものは同梱していないので、Pythonならdebugpy、Cならlldb-dapのように、言語ごとのデバッガを別に用意して「これを起動して話せ」と教える必要があります。この「別に用意して教える」部分が、次の節で書く2つの設定です。
補完や定義ジャンプのLSPと名前も設定の形も似ていますが、役割は重なりません。LSPはソースコードを文字として読んで答えるのに対し、DAPは実行中のプロセスを止めて中身を覗きます。LSPの設定はNeovimの標準LSP設定(lsp/とvim.lsp.enable)で扱っていて、Neovim 0.12でもDAPのクライアントは本体に入っていません。
vim.packでnvim-dapとdebugpyを入れる
プラグインはNeovim 0.12のvim.packで入れました。ここでは画面を足すnvim-dap-viewも一緒に入れていますが、止めて進めるだけならnvim-dap単体で足ります。vim.pack自体の使い方はNeovim 0.12の標準プラグイン管理 vim.packにまとめてあります。
-- ~/.config/nvim/init.lua
vim.pack.add({
'https://github.com/mfussenegger/nvim-dap',
'https://github.com/igorlfs/nvim-dap-view',
})
debugpyはNeovimとは関係なくpipで入れるPythonのパッケージです。プロジェクトのvenvに混ぜず、デバッガ専用のvenvを1つ作っておくと、プロジェクトをいくつ持っていても入れ直さずに済みます。後の節で確かめるとおり、デバッグされる側のPythonにdebugpyが入っている必要はありません。
# デバッガ専用のvenvを作ってdebugpyを入れる
python3 -m venv ~/.venvs/debugpy
~/.venvs/debugpy/bin/pip install debugpy
~/.venvs/debugpy/bin/python -c 'import debugpy; print(debugpy.__version__)'
# 1.8.22
mason.nvimでdebugpyを入れる方法もありますが、この記事では試していません。WindowsではvenvのPythonがbinではなくScriptsの下に置かれるので、パスの書き方が変わります(Windowsでの環境づくりはWindowsでNeovimをPython開発環境として構築する手順)。
adapterとconfigurationの2つを書く
nvim-dapの設定はdap.adaptersとdap.configurationsの2つに分かれています。adapterは「どのデバッガを、どのコマンドで起動するか」、configurationは「そのデバッガに何をどう実行させるか」です。PythonならdebugpyのアダプタをPythonで起動し、そのアダプタに「今開いているファイルを実行して」と頼む、という分担になります。
-- ~/.config/nvim/init.lua(vim.pack.add の後に書く)
local dap = require('dap')
-- adapter: debugpyのアダプタを、debugpyを入れたPythonで起動する
dap.adapters.python = {
type = 'executable',
command = vim.fn.expand('~/.venvs/debugpy/bin/python'),
args = { '-m', 'debugpy.adapter' },
}
-- configuration: ファイルタイプ python のバッファで使う起動のしかた
dap.configurations.python = {
{
type = 'python', -- 上の dap.adapters.python を使う
request = 'launch',
name = 'このファイルを実行',
program = '${file}', -- 今開いているファイル
pythonPath = vim.fn.expand('~/.venvs/debugpy/bin/python'),
},
}
2つは名前でつながっています。dap.configurationsの鍵はファイルタイプ(python)で、その中のtype = 'python'がdap.adapters.pythonを指します。どちらかが欠けたときに出るメッセージは別物で、手元で片方ずつ消して出したのが次の表です。
| 欠けているもの | :DapContinueで出たメッセージ |
|---|---|
| configuration | No configuration found for `python`. You need to add configs to `dap.configurations.python` (See `:h dap-configuration`) |
| adapter | Config references missing adapter `python`. Available are: |
| adapterのPythonにdebugpyが無い | command `…/bin/python` of adapter `python` exited with 1. Run :DapShowLog to open logs |
1つ目は「このファイルタイプで何を起動すればいいか分からない」、2つ目は「起動のしかたは分かったが、使えと言われたデバッガが登録されていない」という意味です。2つ目のAvailable are:の後ろが空なのは、adapterが1つも無いからです。3つ目はadapterを書いたものの、commandに指定したPythonにdebugpyが入っていないときに出ます。:DapShowLogのログには終了コードしか残らなかったので、同じコマンドを端末で直接打つほうが早く原因に着きます。
$ ~/.venvs/project/bin/python -m debugpy.adapter
/…/bin/python: Error while finding module specification for 'debugpy.adapter' (ModuleNotFoundError: No module named 'debugpy')
この分担のおかげで、他の言語へはadapterを足すだけで広げられます。試しにXcodeに入っているlldb-dap(LLVM 21.0.0)を登録し、clang -g -O0でビルドしたCのプログラムを止めてみました。ブレークポイントで止まり、REPLにsumと打つと(int) $0 = 10のようにlldbの書式で値が返りました。止め方も進め方もPythonと同じコマンドです。
dap.adapters.lldb = {
type = 'executable',
command = '/Applications/Xcode.app/Contents/Developer/usr/bin/lldb-dap', -- xcrun -f lldb-dap で場所を確かめる
name = 'lldb',
}
dap.configurations.c = {
{
name = 'ビルド済みのバイナリを起動',
type = 'lldb',
request = 'launch',
program = '${fileDirname}/${fileBasenameNoExtension}',
cwd = '${workspaceFolder}',
},
}
ブレークポイントを置いて止める
止める対象には、次の短いスクリプトを使いました。合計を出すtotal()の中で止めて、ループのたびに値がどう変わるかを見ます。
import json
def total(items):
s = 0
for it in items:
s += it["price"] * it["qty"]
return s
def main():
data = json.loads('[{"price": 120, "qty": 3}, {"price": 80, "qty": 2}]')
t = total(data)
print("total:", t)
main()
7行目(s += …)にカーソルを置いて:DapToggleBreakpointを打つと、行番号の左にBが付きます。続けて:DapContinueを打つと、configurationが1つしか無いので選択の画面は出ずにそのまま実行が始まって止まりました。止まった行には→が付き、カーソルも7行目へ飛びます。
def total(items):
s = 0
for it in items:
→ s += it["price"] * it["qty"]
return s
左に出る記号は:sign listで一覧できます。既定では次の5つです。止まらないときの切り分けでRが効いてくるので、覚えておくと後で役に立ちます。
| 記号 | sign名 | 意味 |
|---|---|---|
B | DapBreakpoint | ブレークポイント |
C | DapBreakpointCondition | 条件付きブレークポイント |
L | DapLogPoint | 止めずにログを出すポイント |
R | DapBreakpointRejected | デバッガに受け付けられなかった |
→ | DapStopped | いま止まっている行 |
毎回コマンドを打つのは手間なので、ファンクションキーに割り当てておくと楽です。下の3つは手元でF9で置き、F5で止め、F10で1行進むところまで確かめました。
vim.keymap.set('n', '<F5>', dap.continue)
vim.keymap.set('n', '<F9>', dap.toggle_breakpoint)
vim.keymap.set('n', '<F10>', dap.step_over)
ステップ実行とREPLで変数を見る
止まった状態で:DapToggleReplを打つと、下にdap>のプロンプトを持つバッファが開きます。インサートモードで変数名を打ってEnterを押すと、その時点の値が返ります。辞書は中身まで展開されます。
dap> it
{'price': 120, 'qty': 3}
'price': 120
'qty': 3
function variables:
len(): 2
special variables:
dap> s
0
ここからコードのウィンドウへ戻って:DapStepOverを3回打ちました。→は7行目から6行目(for)、2周目の7行目、また6行目と動き、REPLでsを見ると520になっていました。2周目のs += …を実行し終えて、ループを抜けるかを判定する直前の状態です。.scopesと打つと、ローカル変数をまとめて出せます。
dap> .scopes
Locals
it: {'price': 80, 'qty': 2}
items: [{'price': 120, 'qty': 3}, {'price': 80, 'qty': 2}]
s: 520
Globals
special variables:
function variables:
json: <module 'json' from '…/lib/python3.14/json/__init__.py'>
関数の出入りも確かめました。13行目(t = total(data))で止めて:DapStepIntoを打つとtotal()の先頭の5行目s = 0へ入り、そこで:DapStepOutを打つと13行目へ戻りました。呼び出し履歴は.framesで出ます。プログラムがprintしたtotal: 520も、今回の設定では同じREPLのバッファに流れてきました。
画面を足す nvim-dap-viewとnvim-dap-uiの違い
REPLだけでも値は見られますが、ステップのたびに変数名を打ち直すのは疲れます。変数や監視式を常に出しておくための画面を足すプラグインが2つあり、両方を入れて同じ7行目で止めてみました。
nvim-dap-viewはsetup()を呼ばなくても動き、止まった状態で:DapViewOpenを打つと下に1つだけウィンドウが開きます。ウィンドウの上端の'winbar'にWatches [W] Scopes [S] Exceptions [E] Breakpoints [B] Threads [T] REPL [R]と並び、括弧の中の大文字キーで表示を切り替えます。:DapViewWatch it["price"] * it["qty"]で式を監視に足すとit["price"] * it["qty"] = 360と出て、:DapStepOverを2回進めると160に変わりました。Scopesの折りたたみの印にはNerd Fontの字形が使われていて、フォントに字が無い端末では記号が欠けて見えます。
nvim-dap-uiはnvim-nioにも依存し、require('dapui').setup()を呼んでからrequire('dapui').open()で開きます。開くと左にWatches・Stacks・Breakpoints・Scopesの4つ、下にConsoleとREPLの2つ、合わせて6つのウィンドウに画面が分かれました。一度に全部見えるのは利点ですが、幅160桁・高さ45行の端末で、左の40桁ほどと下の12行ほどがパネルに取られました。
| 項目 | nvim-dap-view | nvim-dap-ui |
|---|---|---|
| 依存 | nvim-dapだけ | nvim-dapとnvim-nio |
| setup() | 不要(呼べば設定を変えられる) | 必須 |
| 開き方 | :DapViewOpen | require('dapui').open() |
| 画面 | ウィンドウ1つをタブで切り替え | 6つのウィンドウに分割 |
| GitHubのスター(2026-10-14) | 1,036 | 3,384 |
ノートPCの画面でコードを広く見ていたいならnvim-dap-view、外部モニターで常に全部を並べたいならnvim-dap-ui、という選び方になります。どちらもnvim-dapの上に乗るだけなので、前の2節で書いたadapterとconfigurationはそのまま使えます。
ブレークポイントが止まらないときの切り分け
止まらない原因を3つ再現しました。見分ける手がかりは、行の左の記号と、止まった場所のずれ方です。
ライブラリの中に置いたら R になった
標準ライブラリのjson/__init__.pyを開き、loads()の中の339行目にブレークポイントを置いて実行すると、止まらずに最後まで走り、Bだった記号がRに変わりました。理由はブレークポイントの状態に残っています。
:lua print(vim.inspect(require('dap.breakpoints').get()))
-- 出力から2項目を抜粋
message = 'Breakpoint in file excluded by filters.\nNote: may be excluded because of "justMyCode" option (default == true).Try setting "justMyCode": false in the debug configuration (e.g., launch.json).\n',
verified = false
debugpyは既定でjustMyCodeが有効で、自分のコード以外(標準ライブラリやvenvのsite-packages)を止める対象から外します。configurationにjustMyCode = falseを足して同じことをすると、339行目if isinstance(s, str):で止まりました。ステップ実行にも同じ差が出ます。12行目のjson.loads(…)で:DapStepIntoを打つと、既定ではライブラリを飛ばして13行目へ進み、falseにするとjson/__init__.pyの中へ入りました。ライブラリの挙動を追いたいときだけfalseにした2つ目のconfigurationを足しておくと、:DapContinueのたびに選べます。
保存前のバッファでは別の行で止まった
ファイルの先頭にコメントを2行足し、保存しないまま、2行ずれて9行目になったs += …にブレークポイントを置いて実行しました。デバッグされるのはディスク上のファイルなので、debugpyにとっての9行目はreturn sの下の空行です。結果はループの中では一度も止まらず、REPLの.framesはtotal:8(ディスク上のreturn s)、sは520でした。
紛らわしいのは画面の表示です。→はバッファの8行目、つまりfor it in items:の行に付いていて、ループの途中で止まったように見えました。nvim-dapは未保存の変更について何も警告を出しません。ステータスラインに[+]が出ているあいだは、ブレークポイントの位置も止まった位置も信用しないほうが安全です。
pythonPathがプロジェクトのvenvを向いていない
pythonPathがdebugpyの無いPythonを向いていると止まらない、と考えたくなりますが、手元では止まりました。debugpyの無いvenvをpythonPathに指定しても7行目で止まり、REPLで見ると、デバッガ側のモジュールはadapterのvenvから読み込まれていました。debugpyはadapterが起動するときに、デバッグされる側へ自分を差し込んでいるようです。
食い違いが表に出るのは、プロジェクトのvenvにしか無いパッケージを使っているときです。sixをプロジェクトのvenvにだけ入れ、pythonPathをdebugpyのvenvのまま実行すると、置いたブレークポイントに着く前に1行目のimport sixで止まり、その行にEの診断が付きました。
Thread stopped due to exception of type ModuleNotFoundError (unhandled)
Description: No module named 'six'
Stack trace:
File "…/proj/app2.py", line 1, in <module>
import six
ModuleNotFoundError: No module named 'six'
pythonPathをプロジェクトのvenvのbin/pythonに変えると、REPLの__import__("sys").executableはそのvenvのパスを返し、最後まで通りました。adapterのcommandはdebugpyの入ったPython、configurationのpythonPathはプロジェクトのPython、と別々に指定するのが正しい組み合わせです。NeovimがどのPythonを見ているかの確かめ方はVim/NeovimでPythonの実行パスを設定・確認する方法で扱っています。
| 見えたこと | 原因 | 直し方 |
|---|---|---|
記号がRになる | justMyCodeでライブラリが対象外 | justMyCode = false |
| 止まった行と変数の値が合わない | 保存前のバッファ | :wしてから置き直す |
importの行で例外で止まる | pythonPathがプロジェクトのvenvでない | pythonPathを直す |
保存前のバッファの再現では、→がforの行に付いているのにsがもう520で、しばらく自分のステップ実行の数え間違いを疑っていました。.framesのtotal:8を見て、画面の8行目とファイルの8行目が別の行だと気づくまで5分ほどかかっています。もう1つ意外だったのは、debugpyの無いvenvをpythonPathに指定しても普通に止まったことです。止まらない原因の候補として真っ先に試したのに、外れでした。
まとめ
nvim-dapの設定は、デバッガの起動方法を書くadapterと、何を実行させるかを書くconfigurationの2つで、エラーメッセージもこの2つに対応しています。No configuration foundならconfiguration、missing adapterならadapter、exited with 1ならadapterのPythonにdebugpyが入っているかを見ます。
ブレークポイントが止まらないときは、まず行の左の記号を見ます。Rならデバッガに断られているのでjustMyCodeを疑い、止まった位置がずれているなら未保存の変更を疑います。importで例外が出るならpythonPathがプロジェクトのvenvを向いていません。画面はnvim-dap単体のREPLでも足りますが、変数を並べて見たいならnvim-dap-viewかnvim-dap-uiを足します。