Skip to content

Commit bbfc217

Browse files
committed
feat(completion)!: add snippet support; DRAFT
Detalis: - Insert snippet completion items (both `Snippet` kind and `insertTextFormat`) respecting their snippet nature. - Update `default_process_items` to not exclude items with snippet kind.
1 parent fe6932c commit bbfc217

File tree

3 files changed

+177
-46
lines changed

3 files changed

+177
-46
lines changed

doc/mini-completion.txt

+22-7
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ Features:
2121
LSP client (via 'textDocument/completion' request). Custom
2222
preprocessing of response items is possible (with
2323
`MiniCompletion.config.lsp_completion.process_items`), for example
24-
with fuzzy matching. By default items which are not snippets and
25-
directly start with completed word are kept and sorted according to
26-
LSP specification. Supports `additionalTextEdits`, like auto-import
27-
and others (see 'Notes').
24+
with fuzzy matching. By default items directly starting with completed
25+
word are kept and are sorted according to LSP specification.
26+
Supports `additionalTextEdits`, like auto-import and others (see 'Notes'),
27+
and snippet items (best results require |mini.snippets| dependency).
2828
- If first stage is not set up or resulted into no candidates, fallback
2929
action is executed. The most tested actions are Neovim's built-in
3030
insert completion (see |ins-completion|).
@@ -48,7 +48,6 @@ Features:
4848
(same meaning as in |complete-items|) to items.
4949

5050
What it doesn't do:
51-
- Snippet expansion.
5251
- Many configurable sources.
5352
- Automatic mapping of `<CR>`, `<Tab>`, etc., as those tend to have highly
5453
variable user expectations. See 'Helpful mappings' for suggestions.
@@ -58,8 +57,12 @@ What it doesn't do:
5857
Suggested dependencies (provide extra functionality, will work without them):
5958

6059
- Enabled |MiniIcons| module to highlight LSP kind (requires Neovim>=0.11).
61-
Otherwise |MiniCompletion.default_process_items()| does not add highlighting.
60+
If absent, |MiniCompletion.default_process_items()| does not add highlighting.
6261
Also take a look at |MiniIcons.tweak_lsp_kind()|.
62+
- Enabled |MiniSnippets| module for better handling of snippet items.
63+
If absent and custom snippet handling is not configured, |vim.snippet.expand()|
64+
is used on Neovim>=0.10 and nothing extra is done on earlier versions.
65+
See |MiniCompletion.default_snippet_insert()|.
6366

6467
# Setup ~
6568

@@ -102,6 +105,14 @@ You can override runtime config settings locally to buffer inside
102105
to trigger such request, i.e. select completion item and wait for
103106
`MiniCompletion.config.delay.info` time plus server response time.
104107

108+
- TODO: how to work with snippets:
109+
- Select item.
110+
- Press <C-y> (|complete_CTRL-Y|) or any non-keyword character.
111+
To cancel snippet expand, press <C-e> (|complete_CTRL-E|).
112+
Snippets are either `Snippet` kind or have `S` in "menu".
113+
Their text to be inserted (`word` in |complete-items|) the same as label
114+
(`abbr` field) to reduce visual flicker during completion list navigation.
115+
105116
# Comparisons ~
106117

107118
- 'nvim-cmp':
@@ -214,6 +225,10 @@ Default values:
214225
-- input items. Common use case is custom filter/sort.
215226
-- Default: `default_process_items`
216227
process_items = nil,
228+
229+
-- A function which takes a snippet as string and inserts it at cursor.
230+
-- Default: `default_snippet_insert`
231+
snippet_insert = nil,
217232
},
218233

219234
-- Fallback action as function/string. Executed in Insert mode.
@@ -301,7 +316,7 @@ No need to use it directly, everything is setup in |MiniCompletion.setup|.
301316
Default processing of LSP items
302317

