Skip to content

Commit acafa3b

Browse files
authored
Merge pull request #8 from mhiro2/feat/lsp-symbols-document
feat(lsp): Add document symbols provider
2 parents f3df59e + 280013d commit acafa3b

8 files changed

Lines changed: 295 additions & 6 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,13 @@ require("peekstack").peek.definition({ mode = "inline" })
8989

9090
-- Quick peek (temporary, no stack)
9191
require("peekstack").peek.references({ mode = "quick" })
92+
93+
-- Document symbols in current buffer
94+
require("peekstack").peek.symbols_document()
9295
```
9396

9497
Built-in provider names:
95-
`lsp.definition`, `lsp.implementation`, `lsp.references`, `lsp.type_definition`, `lsp.declaration`,
98+
`lsp.definition`, `lsp.implementation`, `lsp.references`, `lsp.type_definition`, `lsp.declaration`, `lsp.symbols_document`,
9699
`diagnostics.under_cursor`, `diagnostics.in_buffer`, `file.under_cursor`, `grep.search`, `marks.buffer`,
97100
`marks.global`, `marks.all` (marks require their provider enabled; `grep.search` requires `rg`).
98101

doc/peekstack.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ LSP:
215215
lsp.references
216216
lsp.type_definition
217217
lsp.declaration
218+
lsp.symbols_document
218219

219220
Diagnostics:
220221
diagnostics.under_cursor diagnostics at the cursor line (message shown above the preview)
@@ -255,6 +256,7 @@ The main module exposes the following API:
255256
`require("peekstack").peek.references(opts)`
256257
`require("peekstack").peek.type_definition(opts)`
257258
`require("peekstack").peek.declaration(opts)`
259+
`require("peekstack").peek.symbols_document(opts)`
258260
`require("peekstack").peek.diagnostics_cursor(opts)`
259261
`require("peekstack").peek.diagnostics_buffer(opts)`
260262
`require("peekstack").peek.file_under_cursor(opts)`
@@ -270,6 +272,7 @@ Built-in provider names:
270272
lsp.references
271273
lsp.type_definition
272274
lsp.declaration
275+
lsp.symbols_document
273276
diagnostics.under_cursor
274277
diagnostics.in_buffer
275278
file.under_cursor

lua/peekstack/commands.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ function M.setup()
183183
"lsp.references",
184184
"lsp.type_definition",
185185
"lsp.declaration",
186+
"lsp.symbols_document",
186187
"diagnostics.under_cursor",
187188
"diagnostics.in_buffer",
188189
"file.under_cursor",

lua/peekstack/init.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ end
201201
---@field references fun(opts?: table)
202202
---@field type_definition fun(opts?: table)
203203
---@field declaration fun(opts?: table)
204+
---@field symbols_document fun(opts?: table)
204205
---@field diagnostics_cursor fun(opts?: table)
205206
---@field diagnostics_buffer fun(opts?: table)
206207
---@field file_under_cursor fun(opts?: table)
@@ -230,6 +231,9 @@ M.peek = setmetatable({
230231
declaration = function(opts)
231232
return peek_by_provider("lsp.declaration", opts)
232233
end,
234+
symbols_document = function(opts)
235+
return peek_by_provider("lsp.symbols_document", opts)
236+
end,
233237
diagnostics_cursor = function(opts)
234238
return peek_by_provider("diagnostics.under_cursor", opts)
235239
end,
@@ -301,6 +305,7 @@ function M.setup(opts)
301305
"references",
302306
"type_definition",
303307
"declaration",
308+
"symbols_document",
304309
})
305310
end
306311

lua/peekstack/providers/lsp.lua

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,101 @@ local location = require("peekstack.core.location")
22

33
local M = {}
44

5+
---@alias PeekstackLspResultMapper fun(result: any, provider: string, ctx: PeekstackProviderContext): PeekstackLocation[]
6+
7+
---@param symbol table
8+
---@param uri string
9+
---@param provider string
10+
---@param out PeekstackLocation[]
11+
local function append_document_symbol(symbol, uri, provider, out)
12+
if type(symbol) ~= "table" then
13+
return
14+
end
15+
16+
local range = symbol.selectionRange or symbol.range
17+
local start_pos = range and range.start
18+
local end_pos = range and range["end"]
19+
if start_pos and end_pos then
20+
local text = symbol.name
21+
if type(text) ~= "string" then
22+
text = nil
23+
end
24+
if type(symbol.detail) == "string" and symbol.detail ~= "" then
25+
if text and text ~= "" then
26+
text = string.format("%s - %s", text, symbol.detail)
27+
else
28+
text = symbol.detail
29+
end
30+
end
31+
32+
table.insert(out, {
33+
uri = uri,
34+
range = range,
35+
text = text,
36+
kind = symbol.kind,
37+
provider = provider,
38+
})
39+
end
40+
41+
if vim.islist(symbol.children) then
42+
for _, child in ipairs(symbol.children) do
43+
append_document_symbol(child, uri, provider, out)
44+
end
45+
end
46+
end
47+
48+
---@param result any
49+
---@param provider string
50+
---@param _ctx PeekstackProviderContext
51+
---@return PeekstackLocation[]
52+
local function default_result_mapper(result, provider, _ctx)
53+
return location.list_from_lsp(result, provider)
54+
end
55+
56+
---@param result any
57+
---@param provider string
58+
---@param ctx PeekstackProviderContext
59+
---@return PeekstackLocation[]
60+
local function document_symbol_result_mapper(result, provider, ctx)
61+
local items = {}
62+
if not result then
63+
return items
64+
end
65+
66+
local results = vim.islist(result) and result or { result }
67+
68+
local uri
69+
if ctx.bufnr and vim.api.nvim_buf_is_valid(ctx.bufnr) then
70+
uri = vim.uri_from_bufnr(ctx.bufnr)
71+
end
72+
73+
for _, symbol in ipairs(results) do
74+
if type(symbol) == "table" and symbol.location then
75+
local loc = location.normalize(symbol.location, provider)
76+
if loc then
77+
if type(symbol.name) == "string" and symbol.name ~= "" then
78+
loc.text = symbol.name
79+
end
80+
if type(symbol.kind) == "number" then
81+
loc.kind = symbol.kind
82+
end
83+
table.insert(items, loc)
84+
end
85+
elseif uri then
86+
append_document_symbol(symbol, uri, provider, items)
87+
end
88+
end
89+
90+
return items
91+
end
92+
593
---@param ctx PeekstackProviderContext
694
---@param method string
795
---@param provider string
896
---@param params_modifier nil|fun(params: table)
97+
---@param result_mapper nil|PeekstackLspResultMapper
998
---@param cb fun(locations: PeekstackLocation[])
10-
local function request(ctx, method, provider, params_modifier, cb)
99+
local function request(ctx, method, provider, params_modifier, result_mapper, cb)
11100
local bufnr = ctx.bufnr
12101
local clients = vim.lsp.get_clients({ bufnr = bufnr, method = method })
13102
if not clients or vim.tbl_isempty(clients) then
@@ -17,6 +106,7 @@ local function request(ctx, method, provider, params_modifier, cb)
17106

18107
local all_locations = {}
19108
local remaining = #clients
109+
local mapper = result_mapper or default_result_mapper
20110

21111
for _, client in ipairs(clients) do
22112
local params = {
@@ -31,8 +121,10 @@ local function request(ctx, method, provider, params_modifier, cb)
31121
end
32122
client:request(method, params, function(err, result)
33123
if not err and result then
34-
local locs = location.list_from_lsp(result, provider)
35-
vim.list_extend(all_locations, locs)
124+
local ok, locs = pcall(mapper, result, provider, ctx)
125+
if ok and type(locs) == "table" then
126+
vim.list_extend(all_locations, locs)
127+
end
36128
end
37129
remaining = remaining - 1
38130
if remaining == 0 then
@@ -45,10 +137,11 @@ end
45137
---@param method string
46138
---@param provider string
47139
---@param params_modifier nil|fun(params: table)
140+
---@param result_mapper nil|PeekstackLspResultMapper
48141
---@return fun(ctx: PeekstackProviderContext, cb: fun(locations: PeekstackLocation[]))
49-
local function create_provider(method, provider, params_modifier)
142+
local function create_provider(method, provider, params_modifier, result_mapper)
50143
return function(ctx, cb)
51-
request(ctx, method, provider, params_modifier, cb)
144+
request(ctx, method, provider, params_modifier, result_mapper, cb)
52145
end
53146
end
54147

@@ -59,5 +152,8 @@ M.declaration = create_provider("textDocument/declaration", "lsp.declaration")
59152
M.references = create_provider("textDocument/references", "lsp.references", function(params)
60153
params.context = { includeDeclaration = false }
61154
end)
155+
M.symbols_document = create_provider("textDocument/documentSymbol", "lsp.symbols_document", function(params)
156+
params.position = nil
157+
end, document_symbol_result_mapper)
62158

63159
return M

tests/commands_spec.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ describe("peekstack.commands", function()
140140
local names = vim.fn.getcompletion("PeekstackQuickPeek ", "cmdline")
141141

142142
assert.is_true(vim.list_contains(names, "lsp.declaration"))
143+
assert.is_true(vim.list_contains(names, "lsp.symbols_document"))
143144
assert.is_true(vim.list_contains(names, "diagnostics.in_buffer"))
144145
assert.is_true(vim.list_contains(names, "marks.buffer"))
145146
assert.is_true(vim.list_contains(names, "marks.global"))

tests/lsp_provider_spec.lua

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
local lsp_provider = require("peekstack.providers.lsp")
2+
3+
describe("peekstack.providers.lsp", function()
4+
local original_get_clients
5+
local original_notify
6+
local notifications
7+
8+
local function make_ctx()
9+
local winid = vim.api.nvim_get_current_win()
10+
local bufnr = vim.api.nvim_get_current_buf()
11+
return {
12+
winid = winid,
13+
bufnr = bufnr,
14+
source_bufnr = nil,
15+
popup_id = nil,
16+
buffer_mode = nil,
17+
line_offset = 0,
18+
position = { line = 0, character = 0 },
19+
root_winid = winid,
20+
from_popup = false,
21+
}
22+
end
23+
24+
before_each(function()
25+
original_get_clients = vim.lsp.get_clients
26+
original_notify = vim.notify
27+
notifications = {}
28+
vim.notify = function(msg, level)
29+
table.insert(notifications, { msg = msg, level = level })
30+
end
31+
end)
32+
33+
after_each(function()
34+
vim.lsp.get_clients = original_get_clients
35+
vim.notify = original_notify
36+
end)
37+
38+
it("maps DocumentSymbol results with hierarchy flattening", function()
39+
vim.lsp.get_clients = function(opts)
40+
assert.equals("textDocument/documentSymbol", opts.method)
41+
return {
42+
{
43+
request = function(_, method, params, handler, _bufnr)
44+
assert.equals("textDocument/documentSymbol", method)
45+
assert.is_nil(params.position)
46+
assert.is_table(params.textDocument)
47+
48+
handler(nil, {
49+
{
50+
name = "Parent",
51+
detail = "class",
52+
kind = 5,
53+
range = {
54+
start = { line = 0, character = 0 },
55+
["end"] = { line = 20, character = 0 },
56+
},
57+
selectionRange = {
58+
start = { line = 1, character = 2 },
59+
["end"] = { line = 1, character = 8 },
60+
},
61+
children = {
62+
{
63+
name = "Child",
64+
kind = 6,
65+
range = {
66+
start = { line = 3, character = 1 },
67+
["end"] = { line = 3, character = 5 },
68+
},
69+
},
70+
},
71+
},
72+
{ name = "BrokenWithoutRange" },
73+
})
74+
end,
75+
},
76+
}
77+
end
78+
79+
local received
80+
local ctx = make_ctx()
81+
lsp_provider.symbols_document(ctx, function(locations)
82+
received = locations
83+
end)
84+
85+
assert.is_table(received)
86+
assert.equals(2, #received)
87+
assert.equals(vim.uri_from_bufnr(ctx.bufnr), received[1].uri)
88+
assert.equals(1, received[1].range.start.line)
89+
assert.equals(2, received[1].range.start.character)
90+
assert.equals("Parent - class", received[1].text)
91+
assert.equals(5, received[1].kind)
92+
assert.equals("lsp.symbols_document", received[1].provider)
93+
94+
assert.equals(3, received[2].range.start.line)
95+
assert.equals(1, received[2].range.start.character)
96+
assert.equals("Child", received[2].text)
97+
assert.equals(6, received[2].kind)
98+
assert.equals("lsp.symbols_document", received[2].provider)
99+
end)
100+
101+
it("maps SymbolInformation results", function()
102+
vim.lsp.get_clients = function(opts)
103+
assert.equals("textDocument/documentSymbol", opts.method)
104+
return {
105+
{
106+
request = function(_, _method, _params, handler, _bufnr)
107+
handler(nil, {
108+
{
109+
name = "GlobalFn",
110+
kind = 12,
111+
location = {
112+
uri = "file:///tmp/symbol.lua",
113+
range = {
114+
start = { line = 9, character = 4 },
115+
["end"] = { line = 9, character = 12 },
116+
},
117+
},
118+
},
119+
{
120+
name = "Invalid",
121+
location = {},
122+
},
123+
})
124+
end,
125+
},
126+
}
127+
end
128+
129+
local received
130+
lsp_provider.symbols_document(make_ctx(), function(locations)
131+
received = locations
132+
end)
133+
134+
assert.is_table(received)
135+
assert.equals(1, #received)
136+
assert.equals("file:///tmp/symbol.lua", received[1].uri)
137+
assert.equals(9, received[1].range.start.line)
138+
assert.equals(4, received[1].range.start.character)
139+
assert.equals("GlobalFn", received[1].text)
140+
assert.equals(12, received[1].kind)
141+
assert.equals("lsp.symbols_document", received[1].provider)
142+
end)
143+
144+
it("warns when no lsp clients are attached", function()
145+
vim.lsp.get_clients = function(_opts)
146+
return {}
147+
end
148+
149+
local called = false
150+
lsp_provider.symbols_document(make_ctx(), function(_locations)
151+
called = true
152+
end)
153+
154+
assert.is_false(called)
155+
local found = false
156+
for _, item in ipairs(notifications) do
157+
if item.msg == "No LSP clients attached" then
158+
found = true
159+
break
160+
end
161+
end
162+
assert.is_true(found)
163+
end)
164+
end)

0 commit comments

Comments
 (0)