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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
|
local M = {}
local api = vim.api
local lsp = vim.lsp
local protocol = lsp.protocol
local ms = protocol.Methods
--- @alias vim.lsp.CompletionResult lsp.CompletionList | lsp.CompletionItem[]
-- TODO(mariasolos): Remove this declaration once we figure out a better way to handle
-- literal/anonymous types (see https://github.com/neovim/neovim/pull/27542/files#r1495259331).
--- @class lsp.ItemDefaults
--- @field editRange lsp.Range | { insert: lsp.Range, replace: lsp.Range } | nil
--- @field insertTextFormat lsp.InsertTextFormat?
--- @field insertTextMode lsp.InsertTextMode?
--- @field data any
---@param input string unparsed snippet
---@return string parsed snippet
local function parse_snippet(input)
local ok, parsed = pcall(function()
return 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
--- Applies the given defaults to the completion item, modifying it in place.
---
--- @param item lsp.CompletionItem
--- @param defaults lsp.ItemDefaults?
local function apply_defaults(item, defaults)
if not defaults then
return
end
item.insertTextFormat = item.insertTextFormat or defaults.insertTextFormat
item.insertTextMode = item.insertTextMode or defaults.insertTextMode
item.data = item.data or defaults.data
if defaults.editRange then
local textEdit = item.textEdit or {}
item.textEdit = textEdit
textEdit.newText = textEdit.newText or item.textEditText or item.insertText
if defaults.editRange.start then
textEdit.range = textEdit.range or defaults.editRange
elseif defaults.editRange.insert then
textEdit.insert = defaults.editRange.insert
textEdit.replace = defaults.editRange.replace
end
end
end
---@param result vim.lsp.CompletionResult
---@return lsp.CompletionItem[]
local function get_items(result)
if result.items then
for _, item in ipairs(result.items) do
---@diagnostic disable-next-line: param-type-mismatch
apply_defaults(item, result.itemDefaults)
end
return result.items
else
return result
end
end
--- Turns the result of a `textDocument/completion` request into vim-compatible
--- |complete-items|.
---
---@param result vim.lsp.CompletionResult 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 lnum integer 0-indexed
---@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 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
---@private
---@param line string line content
---@param lnum integer 0-indexed line number
---@param client_start_boundary integer 0-indexed word boundary
---@param server_start_boundary? integer 0-indexed word boundary, based on textEdit.range.start.character
---@param result vim.lsp.CompletionResult
---@param encoding string
---@return table[] matches
---@return integer? server_start_boundary
function M._convert_results(
line,
lnum,
cursor_col,
client_start_boundary,
server_start_boundary,
result,
encoding
)
-- Completion response items may be relative to a position different than `client_start_boundary`.
-- Concrete example, with lua-language-server:
--
-- require('plenary.asy|
-- ▲ ▲ ▲
-- │ │ └── cursor_pos: 20
-- │ └────── client_start_boundary: 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 candidates = get_items(result)
local curstartbyte = adjust_start_col(lnum, line, candidates, encoding)
if server_start_boundary == nil then
server_start_boundary = curstartbyte
elseif curstartbyte ~= nil and curstartbyte ~= server_start_boundary then
server_start_boundary = client_start_boundary
end
local prefix = line:sub((server_start_boundary or client_start_boundary) + 1, cursor_col)
local matches = M._lsp_to_complete_items(result, prefix)
return matches, server_start_boundary
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)
assert(base) -- silence luals
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 win = api.nvim_get_current_win()
local cursor = api.nvim_win_get_cursor(win)
local lnum = cursor[1] - 1
local cursor_col = cursor[2]
local line = api.nvim_get_current_line()
local line_to_cursor = line:sub(1, cursor_col)
local client_start_boundary = vim.fn.match(line_to_cursor, '\\k*$') --[[@as integer]]
local server_start_boundary = nil
local items = {}
local function on_done()
local mode = api.nvim_get_mode()['mode']
if mode == 'i' or mode == 'ic' then
vim.fn.complete((server_start_boundary or client_start_boundary) + 1, 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
vim.lsp.log.warn(err.message)
end
if result and vim.fn.mode() == 'i' then
local matches
matches, server_start_boundary = M._convert_results(
line,
lnum,
cursor_col,
client_start_boundary,
server_start_boundary,
result,
client.offset_encoding
)
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
|