Skip to content

Commit 0512693

Browse files
committed
Initial commit of Go debugger using Delve.
1 parent b9e8e65 commit 0512693

File tree

4 files changed

+278
-5
lines changed

4 files changed

+278
-5
lines changed

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ luadoc: init.lua
6565

6666
# External dependencies.
6767

68-
deps: luasocket lua/mobdebug.lua
68+
deps: luasocket lua/mobdebug.lua dkjson.lua
6969

7070
luasocket_zip = v3.0-rc1.zip
7171
mobdebug_zip = 0.70.zip
72+
dkjson_tgz = dkjson-2.5.tar.gz
7273

7374
$(luasocket_zip): ; wget https://github.com/diegonehab/luasocket/archive/$@
7475
luasocket: | $(luasocket_zip)
@@ -77,6 +78,8 @@ luasocket: | $(luasocket_zip)
7778
patch -p1 < luasocket.patch
7879
$(mobdebug_zip): ; wget https://github.com/pkulchenko/MobDebug/archive/$@
7980
lua/mobdebug.lua: | $(mobdebug_zip) ; unzip -d $(dir $@) -j $| "*/src/$(notdir $@)"
81+
$(dkjson_tgz): ; wget http://dkolf.de/src/dkjson-lua.fsl/tarball/$@
82+
dkjson.lua: | $(dkjson_tgz) ; tar xzf $| && mv dkjson-*/$@ $@ && rm -r dkjson-*
8083

8184
# Releases.
8285

@@ -86,7 +89,7 @@ else
8689
archive = git archive HEAD --prefix $(1)/ | tar -xf -
8790
endif
8891

89-
release: debugger | $(luasocket_zip) $(mobdebug_zip)
92+
release: debugger | $(luasocket_zip) $(mobdebug_zip) $(dkjson_tgz)
9093
cp $| $<
9194
make -C $< deps && make -C $< -j ta="../../.."
9295
zip -r $<.zip $< -x "*.zip" "$</.git*" "$</luasocket*" && rm -r $<

go/init.lua

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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

init.lua

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,4 +880,21 @@ events.connect(events.LEXER_LOADED, function(name)
880880
if package.searchpath('debugger.' .. name, package.path) then require('debugger.' .. name) end
881881
end)
882882

883+
local orig_path, orig_cpath = package.path, package.cpath
884+
package.path = table.concat({
885+
_HOME .. '/modules/debugger/lua/?.lua', _USERHOME .. '/modules/debugger/lua/?.lua', package.path
886+
}, ';')
887+
local so = not WIN32 and 'so' or 'dll'
888+
package.cpath = table.concat({
889+
_HOME .. '/modules/debugger/lua/?.' .. so, _USERHOME .. '/modules/debugger/lua/?.' .. so,
890+
package.cpath
891+
}, ';')
892+
---
893+
-- The LuaSocket module.
894+
-- @class table
895+
-- @name socket
896+
M.socket = require('socket')
897+
package.path, package.cpath = orig_path, orig_cpath
898+
package.loaded['socket'], package.loaded['socket.core'] = nil, nil -- clear
899+
883900
return M

lua/init.lua

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ package.cpath = table.concat({
3333
package.cpath
3434
}, ';')
3535
local mobdebug = require('mobdebug')
36-
local socket = require('socket')
3736
package.path, package.cpath = orig_path, orig_cpath
38-
package.loaded['socket'], package.loaded['socket.core'] = nil, nil -- clear
3937

4038
local server, client, proc
4139

@@ -163,7 +161,7 @@ events.connect(events.DEBUGGER_START, function(lang, filename, args, timeout)
163161
if lang ~= 'lua' then return end
164162
if not filename then filename = buffer.filename end
165163
if not server then
166-
server = socket.bind('*', mobdebug.port)
164+
server = debugger.socket.bind('*', mobdebug.port)
167165
server:settimeout(timeout or 5)
168166
end
169167
if filename ~= '-' then

0 commit comments

Comments
 (0)