NeovimでDAPを使ったデバッグ環境の構築と使い方

Neovimでデバッガーを動かすための諸々の設定方法と使い方を解説します。

DAPの全体

デバッガーを動かすなら欠かせないDAPについて先に触れましょう。

DAPとは?

DAPはDebug Adapter Protocolの略で、Microsoftが提唱する「デバッグツールとエディタ間でのやりとり」に関する取り決めのことです。早い話、LSPのデバッグ版です。

デバッガーとエディタ間を橋渡しするアダプタ(LSPでいうとLanguage Server)をインストール・設定して使います。

NeovimでDAPを導入するには?

NeovimのDAPで登場するプラグインについて、それぞれの役割をまとめました。

nvim-dap

まず必須なのはnvim-dapです。
nvim-dapはどの言語でどのアダプタを使うのかを設定します。LSPでいうとnvim-lspconfigのような役割です。実際にデバッガーを起動したり操作するAPIなども提供します。

nvim-dap-ui

入れるのを強くおすすめするのがnvim-dap-uiです。
ボタンでポチポチしてnvim-dapのAPIを呼び出せます。変数やブレークポイントの表示もしてくれます。

画像のコード以外のウィンドウはnvim-dap-uiによる表示です。

nvim-dap-uiによる表示

nvim-dap-virtual-text

入れると便利なのがnvim-dap-virtual-textです。
デバッグで一時停止したときに「どの変数にどんな値が入っているか」などをvirtual textで表示してくれます。

nvim-dap-virtual-textの例

導入方法

各々が使っているプラグインマネージャーでインストールします。

先にリポジトリ名のコピペ欄を載せておきます。

リポジトリ名だけコピペしたい人向け
mfussenegger/nvim-dap
リポジトリ名だけコピペしたい人向け
rcarriga/nvim-dap-ui
リポジトリ名だけコピペしたい人向け
theHamsta/nvim-dap-virtual-text

nvim-dap-uiは依存プラグインもあります。

リポジトリ名だけコピペしたい人向け
nvim-neotest/nvim-nio

lazy.nvimでのインストール例

lazy.nvimだと次のように書いてまとめてインストールできます。

lazy.nvimの例
return {
"rcarriga/nvim-dap-ui",
dependencies = {
"mfussenegger/nvim-dap",
"nvim-neotest/nvim-nio",
{ "theHamsta/nvim-dap-virtual-text", opts = {} },
},
opts = {},
}

遅延読み込みの設定は後で紹介します。

アダプタの登録

まずはnvim-dapを使ってどの言語でどのアダプタを使うのかを設定します。
「デバッグしたい言語にはどんなアダプタがあるのか」「どうやって設定すればいいか」はnvim-dapのwikiのDebug Adapter installationにたくさん例が載っています。

ここではC++を例に挙げます。

local dap = require("dap")
-- dapへアダプタを登録
dap.adapters.codelldb = {
type = "executable",
command = "codelldb",
}
-- ファイルタイプ・アダプタ・設定の紐づけ
dap.configurations.cpp = {
{
name = "Launch file",
type = "codelldb",
request = "launch",
program = function()
return vim.fn.input('Path to executable: ', vim.fn.getcwd() .. '/', 'file')
end,
cwd = "${workspaceFolder}",
stopOnEntry = false,
},
}

分解して見ていきましょう。

アダプタを登録する

まずはアダプタを登録します。今回はcodelldbというアダプタをコマンドとして実行するため、次のように設定しました。

dap.adapters.codelldb = {
type = "executable",
command = "codelldb",
}

もし引数が必要であればargsに書きます。

dap.adapters.codelldb = {
type = "executable",
command = "codelldb",
args = { "--foo", "1" },
}

アダプタをインストール

アダプタのインストールも忘れずに。今回はmason.nvimを使ってインストールしました。

:MasonInstall codelldb

masonでインストールできるアダプタはmason-registry.dev category:dapから確認できます。

言語とアダプタを紐づける

続いて、言語ごとに「どのアダプタをどうやって使うか」設定します。

local dap = require('dap')
dap.configurations.cpp = {
{
name = "Launch file",
type = "codelldb",
request = "launch",
program = function()
return vim.fn.input('Path to executable: ', vim.fn.getcwd() .. '/', 'file')
end,
cwd = '${workspaceFolder}',
stopOnEntry = false,
},
}

これもwikiに書いてあります。
nametyperequestが全アダプタで必須です。他のプロパティはアダプタによって異なります。

たとえばcodelldbでは、programに実行ファイルを指定します。その他のcodelldb起動時に指定できるオプションはLaunching a New Processに書いてあります。

