Skip to content

Commit 7203d51

Browse files
committed
feat: auto brackets support
1 parent e7270db commit 7203d51

File tree

5 files changed

+323
-108
lines changed

5 files changed

+323
-108
lines changed

README.md

+27-4
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@
55
## Features
66

77
- Works out of the box with no additional configuration
8-
- Simple hackable codebase
98
- Updates on every keystroke (0.5-4ms non-blocking, single core)
109
- Typo resistant fuzzy with frecency and proximity bonus
1110
- Extensive LSP support ([tracker](./LSP_TRACKER.md))
12-
- Snippet support (including `friendly-snippets`)
13-
- todo: Cmdline support
14-
- External sources support (todo: including `nvim-cmp` compatibility layer)
11+
- Native `vim.snippet` support (including `friendly-snippets`)
12+
- External sources support (currently incompatible with `nvim-cmp` sources)
13+
- Auto-bracket support based on semantic tokens (experimental, opt-in)
1514
- [Comparison with nvim-cmp](#compared-to-nvim-cmp)
1615

1716
## Installation
@@ -36,6 +35,9 @@
3635
-- set to 'mono' for 'Nerd Font Mono' or 'normal' for 'Nerd Font'
3736
-- adjusts spacing to ensure icons are aligned
3837
nerd_font_variant = 'normal',
38+
39+
-- experimental auto-brackets support
40+
-- accept = { auto_brackets = { enabled = true } }
3941
}
4042
}
4143

@@ -68,6 +70,27 @@
6870
snippet_backward = '<S-Tab>',
6971
},
7072

