From be99745640b54a8cc6189522162b8807c0b516c6 Mon Sep 17 00:00:00 2001 From: "Gustavo \"Guz\" L de Mello" Date: Fri, 12 Dec 2025 10:34:38 -0300 Subject: [PATCH] feat(debugger): golang adapter for dap --- lua/dap-go/COPYRIGHT | 27 +++++ lua/dap-go/init.lua | 272 +++++++++++++++++++++++++++++++++++++++++++ lua/dap-go/ts.lua | 175 ++++++++++++++++++++++++++++ lua/dot/debugger.lua | 4 + 4 files changed, 478 insertions(+) create mode 100644 lua/dap-go/COPYRIGHT create mode 100644 lua/dap-go/init.lua create mode 100644 lua/dap-go/ts.lua diff --git a/lua/dap-go/COPYRIGHT b/lua/dap-go/COPYRIGHT new file mode 100644 index 0000000..7507eec --- /dev/null +++ b/lua/dap-go/COPYRIGHT @@ -0,0 +1,27 @@ +Contents of `dap-go/init.lua` and `dap-go/ts.lua` were copied from Leonardo Luz Almeida's +nvim-dap-go plugin at Git commit `b4421153ead5d726603b02743ea40cf26a51ed5f` , +which is licensed under the MIT License. A copy of the original license can be found at +the nvim-dap-go's GitHub repositoy +and below: + +MIT License + +Copyright (c) 2023 Leonardo Luz Almeida + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lua/dap-go/init.lua b/lua/dap-go/init.lua new file mode 100644 index 0000000..5fecdd0 --- /dev/null +++ b/lua/dap-go/init.lua @@ -0,0 +1,272 @@ +local ts = require("dap-go.ts") + +local M = { + last_testname = "", + last_testpath = "", + test_buildflags = "", + test_verbose = false, +} + +local default_config = { + delve = { + path = "dlv", + initialize_timeout_sec = 20, + port = "${port}", + args = {}, + build_flags = "", + -- Automatically handle the issue on delve Windows versions < 1.24.0 + -- where delve needs to be run in attched mode or it will fail (actually crashes). + detached = vim.fn.has("win32") == 0, + output_mode = "remote", + }, + tests = { + verbose = false, + }, +} + +local internal_global_config = {} + +local function load_module(module_name) + local ok, module = pcall(require, module_name) + assert(ok, string.format("dap-go dependency error: %s not installed", module_name)) + return module +end + +local function get_arguments() + return coroutine.create(function(dap_run_co) + local args = {} + vim.ui.input({ prompt = "Args: " }, function(input) + args = vim.split(input or "", " ") + coroutine.resume(dap_run_co, args) + end) + end) +end + +local function get_build_flags(config) + return coroutine.create(function(dap_run_co) + local build_flags = config.build_flags + vim.ui.input({ prompt = "Build Flags: " }, function(input) + build_flags = vim.split(input or "", " ") + coroutine.resume(dap_run_co, build_flags) + end) + end) +end + +local function filtered_pick_process() + local opts = {} + vim.ui.input( + { prompt = "Search by process name (lua pattern), or hit enter to select from the process list: " }, + function(input) + opts["filter"] = input or "" + end + ) + return require("dap.utils").pick_process(opts) +end + +local function setup_delve_adapter(dap, config) + local args = { "dap", "-l", "127.0.0.1:" .. config.delve.port } + vim.list_extend(args, config.delve.args) + + local delve_config = { + type = "server", + port = config.delve.port, + executable = { + command = config.delve.path, + args = args, + detached = config.delve.detached, + cwd = config.delve.cwd, + }, + options = { + initialize_timeout_sec = config.delve.initialize_timeout_sec, + }, + } + + dap.adapters.go = function(callback, client_config) + if client_config.port == nil then + callback(delve_config) + return + end + + local host = client_config.host + if host == nil then + host = "127.0.0.1" + end + + local listener_addr = host .. ":" .. client_config.port + delve_config.port = client_config.port + delve_config.executable.args = { "dap", "-l", listener_addr } + + callback(delve_config) + end +end + +local function setup_go_configuration(dap, configs) + local common_debug_configs = { + { + type = "go", + name = "Debug", + request = "launch", + program = "${file}", + buildFlags = configs.delve.build_flags, + outputMode = configs.delve.output_mode, + }, + { + type = "go", + name = "Debug (Arguments)", + request = "launch", + program = "${file}", + args = get_arguments, + buildFlags = configs.delve.build_flags, + outputMode = configs.delve.output_mode, + }, + { + type = "go", + name = "Debug (Arguments & Build Flags)", + request = "launch", + program = "${file}", + args = get_arguments, + buildFlags = get_build_flags, + outputMode = configs.delve.output_mode, + }, + { + type = "go", + name = "Debug Package", + request = "launch", + program = "${fileDirname}", + buildFlags = configs.delve.build_flags, + outputMode = configs.delve.output_mode, + }, + { + type = "go", + name = "Attach", + mode = "local", + request = "attach", + processId = filtered_pick_process, + buildFlags = configs.delve.build_flags, + }, + { + type = "go", + name = "Debug test", + request = "launch", + mode = "test", + program = "${file}", + buildFlags = configs.delve.build_flags, + outputMode = configs.delve.output_mode, + }, + { + type = "go", + name = "Debug test (go.mod)", + request = "launch", + mode = "test", + program = "./${relativeFileDirname}", + buildFlags = configs.delve.build_flags, + outputMode = configs.delve.output_mode, + }, + } + + if dap.configurations.go == nil then + dap.configurations.go = {} + end + + for _, config in ipairs(common_debug_configs) do + table.insert(dap.configurations.go, config) + end + + if configs == nil or configs.dap_configurations == nil then + return + end + + for _, config in ipairs(configs.dap_configurations) do + if config.type == "go" then + table.insert(dap.configurations.go, config) + end + end +end + +function M.setup(opts) + internal_global_config = vim.tbl_deep_extend("force", default_config, opts or {}) + M.test_buildflags = internal_global_config.delve.build_flags + M.test_verbose = internal_global_config.tests.verbose + + local dap = load_module("dap") + setup_delve_adapter(dap, internal_global_config) + setup_go_configuration(dap, internal_global_config) +end + +local function debug_test(testname, testpath, build_flags, extra_args, custom_config) + local dap = load_module("dap") + + local config = { + type = "go", + name = testname, + request = "launch", + mode = "test", + program = testpath, + args = { "-test.run", "^" .. testname .. "$" }, + buildFlags = build_flags, + outputMode = "remote", + } + config = vim.tbl_deep_extend("force", config, custom_config or {}) + + if not vim.tbl_isempty(extra_args) then + table.move(extra_args, 1, #extra_args, #config.args + 1, config.args) + end + + dap.run(config) +end + +function M.debug_test(custom_config) + local test = ts.closest_test() + + if test.name == "" or test.name == nil then + vim.notify("no test found") + return false + end + + M.last_testname = test.name + M.last_testpath = test.package + + local msg = string.format("starting debug session '%s : %s'...", test.package, test.name) + vim.notify(msg) + + local extra_args = {} + if M.test_verbose then + extra_args = { "-test.v" } + end + + debug_test(test.name, test.package, M.test_buildflags, extra_args, custom_config) + + return true +end + +function M.debug_last_test() + local testname = M.last_testname + local testpath = M.last_testpath + + if testname == "" then + vim.notify("no last run test found") + return false + end + + local msg = string.format("starting debug session '%s : %s'...", testpath, testname) + vim.notify(msg) + + local extra_args = {} + if M.test_verbose then + extra_args = { "-test.v" } + end + + debug_test(testname, testpath, M.test_buildflags, extra_args) + + return true +end + +function M.get_build_flags() + return get_build_flags(internal_global_config) +end + +function M.get_arguments() + return get_arguments() +end + +return M diff --git a/lua/dap-go/ts.lua b/lua/dap-go/ts.lua new file mode 100644 index 0000000..1872507 --- /dev/null +++ b/lua/dap-go/ts.lua @@ -0,0 +1,175 @@ +local M = {} + +local tests_query = [[ +(function_declaration + name: (identifier) @testname + parameters: (parameter_list + . (parameter_declaration + type: (pointer_type) @type) .) + (#match? @type "*testing.(T|M)") + (#match? @testname "^Test.+$")) @parent +]] + +local subtests_query = [[ +(call_expression + function: (selector_expression + operand: (identifier) + field: (field_identifier) @run) + arguments: (argument_list + (interpreted_string_literal) @testname + [ + (func_literal) + (identifier) + ]) + (#eq? @run "Run")) @parent +]] + +local function format_subtest(testcase, test_tree) + local parent + if testcase.parent then + for _, curr in pairs(test_tree) do + if curr.name == testcase.parent then + parent = curr + break + end + end + return string.format("%s/%s", format_subtest(parent, test_tree), testcase.name) + else + return testcase.name + end +end + +local function get_closest_above_cursor(test_tree) + local result + for _, curr in pairs(test_tree) do + if not result then + result = curr + else + local node_row1, _, _, _ = curr.node:range() + local result_row1, _, _, _ = result.node:range() + if node_row1 > result_row1 then + result = curr + end + end + end + if result then + return format_subtest(result, test_tree) + end + return nil +end + +local function is_parent(dest, source) + if not (dest and source) then + return false + end + if dest == source then + return false + end + + local current = source + while current ~= nil do + if current == dest then + return true + end + + current = current:parent() + end + + return false +end + +local function get_closest_test() + local stop_row = vim.api.nvim_win_get_cursor(0)[1] + local ft = vim.api.nvim_buf_get_option(0, "filetype") + assert(ft == "go", "can only find test in go files, not " .. ft) + local parser = vim.treesitter.get_parser(0) + local root = (parser:parse()[1]):root() + + local test_tree = {} + + local test_query = vim.treesitter.query.parse(ft, tests_query) + assert(test_query, "could not parse test query") + for _, match, _ in test_query:iter_matches(root, 0, 0, stop_row, { all = true }) do + local test_match = {} + for id, nodes in pairs(match) do + for _, node in ipairs(nodes) do + local capture = test_query.captures[id] + if capture == "testname" then + local name = vim.treesitter.get_node_text(node, 0) + test_match.name = name + end + if capture == "parent" then + test_match.node = node + end + end + end + table.insert(test_tree, test_match) + end + + local subtest_query = vim.treesitter.query.parse(ft, subtests_query) + assert(subtest_query, "could not parse test query") + for _, match, _ in subtest_query:iter_matches(root, 0, 0, stop_row, { all = true }) do + local test_match = {} + for id, nodes in pairs(match) do + for _, node in ipairs(nodes) do + local capture = subtest_query.captures[id] + if capture == "testname" then + local name = vim.treesitter.get_node_text(node, 0) + test_match.name = string.gsub(string.gsub(name, " ", "_"), '"', "") + end + if capture == "parent" then + test_match.node = node + end + end + end + table.insert(test_tree, test_match) + end + + table.sort(test_tree, function(a, b) + return is_parent(a.node, b.node) + end) + + for _, parent in ipairs(test_tree) do + for _, child in ipairs(test_tree) do + if is_parent(parent.node, child.node) then + child.parent = parent.name + end + end + end + + return get_closest_above_cursor(test_tree) +end + +local function get_package_name() + local test_dir = vim.fn.fnamemodify(vim.fn.expand("%:.:h"), ":r") + return "./" .. test_dir +end + +M.closest_test = function() + local package_name = get_package_name() + local test_case = get_closest_test() + local test_scope + if test_case then + test_scope = "testcase" + else + test_scope = "package" + end + return { + package = package_name, + name = test_case, + scope = test_scope, + } +end + +M.get_root_dir = function() + local id, client = next(vim.lsp.buf_get_clients()) + if id == nil then + error({ error_msg = "lsp client not attached" }) + end + if not client.config.root_dir then + error({ error_msg = "lsp root_dir not defined" }) + end + return client.config.root_dir +end + +return M diff --git a/lua/dot/debugger.lua b/lua/dot/debugger.lua index 5d4ddc9..af347e9 100644 --- a/lua/dot/debugger.lua +++ b/lua/dot/debugger.lua @@ -16,3 +16,7 @@ dap.listeners.before.event_exited.dapui_config = function() dapui.close() end +-- Languages + +-- Go +require("dap-go").setup()