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:
- My DAP REPL was useless, screaming
No active session
whenever I tried to inspect a variable. - 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!