Skip to content

Commit d1d9596

Browse files
committed
feat(cmdline): handle ex special characters and filename modifiers
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 ce26f9f commit d1d9596

File tree

3 files changed

+179
-96
lines changed

3 files changed

+179
-96
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: 68 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,88 +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-
24-
if is_path_completion and not contains_vim_expr(line) then
25-
-- Split the line into tokens, respecting escaped spaces in paths
26-
local tokens = path_lib:split_unescaped(line:gsub('^%s+', ''))
27-
local cmd = tokens[1]
28-
local args = {}
29-
30-
for i = 2, #tokens do
31-
local arg = tokens[i]
32-
-- Escape argument if it contains unescaped spaces
33-
-- Some commands may expect escaped paths (:edit), others may not (:view)
34-
if arg and arg ~= '' and not arg:find('\\ ') then arg = path_lib:fnameescape(arg) end
35-
table.insert(args, arg)
36-
end
37-
return line, { cmd, unpack(args) }
38-
end
39-
40-
return line, vim.split(line:gsub('^%s+', ''), ' ', { plain = true })
41-
end
42-
43-
-- Find the longest match for a given set of patterns
44-
---@param str string
45-
---@param patterns table
46-
---@return string
47-
local function longest_match(str, patterns)
48-
local best = ''
49-
for _, pat in ipairs(patterns) do
50-
local m = str:match(pat)
51-
if m and #m > #best then best = m end
52-
end
53-
return best
54-
end
55-
56-
--- Returns completion items for a given pattern and type, with special handling for shell commands on Windows/WSL.
57-
--- @param pattern string The partial command to match for completion
58-
--- @param type string The type of completion
59-
--- @param completion_type? string Original completion type from vim.fn.getcmdcompltype()
60-
--- @return table completions
61-
local function get_completions(pattern, type, completion_type)
62-
-- If a shell command is requested on Windows or WSL, update PATH to avoid performance issues.
63-
if completion_type == 'shellcmd' then
64-
local separator, filter_fn
65-
66-
if vim.fn.has('win32') == 1 then
67-
separator = ';'
68-
-- Remove System32 folder on native Windows
69-
filter_fn = function(part) return not part:lower():match('^[a-z]:\\windows\\system32$') end
70-
elseif vim.fn.has('wsl') == 1 then
71-
separator = ':'
72-
-- Remove all Windows filesystem mounts on WSL
73-
filter_fn = function(part) return not part:lower():match('^/mnt/[a-z]/') end
74-
end
75-
76-
if filter_fn then
77-
local orig_path = vim.env.PATH
78-
local new_path = table.concat(vim.tbl_filter(filter_fn, vim.split(orig_path, separator)), separator)
79-
vim.env.PATH = new_path
80-
local completions = vim.fn.getcompletion(pattern, type)
81-
vim.env.PATH = orig_path
82-
return completions
83-
end
84-
end
85-
86-
return vim.fn.getcompletion(pattern, type)
87-
end
88-
8911
--- @class blink.cmp.Source
9012
local cmdline = {
9113
---@type table<string, vim.api.keyset.get_option_info?>
@@ -108,7 +30,7 @@ function cmdline:enabled()
10830
end
10931

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

11335
---@param context blink.cmp.Context
11436
---@param callback fun(result?: blink.cmp.CompletionResponse)
@@ -118,11 +40,12 @@ function cmdline:get_completions(context, callback)
11840

11941
local is_path_completion = vim.tbl_contains(constants.completion_types.path, completion_type)
12042
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)
12144

122-
local context_line, arguments = smart_split(context, is_path_completion or is_buffer_completion)
123-
local cmd = arguments[1]
45+
local should_split_path = (is_path_completion or is_buffer_completion) and not is_filename_modifier_completion
46+
local context_line, arguments = cmdline_utils.smart_split(context.line, should_split_path)
12447
local before_cursor = context_line:sub(1, context.cursor[2])
125-
local _, args_before_cursor = smart_split({ line = before_cursor }, is_path_completion or is_buffer_completion)
48+
local _, args_before_cursor = cmdline_utils.smart_split(before_cursor, should_split_path)
12649
local arg_number = #args_before_cursor
12750

