Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 674fc48

Browse files
committedMar 2, 2024
feat(lsp): completion side effects
1 parent dc8c086 commit 674fc48

File tree

1 file changed

+344
-3
lines changed

1 file changed

+344
-3
lines changed
 

Diff for: ‎runtime/lua/vim/lsp/_completion.lua

+344-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ local lsp = vim.lsp
44
local protocol = lsp.protocol
55
local ms = protocol.Methods
66

7+
local rtt_ms = 50
8+
local ns_to_ms = 0.000001
9+
710
--- @alias vim.lsp.CompletionResult lsp.CompletionList | lsp.CompletionItem[]
811

912
-- TODO(mariasolos): Remove this declaration once we figure out a better way to handle
@@ -14,11 +17,96 @@ local ms = protocol.Methods
1417
--- @field insertTextMode lsp.InsertTextMode?
1518
--- @field data any
1619

20+
--- @class vim.lsp.completion.BufHandle
21+
--- @field clients table<integer, vim.lsp.Client>
22+
--- @field triggers table<string, vim.lsp.Client[]>
23+
24+
--- @type table<integer, vim.lsp.completion.BufHandle>
25+
local buf_handles = {}
26+
27+
--- @class vim.lsp.completion.Context
28+
--- @field last_request_time integer?
29+
--- @field pending_requests function[]
30+
--- @field cursor { [1]: integer, [2]: integer }?
31+
--- @field isIncomplete boolean
32+
--- @field suppress_completeDone boolean
33+
local Context = {
34+
pending_requests = {},
35+
isIncomplete = false,
36+
suppress_completeDone = false,
37+
}
38+
39+
function Context:cancel_pending()
40+
for _, cancel in ipairs(self.pending_requests) do
41+
cancel()
42+
end
43+
44+
self.pending_requests = {}
45+
end
46+
47+
function Context:reset()
48+
-- Note that the cursor isn't reset here, it needs to survive a `CompleteDone` event.
49+
self.last_request_time = nil
50+
self:cancel_pending()
51+
end
52+
53+
--- @type uv.uv_timer_t?
54+
local completion_timer = nil
55+
56+
--- @param timer uv.uv_timer_t?
57+
--- @return uv.uv_timer_t
58+
local function new_timer(timer)
59+
timer = assert(vim.uv.new_timer())
60+
return timer
61+
end
62+
63+
--- @param timer uv.uv_timer_t?
64+
local function reset_timer(timer)
65+
if timer then
66+
timer:stop()
67+
timer:close()
68+
end
69+
70+
timer = nil
71+
end
72+
73+
--- @param window integer
74+
--- @param warmup integer
75+
--- @return fun(sample: number): number
76+
local function exp_avg(window, warmup)
77+
local count = 0
78+
local sum = 0
79+
local value = 0
80+
81+
return function(sample)
82+
if count < warmup then
83+
count = count + 1
84+
sum = sum + sample
85+
value = sum / count
86+
else
87+
local factor = 2.0 / (window + 1)
88+
value = value * (1 - factor) + sample * factor
89+
end
90+
return value
91+
end
92+
end
93+
local compute_new_average = exp_avg(10, 10)
94+
95+
--- @return number
96+
local function next_debounce()
97+
if not Context.last_request_time then
98+
return rtt_ms
99+
end
100+
101+
local ms_since_request = (vim.uv.hrtime() - Context.last_request_time) * ns_to_ms
102+
return math.max((ms_since_request - rtt_ms) * -1, 0)
103+
end
104+
17105
---@param input string unparsed snippet
18106
---@return string parsed snippet
19107
local function parse_snippet(input)
20108
local ok, parsed = pcall(function()
21-
return vim.lsp._snippet_grammar.parse(input)
109+
return lsp._snippet_grammar.parse(input)
22110
end)
23111
return ok and tostring(parsed) or input
24112
end
@@ -159,7 +247,7 @@ local function adjust_start_col(lnum, line, items, encoding)
159247
end
160248
end
161249
if min_start_char then
162-
return vim.lsp.util._str_byteindex_enc(line, min_start_char, encoding)
250+
return lsp.util._str_byteindex_enc(line, min_start_char, encoding)
163251
else
164252
return nil
165253
end
@@ -246,7 +334,7 @@ function M.omnifunc(findstart, base)
246334
local params = util.make_position_params(win, client.offset_encoding)
247335
client.request(ms.textDocument_completion, params, function(err, result)
248336
if err then
249-
vim.lsp.log.warn(err.message)
337+
lsp.log.warn(err.message)
250338
end
251339
if result and vim.fn.mode() == 'i' then
252340
local matches
@@ -273,4 +361,257 @@ function M.omnifunc(findstart, base)
273361
return -2
274362
end
275363

364+
--- Initializes the completion commands for the given client.
365+
---
366+
--- @param client vim.lsp.Client
367+
local function init_commands(client)
368+
local trigger_completion_cmd = 'editor.action.triggerSuggest'
369+
370+
-- Check if the command is in the global registry or in the client's commands.
371+
if not lsp.commands[trigger_completion_cmd] and not client.commands[trigger_completion_cmd] then
372+
client.commands[trigger_completion_cmd] = function()
373+
pcall(M.trigger_completion)
374+
end
375+
end
376+
end
377+
378+
--- @param clients table<integer, vim.lsp.Client>
379+
--- @param bufnr integer
380+
--- @param win integer
381+
--- @param callback fun(responses: table<integer, { err: lsp.ResponseError, result: vim.lsp.CompletionResult }>)
382+
--- @return function # Cancellation function
383+
local function request(clients, bufnr, win, callback)
384+
local responses = {} --- @type table<integer, { err: lsp.ResponseError, result: any }>
385+
local request_ids = {} --- @type table<integer, integer>
386+
local remaining_requests = vim.tbl_count(clients)
387+
388+
for client_id, client in pairs(clients) do
389+
local params = lsp.util.make_position_params(win, client.offset_encoding)
390+
local ok, request_id = client.request(ms.textDocument_completion, params, function(err, result)
391+
responses[client_id] = { err = err, result = result }
392+
remaining_requests = remaining_requests - 1
393+
if remaining_requests == 0 then
394+
callback(responses)
395+
end
396+
end, bufnr)
397+
398+
if ok then
399+
request_ids[client_id] = request_id
400+
end
401+
end
402+
403+
return function()
404+
for client_id, request_id in pairs(request_ids) do
405+
local client = lsp.get_client_by_id(client_id)
406+
if client then
407+
client.cancel_request(request_id)
408+
end
409+
end
410+
end
411+
end
412+
413+
--- @param handle vim.lsp.completion.BufHandle
414+
local function insert_char_pre_cb(handle)
415+
if tonumber(vim.fn.pumvisible()) == 1 then
416+
if Context.isIncomplete then
417+
reset_timer(completion_timer)
418+
419+
-- Calling vim.fn.complete while pumvisible will trigger `CompleteDone` for the active completion window,
420+
-- so we suppress it to avoid resetting the completion context.
421+
Context.suppress_completeDone = true
422+
423+
local debounce_ms = next_debounce()
424+
if debounce_ms == 0 then
425+
vim.schedule(M.trigger_completion)
426+
else
427+
completion_timer = new_timer(completion_timer)
428+
completion_timer:start(debounce_ms, 0, vim.schedule_wrap(M.trigger_completion))
429+
end
430+
end
431+
432+
return
433+
end
434+
435+
local char = api.nvim_get_vvar('char')
436+
if not completion_timer and handle.triggers[char] then
437+
completion_timer = assert(vim.uv.new_timer())
438+
completion_timer:start(25, 0, function()
439+
reset_timer(completion_timer)
440+
vim.schedule(M.trigger_completion)
441+
end)
442+
end
443+
end
444+
445+
local function text_changed_p_cb()
446+
Context.cursor = api.nvim_win_get_cursor(0)
447+
end
448+
449+
local function text_changed_i_cb()
450+
if not Context.cursor or completion_timer then
451+
return
452+
end
453+
454+
local cursor = api.nvim_win_get_cursor(0)
455+
if cursor[1] == Context.cursor[1] and cursor[2] <= Context.cursor[2] then
456+
completion_timer = new_timer(completion_timer)
457+
completion_timer:start(150, 0, vim.schedule_wrap(M.trigger_completion))
458+
elseif cursor[1] ~= Context.cursor[1] then
459+
Context.cursor = nil
460+
end
461+
end
462+
463+
local function insert_leave_cb()
464+
reset_timer(completion_timer)
465+
Context.cursor = nil
466+
Context:reset()
467+
end
468+
469+
local function complete_done_cb()
470+
if Context.suppress_completeDone then
471+
Context.suppress_completeDone = false
472+
return
473+
end
474+
end
475+
476+
function M.trigger_completion()
477+
reset_timer(completion_timer)
478+
Context:cancel_pending()
479+
480+
local win = api.nvim_get_current_win()
481+
local bufnr = api.nvim_get_current_buf()
482+
local cursor_row, cursor_col = unpack(api.nvim_win_get_cursor(win)) --- @type integer, integer
483+
local line = api.nvim_get_current_line()
484+
local line_to_cursor = line:sub(1, cursor_col)
485+
local clients = (buf_handles[bufnr] or {}).clients or {}
486+
local word_boundary = vim.fn.match(line_to_cursor, '\\k*$')
487+
local start_time = vim.uv.hrtime()
488+
Context.last_request_time = start_time
489+
490+
local cancel_request = request(clients, bufnr, win, function(responses)
491+
local end_time = vim.uv.hrtime()
492+
rtt_ms = compute_new_average((end_time - start_time) * ns_to_ms)
493+
494+
Context.pending_requests = {}
495+
Context.isIncomplete = false
496+
497+
local row_changed = api.nvim_win_get_cursor(win)[1] ~= cursor_row
498+
local mode = api.nvim_get_mode().mode
499+
if row_changed or not (mode == 'i' or mode == 'ic') then
500+
return
501+
end
502+
503+
local matches = {}
504+
local server_start_boundary --- @type integer?
505+
for client_id, response in pairs(responses) do
506+
if response.err then
507+
lsp.log.warn(response.err.message)
508+
end
509+
510+
local result = response.result
511+
if result then
512+
Context.isIncomplete = Context.isIncomplete or result.isIncomplete
513+
local client = lsp.get_client_by_id(client_id)
514+
local encoding = client and client.offset_encoding or 'utf-16'
515+
local client_matches
516+
client_matches, server_start_boundary =
517+
M._convert_results(line, cursor_row - 1, cursor_col, word_boundary, nil, result, encoding)
518+
vim.list_extend(matches, client_matches)
519+
end
520+
end
521+
local start_col = (server_start_boundary or word_boundary) + 1
522+
vim.fn.complete(start_col, matches)
523+
end)
524+
525+
table.insert(Context.pending_requests, cancel_request)
526+
end
527+
528+
--- @param client_id integer Client ID
529+
--- @param bufnr integer Buffer handle, or 0 for the current buffer
530+
function M.attach(client_id, bufnr)
531+
bufnr = (bufnr == 0 and api.nvim_get_current_buf()) or bufnr
532+
533+
if not buf_handles[bufnr] then
534+
buf_handles[bufnr] = { clients = {}, triggers = {} }
535+
536+
-- Attach to buffer events.
537+
api.nvim_buf_attach(bufnr, false, {
538+
on_detach = function(_, buf)
539+
buf_handles[buf] = nil
540+
end,
541+
on_reload = function(_, buf)
542+
M.attach(client_id, buf)
543+
end,
544+
})
545+
546+
-- Set up autocommands.
547+
local group =
548+
api.nvim_create_augroup(string.format('vim/lsp/completion-%d', bufnr), { clear = true })
549+
api.nvim_create_autocmd('InsertCharPre', {
550+
group = group,
551+
buffer = bufnr,
552+
callback = function()
553+
insert_char_pre_cb(buf_handles[bufnr])
554+
end,
555+
})
556+
api.nvim_create_autocmd('TextChangedP', {
557+
group = group,
558+
buffer = bufnr,
559+
callback = function()
560+
text_changed_p_cb()
561+
end,
562+
})
563+
api.nvim_create_autocmd('TextChangedI', {
564+
group = group,
565+
buffer = bufnr,
566+
callback = function()
567+
text_changed_i_cb()
568+
end,
569+
})
570+
api.nvim_create_autocmd('InsertLeave', {
571+
group = group,
572+
buffer = bufnr,
573+
callback = function()
574+
insert_leave_cb()
575+
end,
576+
})
577+
api.nvim_create_autocmd('CompleteDone', {
578+
group = group,
579+
buffer = bufnr,
580+
callback = complete_done_cb,
581+
})
582+
end
583+
584+
if not buf_handles[bufnr].clients[client_id] then
585+
local client = lsp.get_client_by_id(client_id)
586+
assert(client, 'invalid client ID')
587+
588+
-- Add the new client to the buffer's clients.
589+
init_commands(client)
590+
buf_handles[bufnr].clients[client_id] = client
591+
592+
-- Add the new client to the clients that should be triggered by its trigger characters.
593+
--- @type string[]
594+
local triggers = vim.tbl_get(
595+
client.server_capabilities,
596+
'completionProvider',
597+
'triggerCharacters'
598+
) or {}
599+
for _, char in ipairs(triggers) do
600+
local clients_for_trigger = buf_handles[bufnr].triggers[char]
601+
if not clients_for_trigger then
602+
clients_for_trigger = {}
603+
buf_handles[bufnr].triggers[char] = clients_for_trigger
604+
end
605+
local client_exists = vim.iter(clients_for_trigger):any(function(c)
606+
return c.id == client_id
607+
end)
608+
if not client_exists then
609+
table.insert(clients_for_trigger, client)
610+
end
611+
end
612+
end
613+
end
614+
615+
-- TODO(mariasolos): Add M.detach if we decide to use the attach approach.
616+
276617
return M

0 commit comments

Comments
 (0)
Please sign in to comment.