Skip to content

Commit

Permalink
feat: rework cmdline source
Browse files Browse the repository at this point in the history
  • Loading branch information
Saghen committed Dec 16, 2024
1 parent bf1fd6a commit 8f718cc
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 193 deletions.
16 changes: 12 additions & 4 deletions lua/blink/cmp/completion/trigger/context.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
--- @field get_mode fun(): blink.cmp.Mode
--- @field get_cursor fun(): number[]
--- @field set_cursor fun(cursor: number[])
--- @field get_line fun(): string
--- @field get_line fun(num?: number): string
--- @field get_context_bounds fun(line: string, cursor: number[]): blink.cmp.ContextBounds
--- @field get_regex_around_cursor fun(range: string, regex_str: string, exclude_from_prefix_regex_str: string): { start_col: number, length: number }

Expand Down Expand Up @@ -88,9 +88,17 @@ function context.set_cursor(cursor)
vim.fn.setcmdpos(cursor[2])
end

function context.get_line()
return context.get_mode() == 'cmdline' and vim.fn.getcmdline()
or vim.api.nvim_buf_get_lines(0, context.get_cursor()[1] - 1, context.get_cursor()[1], false)[1]
function context.get_line(num)
if context.get_mode() == 'cmdline' then
assert(
num == nil or num == 0,
'Cannot get line number ' .. tostring(num) .. ' in cmdline mode. Only 0 is supported'
)
return vim.fn.getcmdline()
end

if num == nil then num = context.get_cursor()[1] - 1 end
return vim.api.nvim_buf_get_lines(0, num, num + 1, false)[1]
end

--- Moves forward and backwards around the cursor looking for word boundaries
Expand Down
4 changes: 2 additions & 2 deletions lua/blink/cmp/completion/trigger/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ function trigger.show_if_on_trigger_character(opts)
return
end

local cursor_col = vim.api.nvim_win_get_cursor(0)[2]
local char_under_cursor = vim.api.nvim_get_current_line():sub(cursor_col, cursor_col)
local cursor_col = context.get_cursor()[2]
local char_under_cursor = context.get_line():sub(cursor_col, cursor_col)

if trigger.is_trigger_character(char_under_cursor, true) then
trigger.show({ trigger_character = char_under_cursor })
Expand Down
64 changes: 29 additions & 35 deletions lua/blink/cmp/lib/cmdline_events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ function cmdline_events:listen(opts)
-- TextChanged
local on_changed = function(key) opts.on_char_added(key, false) end

local did_backspace = false
local is_change_queued = false
vim.on_key(function(_, escaped_key)
if vim.api.nvim_get_mode().mode ~= 'c' then return end

-- ignore if it's a special key
local key = vim.fn.keytrans(escaped_key)
if vim.regex([[<.*>]]):match_str(key) and not '<Space>' == key then return end
if key == '<BS>' and not is_change_queued then did_backspace = true end
if key:sub(1, 1) == '<' and key:sub(#key, #key) == '>' and key ~= '<Space>' then return end

if not is_change_queued then
is_change_queued = true
did_backspace = false
vim.schedule(function()
on_changed(key)
on_changed(escaped_key)
is_change_queued = false
end)
end
Expand All @@ -50,40 +53,31 @@ function cmdline_events:listen(opts)
callback = function() previous_cmdline = '' end,
})

if vim.fn.has('nvim-0.11.0') == 1 then
vim.api.nvim_create_autocmd('CursorMovedC', {
callback = function()
local is_ignored = self.ignore_next_cursor_moved
self.ignore_next_cursor_moved = false

opts.on_cursor_moved('CursorMovedI', is_ignored)
end,
})
else
-- HACK: check every 16ms (60 times/second) to see if the cursor moved
-- for neovim < 0.11
local timer = vim.uv.new_timer()
local previous_cursor
local callback
callback = vim.schedule_wrap(function()
timer:start(16, 0, callback)
if vim.api.nvim_get_mode().mode ~= 'c' then return end

local cmdline_equal = vim.fn.getcmdline() == previous_cmdline
local cursor_equal = vim.fn.getcmdpos() == previous_cursor

previous_cmdline = vim.fn.getcmdline()
previous_cursor = vim.fn.getcmdpos()

if cursor_equal or not cmdline_equal then return end

local is_ignored = self.ignore_next_cursor_moved
self.ignore_next_cursor_moved = false

opts.on_cursor_moved('CursorMovedI', is_ignored)
end)
-- TODO: switch to CursorMovedC when nvim 0.11 is released
-- HACK: check every 16ms (60 times/second) to see if the cursor moved
-- for neovim < 0.11
local timer = vim.uv.new_timer()
local previous_cursor
local callback
callback = vim.schedule_wrap(function()
timer:start(16, 0, callback)
end
if vim.api.nvim_get_mode().mode ~= 'c' then return end

local cmdline_equal = vim.fn.getcmdline() == previous_cmdline
local cursor_equal = vim.fn.getcmdpos() == previous_cursor

previous_cmdline = vim.fn.getcmdline()
previous_cursor = vim.fn.getcmdpos()

if cursor_equal or (not cmdline_equal and not did_backspace) then return end
did_backspace = false

local is_ignored = self.ignore_next_cursor_moved
self.ignore_next_cursor_moved = false

opts.on_cursor_moved('CursorMovedI', is_ignored)
end)
timer:start(16, 0, callback)