12851
local leading_spaces = context.line:match('^(%s*)') -- leading spaces in the original query
@@ -134,6 +57,10 @@ function cmdline:get_completions(context, callback)
13457
local keyword = context.get_bounds(keyword_config.range)
13558
local current_arg_prefix = current_arg:sub(1, keyword.start_col - #text_before_argument - 1)
13659

60+
local unique_suffixes = {}
61+
local unique_suffixes_limit = 2000
62+
local special_char, vim_expr
63+
13764
local task = async.task
13865
.empty()
13966
:map(function()
@@ -180,21 +107,52 @@ function cmdline:get_completions(context, callback)
180107
-- path completions uniquely expect only the current path
181108
query = is_path_completion and current_arg_prefix or query
182109

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

188144
-- Cmdline mode
189145
else
190146
local query = (text_before_argument .. current_arg_prefix):gsub([[\\]], [[\\\\]])
191-
completions = get_completions(query, 'cmdline', completion_type)
147+
completions = cmdline_utils.get_completions(query, 'cmdline', completion_type)
192148
end
193149

194150
return completions
195151
end)
196152
:schedule()
197153
:map(function(completions)
154+
---@cast completions string[]
155+
198156
-- The getcompletion() api is inconsistent in whether it returns the prefix or not.
199157
--
200158
-- I.e. :set shiftwidth=| will return '2'
@@ -208,11 +166,9 @@ function cmdline:get_completions(context, callback)
208166
-- In all other cases, we want to check for the prefix and remove it from the filter text
209167
-- and add it to the newText
210168

211-
---@cast completions string[]
212-
local unique_prefixes = is_buffer_completion
213-
and #completions < 2000
214-
and path_lib:compute_unique_suffixes(completions)
215-
or {}
169+
if is_buffer_completion and #completions < unique_suffixes_limit then
170+
unique_suffixes = path_lib:compute_unique_suffixes(completions)
171+
end
216172

217173
---@type blink.cmp.CompletionItem[]
218174
local items = {}
@@ -221,19 +177,35 @@ function cmdline:get_completions(context, callback)
221177
local label, label_details
222178
local option_info
223179

180+
-- current (%) or alternate (#) filename with optional modifiers (:)
181+
if is_filename_modifier_completion then
182+
local expanded = vim.fn.expand(vim_expr .. completion)
183+
-- expand in command (e.g. :edit %) but don't in expression (e.g =vim.fn.expand("%"))
184+
new_text = vim_expr:sub(1, 1) == current_arg_prefix:sub(1, 1) and expanded or current_arg_prefix .. completion
185+
186+
if special_char == '#' then
187+
-- special case: we need to display # along with #n
188+
if completion == '' then filter_text = special_char end
189+
label_details = { description = unique_suffixes[new_text] or expanded }
190+
elseif special_char == '%' then
191+
label_details = { description = expanded }
192+
elseif vim.tbl_contains({ ':', '~', '.' }, special_char) then
193+
label_details = { description = constants.modifiers[completion] or expanded }
194+
end
195+
224196
-- path completion in commands, e.g. `chdir <path>` and options, e.g. `:set directory=<path>`
225-
if is_path_completion then
197+
elseif is_path_completion then
226198
filter_text = path_lib.basename_with_sep(completion)
227199
new_text = vim.fn.fnameescape(completion)
228-
if cmd == 'set' then
200+
if arguments[1] == 'set' then
229201
new_text = current_arg_prefix:sub(1, current_arg_prefix:find('=') or #current_arg_prefix) .. new_text
230202
end
231203

232204
-- buffer commands
233205
elseif is_buffer_completion then
234-
label = unique_prefixes[completion] or completion
235-
if unique_prefixes[completion] then
236-
label_details = { description = completion:sub(1, -#unique_prefixes[completion] - 2) }
206+
label = unique_suffixes[completion] or completion
207+
if unique_suffixes[completion] then
208+
label_details = { description = completion:sub(1, -#unique_suffixes[completion] - 2) }
237209
end
238210
new_text = vim.fn.fnameescape(completion)
239211

@@ -274,7 +246,7 @@ function cmdline:get_completions(context, callback)
274246

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

0 commit comments

Comments
 (0)