aboutsummaryrefslogtreecommitdiff
path: root/runtime/lua/vim/lsp/_completion.lua
blob: efd1aaacf735b30c678b94fc281ad7c1b4198c57 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
local M = {}
local api = vim.api
local lsp = vim.lsp
local protocol = lsp.protocol
local ms = protocol.Methods

---@param input string unparsed snippet
---@return string parsed snippet
local function parse_snippet(input)
  local ok, parsed = pcall(function()
    return require('vim.lsp._snippet_grammar').parse(input)
  end)
  return ok and tostring(parsed) or input
end

--- Returns text that should be inserted when selecting completion item. The
--- precedence is as follows: textEdit.newText > insertText > label
---
--- See https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion
---
---@param item lsp.CompletionItem
---@return string
local function get_completion_word(item)
  if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= '' then
    if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
      return item.textEdit.newText
    else
      return parse_snippet(item.textEdit.newText)
    end
  elseif item.insertText ~= nil and item.insertText ~= '' then
    if item.insertTextFormat == protocol.InsertTextFormat.PlainText then
      return item.insertText
    else
      return parse_snippet(item.insertText)
    end
  end
  return item.label
end

---@param result lsp.CompletionList|lsp.CompletionItem[]
---@return lsp.CompletionItem[]
local function get_items(result)
  if result.items then
    return result.items
  end
  return result
end

--- Turns the result of a `textDocument/completion` request into vim-compatible
--- |complete-items|.
---
---@param result lsp.CompletionList|lsp.CompletionItem[] Result of `textDocument/completion`
---@param prefix string prefix to filter the completion items
---@return table[]
---@see complete-items
function M._lsp_to_complete_items(result, prefix)
  local items = get_items(result)
  if vim.tbl_isempty(items) then
    return {}
  end

  local function matches_prefix(item)
    return vim.startswith(get_completion_word(item), prefix)
  end

  items = vim.tbl_filter(matches_prefix, items) --[[@as lsp.CompletionItem[]|]]
  table.sort(items, function(a, b)
    return (a.sortText or a.label) < (b.sortText or b.label)
  end)

  local matches = {}
  for _, item in ipairs(items) do
    local info = ''
    local documentation = item.documentation
    if documentation then
      if type(documentation) == 'string' and documentation ~= '' then
        info = documentation
      elseif type(documentation) == 'table' and type(documentation.value) == 'string' then
        info = documentation.value
      else
        vim.notify(
          ('invalid documentation value %s'):format(vim.inspect(documentation)),
          vim.log.levels.WARN
        )
      end
    end
    local word = get_completion_word(item)
    table.insert(matches, {
      word = word,
      abbr = item.label,
      kind = protocol.CompletionItemKind[item.kind] or 'Unknown',
      menu = item.detail or '',
      info = #info > 0 and info or nil,
      icase = 1,
      dup = 1,
      empty = 1,
      user_data = {
        nvim = {
          lsp = {
            completion_item = item,
          },
        },
      },
    })
  end
  return matches
end

---@param items lsp.CompletionItem[]
local function adjust_start_col(lnum, line, items, encoding)
  local min_start_char = nil
  for _, item in pairs(items) do
    if item.textEdit and item.textEdit.range.start.line == lnum - 1 then
      if min_start_char and min_start_char ~= item.textEdit.range.start.character then
        return nil
      end
      min_start_char = item.textEdit.range.start.character
    end
  end
  if min_start_char then
    return vim.lsp.util._str_byteindex_enc(line, min_start_char, encoding)
  else
    return nil
  end
end

---@param findstart integer 0 or 1, decides behavior
---@param base integer findstart=0, text to match against
---@return integer|table Decided by {findstart}:
--- - findstart=0: column where the completion starts, or -2 or -3
--- - findstart=1: list of matches (actually just calls |complete()|)
function M.omnifunc(findstart, base)
  local bufnr = api.nvim_get_current_buf()
  local clients = lsp.get_clients({ bufnr = bufnr, method = ms.textDocument_completion })
  local remaining = #clients
  if remaining == 0 then
    return findstart == 1 and -1 or {}
  end

  local log = require('vim.lsp.log')
  -- Then, perform standard completion request
  if log.info() then
    log.info('base ', base)
  end

  local win = api.nvim_get_current_win()
  local pos = api.nvim_win_get_cursor(win)
  local line = api.nvim_get_current_line()
  local line_to_cursor = line:sub(1, pos[2])
  log.trace('omnifunc.line', pos, line)

  local word_boundary = vim.fn.match(line_to_cursor, '\\k*$') + 1 --[[@as integer]]
  local items = {}
  local startbyte = nil

  local function on_done()
    local mode = api.nvim_get_mode()['mode']
    if mode == 'i' or mode == 'ic' then
      vim.fn.complete(startbyte or word_boundary, items)
    end
  end

  local util = vim.lsp.util
  for _, client in ipairs(clients) do
    local params = util.make_position_params(win, client.offset_encoding)
    client.request(ms.textDocument_completion, params, function(err, result)
      if err then
        log.warn(err.message)
      end
      if result and vim.fn.mode() == 'i' then
        -- Completion response items may be relative to a position different than `textMatch`.
        -- Concrete example, with sumneko/lua-language-server:
        --
        -- require('plenary.asy|
        --         ▲       ▲   ▲
        --         │       │   └── cursor_pos: 20
        --         │       └────── textMatch: 17
        --         └────────────── textEdit.range.start.character: 9
        --                                 .newText = 'plenary.async'
        --                  ^^^
        --                  prefix (We'd remove everything not starting with `asy`,
        --                  so we'd eliminate the `plenary.async` result
        --
        -- `adjust_start_col` is used to prefer the language server boundary.
        --
        local encoding = client.offset_encoding
        local candidates = get_items(result)
        local curstartbyte = adjust_start_col(pos[1], line, candidates, encoding)
        if startbyte == nil then
          startbyte = curstartbyte
        elseif curstartbyte ~= nil and curstartbyte ~= startbyte then
          startbyte = word_boundary
        end
        local prefix = startbyte and line:sub(startbyte + 1) or line_to_cursor:sub(word_boundary)
        local matches = M._lsp_to_complete_items(result, prefix)
        vim.list_extend(items, matches)
      end
      remaining = remaining - 1
      if remaining == 0 then
        vim.schedule(on_done)
      end
    end, bufnr)
  end

  -- Return -2 to signal that we should continue completion so that we can
  -- async complete.
  return -2
end

return M