vim.api.nvim_create_autocmd('CmdlineLeave', {
callback = function() opts.on_leave() end,
Expand Down
6 changes: 3 additions & 3 deletions lua/blink/cmp/lib/text_edits.lua
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function text_edits.get_from_item(item)
-- from when the items were fetched versus the current.
-- HACK: is there a better way?
-- TODO: take into account the offset_encoding
local offset = vim.api.nvim_win_get_cursor(0)[2] - item.cursor_column
local offset = context.get_cursor()[2] - item.cursor_column
text_edit.range['end'].character = text_edit.range['end'].character + offset

-- convert the offset encoding to utf-8
Expand Down Expand Up @@ -176,10 +176,10 @@ end
function text_edits.clamp_range_to_bounds(range)
range = vim.deepcopy(range)

local start_line = vim.api.nvim_buf_get_lines(0, range.start.line, range.start.line + 1, false)[1]
local start_line = context.get_line(range.start.line)
range.start.character = math.min(math.max(range.start.character, 0), #start_line)

local end_line = vim.api.nvim_buf_get_lines(0, range['end'].line, range['end'].line + 1, false)[1]
local end_line = context.get_line(range['end'].line)
range['end'].character = math.min(math.max(range['end'].character, 0), #end_line)

return range
Expand Down
189 changes: 40 additions & 149 deletions lua/blink/cmp/sources/cmdline/init.lua
Original file line number Diff line number Diff line change
@@ -1,113 +1,10 @@
-- Credit goes to @hrsh7th for the implementation
-- It was simply modified to work with blink.cmp
-- Credit goes to @hrsh7th for the code that this was based on
-- /~https://github.com/hrsh7th/cmp-cmdline
-- License: MIT

local regex = require('blink.cmp.sources.cmdline.regex')

--- @class blink.cmp.Source
local cmdline = {}

---@param word string
---@return boolean?
local function is_boolean_option(word)
local ok, opt = pcall(function() return vim.opt[word]:get() end)
if ok then return type(opt) == 'boolean' end
end

---@class cmp.Cmdline.Definition
---@field ctype string
---@field regex string
---@field kind lsp.CompletionItemKind
---@field isIncomplete boolean
---@field exec fun(option: table, arglead: string, cmdline: string, force: boolean): lsp.CompletionItem[]
---@field fallback boolean?

---@type cmp.Cmdline.Definition[]
local definitions = {
{
ctype = 'cmdline',
regex = [=[[^[:blank:]]*$]=],
kind = require('blink.cmp.types').CompletionItemKind.Variable,
isIncomplete = true,
---@param option cmp-cmdline.Option
exec = function(option, arglead, target, force)
-- Ignore range only cmdline. (e.g.: 4, '<,'>)
if not force and regex.ONLY_RANGE_REGEX:match_str(target) then return {} end

local _, parsed = pcall(function()
local s, e = regex.COUNT_RANGE_REGEX:match_str(target)
if s and e then target = target:sub(e + 1) end
-- nvim_parse_cmd throw error when the cmdline contains range specifier.
return vim.api.nvim_parse_cmd(target, {}) or {}
end)
parsed = parsed or {}

-- Check ignore cmd.
if vim.tbl_contains(option.ignore_cmds, parsed.cmd) then return {} end

-- Cleanup modifiers.
-- We can just remove modifiers because modifiers is always separated by space.
if arglead ~= target then
while true do
local s, e = regex.MODIFIER_REGEX:match_str(target)
if s == nil then break end
target = string.sub(target, e + 1)
end
end

-- Support `lua vim.treesitter._get|` or `'<,'>del|` completion.
-- In this case, the `vim.fn.getcompletion` will return only `get_query` for `vim.treesitter.get_|`.
-- We should detect `vim.treesitter.` and `get_query` separately.
-- TODO: The `\h\w*` was choosed by huristic. We should consider more suitable detection.
local fixed_input
do
local suffix_pos = vim.regex([[\h\w*$]]):match_str(arglead)
fixed_input = string.sub(arglead, 1, suffix_pos or #arglead)
end

-- The `vim.fn.getcompletion` does not return `*no*cursorline` option.
-- cmp-cmdline corrects `no` prefix for option name.
local is_option_name_completion = regex.OPTION_NAME_COMPLETION_REGEX:match_str(target) ~= nil

--- create items.
local items = {}
local escaped = target:gsub([[\\]], [[\\\\]])
for _, word_or_item in ipairs(vim.fn.getcompletion(escaped, 'cmdline')) do
local word = type(word_or_item) == 'string' and word_or_item or word_or_item.word
local item = { label = word }
table.insert(items, item)
if is_option_name_completion and is_boolean_option(word) then
table.insert(
items,
vim.tbl_deep_extend('force', {}, item, {
label = 'no' .. word,
filterText = word,
})
)
end
end

-- fix label with `fixed_input`
for _, item in ipairs(items) do
if not string.find(item.label, fixed_input, 1, true) then item.label = fixed_input .. item.label end
end

-- fix trailing slash for path like item
if option.treat_trailing_slash then
for _, item in ipairs(items) do
local is_target = string.match(item.label, [[/$]])
is_target = is_target and not (string.match(item.label, [[~/$]]))
is_target = is_target and not (string.match(item.label, [[%./$]]))
is_target = is_target and not (string.match(item.label, [[%.%./$]]))
if is_target then item.label = item.label:sub(1, -2) end
end
end
return items
end,
},
}

function cmdline.new()
local self = setmetatable({}, { __index = cmdline })
self.before_line = ''
Expand All @@ -117,60 +14,54 @@ function cmdline.new()
return self
end

function cmdline:get_trigger_characters() return { ' ', '.', '#', '-', '=' } end
function cmdline:get_trigger_characters() return { ' ', '.', '#', '-', '=', '/' } end

function cmdline:get_completions(context, callback)
local cursor_before_line = context.line:sub(0, context.cursor[2])
local arguments = vim.split(context.line, ' ', { plain = true })
local arg_number = #vim.split(context.line:sub(1, context.cursor[2] + 1), ' ', { plain = true })
local text_before_cursor = table.concat(require('blink.cmp.lib.utils').slice(arguments, 1, arg_number - 1), ' ')
.. (arg_number > 1 and ' ' or '')

local current_arg = arguments[arg_number]
local keyword_config = require('blink.cmp.config').completion.keyword
local keyword = context.get_regex_around_cursor(
keyword_config.range,
keyword_config.regex,
keyword_config.exclude_from_prefix_regex
)
local current_arg_prefix = current_arg:sub(1, keyword.start_col - 1)

local query = (text_before_cursor .. current_arg_prefix):gsub([[\\]], [[\\\\]])
local completions = vim.fn.getcompletion(query, 'cmdline')

local offset = 0
local ctype = ''
local items = {}
local kind
local isIncomplete = false
for _, def in ipairs(definitions) do
local s, e = vim.regex(def.regex):match_str(cursor_before_line)
if s and e then
offset = s
ctype = def.ctype
items = def.exec(
vim.tbl_deep_extend('keep', {}, regex.DEFAULT_OPTION),
string.sub(cursor_before_line, s + 1),
cursor_before_line,
false -- TODO:
)
kind = def.kind
isIncomplete = def.isIncomplete
if not (#items == 0 and def.fallback) then break end
for _, completion in ipairs(completions) do
-- remove prefix from the label
if string.find(completion, current_arg_prefix, 1, true) == 1 then
completion = completion:sub(#current_arg_prefix + 1)
end
end

local labels = {}
for _, item in ipairs(items) do
item.kind = kind
labels[item.label] = true
end

-- `vim.fn.getcompletion` does not handle fuzzy matches. So, we must return all items, including items that were matched in the previous input.
local should_merge_previous_items = false
if #cursor_before_line > #self.before_line then
should_merge_previous_items = string.find(cursor_before_line, self.before_line, 1, true) == 1
elseif #cursor_before_line < #self.before_line then
should_merge_previous_items = string.find(self.before_line, cursor_before_line, 1, true) == 1
end

if should_merge_previous_items and self.offset == offset and self.ctype == ctype then
for _, item in ipairs(self.items) do
if not labels[item.label] then table.insert(items, item) end
end
-- add prefix to the newText
local new_text = completion
if string.find(new_text, current_arg_prefix, 1, true) ~= 1 then new_text = current_arg_prefix .. completion end

table.insert(items, {
label = completion,
insertText = completion,
textEdit = {
newText = new_text,
range = {
start = { line = 0, character = #text_before_cursor },
['end'] = { line = 0, character = #text_before_cursor + #current_arg },
},
},
kind = require('blink.cmp.types').CompletionItemKind.Property,
})
end
self.before_line = cursor_before_line
self.offset = offset
self.ctype = ctype
self.items = items

callback({
is_incomplete_backward = true,
is_incomplete_forward = isIncomplete,
is_incomplete_backward = false,
is_incomplete_forward = false,
items = items,
})
end
Expand Down

0 comments on commit 8f718cc

Please sign in to comment.