303318
Steps:
304-
- Filter out items not matching `base` and snippet items.
319+
- Filter out items not starting with `base`.
305320
- Sort by LSP specification.
306321
- If |MiniIcons| is enabled, add <kind_hlgroup> based on the "lsp" category.
307322

lua/mini/completion.lua

+105-32
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
--- LSP client (via 'textDocument/completion' request). Custom
2222
--- preprocessing of response items is possible (with
2323
--- `MiniCompletion.config.lsp_completion.process_items`), for example
24-
--- with fuzzy matching. By default items which are not snippets and
25-
--- directly start with completed word are kept and sorted according to
26-
--- LSP specification. Supports `additionalTextEdits`, like auto-import
27-
--- and others (see 'Notes').
24+
--- with fuzzy matching. By default items directly starting with completed
25+
--- word are kept and are sorted according to LSP specification.
26+
--- Supports `additionalTextEdits`, like auto-import and others (see 'Notes'),
27+
--- and snippet items (best results require |mini.snippets| dependency).
2828
--- - If first stage is not set up or resulted into no candidates, fallback
2929
--- action is executed. The most tested actions are Neovim's built-in
3030
--- insert completion (see |ins-completion|).
@@ -48,7 +48,6 @@
4848
--- (same meaning as in |complete-items|) to items.
4949
---
5050
--- What it doesn't do:
51-
--- - Snippet expansion.
5251
--- - Many configurable sources.
5352
--- - Automatic mapping of `<CR>`, `<Tab>`, etc., as those tend to have highly
5453
--- variable user expectations. See 'Helpful mappings' for suggestions.
@@ -58,8 +57,12 @@
5857
--- Suggested dependencies (provide extra functionality, will work without them):
5958
---
6059
--- - Enabled |MiniIcons| module to highlight LSP kind (requires Neovim>=0.11).
61-
--- Otherwise |MiniCompletion.default_process_items()| does not add highlighting.
60+
--- If absent, |MiniCompletion.default_process_items()| does not add highlighting.
6261
--- Also take a look at |MiniIcons.tweak_lsp_kind()|.
62+
--- - Enabled |MiniSnippets| module for better handling of snippet items.
63+
--- If absent and custom snippet handling is not configured, |vim.snippet.expand()|
64+
--- is used on Neovim>=0.10 and nothing extra is done on earlier versions.
65+
--- See |MiniCompletion.default_snippet_insert()|.
6366
---
6467
--- # Setup ~
6568
---
@@ -102,6 +105,14 @@
102105
--- to trigger such request, i.e. select completion item and wait for
103106
--- `MiniCompletion.config.delay.info` time plus server response time.
104107
---
108+
--- - TODO: how to work with snippets:
109+
--- - Select item.
110+
--- - Press <C-y> (|complete_CTRL-Y|) or any non-keyword character.
111+
--- To cancel snippet expand, press <C-e> (|complete_CTRL-E|).
112+
--- Snippets are either `Snippet` kind or have `S` in "menu".
113+
--- Their text to be inserted (`word` in |complete-items|) the same as label
114+
--- (`abbr` field) to reduce visual flicker during completion list navigation.
115+
---
105116
--- # Comparisons ~
106117
---
107118
--- - 'nvim-cmp':
@@ -267,6 +278,10 @@ MiniCompletion.config = {
267278
-- input items. Common use case is custom filter/sort.
268279
-- Default: `default_process_items`
269280
process_items = nil,
281+
282+
-- A function which takes a snippet as string and inserts it at cursor.
283+
-- Default: `default_snippet_insert`
284+
snippet_insert = nil,
270285
},
271286

