Beyond IO.inspect: The Holy Trinity of Elixir & Phoenix Debugging with Neovim and Nix

A guide on how to set up the Elixir debugger in Neovim. This post explores the complete configuration using elixir-ls and nvim-dap, with a special focus on making it work seamlessly inside a Nix development environment.

Setting up a proper Elixir debugger in Neovim can feel like a dark art, especially when you throw Nix and devenv into the mix. I recently went down this rabbit hole, trying to get nvim-dap to play nicely with ElixirLS for a Phoenix project, and let me tell you, it was a journey.

It was filled with cryptic errors, silent failures, and some moments of pure frustration.

The First Hurdle: Talking to the Wrong Adapter

Right out of the gate, my debugger failed to launch. My initial nvim-dap config was simple, but it was pointing to the generic elixir-ls command. It turns out ElixirLS ships with two different scripts:

  • language_server.sh for all that sweet LSP goodness.
  • debug_adapter.sh for... well, debugging.

My setup was calling the wrong number.

The Fix: I had to explicitly point DAP to the debug_adapter.sh script provided by Mason. A simple path change, and one problem down.

dap.adapters.mix_task = {
  type = 'executable',
  -- Point directly to the debug adapter script!
  command = vim.fn.expand('~/.local/share/nvim/mason/packages/elixir-ls/debug_adapter.sh'),
  args = {}
}

The Nix Saga: ElixirLS in a Cage

My victory was short-lived. Since my Elixir installation is managed entirely by Nix/devenv (meaning it's not on my system's global PATH), the Mason-installed ElixirLS threw a fit. It couldn't find the elixir executable and crashed.

** (FunctionClauseError) no function clause matching in IO.chardata_to_string/1

I needed a way to make the configuration smart enough to use the Nix-provided environment when available.

The Solution: Lua to rescue. I wrote a helper function that first checks if elixir-ls is in the PATH (which it will be inside a devenv shell). If found, it uses the debug_adapter.sh from that Nix-provided location. If not, it gracefully falls back to the default Mason path.

local function get_elixir_ls_debug_adapter()
  -- Check if elixir-ls is in the shell's PATH (from Nix/devenv)
  local elixir_ls = vim.fn.exepath('elixir-ls')
  if elixir_ls ~= '' then
    local dir = vim.fn.fnamemodify(elixir_ls, ':h')
    local debug_adapter = dir .. '/debug_adapter.sh'
    if vim.fn.filereadable(debug_adapter) == 1 then
      vim.notify("Found Nix environment, using adapter: " .. debug_adapter)
      return debug_adapter
    end
  end
  -- Otherwise, fall back to the Mason-installed one
  local mason_adapter = vim.fn.expand("~/.local/share/nvim/mason/packages/elixir-ls/debug_adapter.sh")
  vim.notify("Using Mason debug adapter: " .. mason_adapter)
  return mason_adapter
end

The Silent Server: Getting Phoenix to Cooperate

With the adapter path sorted, I tried to launch a debug session for my Phoenix server... and nothing. The debugger would start, print a "Sleeping..." message, and then just sit there, mocking me. The server never actually booted up.

After some time with the ElixirLS docs, I discovered that debugging Phoenix apps has some special rules.

The Fix: The launch configuration needed a few key tweaks. You can't just mix phx.server and hope for the best.

  • You must use debugInterpretModulesPatterns to tell the debugger only to interpret your own app's modules. Otherwise, it tries to load everything and chokes.
  • Phoenix's live reload is incompatible with the debugger.
  • Don't set startApps = true.
dap.configurations.elixir = {
  {
    type = "mix_task",
    name = "phoenix server",
    task = "phx.server",
    request = "launch",
    
    projectDir = "${workspaceFolder}",
    -- Tell the debugger which modules are yours!
    debugInterpretModulesPatterns = {"MyCoolApp*", "MyCoolAppWeb*"},
    -- This is important for Phoenix
    exitAfterTaskReturns = false,
  },
}

The Mute REPL & Annoying Errors

I was getting closer. I could launch the debugger and hit a breakpoint, but two smaller issues remained:

  1. My DAP REPL was useless, screaming No active session whenever I tried to inspect a variable.
  2. A constant warning about unsupported exception breakpoints was cluttering my screen.