ビルドの設定

ビルドが必要な言語では、ビルド時にデバッグを有効にするためのオプションを設定します。

たとえばg++でビルドするなら-gをつけます。加えて、-O0をつけて最適化を無効化にします(最適化するとコードの入れ替えなどが起き、デバッグしにくくなるそう)。

Terminal window
g++ -g -O0 main.cpp -o main.out

「デバッグの前にいちいち手動でビルドするなんて面倒じゃ!」という人向けのカスタマイズは後で紹介します。

参考:gcc+gdbによるプログラムのデバッグ 第1回 ステップ実行、変数の操作、ブレークポイント

使い方

まずは実際にデバッグする流れをざっくりと書いておきます。

  1. (ビルドが必要な言語なら)デバッグを有効にしてビルド
  2. ブレークポイントを設定
  3. デバッグ開始
  4. 随時操作&値の確認
  • 入力が必要ならコンソールへ入力
  • ステップオーバーなど、デバッグを進行させる
  • 値を確認

練習用のコード

練習用にC++のコードも用意しました。

ビルドしないとダメだぞ!
#include <iostream>
using namespace std;
int add(int a, int b) {
return a + b;
}
int main() {
int A, B;
cin >> A >> B;
int result = add(A, B);
cout << result << endl;
result = add(A, 7);
cout << result << endl;
}

デバッグの基本操作

nvim-dap-uiのボタンが表示されない場合、後半のトラブルシューティングを読んでください。

ブレークポイントの設定・解除

toggle_breakpointでブレークポイントの設定・解除ができます。

require("dap").toggle_breakpoint()
:DapToggleBreakpoint

ログメッセージの追加

ある行まで来たらメッセージをログとして出力したい場合は次のAPIを呼び出します。

require("dap").set_breakpoint(nil, nil, vim.fn.input("Log point message: "))

設定時にログメッセージが入力できます。

signcolumnに表示される

デフォルトだと設定した行のsigncolumnにB(ブレークポイント)・L(ログ)と表示されます。

DAPのデフォルトのsigncolumn

このsignをアイコンに変更するカスタマイズは後で紹介します。

デバッガーの起動

デバッガーを起動する前に、nvim-dap-uiによるUIを表示しておくと便利です。

require("dapui").toggle()

UIを自動で表示させる方法は後で紹介します。

デバッグの開始はAPIかコマンドを呼び出すか、nvim-dap-uiのplayボタンを押します。

require("dap").continue()
:DapContinue
nvim-dap-uiのボタン一覧

実行するとどうなる?知らんのか

開始すると、入力待機やブレークポイントまで実行されます。

動画は「入力を2つ要求するコード」でのデバッグ例です。画面右下にあるコンソールから入力します。
12行目で止まり、playボタンを押したら最後まで実行されています。

ブレークポイントで止まるとどうなる?知らんのか

画像は12行目のブレークポイントで止まっている例です。

DAPの全体

つまり、11行目までが実行済み、12行目は実行されていない状態です(なのでvirtual-textの表示も32767という代入前の値があったりする)。

変数などを一通り確認し終わったら、continueのAPIやコマンドまたはplayボタンを押して次のブレークポイントへ進みましょう。

入力が必要なかったりブレークポイントが設定されていなければ最後まで実行されます。

ログメッセージはREPLに表示されます。

ログメッセージ

ステップ〇〇のまとめ

ステップ〇〇系で処理を詳しく辿れます。ボタン・APIは次のとおりです。

nvim-dap-uiのボタン一覧
require("dap").step_over() -- 次のステップ(行)まで進める
require("dap").step_into() -- 関数などの中へ
require("dap").step_out() -- 関数などから外へ
require("dap").step_back() -- 1ステップ戻る
:DapStepOver
:DapStepInto
:DapStepOut
# backはコマンドがないっぽい

ブレークポイントで止まった後、1行ずつ処理を追いたい場合にstep_overを使います。

「現在止まっている行に関数があって、その関数の中の処理まで追いたい」
そんなケースではstep_intoを使い、戻るときにstep_outを使います。

step_backは1ステップ戻るのですが、DAPによって対応がまちまちです。

ステップ〇〇の実際の動き

実際の動きを見てみましょう。

動画の例では、step_intostop_outstep_overを順に実行しています。

virtual-textが便利

nvim-dap-virtual-textを入れているとループ変数などが表示できて便利です。

nvim-dap-virtual-textの例

画像の例では配列のサイズも表示されています(vectorの行)。