272287
-- Fallback action as function/string. Executed in Insert mode.
@@ -420,7 +435,7 @@ end
420435
--- Default processing of LSP items
421436
---
422437
--- Steps:
423-
--- - Filter out items not matching `base` and snippet items.
438+
--- - Filter out items not starting with `base`.
424439
--- - Sort by LSP specification.
425440
--- - If |MiniIcons| is enabled, add <kind_hlgroup> based on the "lsp" category.
426441
---
@@ -429,11 +444,10 @@ end
429444
---
430445
---@return table Array of processed items from LSP response.
431446
MiniCompletion.default_process_items = function(items, base)
432-
local res = vim.tbl_filter(function(item)
433-
-- Keep items which match the base and are not snippets
434-
local text = item.filterText or H.get_completion_word(item)
435-
return vim.startswith(text, base) and item.kind ~= 15
436-
end, items)
447+
local res = vim.tbl_filter(
448+
function(item) return vim.startswith(item.filterText or H.get_completion_word(item), base) end,
449+
items
450+
)
437451

438452
res = vim.deepcopy(res)
439453
table.sort(res, function(a, b) return (a.sortText or a.label) < (b.sortText or b.label) end)
@@ -449,6 +463,16 @@ MiniCompletion.default_process_items = function(items, base)
449463
return res
450464
end
451465

466+
MiniCompletion.default_snippet_insert = function(snippet)
467+
if _G.MiniSnippets then
468+
local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
469+
return insert({ body = snippet })
470+
end
471+
if vim.fn.has('nvim-0.10') == 1 then return vim.snippet.expand(snippet) end
472+
local pos = vim.api.nvim_win_get_cursor(0)
473+
vim.api.nvim_buf_set_text(0, pos[1] - 1, pos[2], pos[1] - 1, pos[2], { snippet })
474+
end
475+
452476
-- Helper data ================================================================
453477
-- Module default config
454478
H.default_config = vim.deepcopy(MiniCompletion.config)
@@ -703,14 +727,7 @@ H.on_completedonepre = function()
703727

704728
-- Do extra actions for LSP completion items
705729
local lsp_data = H.table_get(vim.v.completed_item, { 'user_data', 'nvim', 'lsp' })
706-
if lsp_data ~= nil then
707-
-- Prefer resolved item over the one from 'textDocument/completion'
708-
local resolved = (H.info.lsp.result or {})[lsp_data.client_id]
709-
local item = (resolved == nil or resolved.err) and lsp_data.completion_item or resolved.result
710-
711-
-- Try to apply additional text edits
712-
H.apply_additional_text_edits(item.additionalTextEdits, lsp_data.client_id)
713-
end
730+
H.make_lsp_extra_actions(lsp_data)
714731

715732
-- Stop processes
716733
MiniCompletion.stop({ 'completion', 'info' })
@@ -918,29 +935,44 @@ H.make_completion_request = function()
918935
H.completion.lsp.cancel_fun = cancel_fun
919936
end
920937

921-
-- This is a truncated version of
922-
-- `vim.lsp.util.text_document_completion_list_to_complete_items` which does
923-
-- not filter and sort items.
924-
-- For extra information see 'Response' section:
938+
-- Source:
925939
-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_completion
926940
H.lsp_completion_response_items_to_complete_items = function(items, client_id)
927941
if vim.tbl_count(items) == 0 then return {} end
928942