The Fixes: The first one was a classic user error. The REPL only works when execution is actually paused at a breakpoint. For the second, it's just an expected behavior, Elixir's debugger doesn't support exception breakpoints. A single line of config silenced it for good.

dap.defaults.elixir.exception_breakpoints = {}

Complete, Working Config

After all that troubleshooting, here is the complete, battle-tested configuration for your nvim-dap setup. It includes the adapter logic, correct Phoenix settings, a test runner, and some nice UI/keymap defaults.

return {
  {
    "mfussenegger/nvim-dap",
    dependencies = {
      "rcarriga/nvim-dap-ui",
      "nvim-neotest/nvim-nio",
      "williamboman/mason.nvim",
    },
    config = function()
      local dap = require "dap"
      local dapui = require "dapui"

      -- A nice, spacious UI layout
      dapui.setup({
        layouts = {
          {
            elements = { { id = "scopes", size = 0.25 }, "breakpoints", "stacks", "watches" },
            size = 40,
            position = "left",
          },
          {
            elements = { "repl", "console" },
            size = 0.25,
            position = "bottom",
          },
        },
      })

      -- Smart adapter detection for Nix/devenv vs. Mason
      local function get_elixir_ls_debug_adapter()
        local elixir_ls = vim.fn.exepath('elixir-ls')
        if elixir_ls ~= '' then
          local dir = vim.fn.fnamemodify(elixir_ls, ':h')
          local debug_adapter = dir .. '/debug_adapter.sh'
          if vim.fn.filereadable(debug_adapter) == 1 then
            vim.notify("Found Nix environment, using adapter: " .. debug_adapter, vim.log.levels.INFO)
            return debug_adapter
          end
        end
        local mason_adapter = vim.fn.expand "~/.local/share/nvim/mason/packages/elixir-ls/debug_adapter.sh"
        vim.notify("Using Mason debug adapter: " .. mason_adapter, vim.log.levels.INFO)
        return mason_adapter
      end

      dap.adapters.mix_task = {
        type = "executable",
        command = get_elixir_ls_debug_adapter(),
        args = {},
      }

      -- Silence the unsupported exception breakpoint warning
      dap.defaults.elixir.exception_breakpoints = {}

      dap.configurations.elixir = {
        -- Phoenix server config
        {
          type = "mix_task",
          name = "phoenix server",
          task = "phx.server",
          request = "launch",
          projectDir = "${workspaceFolder}",
          exitAfterTaskReturns = false,
          debugAutoInterpretAllModules = false,
          -- IMPORTANT: Change these patterns to match your app!
          debugInterpretModulesPatterns = {"MyCoolApp*", "MyCoolAppWeb*"},
          env = { MIX_ENV = "dev" },
        },
        -- Mix test config
        {
          type = "mix_task",
          name = "mix test",
          task = "test",
          taskArgs = {"--trace"},
          request = "launch",
          projectDir = "${workspaceFolder}",
          requireFiles = {
            "test/**/test_helper.exs",
            "test/**/*_test.exs"
          },
        },
      }

      -- Auto open/close DAP UI
      dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end
      dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end
      dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end

      -- Handy Keymaps
      vim.keymap.set("n", "<Leader>db", dap.toggle_breakpoint, { desc = "Toggle Breakpoint" })
      vim.keymap.set("n", "<F5>", dap.continue, { desc = "Continue (F5)" })
      vim.keymap.set("n", "<F10>", dap.step_over, { desc = "Step Over (F10)" })
      vim.keymap.set("n", "<F11>", dap.step_into, { desc = "Step Into (F11)" })
      vim.keymap.set("n", "<F12>", dap.step_out, { desc = "Step Out (F12)" })

    end,
  },
}

Final Tips & Gotchas

  • Launch from Nix! Always, always, always start Neovim from within your devenv shell. This is non-negotiable.
  • Set Your Modules: Remember to change debugInterpretModulesPatterns to match your application's module names (e.g., MyApp*, MyAppWeb*).
  • REPL Usage: The REPL only works when you're paused at a breakpoint.
  • No Live Reload: Phoenix's live reload feature is disabled during a debug session. You'll have to manually restart if you make changes.

Getting Neovim, DAP, ElixirLS, and Nix to work together was a challenge, but the payoff is a powerful, integrated debugging experience right in your editor. No more IO.inspect/1 spam

Happy debugging!