Debugging Lua in Neovim

  Saturday, June 10, 2023 » Neovim Lua

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.