Skip to content

Commit f6ca10f

Browse files
authored
feat(cmdline): handle ex special characters and filename modifiers (#2074)
Improve completion support for special characters `%`, `#` and `#n` as defined in :h cmdline-special. Provide candidate suggestions when chaining filename modifiers (e.g. :h, :p) as described in :h filename-modifiers. Closes #2068
1 parent 7770a67 commit f6ca10f

File tree

4 files changed

+200
-107
lines changed

4 files changed

+200
-107
lines changed

lua/blink/cmp/sources/cmdline/constants.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ return {
109109
syntax = 'syntax',
110110
user = 'user',
111111
},
112+
modifiers = {
113+
p = 'full path',
114+
h = 'directory (head)',
115+
t = 'filename (tail)',
116+
r = 'basename (root, no ext)',
117+
e = 'extension',
118+
s = 'substitute first occurrence',
119+
gs = 'substitute all occurrences',
120+
S = 'escape for shell',
121+
['~'] = 'relative to home directory',
122+
['.'] = 'relative to current directory',
123+
},
112124
completion_types = {
113125
buffer = { 'buffer', 'diff_buffer' },
114126
path = { 'dir', 'dir_in_path', 'file', 'file_in_path', 'runtime' },

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

Lines changed: 72 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -4,89 +4,10 @@
44

55
local async = require('blink.cmp.lib.async')
66
local constants = require('blink.cmp.sources.cmdline.constants')
7+
local cmdline_utils = require('blink.cmp.sources.cmdline.utils')
78
local utils = require('blink.cmp.sources.lib.utils')
89
local path_lib = require('blink.cmp.sources.path.lib')
910

10-
--- Split the command line into arguments, handling path escaping and trailing spaces.
11-
--- For path completions, split by paths and normalize each one if needed.
12-
--- For other completions, splits by spaces and preserves trailing empty arguments.
13-
---@param context table
14-
---@param is_path_completion boolean
15-
---@return string, table
16-
local function smart_split(context, is_path_completion)
17-
local line = context.line
18-
19-
local function contains_vim_expr(line)
20-
-- Checks for common Vim expressions: %, #, %:h, %:p, etc.
21-
return vim.regex([[%\%(:[phtrwe~.]\)\?]]):match_str(line) ~= nil
22-
end
23-
local function contains_wildcard(line) return line:find('[%*%?%[%]]') ~= nil end
24-
25-
if is_path_completion and not contains_vim_expr(line) and not contains_wildcard(line) then
26-
-- Split the line into tokens, respecting escaped spaces in paths
27-
local tokens = path_lib:split_unescaped(line:gsub('^%s+', ''))
28-
local cmd = tokens[1]
29-
local args = {}
30-
31-
for i = 2, #tokens do
32-
local arg = tokens[i]
33-
-- Escape argument if it contains unescaped spaces
34-
-- Some commands may expect escaped paths (:edit), others may not (:view)
35-
if arg and arg ~= '' and not arg:find('\\ ') then arg = path_lib:fnameescape(arg) end
36-
table.insert(args, arg)
37-
end
38-
return line, { cmd, unpack(args) }
39-
end
40-
41-
return line, vim.split(line:gsub('^%s+', ''), ' ', { plain = true })
42-
end
43-
44-
-- Find the longest match for a given set of patterns
45-
---@param str string
46-
---@param patterns table
47-
---@return string
48-
local function longest_match(str, patterns)
49-
local best = ''
50-
for _, pat in ipairs(patterns) do
51-
local m = str:match(pat)
52-
if m and #m > #best then best = m end
53-
end
54-
return best
55-
end
56-
57-
--- Returns completion items for a given pattern and type, with special handling for shell commands on Windows/WSL.
58-
--- @param pattern string The partial command to match for completion
59-
--- @param type string The type of completion
60-
--- @param completion_type? string Original completion type from vim.fn.getcmdcompltype()
61-
--- @return table completions
62-
local function get_completions(pattern, type, completion_type)
63-
-- If a shell command is requested on Windows or WSL, update PATH to avoid performance issues.
64-
if completion_type == 'shellcmd' then
65-
local separator, filter_fn
66-
67-
if vim.fn.has('win32') == 1 then
68-
separator = ';'
69-
-- Remove System32 folder on native Windows
70-
filter_fn = function(part) return not part:lower():match('^[a-z]:\\windows\\system32$') end
71-
elseif vim.fn.has('wsl') == 1 then
72-
separator = ':'
73-
-- Remove all Windows filesystem mounts on WSL
74-
filter_fn = function(part) return not part:lower():match('^/mnt/[a-z]/') end
75-
end
76-
77-
if filter_fn then
78-
local orig_path = vim.env.PATH
79-
local new_path = table.concat(vim.tbl_filter(filter_fn, vim.split(orig_path, separator)), separator)
80-
vim.env.PATH = new_path
81-
local completions = vim.fn.getcompletion(pattern, type, true)
82-
vim.env.PATH = orig_path
83-
return completions
84-
end
85-
end
86-
87-
return vim.fn.getcompletion(pattern, type, true)
88-
end
89-
9011
--- @class blink.cmp.Source
9112
local cmdline = {
9213
---@type table<string, vim.api.keyset.get_option_info?>
@@ -109,7 +30,7 @@ function cmdline:enabled()
10930
end
11031

11132
---@return table
112-
function cmdline:get_trigger_characters() return { ' ', '.', '#', '-', '=', '/', ':', '!' } end
33+
function cmdline:get_trigger_characters() return { ' ', '.', '#', '-', '=', '/', ':', '!', '%', '~' } end
11334

11435
---@param context blink.cmp.Context
11536
---@param callback fun(result?: blink.cmp.CompletionResponse)
@@ -119,11 +40,15 @@ function cmdline:get_completions(context, callback)
11940

12041
local is_path_completion = vim.tbl_contains(constants.completion_types.path, completion_type)
12142
local is_buffer_completion = vim.tbl_contains(constants.completion_types.buffer, completion_type)
43+
local is_filename_modifier_completion = cmdline_utils.contains_filename_modifiers(context.line)
44+
local is_wildcard_completion = cmdline_utils.contains_wildcard(context.line)
12245

123-
local context_line, arguments = smart_split(context, is_path_completion or is_buffer_completion)
124-
local cmd = arguments[1]
46+
local should_split_path = (is_path_completion or is_buffer_completion)
47+
and not is_filename_modifier_completion
48+
and not is_wildcard_completion
49+
local context_line, arguments = cmdline_utils.smart_split(context.line, should_split_path)
12550
local before_cursor = context_line:sub(1, context.cursor[2])
126-
local _, args_before_cursor = smart_split({ line = before_cursor }, is_path_completion or is_buffer_completion)
51+
local _, args_before_cursor = cmdline_utils.smart_split(before_cursor, should_split_path)
12752
local arg_number = #args_before_cursor
12853

12954
local leading_spaces = context.line:match('^(%s*)') -- leading spaces in the original query
@@ -135,6 +60,10 @@ function cmdline:get_completions(context, callback)
13560
local keyword = context.get_bounds(keyword_config.range)
13661
local current_arg_prefix = current_arg:sub(1, keyword.start_col - #text_before_argument - 1)
13762

63+
local unique_suffixes = {}
64+
local unique_suffixes_limit = 2000
65+
local special_char, vim_expr
66+
13867
local task = async.task
13968
.empty()
14069
:map(function()
@@ -181,21 +110,52 @@ function cmdline:get_completions(context, callback)
181110
-- path completions uniquely expect only the current path
182111
query = is_path_completion and current_arg_prefix or query
183112

184-
completions = get_completions(query, compl_type, completion_type)
113+
completions = cmdline_utils.get_completions(query, compl_type, completion_type)
185114
if type(completions) ~= 'table' then completions = {} end
186115
end
187116
end
117+
elseif is_filename_modifier_completion then
118+
vim_expr = cmdline_utils.extract_quoted_part(current_arg) or current_arg
119+
special_char = vim_expr:sub(-1)
120+
121+
-- Alternate files
122+
if special_char == '#' then
123+
local alt_buf = vim.fn.bufnr('#')
124+
if alt_buf ~= -1 then
125+
local buffers = { [''] = vim.fn.expand('#') } -- Keep the '#' prefix as a completion option
126+
local curr_buf = vim.api.nvim_get_current_buf()
127+
for _, buf in ipairs(vim.fn.getbufinfo({ bufloaded = 1, buflisted = 1 })) do
128+
if buf.bufnr ~= curr_buf and buf.bufnr ~= alt_buf then
129+
buffers[tostring(buf.bufnr)] = vim.fn.expand('#' .. buf.bufnr)
130+
end
131+
end
132+
completions = vim.tbl_keys(buffers)
133+
if #completions < unique_suffixes_limit then
134+
unique_suffixes = path_lib:compute_unique_suffixes(vim.tbl_values(buffers))
135+
end
136+
end
137+
-- Current file
138+
elseif special_char == '%' then
139+
completions = { '' }
140+
-- Modifiers
141+
elseif special_char == ':' then
142+
completions = vim.tbl_keys(constants.modifiers)
143+
elseif vim.tbl_contains({ '~', '.' }, special_char) then
144+
completions = { special_char }
145+
end
188146

189147
-- Cmdline mode
190148
else
191149
local query = (text_before_argument .. current_arg_prefix):gsub([[\\]], [[\\\\]])
192-
completions = get_completions(query, 'cmdline', completion_type)
150+
completions = cmdline_utils.get_completions(query, 'cmdline', completion_type)
193151
end
194152

195153
return completions
196154
end)
197155
:schedule()
198156
:map(function(completions)
157+
---@cast completions string[]
158+
199159
-- The getcompletion() api is inconsistent in whether it returns the prefix or not.
200160
--
201161
-- I.e. :set shiftwidth=| will return '2'
@@ -209,11 +169,9 @@ function cmdline:get_completions(context, callback)
209169
-- In all other cases, we want to check for the prefix and remove it from the filter text
210170
-- and add it to the newText
211171

212-
---@cast completions string[]
213-
local unique_prefixes = is_buffer_completion
214-
and #completions < 2000
215-
and path_lib:compute_unique_suffixes(completions)
216-
or {}
172+
if is_buffer_completion and #completions < unique_suffixes_limit then
173+
unique_suffixes = path_lib:compute_unique_suffixes(completions)
174+
end
217175

218176
---@type blink.cmp.CompletionItem[]
219177
local items = {}
@@ -222,19 +180,36 @@ function cmdline:get_completions(context, callback)
222180
local label, label_details
223181
local option_info
224182

183+
-- current (%) or alternate (#) filename with optional modifiers (:)
184+
if is_filename_modifier_completion then
185+
local expanded = vim.fn.expand(vim_expr .. completion)
186+
-- expand in command (e.g. :edit %) but don't in expression (e.g =vim.fn.expand("%"))
187+
new_text = vim_expr:sub(1, 1) == current_arg_prefix:sub(1, 1) and expanded or current_arg_prefix .. completion
188+
189+
if special_char == '#' then
190+
-- special case: we need to display # along with #n
191+
if completion == '' then filter_text = special_char end
192+
label_details = { description = unique_suffixes[new_text] or expanded }
193+
elseif special_char == '%' then
194+
label_details = { description = expanded }
195+
elseif vim.tbl_contains({ ':', '~', '.' }, special_char) then
196+
label_details = { description = constants.modifiers[completion] or expanded }
197+
end
198+
225199
-- path completion in commands, e.g. `chdir <path>` and options, e.g. `:set directory=<path>`
226-
if is_path_completion then
200+
elseif is_path_completion then
201+
if current_arg == '~' then label = completion end
227202
filter_text = path_lib.basename_with_sep(completion)
228203
new_text = vim.fn.fnameescape(completion)
229-
if cmd == 'set' then
204+
if arguments[1] == 'set' then
230205
new_text = current_arg_prefix:sub(1, current_arg_prefix:find('=') or #current_arg_prefix) .. new_text
231206
end
232207

233208
-- buffer commands
234209
elseif is_buffer_completion then
235-
label = unique_prefixes[completion] or completion
236-
if unique_prefixes[completion] then
237-
label_details = { description = completion:sub(1, -#unique_prefixes[completion] - 2) }
210+
label = unique_suffixes[completion] or completion
211+
if unique_suffixes[completion] then
212+
label_details = { description = completion:sub(1, -#unique_suffixes[completion] - 2) }
238213
end
239214
new_text = vim.fn.fnameescape(completion)
240215

@@ -281,7 +256,7 @@ function cmdline:get_completions(context, callback)
281256

282257
-- exclude range for commands on the first argument
283258
if arg_number == 1 and completion_type == 'command' then
284-
local prefix = longest_match(current_arg, {
259+
local prefix = cmdline_utils.longest_match(current_arg, {
285260
"^%s*'<%s*,%s*'>%s*", -- Visual range, e.g., '<,>'
286261
'^%s*%d+%s*,%s*%d+%s*', -- Numeric range, e.g., 3,5
287262
'^%s*[%p]+%s*', -- One or more punctuation characters
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
local utils = {}
2+
3+
local path_lib = require('blink.cmp.sources.path.lib')
4+
5+
---@param path string
6+
---@return string
7+
local function fnameescape(path)
8+
path = vim.fn.fnameescape(path)
9+
-- Unescape $FOO and ${FOO}
10+
path = path:gsub('\\(%$[%w_]+)', '%1')
11+
path = path:gsub('\\(%${[%w_]+})', '%1')
12+
-- Unescape %:
13+
path = path:gsub('\\(%%:)', '%1')
14+
return path
15+
end
16+
17+
-- Try to match the content inside the first pair of quotes (excluding)
18+
-- If unclosed, match everything after the first quote (excluding)
19+
---@param s string
20+
---@return string?
21+
function utils.extract_quoted_part(s)
22+
-- pair
23+
local content = s:match([['([^']-)']]) or s:match([["([^"]-)"]])
24+
if content then return content end
25+
-- unclosed
26+
local unclosed = s:match([['(.*)]]) or s:match([["(.*)]])
27+
return unclosed
28+
end
29+
30+
-- Detects whether the provided line contains current (%) or alternate (#, #n) filename
31+
-- or vim expression (<cfile>, <abuf>, ...) with optional modifiers: :h, :p:h
32+
---@param line string
33+
---@return boolean
34+
function utils.contains_filename_modifiers(line)
35+
local pat = [[\v(\s+|'|")((\%|#\d*|\<\w+\>)(:(h|p|t|r|e|s|S|gs|\~|\.)?)*)\<?(\s+|'|"|$)]]
36+
return vim.regex(pat):match_str(line) ~= nil
37+
end
38+
39+
-- Detects whether the provided line contains wildcard, see :h wildcard
40+
---@param line string
41+
---@return boolean
42+
function utils.contains_wildcard(line) return line:find('[%*%?%[%]]') ~= nil end
43+
44+
--- Split the command line into arguments, handling path escaping and trailing spaces.
45+
--- For path completions, split by paths and normalize each one if needed.
46+
--- For other completions, splits by spaces and preserves trailing empty arguments.
47+
---@param line string
48+
---@param is_path_completion boolean
49+
---@return string, table
50+
function utils.smart_split(line, is_path_completion)
51+
if is_path_completion then
52+
-- Split the line into tokens, respecting escaped spaces in paths
53+
local tokens = path_lib:split_unescaped(line:gsub('^%s+', ''))
54+
local cmd = tokens[1]
55+
local args = {}
56+
57+
for i = 2, #tokens do
58+
local arg = tokens[i]
59+
-- Escape argument if it contains unescaped spaces
60+
-- Some commands may expect escaped paths (:edit), others may not (:view)
61+
if arg and arg ~= '' and not arg:find('\\ ') then arg = fnameescape(arg) end
62+
table.insert(args, arg)
63+
end
64+
return line, { cmd, unpack(args) }
65+
end
66+
67+
return line, vim.split(line:gsub('^%s+', ''), ' ', { plain = true })
68+
end
69+
70+
-- Find the longest match for a given set of patterns
71+
---@param str string
72+
---@param patterns string[]
73+
---@return string
74+
function utils.longest_match(str, patterns)
75+
local best = ''
76+
for _, pat in ipairs(patterns) do
77+
local m = str:match(pat)
78+
if m and #m > #best then best = m end
79+
end
80+
return best
81+
end
82+
83+
--- Returns completion items for a given pattern and type, with special handling for shell commands on Windows/WSL.
84+
--- @param pattern string The partial command to match for completion
85+
--- @param type string The type of completion
86+
--- @param completion_type? string Original completion type from vim.fn.getcmdcompltype()
87+
--- @return table completions
88+
function utils.get_completions(pattern, type, completion_type)
89+
-- If a shell command is requested on Windows or WSL, update PATH to avoid performance issues.
90+
if completion_type == 'shellcmd' then
91+
local separator, filter_fn
92+
93+
if vim.fn.has('win32') == 1 then
94+
separator = ';'
95+
-- Remove System32 folder on native Windows
96+
filter_fn = function(part) return not part:lower():match('^[a-z]:\\windows\\system32$') end
97+
elseif vim.fn.has('wsl') == 1 then
98+
separator = ':'
99+
-- Remove all Windows filesystem mounts on WSL
100+
filter_fn = function(part) return not part:lower():match('^/mnt/[a-z]/') end
101+
end
102+
103+
if filter_fn then
104+
local orig_path = vim.env.PATH
105+
local new_path = table.concat(vim.tbl_filter(filter_fn, vim.split(orig_path, separator)), separator)
106+
vim.env.PATH = new_path
107+
local completions = vim.fn.getcompletion(pattern, type, true)
108+
vim.env.PATH = orig_path
109+
return completions
110+
end
111+
end
112+
113+
return vim.fn.getcompletion(pattern, type, true)
114+
end
115+
116+
return utils

0 commit comments

Comments
 (0)