Debugging Lua in Neovim
Table of content
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.