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 :Mason should open the UI.
  • If :Mason is “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 → search codelldb → press i to install.
  • Or run: :MasonInstall codelldb.

After install, codelldb lives here:

~/.local/share/nvim/mason/bin/codelldb

You can either:

  1. Add Mason bin to PATH (so codelldb is found), or
  2. 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 dap and optionally wires dap-python when it’s installed.
  • Creates a basic debug keymap set (continue/step/breakpoint/terminate).
  • Loads .vscode/launch.json so shared configs work across editors.
  • Defines DAP signs and a DapStopped highlight to make breakpoints/stop state visible.
  • Registers codelldb with a Mason absolute path so the adapter is always found.
  • Adds a Zig Launch configuration 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 :DapCloseUI helper 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.zig from the current buffer to treat that directory as the project root.
  • Runs :compiler zig_build so :make produces 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 = false matters here: it ensures the :compiler scripts 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=Debug so symbols and breakpoints are reliable.
  • Scans zig-out/bin for binaries and shows a picker of discovered executables.
  • Launches codelldb with the selected program and your project root as cwd.
  • 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 :Mason doesn’t open, confirm the plugin is installed and you ran :Lazy sync.
  • If DAP can’t find codelldb, use the absolute path in dap.adapters.codelldb.
  • If the debug target forks, add the follow-fork-mode child init command.
🚫 Avoid running optimized builds while debugging; breakpoints may be skipped or reordered.