|
| 1 | +-- Copyright 2022 Mitchell. See LICENSE. |
| 2 | + |
| 3 | +local M = {} |
| 4 | + |
| 5 | +--[[ This comment is for LuaDoc. |
| 6 | +--- |
| 7 | +-- Language debugging support for Go. |
| 8 | +-- Requires Delve to be installed and 'dlv' to be available for `os.spawn()`. |
| 9 | +-- @field logging (boolean) |
| 10 | +-- Whether or not to enable logging. Log messages are printed to stdout. |
| 11 | +-- @field log_rpc (boolean) |
| 12 | +-- Whether or not to enable logging of JSON RPC messages sent to and received from Delve. |
| 13 | +-- Log messages are printed to stdout. |
| 14 | +module('debugger.go')]] |
| 15 | + |
| 16 | +M.logging = true |
| 17 | +M.log_rpc = true |
| 18 | + |
| 19 | +local debugger = require('debugger') |
| 20 | +local json = require('debugger.dkjson') |
| 21 | + |
| 22 | +local proc, client, breakpoints, watchpoints, rpc_id |
| 23 | + |
| 24 | +-- Sends a JSON-RPC request to Delve, the Go debugger. |
| 25 | +-- @param method String method name. |
| 26 | +-- @param params Table of parameters for the method. |
| 27 | +-- @usage request('Command', {name = 'continue'}) |
| 28 | +local function request(method, params) |
| 29 | + rpc_id = rpc_id + 1 |
| 30 | + local message = {id = rpc_id, method = 'RPCServer.' .. method, params = {params or {}}} |
| 31 | + local data = json.encode(message) |
| 32 | + if M.log_rpc then print('RPC send: ' .. data) end |
| 33 | + client:send(data) |
| 34 | + client:send('\n') |
| 35 | + data = client:receive() -- assume all messages are on a single line |
| 36 | + if M.log_rpc then print('RPC recv: ' .. data) end |
| 37 | + message = json.decode(data) |
| 38 | + return message.result ~= json.null and message.result or nil |
| 39 | +end |
| 40 | + |
| 41 | +-- Map of unprintable characters to their escaped version. |
| 42 | +local escaped = {['\t'] = '\\t', ['\r'] = '\\r', ['\n'] = '\\n'} |
| 43 | + |
| 44 | +-- Returns the value of the given variable as a pretty-printed string. |
| 45 | +-- @param variable Variable to pretty-print. |
| 46 | +-- @param multi_line Whether or not to print on multiple lines. The default value is `false`. |
| 47 | +-- @param indent_level Internal level of indentation for multi-line printing. |
| 48 | +local function pretty_print(variable, multi_line, indent_level) |
| 49 | + if not indent_level then indent_level = 0 end |
| 50 | + local value |
| 51 | + if #variable.children > 0 or variable.cap > 0 then |
| 52 | + local items = {} |
| 53 | + items[#items + 1] = variable.type .. '{' |
| 54 | + local indent = multi_line and string.rep(' ', 2) |
| 55 | + for _, child in ipairs(variable.children) do |
| 56 | + if child.name == '' and #child.children > 0 then |
| 57 | + -- Avoid nested *foo.Bar{ foo.Bar{ baz = "quux" } } results. |
| 58 | + return pretty_print(child, multi_line, indent_level) |
| 59 | + end |
| 60 | + local child_value = pretty_print(child, multi_line, indent_level + 2) |
| 61 | + local line = child.name ~= '' and string.format('%s = %s,', child.name, child_value) or |
| 62 | + child_value |
| 63 | + items[#items + 1] = indent and indent .. line or line |
| 64 | + end |
| 65 | + items[#items + 1] = indent_level > 0 and '},' or '}' |
| 66 | + return table.concat(items, multi_line and '\n' .. string.rep(' ', indent_level) or ' ') |
| 67 | + elseif variable.type == 'string' then |
| 68 | + return string.format('"%s" (%s)', variable.value:gsub('[\t\r\n]', escaped), variable.type) |
| 69 | + elseif variable.value ~= '' then |
| 70 | + return string.format('%s (%s)', variable.value, variable.type) |
| 71 | + else |
| 72 | + return variable.type |
| 73 | + end |
| 74 | +end |
| 75 | + |
| 76 | +-- Computes the current debugger state from a Delve state. |
| 77 | +-- @param state State returned by a Delve Command. |
| 78 | +local function get_state(state) |
| 79 | + if state.exited then |
| 80 | + debugger.stop('go') -- program exited |
| 81 | + return nil |
| 82 | + end |
| 83 | + local thread = state.currentGoroutine or state.currentThread |
| 84 | + local location = thread.currentLoc or thread |
| 85 | + -- Fetch stack frames. |
| 86 | + local call_stack = {} |
| 87 | + for i, frame in ipairs(request('Stacktrace', {Id = thread.id, Depth = 999}).Locations) do |
| 88 | + call_stack[i] = string.format('%s:%d', frame.file, frame.line) |
| 89 | + if frame.file == location.file and frame.line == location.line then |
| 90 | + call_stack.pos = i |
| 91 | + call_stack.thread_id = thread.id |
| 92 | + end |
| 93 | + end |
| 94 | + -- Fetch frame variables. |
| 95 | + local scope = {GoroutineID = thread.id, Frame = call_stack.pos - 1} |
| 96 | + local cfg = {FollowPointers = true} |
| 97 | + local params = {Scope = scope, Cfg = cfg} |
| 98 | + local variables = {} |
| 99 | + for _, variable in ipairs(request('ListLocalVars', params).Variables) do |
| 100 | + variables[variable.name] = pretty_print( |
| 101 | + request('Eval', {Scope = scope, Expr = variable.name}).Variable) |
| 102 | + end |
| 103 | + for _, arg in ipairs(request('ListFunctionArgs', params).Args) do |
| 104 | + variables[arg.name] = pretty_print(not arg.name:find('^~') and |
| 105 | + request('Eval', {Scope = scope, Expr = arg.name}).Variable or arg) |
| 106 | + end |
| 107 | + return { |
| 108 | + file = location.file, line = location.line, call_stack = call_stack, variables = variables |
| 109 | + } |
| 110 | +end |
| 111 | + |
| 112 | +-- Helper function to update debugger state if possible. |
| 113 | +local function update_state(state) |
| 114 | + local state = get_state((state or request('State', {NonBlocking = true})).State) |
| 115 | + if state then debugger.update_state(state) end |
| 116 | +end |
| 117 | + |
| 118 | +-- Starts the Delve debugger. |
| 119 | +-- Launches Delve in a separate process for a package in a project directory, passing any command |
| 120 | +-- line arguments given. If no package or project directory are given, they are inferred from |
| 121 | +-- the current Go file. |
| 122 | +events.connect(events.DEBUGGER_START, function(lang, root, package, args) |
| 123 | + if lang ~= 'go' then return end |
| 124 | + if not package then |
| 125 | + root = assert(io.get_project_root(), _L['No project root found']) |
| 126 | + package = buffer.filename:sub(#root + 2):gsub('\\', '/'):match('^(.+)/') or '' |
| 127 | + end |
| 128 | + -- Try debugging the current package first. If there is no main, then try debugging the |
| 129 | + -- current package's tests. |
| 130 | + local dlv_cmd = 'dlv --headless --api-version=2 --log --log-output=rpc %s ./%s -- %s' |
| 131 | + for _, command in pairs{'debug', 'test'} do |
| 132 | + local args = { |
| 133 | + dlv_cmd:format(command, package, args or ''), root, function(output) |
| 134 | + local orig_view = view |
| 135 | + ui.print((output:gsub('\r?\n$', ''))) |
| 136 | + if view ~= orig_view then ui.goto_view(orig_view) end |
| 137 | + end |
| 138 | + } |
| 139 | + if env then table.insert(args, 3, env) end |
| 140 | + if M.logging then print('os.spawn: ' .. args[1]) end |
| 141 | + proc = assert(os.spawn(table.unpack(args))) |
| 142 | + local port = tonumber(proc:read('l'):match(':(%d+)')) |
| 143 | + if M.logging then print('connecting to ' .. port) end |
| 144 | + client = debugger.socket.connect('localhost', port) |
| 145 | + if not client then goto continue end -- could not launch process; connection refused |
| 146 | + if M.logging then print('connected') end |
| 147 | + breakpoints, watchpoints = {}, {} |
| 148 | + rpc_id = 0 |
| 149 | + do return true end -- a debugger was started for this language |
| 150 | + ::continue:: |
| 151 | + end |
| 152 | +end) |
| 153 | + |
| 154 | +-- Runs continue, step over, step into, and step out of commands, and updates the debugger state. |
| 155 | +-- @param name The Delve command name to run. One of 'continue', 'step', 'next', or 'stepOut'. |
| 156 | +local function run_command(name) |
| 157 | + update_state(request('Command', {name = name})) |
| 158 | +end |
| 159 | + |
| 160 | +-- Handle Go debugger continuation commands. |
| 161 | +events.connect(events.DEBUGGER_CONTINUE, function(lang) |
| 162 | + if lang == 'go' then run_command('continue') end |
| 163 | +end) |
| 164 | +events.connect(events.DEBUGGER_STEP_INTO, function(lang) |
| 165 | + if lang == 'go' then run_command('step') end |
| 166 | +end) |
| 167 | +events.connect(events.DEBUGGER_STEP_OVER, function(lang) |
| 168 | + if lang == 'go' then run_command('next') end |
| 169 | +end) |
| 170 | +events.connect(events.DEBUGGER_STEP_OUT, function(lang) |
| 171 | + if lang == 'go' then run_command('stepOut') end |
| 172 | +end) |
| 173 | +events.connect(events.DEBUGGER_PAUSE, function(lang) |
| 174 | + if lang == 'go' then run_command('halt') end |
| 175 | +end) |
| 176 | +events.connect(events.DEBUGGER_RESTART, function(lang) |
| 177 | + if lang == 'go' then request('Restart') end |
| 178 | +end) |
| 179 | + |
| 180 | +-- Stops the Go debugger. |
| 181 | +events.connect(events.DEBUGGER_STOP, function(lang) |
| 182 | + if lang ~= 'go' then return end |
| 183 | + request('halt') |
| 184 | + request('Detach', {Kill = true}) |
| 185 | + client:close() |
| 186 | + if proc and proc:status() ~= 'terminated' then proc:kill() end |
| 187 | + proc = nil |
| 188 | +end) |
| 189 | + |
| 190 | +-- Add and remove breakpoints and watches. |
| 191 | +events.connect(events.DEBUGGER_BREAKPOINT_ADDED, function(lang, file, line) |
| 192 | + if lang ~= 'go' then return end |
| 193 | + local response = request('CreateBreakpoint', {Breakpoint = {file = file, line = line}}) |
| 194 | + if not response then return end -- file not found in current debug session |
| 195 | + breakpoints[string.format('%s:%d', file, line)] = response.Breakpoint.id |
| 196 | +end) |
| 197 | +events.connect(events.DEBUGGER_BREAKPOINT_REMOVED, function(lang, file, line) |
| 198 | + if lang ~= 'go' then return end |
| 199 | + local location = string.format('%s:%d', file, line) |
| 200 | + request('ClearBreakpoint', {Id = breakpoints[location]}) |
| 201 | + breakpoints[location] = nil |
| 202 | +end) |
| 203 | +events.connect(events.DEBUGGER_WATCH_ADDED, function(lang, var, id, no_break) |
| 204 | + if lang ~= 'go' then return end |
| 205 | + -- TODO: request dlv to break on value change |
| 206 | + watchpoints[var] = true |
| 207 | + update_state() -- add watch to variables list |
| 208 | +end) |
| 209 | +events.connect(events.DEBUGGER_WATCH_REMOVED, function(lang, var, id) |
| 210 | + if lang ~= 'go' then return end |
| 211 | + -- TODO: request dlv delete watchpoint |
| 212 | + watchpoints[var] = nil |
| 213 | + update_state() -- remove watch from variables list |
| 214 | +end) |
| 215 | + |
| 216 | +-- Set the current stack frame. |
| 217 | +events.connect(events.DEBUGGER_SET_FRAME, function(lang, level) |
| 218 | + if lang ~= 'go' then return end |
| 219 | + local call_stack = get_state(request('State', {NonBlocking = true}).State).call_stack |
| 220 | + for i, frame in ipairs(call_stack) do |
| 221 | + if i == level then |
| 222 | + local file, line = frame:match('^(.+):(%d+)$') |
| 223 | + local state = { |
| 224 | + State = {currentThread = {file = file, line = tonumber(line), id = call_stack.thread_id}} |
| 225 | + } -- simulate |
| 226 | + update_state(state) |
| 227 | + return |
| 228 | + end |
| 229 | + end |
| 230 | +end) |
| 231 | + |
| 232 | +-- Inspect the value of a symbol/variable at a given position. |
| 233 | +events.connect(events.DEBUGGER_INSPECT, function(lang, pos) |
| 234 | + if lang ~= 'go' then return end |
| 235 | + if buffer:name_of_style(buffer.style_at[pos]) ~= 'identifier' then return end |
| 236 | + local s = buffer:position_from_line(buffer:line_from_position(pos)) |
| 237 | + local e = buffer:word_end_position(pos, true) |
| 238 | + local line_part = buffer:text_range(s, e) |
| 239 | + local symbol = line_part:match('[%w_%.]+$') |
| 240 | + local result = request('Eval', {Scope = {GoroutineID = -1, Frame = 0}, Expr = symbol}) |
| 241 | + if not result then return end |
| 242 | + view:call_tip_show(pos, string.format('%s = %s', symbol, pretty_print(result.Variable, true))) |
| 243 | +end) |
| 244 | + |
| 245 | +-- Evaluate an arbitrary expression. |
| 246 | +events.connect(events.DEBUGGER_COMMAND, function(lang, text) |
| 247 | + if lang ~= 'go' then return end |
| 248 | + local result = request('Eval', {Scope = {GoroutineID = -1, Frame = 0}, Expr = text}) |
| 249 | + if not result then return end |
| 250 | + local orig_view = view |
| 251 | + ui.print(pretty_print(result.Variable, true)) |
| 252 | + ui.goto_view(orig_view) |
| 253 | +end) |
| 254 | + |
| 255 | +return M |
0 commit comments