complete.lua
Branches > main > 9228ac5 > files / nvim / lua / complete.lua
local api = vim.api
local autocmd = api.nvim_create_autocmd
local protocol = vim.lsp.protocol
local max_shown = 200
local clojure_lsp_max_items = 600
local symbol_chars = "[^%s%(%)%[%]{}\"',`;@^~\\]"
local prose_filetypes = {[""] = true, markdown = true, text = true, gitcommit = true}
local function syntax_names_at(buf, row, col)
local names = {}
if vim.treesitter.highlighter.active[buf] then
for _, capture in ipairs(vim.treesitter.get_captures_at_pos(buf, row - 1, col - 1)) do
names[#names + 1] = capture.capture
end
else
for _, id in ipairs(vim.fn.synstack(row, col)) do
names[#names + 1] = vim.fn.synIDattr(id, "name")
end
end
return names
end
local function in_string_or_comment(buf, row, col)
if col == 0 then return false end
for _, name in ipairs(syntax_names_at(buf, row, col)) do
name = name:lower()
if name:match("string") or name:match("comment") then return true end
end
return false
end
local function namespace_of(detail)
if not detail then return end
return detail:match("^([%w%.%-_]+)/") or detail:match("^([%w%._]+)%.[%w_$]+$")
end
local function from_lsp(item)
local is_snippet = item.insertTextFormat == protocol.InsertTextFormat.Snippet
local insert = item.textEdit and item.textEdit.newText or item.insertText or item.label
return {label = item.label,
word = is_snippet and item.label or insert,
snippet = is_snippet and insert or nil,
ns = namespace_of(item.detail),
is_local = item.kind == protocol.CompletionItemKind.Variable and item.detail == "",
lsp = item}
end
local function ns_and_aliases(buf)
local ns_lines = {}
for _, line in ipairs(api.nvim_buf_get_lines(buf, 0, 200, false)) do
if #ns_lines > 0 and line:match("^%(") then break end
if #ns_lines > 0 or line:match("^%(ns%s") then ns_lines[#ns_lines + 1] = line end
end
local ns_form = table.concat(ns_lines, "\n")
local aliases = {}
for lib, alias in ns_form:gmatch("%[([^%s%]]+)[^%]]-:as%s+([^%s%]]+)") do
aliases[lib] = alias
end
return ns_form:match("^%(ns%s+([^%s%)]+)"), aliases
end
local function auto_resolved_keyword(keyword)
return {label = keyword, word = keyword, auto_resolved = true}
end
local function add_auto_resolved_keywords(buf, items)
local ns, aliases = ns_and_aliases(buf)
local keywords = {}
for _, item in ipairs(items) do
local kw_ns, name = item.label:match("^:([^/]+)/(.+)$")
if kw_ns and kw_ns == ns then
keywords[#keywords + 1] = auto_resolved_keyword("::" .. name)
elseif aliases[kw_ns] then
keywords[#keywords + 1] = auto_resolved_keyword("::" .. aliases[kw_ns] .. "/" .. name)
end
end
for _, line in ipairs(api.nvim_buf_get_lines(buf, 0, -1, false)) do
for keyword in line:gmatch("::" .. symbol_chars .. "+") do
keywords[#keywords + 1] = auto_resolved_keyword(keyword)
end
end
return vim.list_extend(items, keywords)
end
local function mark_buffer_keywords(buf, items)
local in_buffer = {}
for _, line in ipairs(api.nvim_buf_get_lines(buf, 0, -1, false)) do
for token in line:gmatch(symbol_chars .. "+") do
if token:match("^:[^:]") then in_buffer[token] = true end
end
end
for _, item in ipairs(items) do
item.in_buffer = in_buffer[item.label]
end
return items
end
local function is_unfiltered_fallback(items, word)
if word == "" or #items < clojure_lsp_max_items then return false end
for _, item in ipairs(items) do
if not vim.startswith(item.label:lower(), word:lower()) then return true end
end
return false
end
local clojure = {}
function clojure.word(before)
return (before:match(symbol_chars .. "*$"):gsub("^#+", ""))
end
function clojure.request_prefix(word)
return word:match("^:") or word:match("^.*/") or word:sub(1, 1)
end
function clojure.completes_nothing_typed(before)
return before:match("[%s%(%[{]$") ~= nil
end
local last_locals = {}
function clojure.fetch(buf, word, done)
local client = vim.lsp.get_clients({bufnr = buf, method = "textDocument/completion"})[1]
if not client then return end
local form_start = vim.fn.search("^[(\\[{]", "bcnW")
local function same_form()
return last_locals.buf == buf and last_locals.form_start == form_start
end
if word == "" then
local before = api.nvim_get_current_line():sub(1, api.nvim_win_get_cursor(0)[2])
local after_open_bracket = before:match("[%(%[{]%s*$")
if not after_open_bracket then
if same_form() then done(last_locals.items) end
return
end
end
local params = vim.lsp.util.make_position_params(0, client.offset_encoding)
local request_id, cancelled
local function request(retries_left)
_, request_id = client:request("textDocument/completion", params, function(err, result)
if err or not result or cancelled then return end
local items = vim.tbl_map(from_lsp, result.items or result)
if retries_left > 0 and is_unfiltered_fallback(items, word) then
return vim.defer_fn(function()
if not cancelled then request(retries_left - 1) end
end, 50)
end
if word == "" then
local locals = vim.tbl_filter(function(item) return item.is_local end, items)
if #locals > 0 then
last_locals = {buf = buf, form_start = form_start, items = locals}
elseif same_form() then
items = last_locals.items
end
end
local truncated = result.isIncomplete or #items >= clojure_lsp_max_items
done(add_auto_resolved_keywords(buf, mark_buffer_keywords(buf, items)), client, truncated)
end, buf)
end
request(5)
return function()
cancelled = true
if request_id then client:cancel_request(request_id) end
end
end
local buffer_words = {}
function buffer_words.word(before)
return before:match("[%w_]*$")
end
function buffer_words.request_prefix()
return ""
end
function buffer_words.fetch(buf, word, done)
local row, col = unpack(api.nvim_win_get_cursor(0))
local items, seen = {}, {}
for i, line in ipairs(api.nvim_buf_get_lines(buf, 0, -1, false)) do
if i == row then line = line:sub(1, col - #word) .. " " .. line:sub(col + 1) end
for w in line:gmatch("[%w_]+") do
if not seen[w] and not w:match("^%d") then
seen[w] = true
items[#items + 1] = {label = w, word = w}
end
end
end
done(items)
end
local function source_for(buf)
local ft = vim.bo[buf].filetype
if ft == "clojure" then return clojure end
if vim.bo[buf].buftype == "" and not prose_filetypes[ft] then return buffer_words end
end
local session
local cancel_fetch
local accepted_at
local function close()
if vim.fn.pumvisible() == 1 then
vim.fn.complete(vim.fn.col("."), {})
end
end
local function set_candidates(s, items)
s.items, s.labels, s.by_label = items, {}, {}
for _, item in ipairs(items) do
if not s.by_label[item.label] then
s.by_label[item.label] = item
s.labels[#s.labels + 1] = item.label
end
end
end
local function group_of(item, word_is_capitalized, word_is_keyword)
if item.is_local or (word_is_keyword and item.in_buffer) then return 1 end
if word_is_capitalized and not item.label:match("^%u") then return 3 end
return 2
end
local function wanted(item, word)
return word:match("^::") or not item.label:match("^::")
end
local function matching(s, word)
if word == "" then
return vim.tbl_filter(function(item) return item.is_local end, s.items)
end
local fuzzy = {}
for _, label in ipairs(vim.fn.matchfuzzy(s.labels, word)) do
local item = s.by_label[label]
if wanted(item, word) then fuzzy[#fuzzy + 1] = item end
end
local prefixed = vim.tbl_filter(function(item)
return vim.startswith(item.label:lower(), word:lower())
end, fuzzy)
local items = (#prefixed > 0 and not word:match("^::")) and prefixed or fuzzy
local is_prefixed = {}
for _, item in ipairs(prefixed) do is_prefixed[item] = true end
local fuzzy_rank = {}
for i, item in ipairs(items) do fuzzy_rank[item] = i end
local word_is_capitalized = word:match("^%u") ~= nil
local word_is_keyword = word:match("^:") ~= nil
table.sort(items, function(a, b)
local group_a = group_of(a, word_is_capitalized, word_is_keyword)
local group_b = group_of(b, word_is_capitalized, word_is_keyword)
if group_a ~= group_b then return group_a < group_b end
if is_prefixed[a] ~= is_prefixed[b] then return is_prefixed[a] ~= nil end
if #prefixed > 0 and #a.label ~= #b.label then return #a.label < #b.label end
return fuzzy_rank[a] < fuzzy_rank[b]
end)
return items
end
local function show(s, word)
local items = matching(s, word)
if #items == 0 then return close() end
local entries = {}
for i = 1, math.min(#items, max_shown) do
entries[i] = {word = items[i].word,
abbr = items[i].label,
menu = items[i].ns,
equal = 1,
user_data = {complete = i}}
end
s.shown = items
vim.fn.complete(s.start + 1, entries)
end
local function refresh()
local buf = api.nvim_get_current_buf()
local row, col = unpack(api.nvim_win_get_cursor(0))
if accepted_at and vim.deep_equal(accepted_at, {buf, row, col}) then return end
accepted_at = nil
local navigating_menu = vim.fn.complete_info({"selected"}).selected ~= -1
if navigating_menu then return end
local source = source_for(buf)
if not source then return end
local before = api.nvim_get_current_line():sub(1, col)
local word = source.word(before)
local wants_completion = word ~= ""
or (source.completes_nothing_typed and source.completes_nothing_typed(before))
if in_string_or_comment(buf, row, col) or word:match("^%d") or not wants_completion then
session = nil
return close()
end
local start = col - #word
local key = table.concat({buf, row, start, source.request_prefix(word)}, ":")
local still_typing_same_word = session and session.key == key and vim.startswith(word, session.word)
local more_typing_narrows_results = session and session.truncated and word ~= session.word
and not word:match("^::")
if still_typing_same_word and not more_typing_narrows_results then
if session.items then show(session, word) end
return
end
if cancel_fetch then cancel_fetch() end
local s = {key = key, row = row, start = start, word = word}
if session and session.items and session.row == row and session.start == start then
s.items, s.labels, s.by_label = session.items, session.labels, session.by_label
show(s, word)
end
session = s
cancel_fetch = source.fetch(buf, word, function(items, client, truncated)
if session ~= s or not api.nvim_get_mode().mode:match("^i") then return end
cancel_fetch = nil
set_candidates(s, items)
s.client, s.truncated = client, truncated
refresh()
end)
end
local function apply_extra_edits(s, item)
local client, lsp_item = s.client, item.lsp
if not (client and lsp_item) then return end
local buf = api.nvim_get_current_buf()
local function apply(resolved)
if resolved.additionalTextEdits then
vim.lsp.util.apply_text_edits(resolved.additionalTextEdits, buf, client.offset_encoding)
end
end
if lsp_item.additionalTextEdits or not client:supports_method("completionItem/resolve") then
apply(lsp_item)
else
client:request("completionItem/resolve", lsp_item, function(err, resolved)
if not err and resolved then apply(resolved) end
end, buf)
end
end
local function accept(s, item)
if item.snippet then
local row, col = unpack(api.nvim_win_get_cursor(0))
api.nvim_buf_set_text(0, row - 1, s.start, row - 1, col, {""})
api.nvim_win_set_cursor(0, {row, s.start})
vim.snippet.expand(item.snippet)
end
apply_extra_edits(s, item)
end
autocmd({"TextChangedI", "TextChangedP"}, {callback = refresh})
autocmd("CompleteDone", {callback = function()
local data = vim.v.completed_item.user_data
if type(data) ~= "table" or not data.complete then return end
local s = session
local item = s and s.shown and s.shown[data.complete]
local col = api.nvim_win_get_cursor(0)[2]
local was_inserted = item and api.nvim_get_current_line():sub(s.start + 1, col) == item.word
if not was_inserted then return end
accept(s, item)
local row, new_col = unpack(api.nvim_win_get_cursor(0))
accepted_at = {api.nvim_get_current_buf(), row, new_col}
end})
autocmd("InsertLeave", {callback = function()
if cancel_fetch then cancel_fetch() end
session, cancel_fetch, accepted_at = nil, nil, nil
end})
vim.keymap.set("i", "<CR>", function()
local nothing_selected = vim.fn.pumvisible() == 1 and vim.fn.complete_info({"selected"}).selected == -1
return nothing_selected and "<C-e><CR>" or "<CR>"
end, {expr = true})
vim.opt.completeopt = {"menu", "menuone", "noinsert", "noselect"}
vim.opt.shortmess:append("c")