929943
local res, item_kinds = {}, vim.lsp.protocol.CompletionItemKind
944+
local snippet_kind = vim.lsp.protocol.CompletionItemKind.Snippet
945+
local snippet_inserttextformat = vim.lsp.protocol.InsertTextFormat.Snippet
930946
for _, item in pairs(items) do
931-
local label_details, menu = item.labelDetails, nil
932-
if label_details ~= nil then menu = (label_details.detail or '') .. (label_details.description or '') end
947+
local word = H.get_completion_word(item)
948+
949+
local is_snippet_kind = item.kind == snippet_kind
950+
local is_snippet_format = item.insertTextFormat == snippet_inserttextformat
951+
-- Treat item as snippet only if it has tabstops. This is important to make
952+
-- "implicit" expand work with LSP servers that report even regular words
953+
-- as `InsertTextFormat.Snippet` (like `gopls`).
954+
local needs_snippet_insert = (is_snippet_kind or is_snippet_format)
955+
and (word:find('[^\\]%${?%d') ~= nil or word:find('^%${?%d') ~= nil)
956+
957+
local details = item.labelDetails or {}
958+
local snippet_clue = needs_snippet_insert and 'S' or ''
959+
local label_detail = (details.detail or '') .. (details.description or '')
960+
label_detail = snippet_clue .. ((snippet_clue ~= '' and label_detail ~= '') and ' ' or '') .. label_detail
961+
962+
local lsp_data = { completion_item = item, client_id = client_id, needs_snippet_insert = needs_snippet_insert }
933963
table.insert(res, {
934-
word = H.get_completion_word(item),
964+
-- Show less for snippet items (usually less confusion)
965+
word = needs_snippet_insert and item.label or word,
935966
abbr = item.label,
936967
kind = item_kinds[item.kind] or 'Unknown',
937968
kind_hlgroup = item.kind_hlgroup,
938-
menu = menu,
939-
-- Do not set `info` field in favor of trying to first resolve it
969+
menu = label_detail,
970+
-- NOTE: info will be attempted to resolve, use snippet text as fallback
971+
info = needs_snippet_insert and word or nil,
940972
icase = 1,
941973
dup = 1,
942974
empty = 1,
943-
user_data = { nvim = { lsp = { completion_item = item, client_id = client_id } } },
975+
user_data = { nvim = { lsp = lsp_data } },
944976
})
945977
end
946978
return res
@@ -966,11 +998,52 @@ H.make_add_kind_hlgroup = function()
966998
end
967999

9681000
H.get_completion_word = function(item)
969-
-- Completion word (textEdit.newText > insertText > label). This doesn't
970-
-- support snippet expansion.
9711001
return H.table_get(item, { 'textEdit', 'newText' }) or item.insertText or item.label or ''
9721002
end
9731003

1004+
H.make_lsp_extra_actions = function(lsp_data)
1005+
if lsp_data == nil then return end
1006+
1007+
-- Prefer resolved item over the one from 'textDocument/completion'
1008+
local resolved = (H.info.lsp.result or {})[lsp_data.client_id]
1009+
local item = (resolved == nil or resolved.err) and lsp_data.completion_item or resolved.result
1010+
1011+
if item.additionalTextEdits == nil and not lsp_data.needs_snippet_insert then return end
1012+
local snippet = lsp_data.needs_snippet_insert and H.get_completion_word(item) or nil
1013+
1014+
-- Make extra actions not only after an explicit `<C-y>` (accept completed
1015+
-- item), but also after implicit non-keyword character. This needs:
1016+
-- - Keeping track of text after cursor (as non-keyword character might
1017+
-- insert extra text and move cursor, like in 'mini.pairs')
1018+
-- - Delay actual execution to operate *after* character is inserted (as it
1019+
-- is not immediate). This works around that character being "inserted"
1020+
-- after snippet expansion.
1021+
local cur = vim.api.nvim_win_get_cursor(0)
1022+
local ref_text_after_cursor = vim.api.nvim_buf_get_text(0, cur[1] - 1, cur[2], cur[1] - 1, -1, {})[1]
1023+
1024+
vim.schedule(function()
1025+
-- Do nothing if user exited Insert mode
1026+
if vim.fn.mode() ~= 'i' then return end
1027+
1028+
-- Try to apply additional text edits
1029+
H.apply_additional_text_edits(item.additionalTextEdits, lsp_data.client_id)
1030+
1031+
-- Expand snippet: cleanup text from side effects and insert snippet
1032+
if snippet == nil then return end
1033+
local from, to = H.completion.start_pos, vim.api.nvim_win_get_cursor(0)
1034+
1035+
-- - NOTE: replace text under condition to preserve extmarks.
1036+
local cur_text_after_cursor = vim.api.nvim_buf_get_text(0, to[1] - 1, to[2], to[1] - 1, -1, {})[1]
1037+
if cur_text_after_cursor ~= ref_text_after_cursor then
1038+
vim.api.nvim_buf_set_text(0, to[1] - 1, to[2], to[1] - 1, -1, { ref_text_after_cursor })
1039+
end
1040+
pcall(vim.api.nvim_buf_set_text, 0, from[1] - 1, from[2], to[1] - 1, to[2], { '' })
1041+
1042+
local insert = H.get_config().lsp_completion.snippet_insert or MiniCompletion.default_snippet_insert
1043+
insert(snippet)
1044+
end)
1045+
end
1046+
9741047
H.apply_additional_text_edits = function(edits, client_id)
9751048
-- Code originally inspired by https://github.com/neovim/neovim/issues/12310
9761049
if edits == nil then return end

