Skip to content

Commit dc647b3

Browse files
authored
refactor(cmdline): extract helpers and clarify completion logic (#1907)
Follow-up to commit 2c3d276 The range exclusion logic is now stricter and only applies to command completions, which should make things less confusing. Changed the scope of some helpers to local (private), added more type annotations, and removed some old, messy logic.
1 parent bf612a5 commit dc647b3

File tree

1 file changed

+69
-72
lines changed

1 file changed

+69
-72
lines changed

lua/blink/cmp/sources/cmdline/init.lua

Lines changed: 69 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,54 @@ local async = require('blink.cmp.lib.async')
66
local constants = require('blink.cmp.sources.cmdline.constants')
77
local path_lib = require('blink.cmp.sources.path.lib')
88

9+
--- Split the command line into arguments, handling path escaping and trailing spaces.
10+
--- For path completions, split by paths and normalize each one if needed.
11+
--- For other completions, splits by spaces and preserves trailing empty arguments.
12+
---@param context table
13+
---@param is_path_completion boolean
14+
---@return string, table
15+
local function smart_split(context, is_path_completion)
16+
local line = context.line
17+
18+
if is_path_completion then
19+
-- Split the line into tokens, respecting escaped spaces in paths
20+
local tokens = path_lib:split_unescaped(line:gsub('^%s+', ''))
21+
local cmd = tokens[1]
22+
local args = {}
23+
24+
for i = 2, #tokens do
25+
local arg = tokens[i]
26+
-- Escape argument if it contains unescaped spaces
27+
-- Some commands may expect escaped paths (:edit), others may not (:view)
28+
if arg and arg ~= '' and not arg:find('\\ ') then arg = path_lib:fnameescape(arg) end
29+
table.insert(args, arg)
30+
end
31+
return line, { cmd, unpack(args) }
32+
end
33+
34+
return line, vim.split(line:gsub('^%s+', ''), ' ', { plain = true })
35+
end
36+
37+
-- Find the longest match for a given set of patterns
38+
---@param str string
39+
---@param patterns table
40+
---@return string
41+
local function longest_match(str, patterns)
42+
local best = ''
43+
for _, pat in ipairs(patterns) do
44+
local m = str:match(pat)
45+
if m and #m > #best then best = m end
46+
end
47+
return best
48+
end
49+
50+
---@param name string
51+
---@return boolean?
52+
local function is_boolean_option(name)
53+
local ok, opt = pcall(function() return vim.opt[name]:get() end)
54+
if ok then return type(opt) == 'boolean' end
55+
end
56+
957
--- @class blink.cmp.Source
1058
local cmdline = {}
1159

@@ -15,32 +63,30 @@ function cmdline.new()
1563
self.offset = -1
1664
self.ctype = ''
1765
self.items = {}
18-
return self
66+
return self --[[@as blink.cmp.Source]]
1967
end
2068

69+
---@return boolean
2170
function cmdline:enabled()
2271
return vim.api.nvim_get_mode().mode == 'c' and vim.tbl_contains({ ':', '@' }, vim.fn.getcmdtype())
2372
end
2473

25-
---@param name string
26-
---@return boolean?
27-
function cmdline:is_boolean_option(name)
28-
local ok, opt = pcall(function() return vim.opt[name]:get() end)
29-
if ok then return type(opt) == 'boolean' end
30-
end
31-
74+
---@return table
3275
function cmdline:get_trigger_characters() return { ' ', '.', '#', '-', '=', '/', ':', '!' } end
3376

77+
---@param context blink.cmp.Context
78+
---@param callback fun(result?: blink.cmp.CompletionResponse)
79+
---@return fun()
3480
function cmdline:get_completions(context, callback)
3581
local completion_type = vim.fn.getcmdcompltype()
3682

3783
local is_path_completion = vim.tbl_contains(constants.completion_types.path, completion_type)
3884
local is_buffer_completion = vim.tbl_contains(constants.completion_types.buffer, completion_type)
3985

40-
local context_line, arguments = self:smart_split(context, is_path_completion or is_buffer_completion)
86+
local context_line, arguments = smart_split(context, is_path_completion or is_buffer_completion)
4187
local cmd = arguments[1]
4288
local before_cursor = context_line:sub(1, context.cursor[2])
43-
local _, args_before_cursor = self:smart_split({ line = before_cursor }, is_path_completion or is_buffer_completion)
89+
local _, args_before_cursor = smart_split({ line = before_cursor }, is_path_completion or is_buffer_completion)
4490
local arg_number = #args_before_cursor
4591

4692
local leading_spaces = context.line:match('^(%s*)') -- leading spaces in the original query
@@ -126,27 +172,17 @@ function cmdline:get_completions(context, callback)
126172
-- In all other cases, we want to check for the prefix and remove it from the filter text
127173
-- and add it to the newText
128174

129-
-- Helper function: find the longest match for a given set of patterns
130-
local function longest_match(str, patterns)
131-
local best = ''
132-
for _, pat in ipairs(patterns) do
133-
local m = str:match(pat)
134-
if m and #m > #best then best = m end
135-
end
136-
return best
137-
end
138-
139175
---@cast completions string[]
140-
local is_first_arg = arg_number == 1
141-
local is_lua_expr = completion_type == 'lua'
142176
local unique_prefixes = is_buffer_completion
143177
and #completions < 2000
144178
and path_lib:compute_unique_suffixes(completions)
145179
or {}
146180

181+
---@type blink.cmp.CompletionItem[]
147182
local items = {}
148183
for _, completion in ipairs(completions) do
149-
local filter_text, new_text, label, label_details
184+
local filter_text, new_text = completion, completion
185+
local label, label_details
150186

151187
-- path completion in commands, e.g. `chdir <path>` and options, e.g. `:set directory=<path>`
152188
if is_path_completion then
@@ -162,36 +198,22 @@ function cmdline:get_completions(context, callback)
162198
if #unique_prefixes[completion] then
163199
label_details = { description = completion:sub(1, -#unique_prefixes[completion] - 2) }
164200
end
165-
filter_text = completion
166201
new_text = vim.fn.fnameescape(completion)
167202

168-
-- lua expr, e.g. `:=<expr>`
169-
elseif is_lua_expr then
170-
filter_text = completion
171-
new_text = current_arg_prefix .. completion
172-
173203
-- env variables
174204
elseif completion_type == 'environment' then
175205
filter_text = '$' .. completion
176206
new_text = '$' .. completion
177207

178-
-- for other completions, check if the prefix is already present
179-
elseif not is_first_arg then
180-
local has_prefix = string.find(completion, current_arg_prefix, 1, true) == 1
181-
182-
filter_text = has_prefix and completion:sub(#current_arg_prefix + 1) or completion
183-
new_text = has_prefix and completion or current_arg_prefix .. completion
184-
185-
-- fallback
186-
else
187-
filter_text = completion
188-
new_text = completion
208+
-- for other completions, prepend the prefix
209+
elseif vim.tbl_contains({ 'lua', '' }, completion_type) then
210+
new_text = current_arg_prefix .. completion
189211
end
190212

191213
local start_pos = #text_before_argument + #leading_spaces
192214

193-
-- exclude range on the first argument
194-
if is_first_arg and not is_lua_expr then
215+
-- exclude range for commands on the first argument
216+
if arg_number == 1 and completion_type == 'command' then
195217
local prefix = longest_match(current_arg, {
196218
"^%s*'<%s*,%s*'>%s*", -- Visual range, e.g., '<,>'
197219
'^%s*%d+%s*,%s*%d+%s*', -- Numeric range, e.g., 3,5
@@ -200,6 +222,7 @@ function cmdline:get_completions(context, callback)
200222
start_pos = start_pos + #prefix
201223
end
202224

225+
---@type blink.cmp.CompletionItem
203226
local item = {
204227
label = label or filter_text,
205228
filterText = filter_text,
@@ -224,57 +247,31 @@ function cmdline:get_completions(context, callback)
224247
}
225248
items[#items + 1] = item
226249

227-
if completion_type == 'option' and cmdline:is_boolean_option(filter_text) then
250+
if completion_type == 'option' and is_boolean_option(filter_text) then
228251
filter_text = 'no' .. filter_text
229252
items[#items + 1] = vim.tbl_deep_extend('force', {}, item, {
230253
label = filter_text,
231254
filterText = filter_text,
232255
sortText = filter_text,
233256
textEdit = { newText = 'no' .. new_text },
234-
})
257+
}) --[[@as blink.cmp.CompletionItem]]
235258
end
236259
end
237260

238261
callback({
239262
is_incomplete_backward = completion_type ~= 'help',
240263
is_incomplete_forward = false,
241264
items = items,
265+
---@diagnostic disable-next-line: missing-return
242266
})
243267
end)
244268
:catch(function(err)
245269
vim.notify('Error while fetching completions: ' .. err, vim.log.levels.ERROR, { title = 'blink.cmp' })
270+
---@diagnostic disable-next-line: missing-return
246271
callback({ is_incomplete_backward = false, is_incomplete_forward = false, items = {} })
247272
end)
248273

249274
return function() task:cancel() end
250275
end
251276

252-
--- Split the command line into arguments, handling path escaping and trailing spaces.
253-
--- For path completions, split by paths and normalize each one if needed.
254-
--- For other completions, splits by spaces and preserves trailing empty arguments.
255-
---@param context table
256-
---@param is_path_completion boolean
257-
---@return string, table
258-
function cmdline:smart_split(context, is_path_completion)
259-
local line = context.line
260-
261-
if is_path_completion then
262-
-- Split the line into tokens, respecting escaped spaces in paths
263-
local tokens = path_lib:split_unescaped(line:gsub('^%s+', ''))
264-
local cmd = tokens[1]
265-
local args = {}
266-
267-
for i = 2, #tokens do
268-
local arg = tokens[i]
269-
-- Escape argument if it contains unescaped spaces
270-
-- Some commands may expect escaped paths (:edit), others may not (:view)
271-
if arg and arg ~= '' and not arg:find('\\ ') then arg = path_lib:fnameescape(arg) end
272-
table.insert(args, arg)
273-
end
274-
return line, { cmd, unpack(args) }
275-
end
276-
277-
return line, vim.split(line:gsub('^%s+', ''), ' ', { plain = true })
278-
end
279-
280277
return cmdline

0 commit comments

Comments
 (0)