complete.lua

Branches > main > d3c0a10 > 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")