デバッグの終了

「もういいや」となったらterminatedisconnectで終了させます。

nvim-dap-uiのボタン一覧
require("dap").terminate()
require("dap").disconnect()
:DapTerminate
:DapDisconnect

terminate

terminateはクリーンアップしてから終了します。たとえば「何かの登録処理中だったらそれが終わるまで待ってから終了するからね」という感じ。

disconnect

一方、disconnectはやや複雑です。アダプタがはプログラムと切り離されます。このとき、プログラムが続行するかどうかはアダプタの種類によって変わります。

アダプタの起動方法がrequest: launchならプログラム終了、request: attachなら終了させません。

local dap = require('dap')
dap.configurations.cpp = {
{
name = "Launch file",
type = "codelldb",
request = "launch",
-- ...
},
}

terminateとdisconnectどっちで停止させればいい?

デバッグ・プログラムを両方止めたいならとりあえずはterminateの方で停止すれば良さそうです。

詳しく知りたい人はDAPの仕様書:help dap.terminate()を読みましょう。

参考:Requests_Disconnect | Microsoft DAP specification

カスタマイズ

ここからはカスタマイズの例を紹介します。

signcolumnの変更

DAPのsigncolumnの文字をアイコンに変更するには、vim.fn.sign_defineで設定します。

vim.fn.sign_define("DapBreakpoint", { text = "", texthl = "", linehl = "", numhl = "" })
vim.fn.sign_define("DapBreakpointRejected", { text = "󰉥", texthl = "", linehl = "", numhl = "", })
vim.fn.sign_define("DapLogPoint", { text = "", texthl = "", linehl = "", numhl = "" })
vim.fn.sign_define("DapStopped", { text = "", texthl = "", linehl = "", numhl = "" })

Nerd Fontsのアイコンを探したい人は公式サイトの検索を使うとよいでしょう。

ボタンアイコンの変更

nvim-dap-uiのボタンのアイコンもカスタマイズできます。

筆者の環境ではdisconnectが見づらかったので変更しました。

require("dapui").setup({
controls = {
icons = {
disconnect = "⏻",
},
}
})

ステータスラインとの連携

DAPの情報をステータスラインに表示できます。どこで止まっているのかが分かって便利です。

lualineにDAPの情報を表示

ここではlualine.nvimの例を挙げます。

lualine_x = {
{
function()
return require("dap").status()
end,
icon = { "", color = { fg = "#afdf00" } },
cond = function()
if not package.loaded.dap then
return false
end
local session = require("dap").session()
return session ~= nil
end,
},
},

色の指定は各自の環境に合わせて変更しましょう。

参考:is it possible to have a lualine indicator…… : r/neovim

overseerで自動ビルド

ビルドが必要な場合、毎回手動でやるのは面倒です。そこで、タスクランナーのプラグインoverseer.nvimを使って自動でビルドさせましょう。

overseer.nvimを知らない人・使ってみたい人は別の記事Neovimタスクランナーoverseer.nvimの使い方をどうぞ。

configurationsの各言語の設定でpreLaunchTaskにoverseer.nvimのテンプレート名を書くだけです。

dap.configurations.cpp = {
{
name = "Launch file",
type = "codelldb",
-- 省略
preLaunchTask = "g++ debug build",
},
}

こうすると、デバッグを開始する前にg++ debug buildのタスクを実行してくれます。

テンプレートg++ debug buildは次のような感じです。

.config/nvim/lua/overseer/template/cpp/gpp-debug-build.lua
---@type overseer.TemplateDefinition
return {
name = "g++ debug build",
builder = function()
local file = vim.fn.expand("%:p")
local outfile = vim.fn.expand("%:p:r") .. ".out"
---@type overseer.TaskDefinition
return {
cmd = { "g++" },
-- デバッグフラグ-g、最適化無効-O0
args = { "-g", "-O0", file, "-o", outfile },
components = {
{ "on_output_quickfix", open_on_exit = "failure" },
-- 通知しない
{ "on_complete_notify", statuses = {} },
"default",
},
}
end,
-- DapのpreLaunchTaskから自動呼ぶため、優先度は低くする
priority = 1000,
condition = {
filetype = { "cpp" },
},
}

前述のとおり「デバッグ向け」のフラグをつけましょう。
デバッグ時にビルドの完了通知は煩わしいため、オフにしています。

優先度は下げています。このテンプレートは:OverseerRunのリストから呼ぶことはほとんど無いからです。

templatesへの登録も忘れずに。

require("overseer").setup({
templates = {
"builtin",
"cpp.gpp-debug-build",
-- ...
},
})

