Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: First pass at terminal-mode completions #665

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
- Auto-bracket support based on semantic tokens
- Signature help (experimental, opt-in)
- Command line completion
- [Comparison with nvim-cmp](#compared-to-nvim-cmp)
- Support for completions in Neovim's built-in terminal (although no source for shell completions exists yet)
- [Comparison with nvim-cmp](https://cmp.saghen.dev/#compared-to-nvim-cmp)

## Getting Started

Expand Down
5 changes: 4 additions & 1 deletion lua/blink/cmp/completion/accept/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ local function accept(ctx, item, callback)
table.insert(all_text_edits, item.textEdit)
text_edits_lib.apply(all_text_edits)
-- TODO: should move the cursor only by the offset since text edit handles everything else?
ctx.set_cursor({ ctx.get_cursor()[1], item.textEdit.range.start.character + #item.textEdit.newText + offset })
-- TODO: move the term check somewhere else
if ctx.get_mode() ~= 'term' then
ctx.set_cursor({ ctx.get_cursor()[1], item.textEdit.range.start.character + #item.textEdit.newText + offset })
end
end

-- Let the source execute the item itself
Expand Down
8 changes: 7 additions & 1 deletion lua/blink/cmp/completion/trigger/context.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
-- TODO: remove the end_col field from ContextBounds


--- @class blink.cmp.ContextBounds
--- @field line string
--- @field line_number number
Expand Down Expand Up @@ -73,7 +74,11 @@ function context:within_query_bounds(cursor)
return row == bounds.line_number and col >= bounds.start_col and col <= bounds.end_col
end

function context.get_mode() return vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default' end
function context.get_mode()
return (vim.api.nvim_get_mode().mode == 'c' and 'cmdline')
or (vim.api.nvim_get_mode().mode == 't' and 'term')
or 'default'
end

function context.get_cursor()
return context.get_mode() == 'cmdline' and { 1, vim.fn.getcmdpos() - 1 } or vim.api.nvim_win_get_cursor(0)
Expand All @@ -97,6 +102,7 @@ function context.get_line(num)
return vim.fn.getcmdline()
end

-- This method works for normal buffers and the terminal prompt
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
Expand Down
21 changes: 17 additions & 4 deletions lua/blink/cmp/completion/trigger/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ function trigger.activate()
show_in_snippet = config.show_in_snippet,
})
trigger.cmdline_events = require('blink.cmp.lib.cmdline_events').new()
trigger.term_events = require('blink.cmp.lib.term_events').new({
has_context = function() return trigger.context ~= nil end,
})

local function on_char_added(char, is_ignored)
-- we were told to ignore the text changed event, so we update the context
Expand Down Expand Up @@ -77,7 +80,7 @@ function trigger.activate()

local insert_enter_on_trigger_character = config.show_on_trigger_character
and config.show_on_insert_on_trigger_character
and event == 'InsertEnter'
and (event == 'InsertEnter' or event == 'TermEnter')
and trigger.is_trigger_character(char_under_cursor, true)

-- check if we're still within the bounds of the query used for the context
Expand All @@ -95,7 +98,7 @@ function trigger.activate()
trigger.show()

-- prefetch completions without opening window on InsertEnter
elseif event == 'InsertEnter' and config.prefetch_on_insert then
elseif (event == 'InsertEnter' or event == 'TermEnter') and config.prefetch_on_insert then
trigger.show({ prefetch = true })

-- otherwise hide
Expand All @@ -114,6 +117,10 @@ function trigger.activate()
on_cursor_moved = on_cursor_moved,
on_leave = function() trigger.hide() end,
})
trigger.term_events:listen({
on_char_added = on_char_added,
on_term_leave = function() trigger.hide() end,
})
end

function trigger.is_trigger_character(char, is_show_on_x)
Expand All @@ -137,9 +144,15 @@ end

--- Suppresses on_hide and on_show events for the duration of the callback
function trigger.suppress_events_for_callback(cb)
local mode = vim.api.nvim_get_mode().mode == 'c' and 'cmdline' or 'default'
local mode = vim.api.nvim_get_mode().mode
mode = (vim.api.nvim_get_mode().mode == 'c' and 'cmdline')
or (mode == 't' and 'term')
or 'default'

local events = (mode == 'default' and trigger.buffer_events)
or (mode == 'term' and trigger.term_events)
or trigger.cmdline_events

local events = mode == 'default' and trigger.buffer_events or trigger.cmdline_events
if not events then return cb() end

events:suppress_events_for_callback(cb)
Expand Down
11 changes: 9 additions & 2 deletions lua/blink/cmp/config/keymap.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
--- @alias blink.cmp.KeymapCommand

--- | 'fallback' Fallback to the built-in behavior
--- | 'show' Show the completion window
--- | 'hide' Hide the completion window
Expand Down Expand Up @@ -102,6 +103,11 @@
--- cmdline = {
--- preset = 'cmdline',
--- }
---
--- -- optionally, define different keymaps for Neovim's built-in terminal
--- term = {
--- preset = 'term',
--- }
--- }
--- ```
---
Expand All @@ -112,6 +118,7 @@

--- @class (exact) blink.cmp.KeymapConfig : blink.cmp.BaseKeymapConfig
--- @field cmdline? blink.cmp.BaseKeymapConfig Optionally, define a separate keymap for cmdline
--- @field term? blink.cmp.BaseKeymapConfig Optionally, define a separate keymap for cmdline

local keymap = {
--- @type blink.cmp.KeymapConfig
Expand Down Expand Up @@ -142,8 +149,8 @@ function keymap.validate(config)

local validation_schema = {}
for key, value in pairs(config) do
-- nested cmdline keymap
if key == 'cmdline' then
-- nested cmdline/term keymap
if key == 'cmdline' or key == 'term' then
keymap.validate(value)

-- preset
Expand Down
10 changes: 10 additions & 0 deletions lua/blink/cmp/config/sources.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
--- @field default string[] | fun(): string[]
--- @field per_filetype table<string, string[] | fun(): string[]>
--- @field cmdline string[] | fun(): string[]
--- @field term string[] | fun(): string[]
---
--- @field transform_items fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[] Function to transform the items before they're returned
--- @field min_keyword_length number | fun(ctx: blink.cmp.Context): number Minimum number of characters in the keyword to trigger
Expand Down Expand Up @@ -53,6 +54,7 @@ local sources = {
if type == ':' then return { 'cmdline' } end
return {}
end,
term = { 'buffer', 'path' },

transform_items = function(_, items) return items end,
min_keyword_length = 0,
Expand Down Expand Up @@ -102,6 +104,13 @@ local sources = {
name = 'cmdline',
module = 'blink.cmp.sources.cmdline',
},
-- Note: in future we may want a built-in terminal source. For now
-- the infrastructure exists, e.g. so community terminal sources can be
-- added, but this functionality is not baked into blink.cmp.
-- term = {
-- name = 'term',
-- module = 'blink.cmp.sources.term',
-- },
},
},
}
Expand All @@ -116,6 +125,7 @@ function sources.validate(config)
default = { config.default, { 'function', 'table' } },
per_filetype = { config.per_filetype, 'table' },
cmdline = { config.cmdline, { 'function', 'table' } },
term = { config.term, { 'function', 'table' } },

transform_items = { config.transform_items, 'function' },
min_keyword_length = { config.min_keyword_length, { 'number', 'function' } },
Expand Down
38 changes: 35 additions & 3 deletions lua/blink/cmp/keymap/apply.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ function apply.keymap_to_current_buffer(keys_to_commands)
if command == 'fallback' then
return fallback()

-- run user defined functions
-- run user defined functions
elseif type(command) == 'function' then
if command(require('blink.cmp')) then return end

-- otherwise, run the built-in command
-- otherwise, run the built-in command
elseif require('blink.cmp')[command]() then
return
end
Expand Down Expand Up @@ -66,6 +66,38 @@ function apply.keymap_to_current_buffer(keys_to_commands)
end
end

function apply.term_keymaps(keys_to_commands)
-- skip if we've already applied the keymaps
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(0, 't')) do
if mapping.desc == 'blink.cmp' then return end
end

-- terminal mode: uses insert commands only
for key, commands in pairs(keys_to_commands) do
if #commands == 0 then goto continue end

local fallback = require('blink.cmp.keymap.fallback').wrap('i', key)
apply.set('t', key, function()
for _, command in ipairs(commands) do
-- special case for fallback
if command == 'fallback' then
return fallback()

-- run user defined functions
elseif type(command) == 'function' then
if command(require('blink.cmp')) then return end

-- otherwise, run the built-in command
elseif require('blink.cmp')[command]() then
return
end
end
end)

::continue::
end
end

function apply.cmdline_keymaps(keys_to_commands)
-- cmdline mode: uses only insert commands
for key, commands in pairs(keys_to_commands) do
Expand Down Expand Up @@ -102,7 +134,7 @@ end
--- @param key string
--- @param callback fun(): string | nil
function apply.set(mode, key, callback)
if mode == 'c' then
if mode == 'c' or mode == 't' then
vim.api.nvim_set_keymap(mode, key, '', {
callback = callback,
expr = true,
Expand Down
8 changes: 8 additions & 0 deletions lua/blink/cmp/keymap/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ function keymap.setup()
local cmdline_mappings = keymap.get_mappings(config.keymap.cmdline or config.keymap)
require('blink.cmp.keymap.apply').cmdline_keymaps(cmdline_mappings)
end


-- Apply term keymaps
local term_sources = require('blink.cmp.config').sources.term
if type(term_sources) ~= 'table' or #term_sources > 0 then
local term_mappings = keymap.get_mappings(config.keymap.term or config.keymap)
require('blink.cmp.keymap.apply').term_keymaps(term_mappings)
end
end

return keymap
92 changes: 92 additions & 0 deletions lua/blink/cmp/lib/term_events.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
--- @class blink.cmp.TermEvents
--- @field has_context fun(): boolean
--- @field ignore_next_text_changed boolean
--- @field ignore_next_cursor_moved boolean
---
--- @field new fun(opts: blink.cmp.TermEventsOptions): blink.cmp.TermEvents
--- @field listen fun(self: blink.cmp.TermEvents, opts: blink.cmp.TermEventsListener)
--- @field suppress_events_for_callback fun(self: blink.cmp.TermEvents, cb: fun())

--- @class blink.cmp.TermEventsOptions
--- @field has_context fun(): boolean

--- @class blink.cmp.TermEventsListener
--- @field on_char_added fun(char: string, is_ignored: boolean)
--- @field on_term_leave fun()

--- @type blink.cmp.TermEvents
--- @diagnostic disable-next-line: missing-fields
local term_events = {}

function term_events.new(opts)
return setmetatable({
has_context = opts.has_context,
ignore_next_text_changed = false,
ignore_next_cursor_moved = false,
}, { __index = term_events })
end

local term_on_key_ns = vim.api.nvim_create_namespace('blink-term-keypress')

--- Normalizes the autocmds + ctrl+c into a common api and handles ignored events
function term_events:listen(opts)
local last_char = ''
-- There's no terminal equivalent to 'InsertCharPre', so we need to simulate
-- something similar to this by watching with `vim.on_key()`
vim.api.nvim_create_autocmd('TermEnter', {
callback = function()
vim.on_key(function(k) last_char = k end, term_on_key_ns)
end
})
vim.api.nvim_create_autocmd('TermLeave', {
callback = function()
vim.on_key(nil, term_on_key_ns)
last_char = ''
end
})

vim.api.nvim_create_autocmd('TextChangedT', {
callback = function()
if not require('blink.cmp.config').enabled() then return end

local is_ignored = self.ignore_next_text_changed
self.ignore_next_text_changed = false

-- no characters added so let cursormoved handle it
if last_char == '' then return end

opts.on_char_added(last_char, is_ignored)

last_char = ''
end,
})

-- definitely leaving the context
vim.api.nvim_create_autocmd({ 'ModeChanged', 'TermLeave' }, {
callback = function()
last_char = ''
vim.schedule(function() opts.on_term_leave() end)
end,
})
end

--- Suppresses autocmd events for the duration of the callback
--- HACK: there's likely edge cases with this since we can't know for sure
--- if the autocmds will fire for cursor_moved afaik
function term_events:suppress_events_for_callback(cb)
local cursor_before = vim.api.nvim_win_get_cursor(0)
local changed_tick_before = vim.api.nvim_buf_get_changedtick(0)

cb()

local cursor_after = vim.api.nvim_win_get_cursor(0)
local changed_tick_after = vim.api.nvim_buf_get_changedtick(0)

local is_term_mode = vim.api.nvim_get_mode().mode == 't'
self.ignore_next_text_changed = changed_tick_after ~= changed_tick_before and is_term_mode
-- TODO: does this guarantee that the CursorMovedI event will fire?
self.ignore_next_cursor_moved = (cursor_after[1] ~= cursor_before[1] or cursor_after[2] ~= cursor_before[2])
and is_term_mode
end

return term_events
35 changes: 27 additions & 8 deletions lua/blink/cmp/lib/text_edits.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,36 @@ function text_edits.apply(edits)
local mode = context.get_mode()
if mode == 'default' then return vim.lsp.util.apply_text_edits(edits, vim.api.nvim_get_current_buf(), 'utf-8') end

assert(mode == 'cmdline', 'Unsupported mode for text edits: ' .. mode)
assert(#edits == 1, 'Cmdline mode only supports one text edit. Contributions welcome!')
assert(mode == 'cmdline' or mode == 'term', 'Unsupported mode for text edits: ' .. mode)

local edit = edits[1]
local line = context.get_line()
local edited_line = line:sub(1, edit.range.start.character)
if mode == 'cmdline' then
assert(#edits == 1, 'Cmdline mode only supports one text edit. Contributions welcome!')

local edit = edits[1]
local line = context.get_line()
local edited_line = line:sub(1, edit.range.start.character)
.. edit.newText
.. line:sub(edit.range['end'].character + 1)
-- FIXME: for some reason, we have to set the cursor here, instead of later,
-- because this will override the cursor position set later
vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1)
-- FIXME: for some reason, we have to set the cursor here, instead of later,
-- because this will override the cursor position set later
vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1)
end

if mode == 'term' then
assert(#edits == 1, 'Terminal mode only supports one text edit. Contributions welcome!')

if vim.bo.channel and vim.bo.channel ~= 0 then
local edit = edits[1]
local cur_col = vim.api.nvim_win_get_cursor(0)[2]
local n_replaced = cur_col - edit.range.start.character
local backspace_keycode = '\8'

vim.fn.chansend(
vim.bo.channel,
backspace_keycode:rep(n_replaced) .. edit.newText
)
end
end
end

------- Undo -------
Expand Down