From 968cf1cba5abfb0e8bfd14f2af3faa4de8ea1a03 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 30 Sep 2025 20:40:56 +0200 Subject: [PATCH] fix(dap.hover): remove tree and make nodes self-contained subtrees --- lua/ow/dap/hover.lua | 77 +++---- lua/ow/dap/hover/node.lua | 134 ++++++++++++ lua/ow/dap/hover/tree.lua | 212 ------------------- lua/ow/dap/hover/window.lua | 398 +++++++++++++++--------------------- 4 files changed, 328 insertions(+), 493 deletions(-) delete mode 100644 lua/ow/dap/hover/tree.lua diff --git a/lua/ow/dap/hover.lua b/lua/ow/dap/hover.lua index dc520c1..184481b 100644 --- a/lua/ow/dap/hover.lua +++ b/lua/ow/dap/hover.lua @@ -1,61 +1,22 @@ local Item = require("ow.dap.item") -local Tree = require("ow.dap.hover.tree") +local Node = require("ow.dap.hover.node") local Window = require("ow.dap.hover.window") local log = require("ow.log") ----@async ----@param expr string ----@param session dap.Session ----@param frame_id number ----@param line_nr integer ----@param col_nr integer ----@param current_file string -local function eval(expr, session, frame_id, line_nr, col_nr, current_file) - local win = Window.get_instance() - win:close() - - local request = { - expression = expr, - frameId = frame_id, - context = "hover", - line = line_nr, - column = col_nr, - source = { - path = current_file, - }, - } - - local err, resp = session:request("evaluate", request) - if err then - log.warning("Failed to evaluate '%s': %s", expr, err) - end - if err or not resp then - return - end - - local item = Item.new(expr, resp.type, resp.result, resp.variablesReference) - win.tree = Tree.new(session) - - win.tree:build(item) - local content = win.tree:render() - - win:show(content) -end - ---@async local function hover_async() - local win = Window.get_instance() - if win.winid and vim.api.nvim_win_is_valid(win.winid) then - vim.api.nvim_set_current_win(win.winid) - return - end - local dap = require("dap") local session = dap.session() if not session then return end + local win = Window.get_instance(session) + if win.winid and vim.api.nvim_win_is_valid(win.winid) then + vim.api.nvim_set_current_win(win.winid) + return + end + local capabilities = session.capabilities or {} local supports_hover = capabilities.supportsEvaluateForHovers if not supports_hover then @@ -132,7 +93,29 @@ local function hover_async() frame_id = resp.stackFrames[1].id end - eval(expr, session, frame_id, line_nr, col_nr, current_file) + local request = { + expression = expr, + frameId = frame_id, + context = "hover", + line = line_nr, + column = col_nr, + source = { + path = current_file, + }, + } + + local err, resp = session:request("evaluate", request) + if err then + log.warning("Failed to evaluate '%s': %s", expr, err) + end + if err or not resp then + return + end + + local item = Item.new(expr, resp.type, resp.result, resp.variablesReference) + local root = Node.new(item, nil, session.filetype) + root:load_children(session) + win:show(root) end local function hover() diff --git a/lua/ow/dap/hover/node.lua b/lua/ow/dap/hover/node.lua index d173299..012a28d 100644 --- a/lua/ow/dap/hover/node.lua +++ b/lua/ow/dap/hover/node.lua @@ -1,3 +1,6 @@ +local Item = require("ow.dap.item") +local log = require("ow.log") + ---@class ow.dap.hover.Node ---@field lang string ---@field item ow.dap.Item @@ -23,6 +26,104 @@ function Node.new(item, parent, lang) }, Node) end +---@return integer +function Node:size() + local count = 1 + + if self.is_expanded and self.children then + for _, child in ipairs(self.children) do + count = count + child:size() + end + end + + return count +end + +---@param n integer +---@return ow.dap.hover.Node? +function Node:at(n) + if n < 1 then + return nil + end + + local current = 0 + + ---@param node ow.dap.hover.Node + local function search(node) + current = current + 1 + if current == n then + return node + end + + if node.is_expanded and node.children then + for _, child in ipairs(node.children) do + local found = search(child) + if found then + return found + end + end + end + end + + return search(self) +end + +---@param target ow.dap.hover.Node? if nil, returns index of self +---@return integer? +function Node:index_of(target) + target = target or self + local current = 0 + + local function search(node) + current = current + 1 + if node == target then + return current + end + + if node.is_expanded and node.children then + for _, child in ipairs(node.children) do + local found = search(child) + if found then + return found + end + end + end + end + + return search(self) +end + +---@param session dap.Session +function Node:expand_all(session) + if not self:is_expandable() then + return true + end + + if not self.is_expanded then + local success = self:load_children(session) + if not success then + return false + end + self.is_expanded = true + end + + for _, child in ipairs(self.children) do + local success = child:expand_all(session) + if not success then + return false + end + end + + return true +end + +function Node:collapse_all() + self.is_expanded = false + for _, child in ipairs(self.children) do + child:collapse_all() + end +end + ---@return boolean function Node:is_container() return self.item.variablesReference and self.item.variablesReference > 0 @@ -133,4 +234,37 @@ function Node:format_into(content) content:add_with_treesitter(text, self.lang) end +---@async +---@param session dap.Session +---@return boolean +function Node:load_children(session) + if not self:is_container() or #self.children > 0 then + return true -- Already loaded or not a container + end + + local err, resp = session:request("variables", { + variablesReference = self.item.variablesReference, + }) + if err then + log.warning("Failed to get variables for %s: %s", self.item.name, err) + end + if err or not resp or #resp.variables == 0 then + return false + end + + for i, var in ipairs(resp.variables) do + local item = Item.from_var(var) + local child = Node.new(item, self, self.lang) + child.is_last_child = (i == #resp.variables) + + if item.name:match("^%d+$") then + item.name = "[" .. item.name .. "]" + end + + table.insert(self.children, child) + end + + return true +end + return Node diff --git a/lua/ow/dap/hover/tree.lua b/lua/ow/dap/hover/tree.lua deleted file mode 100644 index 3733542..0000000 --- a/lua/ow/dap/hover/tree.lua +++ /dev/null @@ -1,212 +0,0 @@ -local Content = require("ow.dap.hover.content") -local Item = require("ow.dap.item") -local Node = require("ow.dap.hover.node") -local log = require("ow.log") - ----@class ow.dap.hover.Tree ----@field lang string ----@field session dap.Session ----@field root ow.dap.hover.Node? -local Tree = {} -Tree.__index = Tree - ----@param session dap.Session ----@return ow.dap.hover.Tree -function Tree.new(session) - return setmetatable({ - lang = session.filetype, - session = session, - root = nil, - }, Tree) -end - ----@async ----@param item ow.dap.Item -function Tree:build(item) - self.root = Node.new(item, nil, self.lang) - - if self.root:is_container() then - self:load_children(self.root) - self.root.is_expanded = true - end -end - ----@async ----@param node ow.dap.hover.Node ----@return boolean success -function Tree:load_children(node) - if not node:is_container() or #node.children > 0 then - return true -- Already loaded or not a container - end - - local err, resp = self.session:request("variables", { - variablesReference = node.item.variablesReference, - }) - if err then - log.warning("Failed to get variables for %s: %s", node.item.name, err) - end - if err or not resp or #resp.variables == 0 then - return false - end - - for i, var in ipairs(resp.variables) do - local item = Item.from_var(var) - local child = Node.new(item, node, self.lang) - child.is_last_child = (i == #resp.variables) - - if item.name:match("^%d+$") then - item.name = "[" .. item.name .. "]" - end - - table.insert(node.children, child) - end - - return true -end - ----@async ----@return ow.dap.hover.Content -function Tree:render() - if not self.root then - return Content.new() - end - - local content = Content.new() - - self:render_subtree(self.root, content) - return content -end - ----@async ----@param node ow.dap.hover.Node ----@param content ow.dap.hover.Content -function Tree:render_subtree(node, content) - node:format_into(content) - - if node.is_expanded then - for _, child in ipairs(node.children) do - content:newline() - self:render_subtree(child, content) - end - end -end - ----@param node ow.dap.hover.Node ----@return integer -function Tree:count_subtree_nodes(node) - local count = 1 - - if node.is_expanded then - for _, child in ipairs(node.children) do - count = count + self:count_subtree_nodes(child) - end - end - - return count -end - ----@param target_line integer ----@return ow.dap.hover.Node? -function Tree:get_node_at_line(target_line) - local current_line = 0 - - ---@param node ow.dap.hover.Node - local function search(node) - current_line = current_line + 1 - if current_line == target_line then - return node - end - - if node.is_expanded and node.children then - for _, child in ipairs(node.children) do - local found = search(child) - if found then - return found - end - end - end - end - - if self.root then - return search(self.root) - end -end - ----@param target_node ow.dap.hover.Node ----@return integer? -function Tree:get_line_for_node(target_node) - local current_line = 0 - - ---@param node ow.dap.hover.Node - local function search(node) - current_line = current_line + 1 - if node == target_node then - return current_line - end - - if node.is_expanded and node.children then - for _, child in ipairs(node.children) do - local found = search(child) - if found then - return found - end - end - end - return nil - end - - if self.root then - return search(self.root) - end -end - ----@async ----@param node ow.dap.hover.Node ----@return boolean success -function Tree:toggle_node(node) - if not node.is_expanded then - local success = self:load_children(node) - if not success then - return false - end - end - - node.is_expanded = not node.is_expanded - return true -end - ----@async ----@param node ow.dap.hover.Node ----@return boolean success -function Tree:expand_all_children(node) - if not node:is_expandable() then - return true - end - - if not node.is_expanded then - local success = self:load_children(node) - if not success then - return false - end - node.is_expanded = true - end - - for _, child in ipairs(node.children) do - local success = self:expand_all_children(child) - if not success then - return false - end - end - - return true -end - ----@param node ow.dap.hover.Node -function Tree:collapse_all_children(node) - node.is_expanded = false - for _, child in ipairs(node.children) do - self:collapse_all_children(child) - end -end - -return Tree diff --git a/lua/ow/dap/hover/window.lua b/lua/ow/dap/hover/window.lua index 4c1e4e2..aa152c3 100644 --- a/lua/ow/dap/hover/window.lua +++ b/lua/ow/dap/hover/window.lua @@ -1,39 +1,16 @@ local Content = require("ow.dap.hover.content") local log = require("ow.log") ----@class ow.dap.hover.NodeInfo ----@field node ow.dap.hover.Node? ----@field line_number integer ----@field subtree_count integer -local NodeInfo = {} -NodeInfo.__index = NodeInfo - ----@param node ow.dap.hover.Node? ----@param line_number integer ----@param subtree_count integer ----@return ow.dap.hover.NodeInfo -function NodeInfo.new(node, line_number, subtree_count) - return setmetatable({ - node = node, - line_number = line_number, - subtree_count = subtree_count, - }, NodeInfo) -end - ----@return boolean -function NodeInfo:is_valid() - return self.node ~= nil -end - ---@class ow.dap.hover.Window ---@field NAMESPACE string +---@field NS_ID integer ---@field max_width? integer ---@field max_height? integer ---@field winid? integer ---@field bufnr? integer ----@field NS_ID integer ---@field augroup? integer ----@field tree ow.dap.hover.Tree? +---@field session dap.Session +---@field root ow.dap.hover.Node local Window = {} Window.__index = Window @@ -42,8 +19,9 @@ Window.NS_ID = vim.api.nvim_create_namespace(Window.NAMESPACE) local instance = nil +---@param session dap.Session ---@return ow.dap.hover.Window -function Window.get_instance() +function Window.get_instance(session) if instance then return instance end @@ -54,6 +32,8 @@ function Window.get_instance() winid = nil, bufnr = nil, augroup = nil, + session = session, + root = nil, }, Window) return instance @@ -73,7 +53,7 @@ function Window:close() self.winid = nil self.bufnr = nil self.augroup = nil - self.tree = nil + self.root = nil end ---@return integer @@ -97,32 +77,173 @@ function Window:compute_height() return math.min(self.max_height or text_height, text_height) end ----@param content ow.dap.hover.Content -function Window:show(content) +---@param callback fun() +function Window:update_buffer(callback) + local prev_scrolloff = vim.wo[self.winid].scrolloff + vim.wo[self.winid].scrolloff = 0 + vim.bo[self.bufnr].modifiable = true + callback() + vim.bo[self.bufnr].modifiable = false + vim.wo[self.winid].scrolloff = prev_scrolloff +end + +---@async +---@param node ow.dap.hover.Node +---@param start_line integer 1-indexed line number +---@param line_count integer number of lines to replace +function Window:refresh_tree(node, start_line, line_count) + local content = Content.new() + node:format_into(content) + local lines = content:get_lines() + + self:update_buffer(function() + vim.api.nvim_buf_set_lines( + self.bufnr, + start_line - 1, + start_line - 1 + line_count, + true, + lines + ) + end) + + content:apply_highlights(Window.NS_ID, self.bufnr, start_line - 1) + + vim.api.nvim_win_set_config(self.winid, { + width = self:compute_width(), + }) + vim.api.nvim_win_set_config(self.winid, { + height = self:compute_height(), + }) +end + +function Window:toggle_node() + coroutine.wrap(function() + local ok, err = xpcall(function() + local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] + local node = self.root:at(lnum) + if not node or not node:is_expandable() then + return + end + + if not node.is_expanded then + local success = node:load_children(self.session) + if not success then + return + end + end + + local prev_size = node:size() + node.is_expanded = not node.is_expanded + self:refresh_tree(node, lnum, prev_size) + end, debug.traceback) + + if not ok then + log.error("Expansion failed:\n%s", err) + end + end)() +end + +function Window:collapse_parent() + coroutine.wrap(function() + local ok, err = xpcall(function() + if self:goto_parent() then + self:toggle_node() + end + end, debug.traceback) + + if not ok then + log.error("Collapse failed:\n%s", err) + end + end)() +end + +function Window:expand_all_at_cursor() + coroutine.wrap(function() + local ok, err = xpcall(function() + local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] + local node = self.root:at(lnum) + if not node or not node:is_expandable() then + return + end + + local prev_size = node:size() + node:expand_all(self.session) + self:refresh_tree(node, lnum, prev_size) + end, debug.traceback) + + if not ok then + log.error("Expand all failed:\n%s", err) + end + end)() +end + +function Window:collapse_all_at_cursor() + coroutine.wrap(function() + local ok, err = xpcall(function() + local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] + local node = self.root:at(lnum) + if not node or not node:is_expandable() then + return + end + + local prev_size = node:size() + node:collapse_all() + self:refresh_tree(node, lnum, prev_size) + end, debug.traceback) + + if not ok then + log.error("Collapse all failed:\n%s", err) + end + end)() +end + +---@return boolean success if parent line was found +function Window:goto_parent() + local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] + local node = self.root:at(lnum) + if not node or not node.parent then + return false + end + + local parent_line = self.root:index_of(node.parent) + if parent_line then + vim.api.nvim_win_set_cursor(self.winid, { parent_line, 0 }) + return true + end + + return false +end + +function Window:yank_value() + local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] + local node = self.root:at(lnum) + if not node then + return + end + + vim.fn.setreg('"', node.item.value) + vim.fn.setreg("+", node.item.value) +end + +---@async +---@param root ow.dap.hover.Node +function Window:show(root) + self.root = root + local prev_buf = vim.api.nvim_get_current_buf() self.bufnr = vim.api.nvim_create_buf(false, true) - - local lines = content:get_lines() - vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, lines) - vim.bo[self.bufnr].modifiable = false - self.winid = vim.api.nvim_open_win(self.bufnr, false, { relative = "cursor", - width = self:compute_width(), + width = 1, height = 1, row = 1, col = 0, border = "rounded", style = "minimal", - hide = true, }) + vim.wo[self.winid].wrap = false - vim.api.nvim_win_set_config(self.winid, { - height = self:compute_height(), - hide = false, - }) - - content:apply_highlights(Window.NS_ID, self.bufnr, 0) + self:refresh_tree(self.root, 1, 1) self.augroup = vim.api.nvim_create_augroup(Window.NAMESPACE, { clear = true }) @@ -196,195 +317,4 @@ function Window:show(content) end, { buffer = self.bufnr, nowait = true }) end ----@param callback fun() -function Window:update_buffer(callback) - local prev_scrolloff = vim.wo[self.winid].scrolloff - vim.wo[self.winid].scrolloff = 0 - vim.bo[self.bufnr].modifiable = true - callback() - vim.bo[self.bufnr].modifiable = false - vim.wo[self.winid].scrolloff = prev_scrolloff -end - ----@return ow.dap.hover.NodeInfo -function Window:get_current_node_info() - local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] - local node = self.tree:get_node_at_line(lnum) - - if not node then - return NodeInfo.new(nil, lnum, 0) - end - - local subtree_count = self.tree:count_subtree_nodes(node) - return NodeInfo.new(node, lnum, subtree_count) -end - ----@async ----@param node ow.dap.hover.Node ----@param start_line integer 1-indexed line number ----@param line_count integer number of lines to replace -function Window:refresh_subtree(node, start_line, line_count) - local content = Content.new() - self.tree:render_subtree(node, content) - local lines = content:get_lines() - - self:update_buffer(function() - vim.api.nvim_buf_set_lines( - self.bufnr, - start_line - 1, - start_line - 1 + line_count, - true, - lines - ) - end) - - content:apply_highlights(Window.NS_ID, self.bufnr, start_line - 1) - - vim.api.nvim_win_set_config(self.winid, { - width = self:compute_width(), - }) - vim.api.nvim_win_set_config(self.winid, { - height = self:compute_height(), - }) -end - -function Window:toggle_node() - if not self.tree then - return - end - - coroutine.wrap(function() - local ok, err = xpcall(function() - local info = self:get_current_node_info() - if not info:is_valid() or not info.node:is_expandable() then - return - end - - local success = self.tree:toggle_node(info.node) - if not success then - return - end - - self:refresh_subtree( - info.node, - info.line_number, - info.subtree_count - ) - end, debug.traceback) - - if not ok then - log.error("Expansion failed:\n%s", err) - end - end)() -end - -function Window:collapse_parent() - if not self.tree then - return - end - - coroutine.wrap(function() - local ok, err = xpcall(function() - if self:goto_parent() then - self:toggle_node() - end - end, debug.traceback) - - if not ok then - log.error("Collapse failed:\n%s", err) - end - end)() -end - -function Window:expand_all_at_cursor() - if not self.tree then - return - end - - coroutine.wrap(function() - local ok, err = xpcall(function() - local info = self:get_current_node_info() - if not info:is_valid() or not info.node:is_expandable() then - return - end - - local success = self.tree:expand_all_children(info.node) - if not success then - return - end - - self:refresh_subtree( - info.node, - info.line_number, - info.subtree_count - ) - end, debug.traceback) - - if not ok then - log.error("Expand all failed:\n%s", err) - end - end)() -end - -function Window:collapse_all_at_cursor() - if not self.tree then - return - end - - coroutine.wrap(function() - local ok, err = xpcall(function() - local info = self:get_current_node_info() - if not info:is_valid() or not info.node:is_container() then - return - end - - self.tree:collapse_all_children(info.node) - self:refresh_subtree( - info.node, - info.line_number, - info.subtree_count - ) - end, debug.traceback) - - if not ok then - log.error("Collapse all failed:\n%s", err) - end - end)() -end - ----@return boolean success if parent line was found -function Window:goto_parent() - if not self.tree then - return false - end - - local lnum = vim.api.nvim_win_get_cursor(self.winid)[1] - local node = self.tree:get_node_at_line(lnum) - if not node or not node.parent then - return false - end - - local parent_line = self.tree:get_line_for_node(node.parent) - if parent_line then - vim.api.nvim_win_set_cursor(self.winid, { parent_line, 0 }) - return true - end - - return false -end - -function Window:yank_value() - if not self.tree then - return - end - - local info = self:get_current_node_info() - if not info:is_valid() then - return - end - - vim.fn.setreg('"', info.node.item.value) - vim.fn.setreg("+", info.node.item.value) -end - return Window