ディレクトリによって実行ファイルを変更

特定のディレクトリで実行ファイルの指定を固定したい場合、次のように分岐してあげます。

dap.configurations.cpp = {
{
-- 省略
program = function()
if vim.fn.getcwd():find("^" .. vim.fn.expand("~/ghq/github.com/eetann/example")) then
return vim.fn.getcwd() .. "/main.out"
end
return vim.fn.input("Path to executable: ", vim.fn.getcwd() .. "/a.out", "file")
end,
-- 省略
},
}

単純ですが楽になります。

逆アセンブルを非表示にする

codelldbでは、デバッグ情報が使えない部分は逆アセンブルの表示が出てきます(最後までステップオーバーしてみると出てきたりする)。

筆者は必要ないため、次のように引数を渡して非表示にしています。

dap.adapters.codelldb = {
type = "executable",
command = "codelldb",
args = {
"--settings",
vim.json.encode({
showDisassembly = "never",
}),
},
}

codelldbの設定はMANUAL.mdに書いてあります。

lazy.nvimので遅延読み込み・キーマップの紹介

keysを使ってキーマップを定義しています。実際に使っているのは上4つだけです。ほかはdap-uiのボタンでポチポチしています。

keys = {
{ "<space>du", function() require("dapui").toggle() end, desc = "toggle dap-ui", },
{ "<space>dd", function() require("dap").continue() end, desc = "Debug: start/continue", },
{ "<space>dm", function() require("dap").toggle_breakpoint() end, desc = "Debug: toggle break(mark)", },
{
"<space>dM",
function()
require("dap").set_breakpoint(nil, nil, vim.fn.input("Log point message: "))
end,
desc = "Debug: set break(mark) with message",
},
{ "<space>de", function() require("dapui").eval() end, desc = "Debug: eval at cursor", },
{ "<space>dE", function() require("dapui").eval(vim.fn.input("[Expression] > ")) end, desc = "Debug: eval expression", },
{ "<space>d[[", function() require("dap").step_back() end, desc = "Debug: step back (1ステップ戻る)", },
{ "<space>d]]", function() require("dap").step_over() end, desc = "Debug: step over (次のステップまで進める)", },
{ "<space>d}}", function() require("dap").step_into() end, desc = "Debug: step into (関数の中へ)", },
{ "<space>d{{", function() require("dap").step_out() end, desc = "Debug: step out (関数から外へ)", },
{ "<space>dh", function() require("dap.ui.widgets").hover() end, desc = "Debug: hover", },
{ "<space>dq", function() require("dap").terminate() end, desc = "Debug: terminate (デバッグの停止)", },
}

Styluaでフォーマットされてしまうのが気になる場合はignoreで無視させましょう。

-- stylua: ignore start
-- ここにコード
-- stylua: ignore end

トラブルシューティング

遭遇しがちなトラブルと解決方法をまとめました。

まずはTroubleshootingのチェックリストで解決すると良さそうです。

ブレークポイントで止まらない

ブレークポイントを設定したにもかかわらず、止まってくれずに素どおりされるトラブルが起きた場合。

この場合、signcolumnはB(ブレークポイント)ではなくR(リジェクト=拒否)に変わっているでしょう。

これは「デバッグ向けのビルド」にしていないことが原因です。
詳しくはビルドの設定に書きました。

nvim-dap-uiのボタンが表示されない

nvim-dap-uiのボタンが表示されない場合、winbarの設定と競合しています。

筆者のケースではlualineでwinbarを上書きしてしまっていたのが原因でした。nvim-dap-uiのウィンドウでは、lualine側のwinbarの表示を無効化しました。

require("lualine").setup({
options = {
disabled_filetypes = {
winbar = {
"dap-repl",
},
},
},
-- ……
})

無事ボタンが表示されています。

REPLのwinbarの表示を変更

さらに見た目をスッキリさせたい場合、REPL以外のwinbarも無効化してしまいましょう。

require("lualine").setup({
options = {
disabled_filetypes = {
winbar = {
"dap-repl",
"dapui_breakpoints",
"dapui_console",
"dapui_scopes",
"dapui_watches",
"dapui_stacks",
},
},
},
-- ……
})

DAPのヘルプはいったいどこへ……?

:help dapはパラグラフ削除のヘルプが開きます。

nvim-dapなら:help dap.txtやAPI名でヘルプを開きましょう。


以上、NeovimでDAPを使ってデバッグする方法のまとめでした。

初見ではとっつきにくそうな感じでしたが、使ってみると便利です。