tests/test_completion.lua

+50-7
Original file line numberDiff line numberDiff line change
@@ -608,13 +608,11 @@ end
608608

609609
T['Manual completion']['uses `vim.lsp.protocol.CompletionItemKind` in LSP step'] = function()
610610
child.set_size(17, 30)
611-
child.lua([[vim.lsp.protocol = {
612-
CompletionItemKind = {
613-
[1] = 'Text', Text = 1,
614-
[2] = 'Method', Method = 2,
615-
[3] = 'S Something', ['S Something'] = 3,
616-
[4] = 'Fallback', Fallback = 4,
617-
},
611+
child.lua([[vim.lsp.protocol.CompletionItemKind = {
612+
[1] = 'Text', Text = 1,
613+
[2] = 'Method', Method = 2,
614+
[3] = 'S Something', ['S Something'] = 3,
615+
[4] = 'Fallback', Fallback = 4,
618616
}]])
619617
type_keys('i', '<C-Space>')
620618
child.expect_screenshot()
@@ -1330,4 +1328,49 @@ T['Scroll']['respects `config.mappings`'] = function()
13301328
eq(get_lines(), { '' })
13311329
end
13321330

1331+
T['Snippets'] = new_set()
1332+
1333+
T['Snippets']['work'] = function() MiniTest.skip() end
1334+
1335+
T['Snippets']['can be triggered by non-keyword'] = function()
1336+
-- Should work with regular non-keyword character
1337+
1338+
-- Should work with `<CR>` (with or without recommended remap)
1339+
1340+
-- Should properly do nothing after `<Esc>` / `<C-c>` (exit to Normal mode)
1341+
1342+
-- Should work when non-keyword char triggers Insert mode mapping that
1343+
-- inserts more characters (like in 'mini.pairs')
1344+
child.cmd('inoremap ( (abc)<Left><Left><Left>')
1345+
set_lines({ ' text after cursor' })
1346+
set_cursor(1, 0)
1347+
type_keys('i')
1348+
-- - TODO: prepare snippet completion
1349+
type_keys('(')
1350+
1351+
MiniTest.skip()
1352+
end
1353+
1354+
T['Snippets']['are not inserted if have no tabstops'] = function()
1355+
-- This allows inserting snippets "implicitly" after typing non-keyword
1356+
-- character. Without this, LSP serers which report any inserted text as
1357+
-- snippet will "eat" the next typed non-keyword charater.
1358+
1359+
-- Reference snippets to test:
1360+
-- - No insert:
1361+
-- - Just\ntext
1362+
-- - Text with $TM_FILENAME $VAR
1363+
-- - Text with \$1 escaped dollar
1364+
-- - Text with \${1} escaped dollar
1365+
-- - Insert:
1366+
-- - Has $1 tabstop
1367+
-- - $1 has tabstop
1368+
-- - Has ${1} tabstop
1369+
-- - ${1} has tabstop
1370+
-- - Has ${1:aaa} tabstop
1371+
-- - Has $0 tabstop
1372+
-- - Has ${0} tabstop
1373+
MiniTest.skip()
1374+
end
1375+
13331376
return T

0 commit comments

Comments
 (0)