├── .luacheckrc ├── README.md └── lua ├── lua_debugger.lua └── lua_debugger └── log.lua /.luacheckrc: -------------------------------------------------------------------------------- 1 | ignore = { 2 | "631", -- max_line_length 3 | } 4 | read_globals = { 5 | "vim", 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UNMAINTAINED 2 | 3 | This was a prototype that never made it into a workable state. 4 | There is now [lua-debug.nvim](https://github.com/jbyuki/lua-debug.nvim) which can be used instead. 5 | 6 | 7 | # Neovim Lua Debug Adapter 8 | 9 | 10 | `nvim-lua-debugger` is a Debug Adapter that allows debugging lua plugins written for Neovim. 11 | It is the server component in the [Debug Adapter Protocol][1]. 12 | 13 | To use the debugger you'll need a client implementing the Debug Adapter Protocol: 14 | 15 | - [vimspector][2] 16 | - [nvim-dap][3] 17 | 18 | 19 | ## MVP TODO 20 | 21 | - [ ] initialization parts of the protocol 22 | - [ ] setting breakpoints 23 | - [ ] stopped event 24 | - [ ] threads request handling 25 | - [ ] stackTrace request handling 26 | - [ ] scopes request handling 27 | - [ ] variables request handling 28 | 29 | 30 | ## Installation 31 | 32 | - Requires [Neovim HEAD/nightly][4] 33 | - nvim-lua-debugger is a plugin. Install it like any other Vim plugin. 34 | - Call `:packadd nvim-lua-debugger` if you install `nvim-lua-debugger` to `'packpath'`. 35 | 36 | 37 | ## Usage with nvim-dap 38 | 39 | Add a new adapter entry: 40 | 41 | ```lua 42 | local dap = require('dap') 43 | dap.adapters.neovim = function(callback) 44 | local server = require('lua_debugger').launch() 45 | callback({ type = 'server'; host = server.host; port = server.port; }) 46 | end 47 | ``` 48 | 49 | Add a new configuration entry: 50 | 51 | ```lua 52 | local dap = require('dap') 53 | dap.configurations.lua = { 54 | { 55 | type = 'neovim'; 56 | request = 'attach'; 57 | name = "Attach to running neovim instance"; 58 | }, 59 | } 60 | ``` 61 | 62 | Then edit a ``lua`` file within Neovim and call `:lua require'dap'.continue()` to start debugging. 63 | 64 | 65 | [1]: https://microsoft.github.io/debug-adapter-protocol/overview 66 | [2]: https://github.com/puremourning/vimspector 67 | [3]: https://github.com/mfussenegger/nvim-dap 68 | [4]: https://github.com/neovim/neovim/releases/tag/nightly 69 | -------------------------------------------------------------------------------- /lua/lua_debugger.lua: -------------------------------------------------------------------------------- 1 | local uv = vim.loop 2 | local M = {} 3 | local handlers = {} 4 | local session = {} 5 | local log = require('lua_debugger.log').create_logger('lua_debugger.log') 6 | 7 | local json_decode = vim.fn.json_decode 8 | local json_encode = vim.fn.json_encode 9 | 10 | 11 | 12 | local function msg_with_content_length(msg) 13 | return table.concat { 14 | 'Content-Length: '; 15 | tostring(#msg); 16 | '\r\n\r\n'; 17 | msg 18 | } 19 | end 20 | 21 | 22 | local function parse_headers(header) 23 | if type(header) ~= 'string' then 24 | return nil 25 | end 26 | local headers = {} 27 | for line in vim.gsplit(header, '\r\n', true) do 28 | if line == '' then 29 | break 30 | end 31 | local key, value = line:match('^%s*(%S+)%s*:%s*(.+)%s*$') 32 | if key then 33 | key = key:lower():gsub('%-', '_') 34 | headers[key] = value 35 | else 36 | error(string.format("Invalid header line %q", line)) 37 | end 38 | end 39 | headers.content_length = tonumber(headers.content_length) 40 | or error(string.format("Content-Length not found in headers. %q", header)) 41 | return headers 42 | end 43 | 44 | 45 | local header_start_pattern = ("content"):gsub("%w", function(c) return "["..c..c:upper().."]" end) 46 | local function parse_chunk_loop() 47 | local buffer = '' 48 | while true do 49 | local start, finish = buffer:find('\r\n\r\n', 1, true) 50 | if start then 51 | local buffer_start = buffer:find(header_start_pattern) 52 | local headers = parse_headers(buffer:sub(buffer_start, start - 1)) 53 | buffer = buffer:sub(finish + 1) 54 | local content_length = headers.content_length 55 | while #buffer < content_length do 56 | buffer = buffer .. (coroutine.yield() 57 | or error("Expected more data for the body. The server may have died.")) 58 | end 59 | local body = buffer:sub(1, content_length) 60 | buffer = buffer:sub(content_length + 1) 61 | buffer = buffer .. (coroutine.yield(headers, body) 62 | or error("Expected more data for the body. The server may have died.")) 63 | else 64 | buffer = buffer .. (coroutine.yield() 65 | or error("Expected more data for the header. The server may have died.")) 66 | end 67 | end 68 | end 69 | 70 | 71 | local function create_read_loop(server, client, handle_request) 72 | local parse_chunk = coroutine.wrap(parse_chunk_loop) 73 | parse_chunk() 74 | return function (err, chunk) 75 | assert(not err, err) 76 | if not chunk then 77 | -- EOF 78 | client:close() 79 | server:close() 80 | debug.sethook() 81 | return 82 | end 83 | while true do 84 | local headers, body = parse_chunk(chunk) 85 | if headers then 86 | vim.schedule(function() 87 | handle_request(client, body) 88 | end) 89 | chunk = '' 90 | else 91 | break 92 | end 93 | end 94 | end 95 | end 96 | 97 | 98 | local function mk_event(event) 99 | local result = { 100 | type = 'event'; 101 | event = event; 102 | seq = session.seq or 1; 103 | } 104 | session.seq = result.seq + 1 105 | return result 106 | end 107 | 108 | 109 | local function mk_response(request, response) 110 | local result = { 111 | type = 'response'; 112 | seq = session.req or 1; 113 | request_seq = request.seq; 114 | command = request.command; 115 | success = true; 116 | } 117 | session.seq = result.seq + 1 118 | return vim.tbl_extend('error', result, response) 119 | end 120 | 121 | 122 | local function debugger_loop() 123 | while true do 124 | local ev = coroutine.yield() 125 | print(ev) 126 | end 127 | end 128 | 129 | 130 | function handlers.initialize(client, request) 131 | local payload = request.arguments 132 | session = { 133 | seq = request.seq; 134 | client = client; 135 | linesStartAt1 = payload.linesStartAt1 or 1; 136 | columnsStartAt1 = payload.columnsStartAt1 or 1; 137 | supportsRunInTerminalRequest = payload.supportsRunInTerminalRequest or false; 138 | breakpoints = {}; 139 | coro_debugger = coroutine.create(debugger_loop) 140 | } 141 | assert( 142 | not payload.pathFormat or payload.pathFormat == 'path', 143 | "Only 'path' pathFormat is supported, got: " .. payload.pathFormat 144 | ) 145 | client:write(msg_with_content_length(json_encode(mk_response( 146 | request, { 147 | body = { 148 | }; 149 | } 150 | )))) 151 | client:write(msg_with_content_length(json_encode(mk_event('initialized')))) 152 | end 153 | 154 | 155 | function handlers.setBreakpoints(client, request) 156 | local payload = request.arguments 157 | local result_bps = {} 158 | local result = { 159 | body = { 160 | breakpoints = result_bps; 161 | }; 162 | }; 163 | 164 | local bps = {} 165 | session.breakpoints = bps 166 | 167 | for _, bp in ipairs(payload.breakpoints or {}) do 168 | local line_bps = bps[bp.line] 169 | if not line_bps then 170 | line_bps = {} 171 | bps[bp.line] = line_bps 172 | end 173 | local full_path = vim.fn.fnamemodify(payload.source.path, ':p') 174 | line_bps[full_path] = true 175 | table.insert(result_bps, { 176 | verified = true; 177 | }) 178 | end 179 | log.trace('set breakpoints', session.breakpoints) 180 | client:write(msg_with_content_length(json_encode(mk_response( 181 | request, result 182 | )))) 183 | end 184 | 185 | 186 | function handlers.attach() 187 | debug.sethook(function(event, line) 188 | if event == "line" then 189 | local bp = session.breakpoints[line] 190 | if not bp then 191 | return 192 | end 193 | local info = debug.getinfo(2, "S") 194 | local source_path = info.source 195 | if source_path:sub(1, 1) == '@' then 196 | local path = source_path:sub(2) 197 | if bp[path] then 198 | local event_msg = mk_event('stopped') 199 | event_msg.body = { 200 | reason = 'breakpoint'; 201 | threadId = 1; 202 | } 203 | session.client:write(msg_with_content_length(json_encode(event_msg))) 204 | vim.schedule_wrap(coroutine.yield) 205 | end 206 | end 207 | end 208 | end, "clr") 209 | end 210 | 211 | 212 | function handlers.threads(client, request) 213 | client:write(msg_with_content_length(json_encode(mk_response( 214 | request, { 215 | body = { 216 | threads = { 217 | { 218 | id = 1; 219 | name = 'main'; 220 | }, 221 | }; 222 | }; 223 | } 224 | )))) 225 | end 226 | 227 | 228 | function handlers.stackTrace(client, request) 229 | client:write(msg_with_content_length(json_encode(mk_response( 230 | request, { 231 | body = { 232 | stackFrames = {} 233 | }; 234 | } 235 | )))) 236 | end 237 | 238 | 239 | local function handle_request(client, request_str) 240 | local request = json_decode(request_str) 241 | log.trace('handle_request', request) 242 | assert(request.type == 'request', 'request must have type `request` not ' .. vim.inspect(request)) 243 | local handler = handlers[request.command] 244 | assert(handler, 'Missing handler for ' .. request.command) 245 | handler(client, request) 246 | end 247 | 248 | 249 | local function on_connect(server, client) 250 | log.trace('Client connected', client:getsockname(), client:getpeername()) 251 | client:read_start(create_read_loop(server, client, handle_request)) 252 | end 253 | 254 | 255 | function M.launch() 256 | log.trace('Launching Debug Adapter') 257 | local server = uv.new_tcp() 258 | local host = '127.0.0.1' 259 | server:bind(host, 0) 260 | server:listen(128, function(err) 261 | assert(not err, err) 262 | local sock = uv.new_tcp() 263 | server:accept(sock) 264 | on_connect(server, sock) 265 | end) 266 | return { 267 | host = host; 268 | port = server:getsockname().port 269 | } 270 | end 271 | 272 | 273 | function M.set_log_level(level) 274 | log.set_level(level) 275 | end 276 | 277 | 278 | return M 279 | -------------------------------------------------------------------------------- /lua/lua_debugger/log.lua: -------------------------------------------------------------------------------- 1 | -- Similar to lsp/log.lua in neovim, 2 | -- but allows to create multiple loggers with different filenames each 3 | 4 | local M = {} 5 | local loggers = {} 6 | 7 | M.levels = { 8 | TRACE = 0; 9 | DEBUG = 1; 10 | INFO = 2; 11 | WARN = 3; 12 | ERROR = 4; 13 | } 14 | 15 | local log_date_format = "%FT%H:%M:%SZ%z" 16 | 17 | function M.create_logger(filename) 18 | local logger = loggers[filename] 19 | if logger then 20 | return logger 21 | end 22 | logger = {} 23 | loggers[filename] = logger 24 | 25 | local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/" 26 | local function path_join(...) 27 | return table.concat(vim.tbl_flatten{...}, path_sep) 28 | end 29 | local logfilename = path_join(vim.fn.stdpath('cache'), filename) 30 | 31 | local current_log_level = M.levels.INFO 32 | 33 | function logger.set_level(level) 34 | current_log_level = assert( 35 | M.levels[tostring(level):upper()], 36 | string.format('Log level must be one of (trace, debug, info, warn, error), got: %q', level) 37 | ) 38 | end 39 | 40 | function logger.get_filename() 41 | return logfilename 42 | end 43 | 44 | vim.fn.mkdir(vim.fn.stdpath('cache'), "p") 45 | local logfile = assert(io.open(logfilename, "a+")) 46 | for level, levelnr in pairs(M.levels) do 47 | logger[level:lower()] = function(...) 48 | local argc = select('#', ...) 49 | if levelnr < current_log_level then 50 | return false 51 | end 52 | if argc == 0 then 53 | return true 54 | end 55 | local info = debug.getinfo(2, 'Sl') 56 | local fileinfo = string.format('%s:%s', info.short_src, info.currentline) 57 | local parts = { 58 | table.concat({'[', level, ']', os.date(log_date_format), ']', fileinfo, ']'}, ' ') 59 | } 60 | for i = 1, argc do 61 | local arg = select(i, ...) 62 | if arg == nil then 63 | table.insert(parts, "nil") 64 | else 65 | table.insert(parts, vim.inspect(arg)) 66 | end 67 | end 68 | logfile:write(table.concat(parts, '\t'), '\n') 69 | logfile:flush() 70 | end 71 | end 72 | logfile:write('\n') 73 | return logger 74 | end 75 | 76 | return M 77 | --------------------------------------------------------------------------------