301 lines
9.3 KiB
Lua
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
|