Files
nvim/lua/lsp/completion/session.lua
T

301 lines
9.3 KiB
Lua

---@class ow.lsp.CompletionResponse : ow.lsp.Response
---@field result lsp.CompletionItem[] | lsp.CompletionList
local Item = require("lsp.completion.item")
local kind = require("lsp.kind")
local log = require("log")
local util = require("util")
local REQUEST_DEBOUNCE_MS = 50
---@param item_defaults? lsp.CompletionItemDefaults
---@return lsp.Range?
local function default_range(item_defaults)
local edit_range = item_defaults and item_defaults.editRange
if type(edit_range) ~= "table" then
return nil
end
return edit_range.insert or (edit_range.start and edit_range)
end
---@param item lsp.CompletionItem
---@return lsp.Range?
local function item_range(item)
local te = item.textEdit
if type(te) ~= "table" then
return nil
end
return te.range or te.insert
end
---@param response ow.lsp.CompletionResponse
---@return lsp.Position?
local function edit_start(response)
if not response.result or response.err then
return nil
end
local result = response.result
local defaults = default_range(result.items and result.itemDefaults)
if defaults then
return defaults.start
end
local items = result.items or result
if type(items) ~= "table" then
return nil
end
for _, item in ipairs(items) do
local range = item_range(item)
if range then
return range.start
end
end
return nil
end
---@param items lsp.CompletionItem[]
---@param defaults? lsp.CompletionItemDefaults
local function apply_defaults(items, defaults)
if type(defaults) ~= "table" then
return
end
local range = default_range(defaults)
for _, item in ipairs(items) do
item.commitCharacters = item.commitCharacters
or defaults.commitCharacters
item.data = item.data or defaults.data
item.insertTextFormat = item.insertTextFormat
or defaults.insertTextFormat
item.insertTextMode = item.insertTextMode or defaults.insertTextMode
if range and type(item.textEdit) ~= "table" then
item.textEdit = {
range = range,
newText = item.textEditText or item.insertText or item.label,
}
end
end
end
---@param responses table<integer, ow.lsp.CompletionResponse>
---@param base string text between start col and cursor, used for fuzzy match
---@return vim.v.completed_item[]
local function build_items(responses, base)
---@type vim.v.completed_item[]
local items = {}
for client_id, response in pairs(responses) do
if response.result and not response.err then
local raw_items = response.result.items or response.result
if type(raw_items) == "table" then
apply_defaults(raw_items, response.result.itemDefaults)
for _, raw in ipairs(raw_items) do
local item = Item.from_lsp(raw, client_id)
local kind_icon, kind_hl = kind.get(raw.kind)
items[#items + 1] = {
word = item.word,
abbr = item.abbr,
kind = kind_icon,
kind_hlgroup = kind_hl,
icase = 1,
dup = 1,
empty = 1,
user_data = {
ow = {
item = item,
},
},
filter = raw.filterText or raw.label,
}
end
end
end
end
if base ~= "" then
if vim.o.completeopt:find("fuzzy", 1, true) then
items = vim.fn.matchfuzzy(items, base, { key = "filter" })
else
items = vim.tbl_filter(function(item)
---@diagnostic disable-next-line: undefined-field
return vim.startswith(item.filter, base)
end, items)
end
end
return items
end
local function buffer_has_completion_client()
return #vim.lsp.get_clients({
bufnr = 0,
method = "textDocument/completion",
}) > 0
end
---@return integer start, integer cursor both 0-indexed byte columns
local function word_bounds()
local line = vim.api.nvim_get_current_line()
local cursor = vim.fn.col(".") - 1
local start = vim.fn.match(line:sub(1, cursor), "\\k*$")
return start, cursor
end
---@param char string
---@return boolean
local function is_trigger_char(char)
local clients = vim.lsp.get_clients({
bufnr = 0,
method = "textDocument/completion",
})
for _, client in ipairs(clients) do
local chars = vim.tbl_get(
client,
"server_capabilities",
"completionProvider",
"triggerCharacters"
)
if chars and vim.list_contains(chars, char) then
return true
end
end
return false
end
---@class ow.lsp.completion.Session
---@field private generation integer monotonic counter for discarding stale async callbacks
---@field private cancel function?
---@field private trigger_char string?
---@field private manual boolean
---@field is_incomplete boolean server flagged last response as truncated
local Session = {}
Session.__index = Session
---@return ow.lsp.completion.Session
function Session.new()
return setmetatable({
generation = 0,
cancel = nil,
trigger_char = nil,
manual = false,
is_incomplete = false,
}, Session)
end
function Session:discard()
if self.cancel then
pcall(self.cancel)
end
self.generation = self.generation + 1
self.cancel = nil
end
---@param trigger_kind integer
---@param trigger_char? string
---@param manual? boolean true when invoked by the user (via M.trigger)
function Session:dispatch(trigger_kind, trigger_char, manual)
self:discard()
self.trigger_char = trigger_char
self.manual = manual or false
local gen = self.generation
local buf = vim.api.nvim_get_current_buf()
local params = vim.lsp.util.make_position_params(0, "utf-16")
---@cast params lsp.CompletionParams
params.context = {
triggerKind = trigger_kind,
triggerCharacter = trigger_char,
}
self.cancel = vim.lsp.buf_request_all(
buf,
"textDocument/completion",
params,
function(result, ctx)
if self.generation ~= gen then
return
end
vim.schedule(function()
-- Re-check: another dispatch may have fired between response
-- and the scheduled callback running.
if self.generation ~= gen then
return
end
if vim.fn.mode() ~= "i" then
return
end
local word_start, cursor = word_bounds()
if
not self.manual
and not self.trigger_char
and word_start == cursor
then
return
end
self.is_incomplete = false
for client_id, response in pairs(result) do
if response.err then
log.warning(
"client %d: %s failed: %s",
client_id,
ctx.method,
response.err.message
)
end
local r = response.result
if type(r) == "table" and r.isIncomplete then
self.is_incomplete = true
break
end
end
local start = word_start
for _, response in pairs(result) do
local pos = edit_start(response)
if pos then
start = pos.character
break
end
end
local base =
vim.api.nvim_get_current_line():sub(start + 1, cursor)
vim.fn.complete(start + 1, build_items(result, base))
end)
end
)
end
local session = Session.new()
local dispatch = util.debounce(function(trigger_kind, char)
if vim.fn.mode() ~= "i" then
return
end
session:dispatch(trigger_kind, char)
end, REQUEST_DEBOUNCE_MS)
local M = {}
function M.trigger()
session:dispatch(vim.lsp.protocol.CompletionTriggerKind.Invoked, nil, true)
end
function M.on_insert_char_pre()
local char = vim.v.char
if char == "" then
return
end
if not buffer_has_completion_client() then
return
end
local is_word = char:match("[%w_]") ~= nil
local is_trigger = not is_word and is_trigger_char(char)
if not (is_word or is_trigger) then
return
end
-- Word chars: let Vim's native in-place filtering narrow the already-
-- visible pum. Re-dispatch only if the server flagged the last response
-- as incomplete (meaning it expects to be re-queried on more input).
if is_word and vim.fn.pumvisible() ~= 0 and not session.is_incomplete then
return
end
local kind_num = is_trigger
and vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter
or vim.lsp.protocol.CompletionTriggerKind.Invoked
dispatch(kind_num, is_trigger and char or nil)
end
return M