73+
accept = {
74+
auto_brackets = {
75+
enabled = false,
76+
default_brackets = { '(', ')' },
77+
override_brackets_for_filetypes = {},
78+
-- Overrides the default blocked filetypes
79+
force_allow_filetypes = {},
80+
blocked_filetypes = {},
81+
-- Synchronously use the kind of the item to determine if brackets should be added
82+
kind_resolution = {
83+
enabled = true,
84+
blocked_filetypes = { 'typescript', 'typescriptreact', 'javascript', 'javascriptreact', 'vue' },
85+
},
86+
-- Asynchronously use semantic token to determine if brackets should be added
87+
semantic_token_resolution = {
88+
enabled = true,
89+
blocked_filetypes = {},
90+
},
91+
},
92+
},
93+
7194
trigger = {
7295
-- regex used to get the text when fuzzy matching
7396
-- changing this may break some sources, so please report if you run into issues

lua/blink/cmp/accept.lua

-104
This file was deleted.

lua/blink/cmp/accept/brackets.lua

+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
-- todo: check if brackets are already added before adding them
2+
-- including support for edge cases like <>() for typescript
3+
4+
local config = require('blink.cmp.config').accept.auto_brackets
5+
local brackets = {
6+
-- stylua: ignore
7+
blocked_filetypes = {
8+
'rust', 'sql', 'ruby', 'perl', 'lisp', 'scheme', 'clojure',
9+
'prolog', 'vb', 'elixir', 'smalltalk', 'applescript'
10+
},
11+
per_filetype = {
12+
-- languages with a space
13+
haskell = { ' ', '' },
14+
fsharp = { ' ', '' },
15+
ocaml = { ' ', '' },
16+
erlang = { ' ', '' },
17+
tcl = { ' ', '' },
18+
nix = { ' ', '' },
19+
helm = { ' ', '' },
20+
21+
shell = { ' ', '' },
22+
sh = { ' ', '' },
23+
bash = { ' ', '' },
24+
fish = { ' ', '' },
25+
zsh = { ' ', '' },
26+
powershell = { ' ', '' },
27+
28+
make = { ' ', '' },
29+
30+
-- languages with square brackets
31+
wl = { '[', ']' },
32+
wolfram = { '[', ']' },
33+
mma = { '[', ']' },
34+
mathematica = { '[', ']' },
35+
},
36+
}
37+
38+
--- @param filetype string
39+
--- @param item blink.cmp.CompletionItem
40+
--- @return 'added' | 'check_semantic_token' | 'skipped', lsp.TextEdit | lsp.InsertReplaceEdit, number
41+
function brackets.add_brackets(filetype, item)
42+
if not brackets.should_run_resolution(filetype, 'kind') then return 'check_semantic_token', item.textEdit, 0 end
43+
44+
-- not a function, skip
45+
if
46+
item.kind ~= vim.lsp.protocol.CompletionItemKind.Function
47+
and item.kind ~= vim.lsp.protocol.CompletionItemKind.Method
48+
then
49+
return 'check_semantic_token', item.textEdit, 0
50+
end
51+
52+
local brackets_for_filetype = brackets.get_for_filetype(filetype, item)
53+
local text_edit = item.textEdit
54+
assert(text_edit ~= nil, 'Got nil text edit while adding brackets via kind')
55+
56+
-- if already contains the brackets, conservatively skip adding brackets
57+
-- todo: won't work for snippets when the brackets_for_filetype is { '{', '}' }
58+
if brackets_for_filetype[1] ~= ' ' and text_edit.newText:match('\\' .. brackets_for_filetype[1]) then
59+
return 'skipped', text_edit, 0
60+
end
61+
62+
text_edit = vim.deepcopy(text_edit)
63+
-- For snippets, we add the cursor position between the brackets as the last placeholder
64+
if item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then
65+
local placeholders = brackets.snippets_extract_placeholders(text_edit.newText)
66+
local last_placeholder_index = math.max(0, unpack(placeholders))
67+
text_edit.newText = text_edit.newText .. brackets[1] .. '$' .. tostring(last_placeholder_index + 1) .. brackets[2]
68+
-- Otherwise, we add as usual
69+
else
70+
text_edit.newText = text_edit.newText .. brackets[1] .. brackets[2]
71+
end
72+
return 'added', text_edit, -#brackets[2]
73+
end
74+
75+
--- @param snippet string
76+
function brackets.snippets_extract_placeholders(snippet)
77+
local placeholders = {}
78+
local pattern = [=[(\$\{(\d+)(:([^}\\]|\\.)*?)?\})]=]
79+
80+
for _, number, _, _ in snippet:gmatch(pattern) do
81+
table.insert(placeholders, tonumber(number))
82+
end
83+
84+
return placeholders
85+
end
86+
87+
--- Asynchronously use semantic tokens to determine if brackets should be added
88+
--- @param filetype string
89+
--- @param item blink.cmp.CompletionItem
90+
--- @param callback fun()
91+
function brackets.add_brackets_via_semantic_token(filetype, item, callback)
92+
vim.print('yo', brackets.should_run_resolution(filetype, 'semantic_token'))
93+
if not brackets.should_run_resolution(filetype, 'semantic_token') then return callback() end
94+
95+
local text_edit = item.textEdit
96+
assert(text_edit ~= nil, 'Got nil text edit while adding brackets via semantic tokens')
97+
local client = vim.lsp.get_client_by_id(item.client_id)
98+
if client == nil then return callback() end
99+
100+
local start_time = vim.uv.hrtime()
101+
local numToTokenType = client.server_capabilities.semanticTokensProvider.legend.tokenTypes
102+
local params = {
103+
textDocument = vim.lsp.util.make_text_document_params(),
104+
range = {
105+
start = { line = text_edit.range.start.line, character = text_edit.range.start.character },
106+
['end'] = { line = text_edit.range.start.line + 1, character = 0 },
107+
},
108+
}
109+
110+
local cursor_before_call = vim.api.nvim_win_get_cursor(0)
111+
112+
client.request('textDocument/semanticTokens/range', params, function(err, result)
113+
if err ~= nil or result == nil or #result.data == 0 then return callback() end
114+
115+
-- cancel if it's been too long, or if the cursor moved
116+
local ms_since_call = (vim.uv.hrtime() - start_time) / 1000000
117+
local cursor_after_call = vim.api.nvim_win_get_cursor(0)
118+
vim.print('semanticTokens: ' .. ms_since_call .. 'ms')
119+
if
120+
ms_since_call > 100
121+
or cursor_before_call[1] ~= cursor_after_call[1]
122+
or cursor_before_call[2] ~= cursor_after_call[2]
123+
then
124+
return callback()
125+
end
126+
127+
-- cancel if the token isn't a function or method
128+
local type = numToTokenType[result.data[4] + 1]
129+
if type ~= 'function' and type ~= 'method' then return callback() end
130+
131+
-- add the brackets
132+
local brackets_for_filetype = brackets.get_for_filetype(filetype, item)
133+
local line = vim.api.nvim_get_current_line()
134+
local start_col = text_edit.range.start.character + #text_edit.newText
135+
local new_line = line:sub(1, start_col)
136+
.. brackets_for_filetype[1]
137+
.. brackets_for_filetype[2]
138+
.. line:sub(start_col + 1)
139+
vim.api.nvim_set_current_line(new_line)
140+
vim.api.nvim_win_set_cursor(0, { cursor_after_call[1], start_col + #brackets_for_filetype[1] })
141+
142+
callback()
143+
end)
144+
end
145+
146+
--- @param filetype string
147+
--- @param item blink.cmp.CompletionItem
148+
--- @return string[]
149+
function brackets.get_for_filetype(filetype, item)
150+
local default = config.default_brackets
151+
local per_filetype = config.override_brackets_for_filetypes[filetype] or brackets.per_filetype[filetype]
152+
153+
if type(per_filetype) == 'function' then return per_filetype(item) or default end
154+
return per_filetype or default
155+
end
156+
157+
--- @param filetype string
158+
--- @param resolution_method 'kind' | 'semantic_token'
159+
--- @return boolean
160+
function brackets.should_run_resolution(filetype, resolution_method)
161+
-- resolution method specific
162+
if not config[resolution_method .. '_resolution'].enabled then return false end
163+
local resolution_blocked_filetypes = config[resolution_method .. '_resolution'].blocked_filetypes
164+
if vim.tbl_contains(resolution_blocked_filetypes, filetype) then return false end
165+
166+
-- global
167+
vim.print(config)
168+
if not config.enabled then return false end
169+
if vim.tbl_contains(config.force_allow_filetypes, filetype) then return true end
170+
return not vim.tbl_contains(config.blocked_filetypes, filetype)
171+
end
172+
173+
return brackets

lua/blink/cmp/accept/init.lua

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
local text_edits_lib = require('blink.cmp.accept.text-edits')
2+
local brackets_lib = require('blink.cmp.accept.brackets')
3+
4+
--- Applies a completion item to the current buffer
5+
--- @param item blink.cmp.CompletionItem
6+
local function accept(item)
7+
item = vim.deepcopy(item)
8+
9+
local text_edit = item.textEdit
10+
if text_edit ~= nil then
11+
-- Adjust the position of the text edit to be the current cursor position
12+
-- since the data might be outdated. We compare the cursor column position
13+
-- from when the items were fetched versus the current.
14+
-- hack: figure out a better way
15+
local offset = vim.api.nvim_win_get_cursor(0)[2] - item.cursor_column
16+
text_edit.range['end'].character = text_edit.range['end'].character + offset
17+
else
18+
-- No text edit so we fallback to our own resolution
19+
text_edit = text_edits_lib.guess_text_edit(vim.api.nvim_get_current_buf(), item)
20+
end
21+
item.textEdit = text_edit
22+
23+
-- Add brackets to the text edit if needed
24+
local brackets_status, text_edit_with_brackets, offset = brackets_lib.add_brackets(vim.bo.filetype, item)
25+
text_edit = text_edit_with_brackets
26+
27+
-- Snippet
28+
if item.insertTextFormat == vim.lsp.protocol.InsertTextFormat.Snippet then
29+
-- We want to handle offset_encoding and the text edit api can do this for us
30+
-- so we empty the newText and apply
31+
local temp_text_edit = vim.deepcopy(text_edit)
32+
temp_text_edit.newText = ''
33+
text_edits_lib.apply_text_edits(item.client_id, { temp_text_edit })
34+
35+
-- Expand the snippet
36+
vim.snippet.expand(text_edit.newText)
37+
38+
-- OR Normal: Apply the text edit and move the cursor
39+
else
40+
text_edits_lib.apply_text_edits(item.client_id, { text_edit })
41+
vim.api.nvim_win_set_cursor(0, {
42+
text_edit.range.start.line + 1,
43+
text_edit.range.start.character + #text_edit.newText + offset,
44+
})
45+
end
46+
47+
-- Check semantic tokens for brackets, if needed, and apply additional text edits
48+
if brackets_status == 'check_semantic_token' then
49+
brackets_lib.add_brackets_via_semantic_token(
50+
vim.bo.filetype,
51+
item,
52+
function() text_edits_lib.apply_additional_text_edits(item) end
53+
)
54+
else
55+
text_edits_lib.apply_additional_text_edits(item)
56+
end
57+
58+
-- Notify the rust module that the item was accessed
59+
require('blink.cmp.fuzzy').access(item)
60+
end
61+
62+
return accept

0 commit comments

Comments
 (0)