Debugging Zig in Neovim with nvim-dap + codelldb
indiedev
Debugging Zig in Neovim with nvim-dap + codelldb
This is the exact setup I use to debug Zig with Neovim. The goal is simple: ==start a debug session without leaving the editor==, with reliable breakpoints and a UI that stays out of the way.
I’ll walk through Mason + codelldb, the DAP core config, DAP UI, and a Zig-specific debug keybinding. All code snippets below match the working configuration.
Note: This post was written with the help of AI.
Install Mason and codelldb (the right way)
Mason is a Neovim package manager for external tools: LSP servers, formatters, linters, and DAP adapters like codelldb.
How to install Mason
- If you’re using for example LazyVim: it’s already included. Just run
:Lazy sync, then:Masonshould open the UI. - If
:Masonis “not an editor command”, add it manually:
Create lua/plugins/mason.lua with:
return {
{
"williamboman/mason.nvim",
config = true,
},
}
Then run :Lazy sync.
Install codelldb via Mason
- In Neovim:
:Mason→ searchcodelldb→ pressito install. - Or run:
:MasonInstall codelldb.
After install, codelldb lives here:
~/.local/share/nvim/mason/bin/codelldb
You can either:
- Add Mason bin to
PATH(socodelldbis found), or - Point DAP to the absolute path (most reliable).
💡 I point DAP at the absolute path so updates don’t break the adapter lookup.
Core DAP config for Zig
This enables DAP, maps the debug keys, wires the codelldb adapter via Mason’s absolute path, and defines a Zig launch config. I also load .vscode/launch.json so I can share configs across editors.
-- lua/plugins/nvim-dap.lua
return {
{
"mfussenegger/nvim-dap",
config = function()
local dap = require("dap")
local ok_python, dap_python = pcall(require, "dap-python")
if ok_python then
dap_python.setup("python")
end
vim.keymap.set("n", "<F5>", dap.continue, { desc = "DAP: Continue" })
vim.keymap.set("n", "<F10>", dap.step_over, { desc = "DAP: Step Over" })
vim.keymap.set("n", "<F11>", dap.step_into, { desc = "DAP: Step Into" })
vim.keymap.set("n", "<F12>", dap.step_out, { desc = "DAP: Step Out" })
vim.keymap.set("n", "<M-b>", dap.toggle_breakpoint, { desc = "DAP: Toggle Breakpoint" })
vim.keymap.set("n", "<leader>dq", dap.terminate, { desc = "DAP: Terminate" })
require("dap.ext.vscode").load_launchjs(nil, {})
local sign = vim.fn.sign_define
sign("DapBreakpoint", { text = "●", texthl = "DapBreakpoint", linehl = "", numhl = "" })
sign(
"DapBreakpointCondition",
{ text = "●", texthl = "DapBreakpointCondition", linehl = "", numhl = "" }
)
sign("DapLogPoint", { text = "◆", texthl = "DapLogPoint", linehl = "", numhl = "" })
sign("DapStopped", { text = "", texthl = "DapStopped", linehl = "DapStopped", numhl = "DapStopped" })
local C = {
grey = "#505050",
}
vim.api.nvim_set_hl(0, "DapStopped", { bg = C.grey })
dap.adapters.codelldb = {
type = "server",
port = "${port}",
executable = {
command = vim.fn.stdpath("data") .. "/mason/bin/codelldb",
args = { "--port", "${port}" },
},
}
dap.configurations.zig = {
{
name = "Launch",
type = "codelldb",
request = "launch",
program = "${workspaceFolder}/zig-out/bin/${workspaceFolderBasename}",
cwd = "${workspaceFolder}",
stopOnEntry = false,
args = {},
},
}
end,
},
}
What this DAP core config does:
- Loads
dapand optionally wiresdap-pythonwhen it’s installed. - Creates a basic debug keymap set (continue/step/breakpoint/terminate).
- Loads
.vscode/launch.jsonso shared configs work across editors. - Defines DAP signs and a
DapStoppedhighlight to make breakpoints/stop state visible. - Registers
codelldbwith a Mason absolute path so the adapter is always found. - Adds a Zig
Launchconfiguration that matches Zig’s default output path.
DAP UI with auto-open/close
The UI makes a huge difference when you’re stepping around Zig. I keep stacks/scopes/breakpoints/watches on the left and REPL/console at the bottom, and double the left panel width for readability.
-- lua/plugins/nvim-dap-ui.lua
return {
{
"rcarriga/nvim-dap-ui",
dependencies = {
"mfussenegger/nvim-dap",
"nvim-neotest/nvim-nio",
},
config = function()
local dap = require("dap")
local dapui = require("dapui")
dapui.setup({
layouts = {
{
elements = {
{ id = "stacks", size = 0.25 },
{ id = "scopes", size = 0.25 },
{ id = "breakpoints", size = 0.25 },
{ id = "watches", size = 0.25 },
},
position = "left",
size = 120, -- doubled width
},
{
elements = {
{ id = "repl", size = 0.35 },
{ id = "console", size = 0.65 },
},
position = "bottom",
size = 10,
},
},
})
dap.listeners.after.event_initialized["dapui_config_open"] = function()
dapui.open()
end
dap.listeners.before.event_terminated["dapui_config_close"] = function()
dapui.close()
end
dap.listeners.before.event_exited["dapui_config_close"] = function()
dapui.close()
end
vim.api.nvim_create_user_command("DapCloseUI", function()
dapui.close()
end, {})
vim.keymap.set({ "n", "v" }, "<M-e>", function()
dapui.eval()
end, { desc = "DAP: Eval" })
end,
},
}
What the UI script does:
- Defines a two-panel layout: left for stacks/scopes/breakpoints/watches and bottom for REPL/console.
- Doubles the left panel width to reduce horizontal scrolling in deep call stacks.
- Auto-opens the UI on session start and closes it on terminate/exit.
- Adds a
:DapCloseUIhelper and an eval keymap for quick hover-style inspections.
💡 The auto-open/close hooks keep your workspace clean when you exit a session.
Zig workflow glue (compiler + build/test/run)
I also use zig.vim because it ships the :compiler scripts. That means I can route build/test errors into Neovim’s quickfix list (instead of parsing output myself), and still keep project-level commands one keypress away.
Here’s what this script is actually doing:
- Finds the nearest
build.zigfrom the current buffer to treat that directory as the project root. - Runs
:compiler zig_buildso:makeproduces Zig-flavored quickfix entries. - Builds and tests via
make(which maps to Zig’s compiler script), then opens quickfix if errors exist. - Adds opinionated keymaps for project-level build/test so I don’t need to remember command variants.
return {
{
url = "https://codeberg.org/ziglang/zig.vim",
lazy = false, -- ensure :compiler scripts are on runtimepath at startup
init = function()
vim.g.zig_fmt_autosave = 0
end,
config = function()
local function zig_project_root()
local buf = vim.api.nvim_buf_get_name(0)
local start = (buf ~= "" and vim.fs.dirname(buf)) or vim.loop.cwd()
local found = vim.fs.find("build.zig", { upward = true, path = start })[1]
return found and vim.fs.dirname(found) or nil
end
local function qf_open_if_needed()
local qf = vim.fn.getqflist({ size = 0 })
if (qf.size or 0) > 0 then
-- vim.cmd("cwindow")
vim.cmd("botright cwindow 30")
end
end
local function run_make_in_dir(dir, compiler, make_args)
local old_cwd = vim.fn.getcwd()
if dir then
vim.cmd("cd " .. vim.fn.fnameescape(dir))
end
vim.cmd("compiler " .. compiler)
local cmd = "make"
if make_args and make_args ~= "" then
cmd = cmd .. " " .. make_args
end
local ok, err = pcall(vim.cmd, cmd)
if not ok then
vim.notify(tostring(err), vim.log.levels.ERROR)
end
qf_open_if_needed()
if dir then
vim.cmd("cd " .. vim.fn.fnameescape(old_cwd))
end
end
local function ensure_root_or_warn()
local root = zig_project_root()
if not root then
vim.notify("Zig: could not find build.zig upwards from current file", vim.log.levels.WARN)
return nil
end
return root
end
-- Project (nearest build.zig): build/test -> quickfix, run -> terminal split
vim.keymap.set("n", "<leader>zb", function()
local root = ensure_root_or_warn()
if root then
run_make_in_dir(root, "zig_build", "")
end
end, { desc = "Zig: build (project)" })
vim.keymap.set("n", "<leader>zt", function()
local root = ensure_root_or_warn()
if root then
run_make_in_dir(root, "zig_build", "test")
end
end, { desc = "Zig: test (project)" })
end,
},
}
💡lazy = falsematters here: it ensures the:compilerscripts exist at startup, so your build/test mappings don’t race plugin loading.
Zig debug: build Debug, then pick a binary
My current workflow is: ==build a Debug binary first==, then pick which program from zig-out/bin you want to debug.
The picker uses vim.ui.select, so it integrates nicely with whatever UI you already have (builtin, Telescope, etc.).
What the debug mapping does step-by-step:
- Builds with
zig build -Doptimize=Debugso symbols and breakpoints are reliable. - Scans
zig-out/binfor binaries and shows a picker of discovered executables. - Launches
codelldbwith the selected program and your project root ascwd. - Sets LLDB’s
follow-fork-mode child, which keeps you attached to the real process when a daemon forks.
vim.keymap.set("n", "<leader>zd", function()
local ok_dap, dap = pcall(require, "dap")
if not ok_dap then
vim.notify("Zig: nvim-dap not available", vim.log.levels.ERROR)
return
end
build_debug_then(function(root)
local bin_dir = vim.fs.joinpath(root, "zig-out", "bin")
local paths = vim.fn.globpath(bin_dir, "*", false, true)
if #paths == 0 then
vim.notify("Zig: no binaries in " .. bin_dir, vim.log.levels.WARN)
return
end
local items = {}
for _, path in ipairs(paths) do
table.insert(items, { label = vim.fs.basename(path), value = path })
end
vim.ui.select(items, {
prompt = "Debug binary:",
format_item = function(item)
return item.label
end,
}, function(choice)
if not choice then
vim.notify("Zig: debug cancelled", vim.log.levels.WARN)
return
end
local program = choice.value
local name = "Debug " .. choice.label
dap.run({
name = name,
type = "codelldb",
request = "launch",
program = program,
cwd = root,
stopOnEntry = false,
initCommands = {
"settings set target.process.follow-fork-mode child",
},
args = {},
})
end)
end)
end, { desc = "Zig: debug (prompt)" })
Why follow-fork-mode child
If your daemon forks, LLDB defaults to following the parent and you lose the process you care about. This line ensures you stay attached to the child.
Quick keymap cheat sheet
| Action | Key |
|---|---|
| Continue | F5 |
| Step over | F10 |
| Step into | F11 |
| Step out | F12 |
| Toggle breakpoint | Alt-b |
| Eval | Alt-e |
| Terminate | <leader>dq |
| Zig: build (project) | <leader>zb |
| Zig: test (project) | <leader>zt |
| Zig: debug (prompt) | <leader>zd |
Common gotchas (and fixes)
- If breakpoints never hit, ensure you built with debug info (e.g.,
zig build -Doptimize=Debug). - If
:Masondoesn’t open, confirm the plugin is installed and you ran:Lazy sync. - If DAP can’t find
codelldb, use the absolute path indap.adapters.codelldb. - If the debug target forks, add the
follow-fork-mode childinit command.
🚫 Avoid running optimized builds while debugging; breakpoints may be skipped or reordered.
Comments