Debugging Lua in Neovim
In this post I want to show you how you can debug Lua scripts with Neovim and nvim-dap. Both regular Lua, but also Lua that uses Neovim as Lua interpreter. The latter is interesting if you want to debug Neovim plugin test cases written using busted
Debugging Lua ΒΆ
To debug regular Lua using nvim-dap
, install nvim-dap and local-lua-debugger-vscode
An example configuration to debug the current file could look like this:
local dap = require("dap")
dap.configurations.lua = {
{
name = 'Current file (local-lua-dbg, lua)',
type = 'local-lua',
request = 'launch',
cwd = '${workspaceFolder}',
program = {
lua = 'lua5.1',
file = '${file}',
},
args = {},
},
}
Please refer to the debug adapter documentation for all configuration options and to the nvim-dap documentation for its general use. You can also use the launch.json
support in nvim-dap to create the configurations in JSON files. (See :help dap-launch.json
)
The focus of this post isn’t nvim-dap basics, but the next part.
Debugging Lua using a Neovim Lua interpreter ΒΆ
I wrote before about how you can turn Neovim into a Lua interpreter. In that post I shared a small Haskell script that acts as adapter to have a command that satisfies a subset of the lua
CLI interface. The minimum required to get luarocks working.
local-lua-debugger-vscode can use a custom interpreter to debug Lua scripts. Unfortunately the nlua.hs
interpreter I showed in the earlier post wasn’t enough. local-lua-debugger
requires a Lua interpreter that supports stdin. Something like echo "print(10)" | nlua
has to work.
To add support for that to the nlua.hs
would’ve required launching Neovim with --embed
and use its msgpack
RPC mechanism. I couldn’t find a msgpack RPC library for Haskell that’s also on Stackage, so - although fun - I decided to abandon Haskell in this case and go with Lua. That’s probably easier to understand for most people anyways.
nlua.lua ΒΆ
The Lua script I created that turns Neovim into a Lua interpreter requires LuaJIT and you need to have an env
executable that supports -S
. The latter should be the case if your system uses a recent coreutils
version.
To recap the requirements, we need to support this interface:
usage: lua [options] [script [args]]
Available options are:
-e stat execute string 'stat'
And support executing code sent via stdin
.
This means we’ll need a way to execute Lua code, process command line arguments, and handle stdin input. Before showing you the full solution, let’s walk through the key parts
shebang ΒΆ
The first thing to know is that we can execute any Lua script with Neovim as interpreter using nvim -l
in a shebang.
For example, create a demo.lua
file with the following content, make it executable with chmod +x demo.lua
and run it using ./demo.lua
:
#!/usr/bin/env -S nvim -l
vim.print("I can access the vim module!")
loadstring and loadfile ΒΆ
The next piece of the puzzle are the Lua built-in functions loadstring
and loadfile
.
loadstring
is a function that takes a string and returns a function. The string is any Lua code, and the returned function evaluates said code if called. An example:
-- A roundabout way to run `print(10)`
assert(loadstring("print(10)"))()
loadfile
is similar, except that it takes a whole file as argument.
In Neovim 0.9 you can call :help loadref-loadstring()
and :help luaref-loadfile()
to see the documentation for them.
stdin handling ΒΆ
In Lua you can use io.lines()
to iterate over lines sent to stdin
. But io.lines()
blocks, and if stdin
is connected to a terminal it will wait forever until you enter something.
Save the following as demo.lua
, make it executable and run it once with echo "Works" | ./demo.lua
and once with only ./demo.lua
. You’ll see that the second variant waits for input, or appears stuck. Given that we need to support cases like nlua -e "print(10)"
- with no stdin input - we need a solution for that.
#!/usr/bin/env -S nvim -l
for line in io.lines() do
print(line)
end
print("Hello?")
I resorted to using the isatty
C function, which I call using LuaJIT’s ffi
module:
local ffi = require("ffi")
ffi.cdef[[
int isatty(int fd);
]]
if ffi.C.isatty(0) == 0 then
for line in io.lines() do
print(line) -- later we'll use loadstring
end
end
On Linux with man pages installed, you can find the documentation for isatty
using man 3 isatty
.
Command-line arguments ΒΆ
The last piece of the puzzle is parsing and handling the command line arguments.
The nvim -l
implementation adds all arguments after -l
to a arg
global variable. See :help lua-args
.
With access to the arguments, we can parse them .
The parsing logic I ended up with is very rudimentary and would break on unexpected input, but that’s good enough for the use-case.
It loops over the arguments and if there is a -e
, it assumes the next argument exists and is a string to execute. It assumes all other arguments are file paths.
local i = 1
while i <= #arg do
local value = arg[i]
if value == "-e" then
assert(loadstring(arg[i + 1]))()
i = i + 2
else
local fn, err = loadfile(value)
if fn then
local result = fn()
if result then
print(result)
end
elseif err then
io.stderr:write(err)
end
i = i + 1
end
end
Final nlua.lua ΒΆ
Update 2023-11-05:
See nlua for a ready-to-use version
You can find the full version in my dotfiles.
Note that my version uses v
instead of nvim
. You need to change that if you’re going to copy it.
I think for local-lua-debugger-vscode
it would be enough to add that script to your $PATH
, but given that luarocks requires Lua interpreters to be in /usr/bin
, I added it there.
To verify if it’s working, run:
echo "print(10)" | nlua.lua
nlua.lua -e "print(10)"
nlua.lua demo.lua
Configure nvim-dap ΒΆ
Now all that’s left is to create a nvim-dap
configuration that tells local-lua-debugger-vscode
to use nlua.lua
as interpreter:
local dap = require("dap")
dap.configurations.lua = {
{
name = 'Current file (local-lua-dbg, nlua)',
type = 'local-lua',
request = 'launch',
cwd = '${workspaceFolder}',
program = {
lua = 'nlua.lua',
file = '${file}',
},
verbose = true,
args = {},
},
}
With this in place, you should be able to debug Lua scripts that use Neovim as interpreter. This is especially useful to debug busted test cases as seen in the appendix.
To debug running Neovim plugins, I recommend using osv because local-lua-debugger
doesn’t yet support attaching to a running program.
Appendix ΒΆ
Busted ΒΆ
To run busted test cases in the debugger you’ll need to add the following snippet to the start of the scripts and a dedicated configuration.
if os.getenv("LOCAL_LUA_DEBUGGER_VSCODE") == "1" then
require("lldebugger").start()
end
See the local-lua-debugger documentation or this issue.
You can also steal from my dotfiles to get a variant that runs only the test case located at the cursor.
Luarocks tips ΒΆ
If you’re going to use this interpreter with Luarocks, I recommend to create a dedicated configuration file, like ~/.luarocks/config-nlua.lua
with:
lua_interpreter = "nlua.lua"
lua_version = "5.1"
variables = {
LUA_INCDIR = "/usr/include/luajit-2.1"
}
To use it, set a LUAROCKS_CONFIG
environment variable: LUAROCKS_CONFIG=$HOME/.luarocks/config-nlua.lua
If you instead change ~/.luarocks/config-lua5.1.lua
it can mess up your regular use of luarocks.