From 55d6670a7eb2667d467489b5c6c6a2ed428cead2 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Wed, 22 Oct 2025 09:32:50 +0200 Subject: [PATCH] feat(picker.lsp): added lsp_incoming_calls and lsp_outgoing_calls. Closes #1843 --- docs/examples/picker.lua | 2 + lua/snacks/picker/config/sources.lua | 22 ++++ lua/snacks/picker/source/lsp/init.lua | 144 +++++++++++++++++++++----- lua/snacks/picker/types.lua | 2 + 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/docs/examples/picker.lua b/docs/examples/picker.lua index c632f757..e23c28ed 100644 --- a/docs/examples/picker.lua +++ b/docs/examples/picker.lua @@ -65,6 +65,8 @@ M.examples.general = { { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, + { "gai", function() Snacks.picker.lsp_incoming_calls() end, desc = "C[a]lls Incoming" }, + { "gao", function() Snacks.picker.lsp_outgoing_calls() end, desc = "C[a]lls Outgoing" }, { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, { "sS", function() Snacks.picker.lsp_workspace_symbols() end, desc = "LSP Workspace Symbols" }, }, diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua index 59f851a5..6893ead0 100644 --- a/lua/snacks/picker/config/sources.lua +++ b/lua/snacks/picker/config/sources.lua @@ -550,6 +550,28 @@ M.lsp_implementations = { jump = { tagstack = true, reuse_win = true }, } +-- LSP incoming calls +---@type snacks.picker.lsp.Config +M.lsp_incoming_calls = { + finder = "lsp_incoming_calls", + format = "lsp_symbol", + include_current = false, + workspace = true, -- this ensures the file is included in the formatter + auto_confirm = true, + jump = { tagstack = true, reuse_win = true }, +} + +-- LSP outgoing calls +---@type snacks.picker.lsp.Config +M.lsp_outgoing_calls = { + finder = "lsp_outgoing_calls", + format = "lsp_symbol", + include_current = false, + workspace = true, -- this ensures the file is included in the formatter + auto_confirm = true, + jump = { tagstack = true, reuse_win = true }, +} + -- LSP references ---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config ---@field include_declaration? boolean default true diff --git a/lua/snacks/picker/source/lsp/init.lua b/lua/snacks/picker/source/lsp/init.lua index 024c6273..5f0e0d4f 100644 --- a/lua/snacks/picker/source/lsp/init.lua +++ b/lua/snacks/picker/source/lsp/init.lua @@ -96,50 +96,83 @@ function M.get_clients(buf, method) end, clients) end ----@param buf number +---@class snacks.picker.lsp.Requester +---@field async snacks.picker.Async +---@field requests {client_id:number, request_id:number}[] +---@field completed number +local R = {} +R.__index = R + +function R.new() + local self = setmetatable({}, R) + self.async = Async.running() + self.requests = {} + self.completed = 0 + self.async:on( + "abort", + vim.schedule_wrap(function() + self:cancel() + end) + ) + return self +end + +function R:cancel() + while #self.requests > 0 do + local req = table.remove(self.requests) + local client = vim.lsp.get_client_by_id(req.client_id) + if client then + client:cancel_request(req.request_id) + end + end +end + +---@param buf number|vim.lsp.Client ---@param method string ---@param params fun(client:vim.lsp.Client):table ---@param cb fun(client:vim.lsp.Client, result:table, params:table) ---@async -function M.request(buf, method, params, cb) - local async = Async.running() - local cancel = {} ---@type fun()[] +function R:request(buf, method, params, cb) + local clients = type(buf) == "number" and M.get_clients(buf, method) or { + wrap(buf --[[@as vim.lsp.Client]]), + } + if vim.tbl_isempty(clients) then + return self.async:resume() + end - async:on( - "abort", - vim.schedule_wrap(function() - vim.tbl_map(pcall, cancel) - cancel = {} - end) - ) vim.schedule(function() - local clients = M.get_clients(buf, method) - if vim.tbl_isempty(clients) then - return async:resume() - end - local remaining = #clients for _, client in ipairs(clients) do local p = params(client) local status, request_id = client:request(method, p, function(_, result) if result then cb(client, result, p) end - remaining = remaining - 1 - if remaining == 0 then - async:resume() - end + self.completed = self.completed + 1 + self.async:resume() end) if status and request_id then - table.insert(cancel, function() - client:cancel_request(request_id) - end) + table.insert(self.requests, { client_id = client.id, request_id = request_id }) end end + self.async:resume() end) + self.async:suspend() + return self +end - async:suspend() - cancel = {} - async = Async.nop() +function R:wait() + while self.completed < #self.requests do + self.async:suspend() + end +end + +---@param buf number +---@param method string +---@param params fun(client:vim.lsp.Client):table +---@param cb fun(client:vim.lsp.Client, result:table, params:table) +---@async +function M.request(buf, method, params, cb) + R.new():request(buf, method, params, cb):wait() end -- Support for older versions of neovim @@ -258,7 +291,7 @@ function M.results_to_items(client, results, opts) detail = result.detail, name = result.name, text = "", - range = result.range, + range = result.range or result.selectionRange, item = result, } local uri = result.location and result.location.uri or result.uri or opts.default_uri @@ -389,6 +422,51 @@ function M.symbols(opts, ctx) end end +---@param opts snacks.picker.lsp.Config +---@param filter snacks.picker.Filter +---@param incoming? boolean +function M.call_hierarchy(opts, filter, incoming) + local method = ("callHierarchy/%sCalls"):format(incoming and "incoming" or "outgoing") + local buf = filter.current_buf + local win = filter.current_win + + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + local requester = R.new() + requester:request(buf, "textDocument/prepareCallHierarchy", function(client) + return vim.lsp.util.make_position_params(win, client.offset_encoding) + end, function(client, result) + ---@cast result lsp.CallHierarchyItem[] + for _, res in ipairs(result or {}) do + requester:request(client, method, function() + return { item = res } + end, function(_, calls) + ---@cast calls (lsp.CallHierarchyIncomingCall|lsp.CallHierarchyOutgoingCall)[] + + local call_items = {} ---@type lsp.CallHierarchyItem[] + ---@param call lsp.CallHierarchyIncomingCall|lsp.CallHierarchyOutgoingCall + for _, call in ipairs(calls) do + if incoming then + for _, range in ipairs(call.fromRanges or {}) do + local from = vim.deepcopy(call.from) + from.selectionRange = range or from.selectionRange + table.insert(call_items, from) + end + else + table.insert(call_items, call.to) + end + end + + local items = M.results_to_items(client, call_items, { default_uri = res.uri }) + vim.tbl_map(cb, items) + end) + end + end) + requester:wait() + end +end + ---@param opts snacks.picker.lsp.references.Config ---@type snacks.picker.finder function M.references(opts, ctx) @@ -402,6 +480,18 @@ function M.references(opts, ctx) ) end +---@param opts snacks.picker.lsp.Config +---@type snacks.picker.finder +function M.incoming_calls(opts, ctx) + return M.call_hierarchy(opts, ctx.filter, true) +end + +---@param opts snacks.picker.lsp.Config +---@type snacks.picker.finder +function M.outgoing_calls(opts, ctx) + return M.call_hierarchy(opts, ctx.filter, false) +end + ---@param opts snacks.picker.lsp.Config ---@type snacks.picker.finder function M.definitions(opts, ctx) diff --git a/lua/snacks/picker/types.lua b/lua/snacks/picker/types.lua index 43043abd..d2409580 100644 --- a/lua/snacks/picker/types.lua +++ b/lua/snacks/picker/types.lua @@ -35,6 +35,8 @@ ---@field lsp_declarations fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_definitions fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_implementations fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker +---@field lsp_incoming_calls fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker +---@field lsp_outgoing_calls fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker ---@field lsp_references fun(opts?: snacks.picker.lsp.references.Config|{}): snacks.Picker ---@field lsp_symbols fun(opts?: snacks.picker.lsp.symbols.Config|{}): snacks.Picker ---@field lsp_type_definitions fun(opts?: snacks.picker.lsp.Config|{}): snacks.Picker