summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/lib
diff options
context:
space:
mode:
authorMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
committerMike Vink <mike@pionative.com>2025-01-19 13:52:52 +0100
commitb77413ff8f59f380612074f0c9bd49093d8db695 (patch)
tree32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/lib
Squashed 'mut/neovim/pack/plugins/start/blink.cmp/' content from commit 1cc3b1a
git-subtree-dir: mut/neovim/pack/plugins/start/blink.cmp git-subtree-split: 1cc3b1a908fbcfd15451c4772759549724f38524
Diffstat (limited to 'lua/blink/cmp/lib')
-rw-r--r--lua/blink/cmp/lib/async.lua217
-rw-r--r--lua/blink/cmp/lib/buffer_events.lua148
-rw-r--r--lua/blink/cmp/lib/cmdline_events.lua104
-rw-r--r--lua/blink/cmp/lib/event_emitter.lua37
-rw-r--r--lua/blink/cmp/lib/text_edits.lua193
-rw-r--r--lua/blink/cmp/lib/utils.lua112
-rw-r--r--lua/blink/cmp/lib/window/docs.lua224
-rw-r--r--lua/blink/cmp/lib/window/init.lua445
-rw-r--r--lua/blink/cmp/lib/window/scrollbar/geometry.lua92
-rw-r--r--lua/blink/cmp/lib/window/scrollbar/init.lua37
-rw-r--r--lua/blink/cmp/lib/window/scrollbar/win.lua107
11 files changed, 1716 insertions, 0 deletions
diff --git a/lua/blink/cmp/lib/async.lua b/lua/blink/cmp/lib/async.lua
new file mode 100644
index 0000000..b9c39ac
--- /dev/null
+++ b/lua/blink/cmp/lib/async.lua
@@ -0,0 +1,217 @@
+--- Allows chaining of async operations without callback hell
+---
+--- @class blink.cmp.Task
+--- @field status blink.cmp.TaskStatus
+--- @field result any | nil
+--- @field error any | nil
+--- @field new fun(fn: fun(resolve: fun(result: any), reject: fun(err: any))): blink.cmp.Task
+---
+--- @field cancel fun(self: blink.cmp.Task)
+--- @field map fun(self: blink.cmp.Task, fn: fun(result: any): blink.cmp.Task | any): blink.cmp.Task
+--- @field catch fun(self: blink.cmp.Task, fn: fun(err: any): blink.cmp.Task | any): blink.cmp.Task
+---
+--- @field on_completion fun(self: blink.cmp.Task, cb: fun(result: any))
+--- @field on_failure fun(self: blink.cmp.Task, cb: fun(err: any))
+--- @field on_cancel fun(self: blink.cmp.Task, cb: fun())
+--- @field _completion_cbs function[]
+--- @field _failure_cbs function[]
+--- @field _cancel_cbs function[]
+--- @field _cancel? fun()
+local task = {
+ __task = true,
+}
+
+---@enum blink.cmp.TaskStatus
+local STATUS = {
+ RUNNING = 1,
+ COMPLETED = 2,
+ FAILED = 3,
+ CANCELLED = 4,
+}
+
+function task.new(fn)
+ local self = setmetatable({}, { __index = task })
+ self.status = STATUS.RUNNING
+ self._completion_cbs = {}
+ self._failure_cbs = {}
+ self._cancel_cbs = {}
+ self.result = nil
+ self.error = nil
+
+ local resolve = function(result)
+ if self.status ~= STATUS.RUNNING then return end
+
+ self.status = STATUS.COMPLETED
+ self.result = result
+
+ for _, cb in ipairs(self._completion_cbs) do
+ cb(result)
+ end
+ end
+
+ local reject = function(err)
+ if self.status ~= STATUS.RUNNING then return end
+
+ self.status = STATUS.FAILED
+ self.error = err
+
+ for _, cb in ipairs(self._failure_cbs) do
+ cb(err)
+ end
+ end
+
+ local success, cancel_fn_or_err = pcall(function() return fn(resolve, reject) end)
+
+ if not success then
+ reject(cancel_fn_or_err)
+ elseif type(cancel_fn_or_err) == 'function' then
+ self._cancel = cancel_fn_or_err
+ end
+
+ return self
+end
+
+function task:cancel()
+ if self.status ~= STATUS.RUNNING then return end
+ self.status = STATUS.CANCELLED
+
+ if self._cancel ~= nil then self._cancel() end
+ for _, cb in ipairs(self._cancel_cbs) do
+ cb()
+ end
+end
+
+--- mappings
+
+function task:map(fn)
+ local chained_task
+ chained_task = task.new(function(resolve, reject)
+ self:on_completion(function(result)
+ local success, mapped_result = pcall(fn, result)
+ if not success then
+ reject(mapped_result)
+ return
+ end
+
+ if type(mapped_result) == 'table' and mapped_result.__task then
+ mapped_result:on_completion(resolve)
+ mapped_result:on_failure(reject)
+ mapped_result:on_cancel(function() chained_task:cancel() end)
+ return
+ end
+ resolve(mapped_result)
+ end)
+ self:on_failure(reject)
+ self:on_cancel(function() chained_task:cancel() end)
+ return function() chained_task:cancel() end
+ end)
+ return chained_task
+end
+
+function task:catch(fn)
+ local chained_task
+ chained_task = task.new(function(resolve, reject)
+ self:on_completion(resolve)
+ self:on_failure(function(err)
+ local success, mapped_err = pcall(fn, err)
+ if not success then
+ reject(mapped_err)
+ return
+ end
+
+ if type(mapped_err) == 'table' and mapped_err.__task then
+ mapped_err:on_completion(resolve)
+ mapped_err:on_failure(reject)
+ mapped_err:on_cancel(function() chained_task:cancel() end)
+ return
+ end
+ resolve(mapped_err)
+ end)
+ self:on_cancel(function() chained_task:cancel() end)
+ return function() chained_task:cancel() end
+ end)
+ return chained_task
+end
+
+--- events
+
+function task:on_completion(cb)
+ if self.status == STATUS.COMPLETED then
+ cb(self.result)
+ elseif self.status == STATUS.RUNNING then
+ table.insert(self._completion_cbs, cb)
+ end
+ return self
+end
+
+function task:on_failure(cb)
+ if self.status == STATUS.FAILED then
+ cb(self.error)
+ elseif self.status == STATUS.RUNNING then
+ table.insert(self._failure_cbs, cb)
+ end
+ return self
+end
+
+function task:on_cancel(cb)
+ if self.status == STATUS.CANCELLED then
+ cb()
+ elseif self.status == STATUS.RUNNING then
+ table.insert(self._cancel_cbs, cb)
+ end
+ return self
+end
+
+--- utils
+
+function task.await_all(tasks)
+ if #tasks == 0 then
+ return task.new(function(resolve) resolve({}) end)
+ end
+
+ local all_task
+ all_task = task.new(function(resolve, reject)
+ local results = {}
+ local has_resolved = {}
+
+ local function resolve_if_completed()
+ -- we can't check #results directly because a table like
+ -- { [2] = { ... } } has a length of 2
+ for i = 1, #tasks do
+ if has_resolved[i] == nil then return end
+ end
+ resolve(results)
+ end
+
+ for idx, task in ipairs(tasks) do
+ task:on_completion(function(result)
+ results[idx] = result
+ has_resolved[idx] = true
+ resolve_if_completed()
+ end)
+ task:on_failure(function(err)
+ reject(err)
+ for _, task in ipairs(tasks) do
+ task:cancel()
+ end
+ end)
+ task:on_cancel(function()
+ for _, sub_task in ipairs(tasks) do
+ sub_task:cancel()
+ end
+ if all_task == nil then
+ vim.schedule(function() all_task:cancel() end)
+ else
+ all_task:cancel()
+ end
+ end)
+ end
+ end)
+ return all_task
+end
+
+function task.empty()
+ return task.new(function(resolve) resolve() end)
+end
+
+return { task = task, STATUS = STATUS }
diff --git a/lua/blink/cmp/lib/buffer_events.lua b/lua/blink/cmp/lib/buffer_events.lua
new file mode 100644
index 0000000..dcca8b8
--- /dev/null
+++ b/lua/blink/cmp/lib/buffer_events.lua
@@ -0,0 +1,148 @@
+--- Exposes three events (cursor moved, char added, insert leave) for triggers to use.
+--- Notably, when "char added" is fired, the "cursor moved" event will not be fired.
+--- Unlike in regular neovim, ctrl + c and buffer switching will trigger "insert leave"
+
+--- @class blink.cmp.BufferEvents
+--- @field has_context fun(): boolean
+--- @field show_in_snippet boolean
+--- @field ignore_next_text_changed boolean
+--- @field ignore_next_cursor_moved boolean
+---
+--- @field new fun(opts: blink.cmp.BufferEventsOptions): blink.cmp.BufferEvents
+--- @field listen fun(self: blink.cmp.BufferEvents, opts: blink.cmp.BufferEventsListener)
+--- @field suppress_events_for_callback fun(self: blink.cmp.BufferEvents, cb: fun())
+
+--- @class blink.cmp.BufferEventsOptions
+--- @field has_context fun(): boolean
+--- @field show_in_snippet boolean
+
+--- @class blink.cmp.BufferEventsListener
+--- @field on_char_added fun(char: string, is_ignored: boolean)
+--- @field on_cursor_moved fun(event: 'CursorMoved' | 'InsertEnter', is_ignored: boolean)
+--- @field on_insert_leave fun()
+
+--- @type blink.cmp.BufferEvents
+--- @diagnostic disable-next-line: missing-fields
+local buffer_events = {}
+
+function buffer_events.new(opts)
+ return setmetatable({
+ has_context = opts.has_context,
+ show_in_snippet = opts.show_in_snippet,
+ ignore_next_text_changed = false,
+ ignore_next_cursor_moved = false,
+ }, { __index = buffer_events })
+end
+
+--- Normalizes the autocmds + ctrl+c into a common api and handles ignored events
+function buffer_events:listen(opts)
+ local snippet = require('blink.cmp.config').snippets
+
+ local last_char = ''
+ vim.api.nvim_create_autocmd('InsertCharPre', {
+ callback = function()
+ if snippet.active() and not self.show_in_snippet and not self.has_context() then return end
+ last_char = vim.v.char
+ end,
+ })
+
+ vim.api.nvim_create_autocmd('TextChangedI', {
+ callback = function()
+ if not require('blink.cmp.config').enabled() then return end
+ if snippet.active() and not self.show_in_snippet and not self.has_context() 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,
+ })
+
+ vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI', 'InsertEnter' }, {
+ callback = function(ev)
+ -- only fire a CursorMoved event (notable not CursorMovedI)
+ -- when jumping between tab stops in a snippet while showing the menu
+ if
+ ev.event == 'CursorMoved'
+ and (vim.api.nvim_get_mode().mode ~= 'v' or not self.has_context() or not snippet.active())
+ then
+ return
+ end
+
+ local is_cursor_moved = ev.event == 'CursorMoved' or ev.event == 'CursorMovedI'
+
+ local is_ignored = is_cursor_moved and self.ignore_next_cursor_moved
+ if is_cursor_moved then self.ignore_next_cursor_moved = false end
+
+ -- characters added so let textchanged handle it
+ if last_char ~= '' then return end
+
+ if not require('blink.cmp.config').enabled() then return end
+ if not self.show_in_snippet and not self.has_context() and snippet.active() then return end
+
+ opts.on_cursor_moved(is_cursor_moved and 'CursorMoved' or ev.event, is_ignored)
+ end,
+ })
+
+ -- definitely leaving the context
+ vim.api.nvim_create_autocmd({ 'ModeChanged', 'BufLeave' }, {
+ callback = function()
+ last_char = ''
+ -- HACK: when using vim.snippet.expand, the mode switches from insert -> normal -> visual -> select
+ -- so we schedule to ignore the intermediary modes
+ -- TODO: deduplicate requests
+ vim.schedule(function()
+ if not vim.tbl_contains({ 'i', 's' }, vim.api.nvim_get_mode().mode) then opts.on_insert_leave() end
+ end)
+ end,
+ })
+
+ -- ctrl+c doesn't trigger InsertLeave so handle it separately
+ local ctrl_c = vim.api.nvim_replace_termcodes('<C-c>', true, true, true)
+ vim.on_key(function(key)
+ if key == ctrl_c then
+ vim.schedule(function()
+ local mode = vim.api.nvim_get_mode().mode
+ if mode ~= 'i' then
+ last_char = ''
+ opts.on_insert_leave()
+ end
+ end)
+ 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 buffer_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_insert_mode = vim.api.nvim_get_mode().mode == 'i'
+
+ self.ignore_next_text_changed = changed_tick_after ~= changed_tick_before and is_insert_mode
+
+ -- HACK: the cursor may move from position (1, 1) to (1, 0) and back to (1, 1) during the callback
+ -- This will trigger a CursorMovedI event, but we can't detect it simply by checking the cursor position
+ -- since they're equal before vs after the callback. So instead, we always mark the cursor as ignored in
+ -- insert mode, but if the cursor was equal, we undo the ignore after a small delay, which practically guarantees
+ -- that the CursorMovedI event will fire
+ -- TODO: It could make sense to override the nvim_win_set_cursor function and mark as ignored if it's called
+ -- on the current buffer
+ local cursor_moved = cursor_after[1] ~= cursor_before[1] or cursor_after[2] ~= cursor_before[2]
+ self.ignore_next_cursor_moved = is_insert_mode
+ if not cursor_moved then vim.defer_fn(function() self.ignore_next_cursor_moved = false end, 10) end
+end
+
+return buffer_events
diff --git a/lua/blink/cmp/lib/cmdline_events.lua b/lua/blink/cmp/lib/cmdline_events.lua
new file mode 100644
index 0000000..6f23ed8
--- /dev/null
+++ b/lua/blink/cmp/lib/cmdline_events.lua
@@ -0,0 +1,104 @@
+--- @class blink.cmp.CmdlineEvents
+--- @field has_context fun(): boolean
+--- @field ignore_next_text_changed boolean
+--- @field ignore_next_cursor_moved boolean
+---
+--- @field new fun(): blink.cmp.CmdlineEvents
+--- @field listen fun(self: blink.cmp.CmdlineEvents, opts: blink.cmp.CmdlineEventsListener)
+--- @field suppress_events_for_callback fun(self: blink.cmp.CmdlineEvents, cb: fun())
+
+--- @class blink.cmp.CmdlineEventsListener
+--- @field on_char_added fun(char: string, is_ignored: boolean)
+--- @field on_cursor_moved fun(event: 'CursorMoved' | 'InsertEnter', is_ignored: boolean)
+--- @field on_leave fun()
+
+--- @type blink.cmp.CmdlineEvents
+--- @diagnostic disable-next-line: missing-fields
+local cmdline_events = {}
+
+function cmdline_events.new()
+ return setmetatable({
+ ignore_next_text_changed = false,
+ ignore_next_cursor_moved = false,
+ }, { __index = cmdline_events })
+end
+
+function cmdline_events:listen(opts)
+ -- TextChanged
+ local on_changed = function(key) opts.on_char_added(key, false) end
+
+ -- We handle backspace as a special case, because the text will have changed
+ -- but we still want to fire the CursorMoved event, and not the TextChanged event
+ local did_backspace = false
+ local is_change_queued = false
+ vim.on_key(function(raw_key, escaped_key)
+ if vim.api.nvim_get_mode().mode ~= 'c' then return end
+
+ -- ignore if it's a special key
+ -- FIXME: odd behavior when escaped_key has multiple keycodes, i.e. by pressing <C-p> and then "t"
+ local key = vim.fn.keytrans(escaped_key)
+ if key == '<BS>' and not is_change_queued then did_backspace = true end
+ if key:sub(1, 1) == '<' and key:sub(#key, #key) == '>' and raw_key ~= ' ' then return end
+ if key == '' then return end
+
+ if not is_change_queued then
+ is_change_queued = true
+ did_backspace = false
+ vim.schedule(function()
+ on_changed(raw_key)
+ is_change_queued = false
+ end)
+ end
+ end)
+
+ -- CursorMoved
+ local previous_cmdline = ''
+ vim.api.nvim_create_autocmd('CmdlineEnter', {
+ callback = function() previous_cmdline = '' 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)
+ 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('CursorMoved', is_ignored)
+ end)
+ timer:start(16, 0, callback)
+
+ vim.api.nvim_create_autocmd('CmdlineLeave', {
+ callback = function() opts.on_leave() end,
+ })
+end
+
+--- Suppresses autocmd events for the duration of the callback
+--- HACK: there's likely edge cases with this
+function cmdline_events:suppress_events_for_callback(cb)
+ local cursor_before = vim.fn.getcmdpos()
+
+ cb()
+
+ if not vim.api.nvim_get_mode().mode == 'c' then return end
+
+ local cursor_after = vim.fn.getcmdpos()
+ self.ignore_next_cursor_moved = self.ignore_next_cursor_moved or cursor_after ~= cursor_before
+end
+
+return cmdline_events
diff --git a/lua/blink/cmp/lib/event_emitter.lua b/lua/blink/cmp/lib/event_emitter.lua
new file mode 100644
index 0000000..d3939cb
--- /dev/null
+++ b/lua/blink/cmp/lib/event_emitter.lua
@@ -0,0 +1,37 @@
+--- @class blink.cmp.EventEmitter<T> : { event: string, autocmd?: string, listeners: table<fun(data: T)>, new: ( fun(event: string, autocmd: string): blink.cmp.EventEmitter ), on: ( fun(self: blink.cmp.EventEmitter, callback: fun(data: T)) ), off: ( fun(self: blink.cmp.EventEmitter, callback: fun(data: T)) ), emit: ( fun(self: blink.cmp.EventEmitter, data?: table) ) };
+--- TODO: is there a better syntax for this?
+
+local event_emitter = {}
+
+--- @param event string
+--- @param autocmd? string
+function event_emitter.new(event, autocmd)
+ local self = setmetatable({}, { __index = event_emitter })
+ self.event = event
+ self.autocmd = autocmd
+ self.listeners = {}
+ return self
+end
+
+function event_emitter:on(callback) table.insert(self.listeners, callback) end
+
+function event_emitter:off(callback)
+ for idx, cb in ipairs(self.listeners) do
+ if cb == callback then table.remove(self.listeners, idx) end
+ end
+end
+
+function event_emitter:emit(data)
+ data = data or {}
+ data.event = self.event
+ for _, callback in ipairs(self.listeners) do
+ callback(data)
+ end
+ if self.autocmd then
+ require('blink.cmp.lib.utils').schedule_if_needed(
+ function() vim.api.nvim_exec_autocmds('User', { pattern = self.autocmd, modeline = false, data = data }) end
+ )
+ end
+end
+
+return event_emitter
diff --git a/lua/blink/cmp/lib/text_edits.lua b/lua/blink/cmp/lib/text_edits.lua
new file mode 100644
index 0000000..2ce76fe
--- /dev/null
+++ b/lua/blink/cmp/lib/text_edits.lua
@@ -0,0 +1,193 @@
+local config = require('blink.cmp.config')
+local context = require('blink.cmp.completion.trigger.context')
+
+local text_edits = {}
+
+--- Applies one or more text edits to the current buffer, assuming utf-8 encoding
+--- @param edits lsp.TextEdit[]
+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!')
+
+ 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)
+end
+
+------- Undo -------
+
+--- Gets the reverse of the text edit, must be called before applying
+--- @param text_edit lsp.TextEdit
+--- @return lsp.TextEdit
+function text_edits.get_undo_text_edit(text_edit)
+ return {
+ range = text_edits.get_undo_range(text_edit),
+ newText = text_edits.get_text_to_replace(text_edit),
+ }
+end
+
+--- Gets the range for undoing an applied text edit
+--- @param text_edit lsp.TextEdit
+function text_edits.get_undo_range(text_edit)
+ text_edit = vim.deepcopy(text_edit)
+ local lines = vim.split(text_edit.newText, '\n')
+ local last_line_len = lines[#lines] and #lines[#lines] or 0
+
+ local range = text_edit.range
+ range['end'].line = range.start.line + #lines - 1
+ range['end'].character = #lines > 1 and last_line_len or range.start.character + last_line_len
+
+ return range
+end
+
+--- Gets the text the text edit will replace
+--- @param text_edit lsp.TextEdit
+--- @return string
+function text_edits.get_text_to_replace(text_edit)
+ local lines = {}
+ for line = text_edit.range.start.line, text_edit.range['end'].line do
+ local line_text = context.get_line()
+ local is_start_line = line == text_edit.range.start.line
+ local is_end_line = line == text_edit.range['end'].line
+
+ if is_start_line and is_end_line then
+ table.insert(lines, line_text:sub(text_edit.range.start.character + 1, text_edit.range['end'].character))
+ elseif is_start_line then
+ table.insert(lines, line_text:sub(text_edit.range.start.character + 1))
+ elseif is_end_line then
+ table.insert(lines, line_text:sub(1, text_edit.range['end'].character))
+ else
+ table.insert(lines, line_text)
+ end
+ end
+ return table.concat(lines, '\n')
+end
+
+------- Get -------
+
+--- Grabbed from vim.lsp.utils. Converts an offset_encoding to byte offset
+--- @param position lsp.Position
+--- @param offset_encoding? 'utf-8'|'utf-16'|'utf-32'
+--- @return number
+local function get_line_byte_from_position(position, offset_encoding)
+ local bufnr = vim.api.nvim_get_current_buf()
+ local col = position.character
+
+ -- When on the first character, we can ignore the difference between byte and character
+ if col == 0 then return 0 end
+
+ local line = vim.api.nvim_buf_get_lines(bufnr, position.line, position.line + 1, false)[1] or ''
+ if vim.fn.has('nvim-0.11.0') == 1 then
+ col = vim.str_byteindex(line, offset_encoding or 'utf-16', col, false) or 0
+ else
+ col = vim.lsp.util._str_byteindex_enc(line, col, offset_encoding or 'utf-16')
+ end
+ return math.min(col, #line)
+end
+
+--- Gets the text edit from an item, handling insert/replace ranges and converts
+--- offset encodings (utf-16 | utf-32) to utf-8
+--- @param item blink.cmp.CompletionItem
+--- @return lsp.TextEdit
+function text_edits.get_from_item(item)
+ local text_edit = vim.deepcopy(item.textEdit)
+
+ -- Guess the text edit if the item doesn't define it
+ if text_edit == nil then return text_edits.guess(item) end
+
+ -- FIXME: temporarily convert insertReplaceEdit to regular textEdit
+ if text_edit.range == nil then
+ if config.completion.keyword.range == 'full' and text_edit.replace ~= nil then
+ text_edit.range = text_edit.replace
+ else
+ text_edit.range = text_edit.insert or text_edit.replace
+ end
+ end
+ text_edit.insert = nil
+ text_edit.replace = nil
+ --- @cast text_edit lsp.TextEdit
+
+ -- Adjust the position of the text edit to be the current cursor position
+ -- since the data might be outdated. We compare the cursor column position
+ -- from when the items were fetched versus the current.
+ -- HACK: is there a better way?
+ -- TODO: take into account the offset_encoding
+ 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
+ -- TODO: we have to do this last because it applies a max on the position based on the length of the line
+ -- so it would break the offset code when removing characters at the end of the line
+ local offset_encoding = text_edits.offset_encoding_from_item(item)
+ text_edit = text_edits.to_utf_8(text_edit, offset_encoding)
+
+ text_edit.range = text_edits.clamp_range_to_bounds(text_edit.range)
+
+ return text_edit
+end
+
+function text_edits.offset_encoding_from_item(item)
+ local client = vim.lsp.get_client_by_id(item.client_id)
+ return client ~= nil and client.offset_encoding or 'utf-8'
+end
+
+function text_edits.to_utf_8(text_edit, offset_encoding)
+ if offset_encoding == 'utf-8' then return text_edit end
+ text_edit = vim.deepcopy(text_edit)
+ text_edit.range.start.character = get_line_byte_from_position(text_edit.range.start, offset_encoding)
+ text_edit.range['end'].character = get_line_byte_from_position(text_edit.range['end'], offset_encoding)
+ return text_edit
+end
+
+--- Uses the keyword_regex to guess the text edit ranges
+--- @param item blink.cmp.CompletionItem
+--- TODO: doesnt work when the item contains characters not included in the context regex
+function text_edits.guess(item)
+ local word = item.insertText or item.label
+
+ local start_col, end_col = require('blink.cmp.fuzzy').guess_edit_range(
+ item,
+ context.get_line(),
+ context.get_cursor()[2],
+ config.completion.keyword.range
+ )
+ local current_line = context.get_cursor()[1]
+
+ -- convert to 0-index
+ return {
+ range = {
+ start = { line = current_line - 1, character = start_col },
+ ['end'] = { line = current_line - 1, character = end_col },
+ },
+ newText = word,
+ }
+end
+
+--- Clamps the range to the bounds of their respective lines
+--- @param range lsp.Range
+--- @return lsp.Range
+--- TODO: clamp start and end lines
+function text_edits.clamp_range_to_bounds(range)
+ range = vim.deepcopy(range)
+
+ 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 = context.get_line(range['end'].line)
+ range['end'].character = math.min(
+ math.max(range['end'].character, range.start.line == range['end'].line and range.start.character or 0),
+ #end_line
+ )
+
+ return range
+end
+
+return text_edits
diff --git a/lua/blink/cmp/lib/utils.lua b/lua/blink/cmp/lib/utils.lua
new file mode 100644
index 0000000..84bcc3d
--- /dev/null
+++ b/lua/blink/cmp/lib/utils.lua
@@ -0,0 +1,112 @@
+local utils = {}
+
+--- Shallow copy table
+--- @generic T
+--- @param t T
+--- @return T
+function utils.shallow_copy(t)
+ local t2 = {}
+ for k, v in pairs(t) do
+ t2[k] = v
+ end
+ return t2
+end
+
+--- Returns the union of the keys of two tables
+--- @generic T
+--- @param t1 T[]
+--- @param t2 T[]
+--- @return T[]
+function utils.union_keys(t1, t2)
+ local t3 = {}
+ for k, _ in pairs(t1) do
+ t3[k] = true
+ end
+ for k, _ in pairs(t2) do
+ t3[k] = true
+ end
+ return vim.tbl_keys(t3)
+end
+
+--- Returns a list of unique values from the input array
+--- @generic T
+--- @param arr T[]
+--- @return T[]
+function utils.deduplicate(arr)
+ local hash = {}
+ for _, v in ipairs(arr) do
+ hash[v] = true
+ end
+ return vim.tbl_keys(hash)
+end
+
+function utils.schedule_if_needed(fn)
+ if vim.in_fast_event() then
+ vim.schedule(fn)
+ else
+ fn()
+ end
+end
+
+--- Flattens an arbitrarily deep table into a single level table
+--- @param t table
+--- @return table
+function utils.flatten(t)
+ if t[1] == nil then return t end
+
+ local flattened = {}
+ for _, v in ipairs(t) do
+ if type(v) == 'table' and vim.tbl_isempty(v) then goto continue end
+
+ if v[1] == nil then
+ table.insert(flattened, v)
+ else
+ vim.list_extend(flattened, utils.flatten(v))
+ end
+
+ ::continue::
+ end
+ return flattened
+end
+
+--- Returns the index of the first occurrence of the value in the array
+--- @generic T
+--- @param arr T[]
+--- @param val T
+--- @return number?
+function utils.index_of(arr, val)
+ for idx, v in ipairs(arr) do
+ if v == val then return idx end
+ end
+ return nil
+end
+
+--- Finds an item in an array using a predicate function
+--- @generic T
+--- @param arr T[]
+--- @param predicate fun(item: T): boolean
+--- @return number?
+function utils.find_idx(arr, predicate)
+ for idx, v in ipairs(arr) do
+ if predicate(v) then return idx end
+ end
+ return nil
+end
+
+--- Slices an array
+--- @generic T
+--- @param arr T[]
+--- @param start number?
+--- @param finish number?
+--- @return T[]
+function utils.slice(arr, start, finish)
+ start = start or 1
+ finish = finish or #arr
+ local sliced = {}
+ for i = start, finish do
+ sliced[#sliced + 1] = arr[i]
+ end
+ return sliced
+end
+
+return utils
diff --git a/lua/blink/cmp/lib/window/docs.lua b/lua/blink/cmp/lib/window/docs.lua
new file mode 100644
index 0000000..f250701
--- /dev/null
+++ b/lua/blink/cmp/lib/window/docs.lua
@@ -0,0 +1,224 @@
+local highlight_ns = require('blink.cmp.config').appearance.highlight_ns
+
+local docs = {}
+
+--- @class blink.cmp.RenderDetailAndDocumentationOpts
+--- @field bufnr number
+--- @field detail? string|string[]
+--- @field documentation? lsp.MarkupContent | string
+--- @field max_width number
+--- @field use_treesitter_highlighting boolean?
+
+--- @class blink.cmp.RenderDetailAndDocumentationOptsPartial
+--- @field bufnr? number
+--- @field detail? string
+--- @field documentation? lsp.MarkupContent | string
+--- @field max_width? number
+--- @field use_treesitter_highlighting boolean?
+
+--- @param opts blink.cmp.RenderDetailAndDocumentationOpts
+function docs.render_detail_and_documentation(opts)
+ local detail_lines = {}
+ local details = type(opts.detail) == 'string' and { opts.detail } or opts.detail or {}
+ --- @cast details string[]
+ details = require('blink.cmp.lib.utils').deduplicate(details)
+ for _, v in ipairs(details) do
+ vim.list_extend(detail_lines, docs.split_lines(v))
+ end
+
+ local doc_lines = {}
+ if opts.documentation ~= nil then
+ local doc = type(opts.documentation) == 'string' and opts.documentation or opts.documentation.value
+ doc_lines = docs.split_lines(doc)
+ end
+
+ detail_lines, doc_lines = docs.extract_detail_from_doc(detail_lines, doc_lines)
+
+ ---@type string[]
+ local combined_lines = vim.list_extend({}, detail_lines)
+
+ -- add a blank line for the --- separator
+ local doc_already_has_separator = #doc_lines > 1 and (doc_lines[1] == '---' or doc_lines[1] == '***')
+ if #detail_lines > 0 and #doc_lines > 0 then table.insert(combined_lines, '') end
+ -- skip original separator in doc_lines, so we can highlight it later
+ vim.list_extend(combined_lines, doc_lines, doc_already_has_separator and 2 or 1)
+
+ vim.api.nvim_buf_set_lines(opts.bufnr, 0, -1, true, combined_lines)
+ vim.api.nvim_set_option_value('modified', false, { buf = opts.bufnr })
+
+ -- Highlight with treesitter
+ vim.api.nvim_buf_clear_namespace(opts.bufnr, highlight_ns, 0, -1)
+
+ if #detail_lines > 0 and opts.use_treesitter_highlighting then
+ docs.highlight_with_treesitter(opts.bufnr, vim.bo.filetype, 0, #detail_lines)
+ end
+
+ -- Only add the separator if there are documentation lines (otherwise only display the detail)
+ if #detail_lines > 0 and #doc_lines > 0 then
+ vim.api.nvim_buf_set_extmark(opts.bufnr, highlight_ns, #detail_lines, 0, {
+ virt_text = { { string.rep('─', opts.max_width), 'BlinkCmpDocSeparator' } },
+ virt_text_pos = 'overlay',
+ })
+ end
+
+ if #doc_lines > 0 and opts.use_treesitter_highlighting then
+ local start = #detail_lines + (#detail_lines > 0 and 1 or 0)
+ docs.highlight_with_treesitter(opts.bufnr, 'markdown', start, start + #doc_lines)
+ end
+end
+
+--- Highlights the given range with treesitter with the given filetype
+--- @param bufnr number
+--- @param filetype string
+--- @param start_line number
+--- @param end_line number
+--- TODO: fallback to regex highlighting if treesitter fails
+--- TODO: only render what's visible
+function docs.highlight_with_treesitter(bufnr, filetype, start_line, end_line)
+ local Range = require('vim.treesitter._range')
+
+ local root_lang = vim.treesitter.language.get_lang(filetype)
+ if root_lang == nil then return end
+
+ local success, trees = pcall(vim.treesitter.get_parser, bufnr, root_lang)
+ if not success or not trees then return end
+
+ trees:parse({ start_line, end_line })
+
+ trees:for_each_tree(function(tree, tstree)
+ local lang = tstree:lang()
+ local highlighter_query = vim.treesitter.query.get(lang, 'highlights')
+ if not highlighter_query then return end
+
+ local root_node = tree:root()
+ local _, _, root_end_row, _ = root_node:range()
+
+ local iter = highlighter_query:iter_captures(tree:root(), bufnr, start_line, end_line)
+ local line = start_line
+ while line < end_line do
+ local capture, node, metadata, _ = iter(line)
+ if capture == nil then break end
+
+ local range = { root_end_row + 1, 0, root_end_row + 1, 0 }
+ if node then range = vim.treesitter.get_range(node, bufnr, metadata and metadata[capture]) end
+ local start_row, start_col, end_row, end_col = Range.unpack4(range)
+
+ if capture then
+ local name = highlighter_query.captures[capture]
+ local hl = 0
+ if not vim.startswith(name, '_') then hl = vim.api.nvim_get_hl_id_by_name('@' .. name .. '.' .. lang) end
+
+ -- The "priority" attribute can be set at the pattern level or on a particular capture
+ local priority = (
+ tonumber(metadata.priority or metadata[capture] and metadata[capture].priority)
+ or vim.highlight.priorities.treesitter
+ )
+
+ -- The "conceal" attribute can be set at the pattern level or on a particular capture
+ local conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal
+
+ if hl and end_row >= line then
+ vim.api.nvim_buf_set_extmark(bufnr, highlight_ns, start_row, start_col, {
+ end_line = end_row,
+ end_col = end_col,
+ hl_group = hl,
+ priority = priority,
+ conceal = conceal,
+ })
+ end
+ end
+
+ if start_row > line then line = start_row end
+ end
+ end)
+end
+
+--- Gets the start and end row of the code block for the given row
+--- Or returns nil if there's no code block
+--- @param lines string[]
+--- @param row number
+--- @return number?, number?
+function docs.get_code_block_range(lines, row)
+ if row < 1 or row > #lines then return end
+ -- get the start of the code block
+ local code_block_start = nil
+ for i = 1, row do
+ local line = lines[i]
+ if line:match('^%s*```') then
+ if code_block_start == nil then
+ code_block_start = i
+ else
+ code_block_start = nil
+ end
+ end
+ end
+ if code_block_start == nil then return end
+
+ -- get the end of the code block
+ local code_block_end = nil
+ for i = row, #lines do
+ local line = lines[i]
+ if line:match('^%s*```') then
+ code_block_end = i
+ break
+ end
+ end
+ if code_block_end == nil then return end
+
+ return code_block_start, code_block_end
+end
+
+--- Avoids showing the detail if it's part of the documentation
+--- or, if the detail is in a code block in the doc,
+--- extracts the code block into the detail
+---@param detail_lines string[]
+---@param doc_lines string[]
+---@return string[], string[]
+--- TODO: Also move the code block into detail if it's at the start of the doc
+--- and we have no detail
+function docs.extract_detail_from_doc(detail_lines, doc_lines)
+ local detail_str = table.concat(detail_lines, '\n')
+ local doc_str = table.concat(doc_lines, '\n')
+ local doc_str_detail_row = doc_str:find(detail_str, 1, true)
+
+ -- didn't find the detail in the doc, so return as is
+ if doc_str_detail_row == nil or #detail_str == 0 or #doc_str == 0 then return detail_lines, doc_lines end
+
+ -- get the line of the match
+ -- hack: surely there's a better way to do this but it's late
+ -- and I can't be bothered
+ local offset = 1
+ local detail_line = 1
+ for line_num, line in ipairs(doc_lines) do
+ if #line + offset > doc_str_detail_row then
+ detail_line = line_num
+ break
+ end
+ offset = offset + #line + 1
+ end
+
+ -- extract the code block, if it exists, and use it as the detail
+ local code_block_start, code_block_end = docs.get_code_block_range(doc_lines, detail_line)
+ if code_block_start ~= nil and code_block_end ~= nil then
+ detail_lines = vim.list_slice(doc_lines, code_block_start + 1, code_block_end - 1)
+
+ local doc_lines_start = vim.list_slice(doc_lines, 1, code_block_start - 1)
+ local doc_lines_end = vim.list_slice(doc_lines, code_block_end + 1, #doc_lines)
+ vim.list_extend(doc_lines_start, doc_lines_end)
+ doc_lines = doc_lines_start
+ else
+ detail_lines = {}
+ end
+
+ return detail_lines, doc_lines
+end
+
+function docs.split_lines(text)
+ local lines = {}
+ for s in text:gmatch('[^\r\n]+') do
+ table.insert(lines, s)
+ end
+ return lines
+end
+
+return docs
diff --git a/lua/blink/cmp/lib/window/init.lua b/lua/blink/cmp/lib/window/init.lua
new file mode 100644
index 0000000..9a5d18b
--- /dev/null
+++ b/lua/blink/cmp/lib/window/init.lua
@@ -0,0 +1,445 @@
+-- TODO: The scrollbar and redrawing logic should be done by wrapping the functions that would
+-- trigger a redraw or update the window
+
+--- @class blink.cmp.WindowOptions
+--- @field min_width? number
+--- @field max_width? number
+--- @field max_height? number
+--- @field cursorline? boolean
+--- @field border? blink.cmp.WindowBorder
+--- @field wrap? boolean
+--- @field winblend? number
+--- @field winhighlight? string
+--- @field scrolloff? number
+--- @field scrollbar? boolean
+--- @field filetype string
+
+--- @class blink.cmp.Window
+--- @field id? number
+--- @field buf? number
+--- @field config blink.cmp.WindowOptions
+--- @field scrollbar? blink.cmp.Scrollbar
+--- @field redraw_queued boolean
+---
+--- @field new fun(config: blink.cmp.WindowOptions): blink.cmp.Window
+--- @field get_buf fun(self: blink.cmp.Window): number
+--- @field get_win fun(self: blink.cmp.Window): number
+--- @field is_open fun(self: blink.cmp.Window): boolean
+--- @field open fun(self: blink.cmp.Window)
+--- @field close fun(self: blink.cmp.Window)
+--- @field set_option_value fun(self: blink.cmp.Window, option: string, value: any)
+--- @field update_size fun(self: blink.cmp.Window)
+--- @field get_content_height fun(self: blink.cmp.Window): number
+--- @field get_border_size fun(self: blink.cmp.Window, border?: 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | 'padded' | string[]): { vertical: number, horizontal: number, left: number, right: number, top: number, bottom: number }
+--- @field get_height fun(self: blink.cmp.Window): number
+--- @field get_content_width fun(self: blink.cmp.Window): number
+--- @field get_width fun(self: blink.cmp.Window): number
+--- @field get_cursor_screen_position fun(): { distance_from_top: number, distance_from_bottom: number }
+--- @field set_cursor fun(self: blink.cmp.Window, cursor: number[])
+--- @field set_height fun(self: blink.cmp.Window, height: number)
+--- @field set_width fun(self: blink.cmp.Window, width: number)
+--- @field set_win_config fun(self: blink.cmp.Window, config: table)
+--- @field get_vertical_direction_and_height fun(self: blink.cmp.Window, direction_priority: ("n" | "s")[]): { height: number, direction: 'n' | 's' }?
+--- @field get_direction_with_window_constraints fun(self: blink.cmp.Window, anchor_win: blink.cmp.Window, direction_priority: ("n" | "s" | "e" | "w")[], desired_min_size?: { width: number, height: number }): { width: number, height: number, direction: 'n' | 's' | 'e' | 'w' }?
+--- @field redraw_if_needed fun(self: blink.cmp.Window)
+
+--- @type blink.cmp.Window
+--- @diagnostic disable-next-line: missing-fields
+local win = {}
+
+--- @param config blink.cmp.WindowOptions
+function win.new(config)
+ local self = setmetatable({}, { __index = win })
+
+ self.id = nil
+ self.buf = nil
+ self.config = {
+ min_width = config.min_width,
+ max_width = config.max_width,
+ max_height = config.max_height or 10,
+ cursorline = config.cursorline or false,
+ border = config.border or 'none',
+ wrap = config.wrap or false,
+ winblend = config.winblend or 0,
+ winhighlight = config.winhighlight or 'Normal:NormalFloat,FloatBorder:NormalFloat',
+ scrolloff = config.scrolloff or 0,
+ scrollbar = config.scrollbar,
+ filetype = config.filetype,
+ }
+ self.redraw_queued = false
+
+ if self.config.scrollbar then
+ self.scrollbar = require('blink.cmp.lib.window.scrollbar').new({
+ enable_gutter = self.config.border == 'none' or self.config.border == 'padded',
+ })
+ end
+
+ return self
+end
+
+function win:get_buf()
+ -- create buffer if it doesn't exist
+ if self.buf == nil or not vim.api.nvim_buf_is_valid(self.buf) then
+ self.buf = vim.api.nvim_create_buf(false, true)
+ vim.api.nvim_set_option_value('tabstop', 1, { buf = self.buf }) -- prevents tab widths from being unpredictable
+ end
+ return self.buf
+end
+
+function win:get_win()
+ if self.id ~= nil and not vim.api.nvim_win_is_valid(self.id) then self.id = nil end
+ return self.id
+end
+
+function win:is_open() return self.id ~= nil and vim.api.nvim_win_is_valid(self.id) end
+
+function win:open()
+ -- window already exists
+ if self.id ~= nil and vim.api.nvim_win_is_valid(self.id) then return end
+
+ -- create window
+ self.id = vim.api.nvim_open_win(self:get_buf(), false, {
+ relative = 'cursor',
+ style = 'minimal',
+ width = self.config.min_width or 1,
+ height = self.config.max_height,
+ row = 1,
+ col = 1,
+ focusable = false,
+ zindex = 1001,
+ border = self.config.border == 'padded' and { ' ', '', '', ' ', '', '', ' ', ' ' } or self.config.border,
+ })
+ vim.api.nvim_set_option_value('winblend', self.config.winblend, { win = self.id })
+ vim.api.nvim_set_option_value('winhighlight', self.config.winhighlight, { win = self.id })
+ vim.api.nvim_set_option_value('wrap', self.config.wrap, { win = self.id })
+ vim.api.nvim_set_option_value('foldenable', false, { win = self.id })
+ vim.api.nvim_set_option_value('conceallevel', 2, { win = self.id })
+ vim.api.nvim_set_option_value('concealcursor', 'n', { win = self.id })
+ vim.api.nvim_set_option_value('cursorlineopt', 'line', { win = self.id })
+ vim.api.nvim_set_option_value('cursorline', self.config.cursorline, { win = self.id })
+ vim.api.nvim_set_option_value('scrolloff', self.config.scrolloff, { win = self.id })
+ vim.api.nvim_set_option_value('filetype', self.config.filetype, { buf = self.buf })
+
+ if self.scrollbar then self.scrollbar:update(self.id) end
+ self:redraw_if_needed()
+end
+
+function win:set_option_value(option, value)
+ if self.id == nil or not vim.api.nvim_win_is_valid(self.id) then return end
+ vim.api.nvim_set_option_value(option, value, { win = self.id })
+end
+
+function win:close()
+ if self.id ~= nil then
+ vim.api.nvim_win_close(self.id, true)
+ self.id = nil
+ end
+ if self.scrollbar then self.scrollbar:update() end
+ self:redraw_if_needed()
+end
+
+--- Updates the size of the window to match the max width and height of the content/config
+function win:update_size()
+ if not self:is_open() then return end
+ local winnr = self:get_win()
+ local config = self.config
+
+ -- todo: never go above the screen width and height
+
+ -- set width to current content width, bounded by min and max
+ local width = self:get_content_width()
+ if config.max_width then width = math.min(width, config.max_width) end
+ if config.min_width then width = math.max(width, config.min_width) end
+ vim.api.nvim_win_set_width(winnr, width)
+
+ -- set height to current line count, bounded by max
+ local height = math.min(self:get_content_height(), config.max_height)
+ vim.api.nvim_win_set_height(winnr, height)
+end
+
+-- todo: fix nvim_win_text_height
+-- @return number
+function win:get_content_height()
+ if not self:is_open() then return 0 end
+ return vim.api.nvim_win_text_height(self:get_win(), {}).all
+end
+
+--- Gets the size of the borders around the window
+--- @return { vertical: number, horizontal: number, left: number, right: number, top: number, bottom: number }
+function win:get_border_size()
+ if not self:is_open() then return { vertical = 0, horizontal = 0, left = 0, right = 0, top = 0, bottom = 0 } end
+
+ local left = 0
+ local right = 0
+ local top = 0
+ local bottom = 0
+
+ local border = self.config.border
+ if border == 'padded' then
+ left = 1
+ right = 1
+ elseif border == 'shadow' then
+ right = 1
+ bottom = 1
+ elseif type(border) == 'string' and border ~= 'none' then
+ left = 1
+ right = 1
+ top = 1
+ bottom = 1
+ elseif type(border) == 'table' then
+ -- borders can be a table of strings and act differently with different # of chars
+ -- so we normalize it: https://neovim.io/doc/user/api.html#nvim_open_win()
+ -- based on nvim-cmp
+ -- TODO: doesn't handle scrollbar
+ local resolved_border = {}
+ while #resolved_border <= 8 do
+ for _, b in ipairs(border) do
+ table.insert(resolved_border, type(b) == 'string' and b or b[1])
+ end
+ end
+
+ top = resolved_border[2] == '' and 0 or 1
+ bottom = resolved_border[6] == '' and 0 or 1
+ left = resolved_border[8] == '' and 0 or 1
+ right = resolved_border[4] == '' and 0 or 1
+ end
+
+ if self.scrollbar and self.scrollbar:is_visible() then
+ local offset = (border == 'none' or border == 'padded') and 1 or 0
+ right = right + offset
+ end
+
+ return { vertical = top + bottom, horizontal = left + right, left = left, right = right, top = top, bottom = bottom }
+end
+
+--- Gets the height of the window, taking into account the border
+function win:get_height()
+ if not self:is_open() then return 0 end
+ return vim.api.nvim_win_get_height(self:get_win()) + self:get_border_size().vertical
+end
+
+--- Gets the width of the longest line in the window
+function win:get_content_width()
+ if not self:is_open() then return 0 end
+ local max_width = 0
+ for _, line in ipairs(vim.api.nvim_buf_get_lines(self.buf, 0, -1, false)) do
+ max_width = math.max(max_width, vim.api.nvim_strwidth(line))
+ end
+ return max_width
+end
+
+--- Gets the width of the window, taking into account the border
+function win:get_width()
+ if not self:is_open() then return 0 end
+ return vim.api.nvim_win_get_width(self:get_win()) + self:get_border_size().horizontal
+end
+
+--- Gets the cursor's distance from all sides of the screen
+function win.get_cursor_screen_position()
+ local screen_height = vim.o.lines
+ local screen_width = vim.o.columns
+
+ -- command line
+ if vim.api.nvim_get_mode().mode == 'c' then
+ local config = require('blink.cmp.config').completion.menu
+ local cmdline_position = config.cmdline_position()
+
+ return {
+ distance_from_top = cmdline_position[1],
+ distance_from_bottom = screen_height - cmdline_position[1] - 1,
+ distance_from_left = cmdline_position[2],
+ distance_from_right = screen_width - cmdline_position[2],
+ }
+ end
+
+ -- default
+ local cursor_line, cursor_column = unpack(vim.api.nvim_win_get_cursor(0))
+ -- todo: convert cursor_column to byte index
+ local pos = vim.fn.screenpos(vim.api.nvim_win_get_number(0), cursor_line, cursor_column)
+
+ return {
+ distance_from_top = pos.row - 1,
+ distance_from_bottom = screen_height - pos.row,
+ distance_from_left = pos.col,
+ distance_from_right = screen_width - pos.col,
+ }
+end
+
+function win:set_cursor(cursor)
+ local winnr = self:get_win()
+ assert(winnr ~= nil, 'Window must be open to set cursor')
+
+ vim.api.nvim_win_set_cursor(winnr, cursor)
+
+ if self.scrollbar then self.scrollbar:update(winnr) end
+ self:redraw_if_needed()
+end
+
+function win:set_height(height)
+ local winnr = self:get_win()
+ assert(winnr ~= nil, 'Window must be open to set height')
+
+ vim.api.nvim_win_set_height(winnr, height)
+
+ if self.scrollbar then self.scrollbar:update(winnr) end
+ self:redraw_if_needed()
+end
+
+function win:set_width(width)
+ local winnr = self:get_win()
+ assert(winnr ~= nil, 'Window must be open to set width')
+
+ vim.api.nvim_win_set_width(winnr, width)
+
+ if self.scrollbar then self.scrollbar:update(winnr) end
+ self:redraw_if_needed()
+end
+
+function win:set_win_config(config)
+ local winnr = self:get_win()
+ assert(winnr ~= nil, 'Window must be open to set window config')
+
+ vim.api.nvim_win_set_config(winnr, config)
+
+ if self.scrollbar then self.scrollbar:update(winnr) end
+ self:redraw_if_needed()
+end
+
+--- Gets the direction with the most space available, prioritizing the directions in the order of the
+--- direction_priority list
+function win:get_vertical_direction_and_height(direction_priority)
+ local constraints = self.get_cursor_screen_position()
+ local max_height = self:get_height()
+ local border_size = self:get_border_size()
+ local function get_distance(direction)
+ return direction == 's' and constraints.distance_from_bottom or constraints.distance_from_top
+ end
+
+ local direction_priority_by_space = vim.fn.sort(vim.deepcopy(direction_priority), function(a, b)
+ local distance_a = math.min(max_height, get_distance(a))
+ local distance_b = math.min(max_height, get_distance(b))
+ return (distance_a < distance_b) and 1 or (distance_a > distance_b) and -1 or 0
+ end)
+
+ local direction = direction_priority_by_space[1]
+ local height = math.min(max_height, get_distance(direction))
+ if height <= border_size.vertical then return end
+ return { height = height - border_size.vertical, direction = direction }
+end
+
+function win:get_direction_with_window_constraints(anchor_win, direction_priority, desired_min_size)
+ local cursor_constraints = self.get_cursor_screen_position()
+
+ -- nvim.api.nvim_win_get_position doesn't return the correct position most of the time
+ -- so we calculate the position ourselves
+ local anchor_config
+ local anchor_win_config = vim.api.nvim_win_get_config(anchor_win:get_win())
+ if anchor_win_config.relative == 'win' then
+ local anchor_relative_win_position = vim.api.nvim_win_get_position(anchor_win_config.win)
+ anchor_config = {
+ row = anchor_win_config.row + anchor_relative_win_position[1] + 1,
+ col = anchor_win_config.col + anchor_relative_win_position[2] + 1,
+ }
+ elseif anchor_win_config.relative == 'editor' then
+ anchor_config = {
+ row = anchor_win_config.row + 1,
+ col = anchor_win_config.col + 1,
+ }
+ end
+ assert(anchor_config ~= nil, 'The anchor window must be relative to a window or the editor')
+
+ -- compensate for the anchor window being too wide given the screen width and configured column
+ if anchor_config.col + anchor_win_config.width > vim.o.columns then
+ anchor_config.col = vim.o.columns - anchor_win_config.width
+ end
+
+ local anchor_border_size = anchor_win:get_border_size()
+ local anchor_col = anchor_config.col - anchor_border_size.left
+ local anchor_row = anchor_config.row - anchor_border_size.top
+ local anchor_height = anchor_win:get_height()
+ local anchor_width = anchor_win:get_width()
+
+ -- we want to avoid covering the cursor line, so we need to get the direction of the window
+ -- that we're anchoring against
+ local cursor_screen_row = vim.api.nvim_get_mode().mode == 'c' and vim.o.lines - 1 or vim.fn.winline()
+ local anchor_is_above_cursor = anchor_config.row - cursor_screen_row < 0
+
+ local screen_height = vim.o.lines
+ local screen_width = vim.o.columns
+
+ local direction_constraints = {
+ n = {
+ vertical = anchor_is_above_cursor and (anchor_row - 1) or cursor_constraints.distance_from_top,
+ horizontal = screen_width - (anchor_col - 1),
+ },
+ s = {
+ vertical = anchor_is_above_cursor and cursor_constraints.distance_from_bottom
+ or (screen_height - (anchor_height + anchor_row - 1 + anchor_border_size.vertical)),
+ horizontal = screen_width - (anchor_col - 1),
+ },
+ e = {
+ vertical = anchor_is_above_cursor and cursor_constraints.distance_from_top
+ or cursor_constraints.distance_from_bottom,
+ horizontal = screen_width - (anchor_col - 1) - anchor_width - anchor_border_size.right,
+ },
+ w = {
+ vertical = anchor_is_above_cursor and cursor_constraints.distance_from_top
+ or cursor_constraints.distance_from_bottom,
+ horizontal = anchor_col - 1 + anchor_border_size.left,
+ },
+ }
+
+ local max_height = self:get_height()
+ local max_width = self:get_width()
+ local direction_priority_by_space = vim.fn.sort(vim.deepcopy(direction_priority), function(a, b)
+ local constraints_a = direction_constraints[a]
+ local constraints_b = direction_constraints[b]
+
+ local is_desired_a = desired_min_size.height <= constraints_a.vertical
+ and desired_min_size.width <= constraints_a.horizontal
+ local is_desired_b = desired_min_size.height <= constraints_b.vertical
+ and desired_min_size.width <= constraints_b.horizontal
+
+ -- If both have desired size, preserve original priority
+ if is_desired_a and is_desired_b then return 0 end
+
+ -- prioritize "a" if it has the desired size and "b" doesn't
+ if is_desired_a then return -1 end
+
+ -- prioritize "b" if it has the desired size and "a" doesn't
+ if is_desired_b then return 1 end
+
+ -- neither have the desired size, so pick based on which has the most space
+ local distance_a = math.min(max_height, constraints_a.vertical, constraints_a.horizontal)
+ local distance_b = math.min(max_height, constraints_b.vertical, constraints_b.horizontal)
+ return distance_a < distance_b and 1 or distance_a > distance_b and -1 or 0
+ end)
+
+ local border_size = self:get_border_size()
+ local direction = direction_priority_by_space[1]
+ local height = math.min(max_height, direction_constraints[direction].vertical)
+ if height <= border_size.vertical then return end
+ local width = math.min(max_width, direction_constraints[direction].horizontal)
+ if width <= border_size.horizontal then return end
+
+ return {
+ width = width - border_size.horizontal,
+ height = height - border_size.vertical,
+ direction = direction,
+ }
+end
+
+--- In cmdline mode, the window won't be redrawn automatically so we redraw ourselves on schedule
+function win:redraw_if_needed()
+ if self.redraw_queued or vim.api.nvim_get_mode().mode ~= 'c' or self:get_win() == nil then return end
+
+ -- We redraw on schedule to avoid the cmdline disappearing during redraw
+ -- and to batch multiple redraws together
+ self.redraw_queued = true
+ vim.schedule(function()
+ self.redraw_queued = false
+ vim.api.nvim__redraw({ win = self:get_win(), flush = true })
+ end)
+end
+
+return win
diff --git a/lua/blink/cmp/lib/window/scrollbar/geometry.lua b/lua/blink/cmp/lib/window/scrollbar/geometry.lua
new file mode 100644
index 0000000..ad481a0
--- /dev/null
+++ b/lua/blink/cmp/lib/window/scrollbar/geometry.lua
@@ -0,0 +1,92 @@
+--- Helper for calculating placement of the scrollbar thumb and gutter
+
+--- @class blink.cmp.ScrollbarGeometry
+--- @field width number
+--- @field height number
+--- @field row number
+--- @field col number
+--- @field zindex number
+--- @field relative string
+--- @field win number
+
+local M = {}
+
+--- @param target_win number
+--- @return number
+local function get_win_buf_height(target_win)
+ local buf = vim.api.nvim_win_get_buf(target_win)
+
+ -- not wrapping, so just get the line count
+ if not vim.wo[target_win].wrap then return vim.api.nvim_buf_line_count(buf) end
+
+ local width = vim.api.nvim_win_get_width(target_win)
+ local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
+ local height = 0
+ for _, l in ipairs(lines) do
+ height = height + math.max(1, (math.ceil(vim.fn.strwidth(l) / width)))
+ end
+ return height
+end
+
+--- @param border string|string[]
+--- @return number
+local function get_col_offset(border)
+ -- we only need an extra offset when working with a padded window
+ if type(border) == 'table' and border[1] == ' ' and border[4] == ' ' and border[7] == ' ' and border[8] == ' ' then
+ return 1
+ end
+ return 0
+end
+
+--- Gets the starting line, handling line wrapping if enabled
+--- @param target_win number
+--- @param width number
+--- @return number
+local get_content_start_line = function(target_win, width)
+ local start_line = math.max(1, vim.fn.line('w0', target_win))
+ if not vim.wo[target_win].wrap then return start_line end
+
+ local bufnr = vim.api.nvim_win_get_buf(target_win)
+ local wrapped_start_line = 1
+ for _, text in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, start_line - 1, false)) do
+ -- nvim_buf_get_lines sometimes returns a blob. see hrsh7th/nvim-cmp#2050
+ if vim.fn.type(text) == vim.v.t_blob then text = vim.fn.string(text) end
+ wrapped_start_line = wrapped_start_line + math.max(1, math.ceil(vim.fn.strdisplaywidth(text) / width))
+ end
+ return wrapped_start_line
+end
+
+--- @param target_win number
+--- @return { should_hide: boolean, thumb: blink.cmp.ScrollbarGeometry, gutter: blink.cmp.ScrollbarGeometry }
+function M.get_geometry(target_win)
+ local config = vim.api.nvim_win_get_config(target_win)
+ local width = config.width
+ local height = config.height
+ local zindex = config.zindex
+
+ local buf_height = get_win_buf_height(target_win)
+ local thumb_height = math.max(1, math.floor(height * height / buf_height + 0.5) - 1)
+
+ local start_line = get_content_start_line(target_win, width or 1)
+
+ local pct = (start_line - 1) / (buf_height - height)
+ local thumb_offset = math.floor((pct * (height - thumb_height)) + 0.5)
+ thumb_height = thumb_offset + thumb_height > height and height - thumb_offset or thumb_height
+ thumb_height = math.max(1, thumb_height)
+
+ local common_geometry = {
+ width = 1,
+ row = thumb_offset,
+ col = width + get_col_offset(config.border),
+ relative = 'win',
+ win = target_win,
+ }
+
+ return {
+ should_hide = height >= buf_height,
+ thumb = vim.tbl_deep_extend('force', common_geometry, { height = thumb_height, zindex = zindex + 2 }),
+ gutter = vim.tbl_deep_extend('force', common_geometry, { row = 0, height = height, zindex = zindex + 1 }),
+ }
+end
+
+return M
diff --git a/lua/blink/cmp/lib/window/scrollbar/init.lua b/lua/blink/cmp/lib/window/scrollbar/init.lua
new file mode 100644
index 0000000..c72615a
--- /dev/null
+++ b/lua/blink/cmp/lib/window/scrollbar/init.lua
@@ -0,0 +1,37 @@
+-- TODO: move the set_config and set_height calls from the menu/documentation/signature files
+-- to helpers in the window lib, and call scrollbar updates from there. This way, consumers of
+-- the window lib don't need to worry about scrollbars
+
+--- @class blink.cmp.ScrollbarConfig
+--- @field enable_gutter boolean
+
+--- @class blink.cmp.Scrollbar
+--- @field win blink.cmp.ScrollbarWin
+---
+--- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.Scrollbar
+--- @field is_visible fun(self: blink.cmp.Scrollbar): boolean
+--- @field update fun(self: blink.cmp.Scrollbar, target_win: number | nil)
+
+--- @type blink.cmp.Scrollbar
+--- @diagnostic disable-next-line: missing-fields
+local scrollbar = {}
+
+function scrollbar.new(opts)
+ local self = setmetatable({}, { __index = scrollbar })
+ self.win = require('blink.cmp.lib.window.scrollbar.win').new(opts)
+ return self
+end
+
+function scrollbar:is_visible() return self.win:is_visible() end
+
+function scrollbar:update(target_win)
+ if target_win == nil or not vim.api.nvim_win_is_valid(target_win) then return self.win:hide() end
+
+ local geometry = require('blink.cmp.lib.window.scrollbar.geometry').get_geometry(target_win)
+ if geometry.should_hide then return self.win:hide() end
+
+ self.win:show_thumb(geometry.thumb)
+ self.win:show_gutter(geometry.gutter)
+end
+
+return scrollbar
diff --git a/lua/blink/cmp/lib/window/scrollbar/win.lua b/lua/blink/cmp/lib/window/scrollbar/win.lua
new file mode 100644
index 0000000..9ac3193
--- /dev/null
+++ b/lua/blink/cmp/lib/window/scrollbar/win.lua
@@ -0,0 +1,107 @@
+--- Manages creating/updating scrollbar gutter and thumb windows
+
+--- @class blink.cmp.ScrollbarWin
+--- @field enable_gutter boolean
+--- @field thumb_win? number
+--- @field gutter_win? number
+--- @field buf? number
+---
+--- @field new fun(opts: blink.cmp.ScrollbarConfig): blink.cmp.ScrollbarWin
+--- @field is_visible fun(self: blink.cmp.ScrollbarWin): boolean
+--- @field show_thumb fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry)
+--- @field show_gutter fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry)
+--- @field hide_thumb fun(self: blink.cmp.ScrollbarWin)
+--- @field hide_gutter fun(self: blink.cmp.ScrollbarWin)
+--- @field hide fun(self: blink.cmp.ScrollbarWin)
+--- @field _make_win fun(self: blink.cmp.ScrollbarWin, geometry: blink.cmp.ScrollbarGeometry, hl_group: string): number
+--- @field redraw_if_needed fun(self: blink.cmp.ScrollbarWin)
+
+--- @type blink.cmp.ScrollbarWin
+--- @diagnostic disable-next-line: missing-fields
+local scrollbar_win = {}
+
+function scrollbar_win.new(opts) return setmetatable(opts, { __index = scrollbar_win }) end
+
+function scrollbar_win:is_visible() return self.thumb_win ~= nil and vim.api.nvim_win_is_valid(self.thumb_win) end
+
+function scrollbar_win:show_thumb(geometry)
+ -- create window if it doesn't exist
+ if self.thumb_win == nil or not vim.api.nvim_win_is_valid(self.thumb_win) then
+ self.thumb_win = self:_make_win(geometry, 'BlinkCmpScrollBarThumb')
+ else
+ -- update with the geometry
+ local thumb_existing_config = vim.api.nvim_win_get_config(self.thumb_win)
+ local thumb_config = vim.tbl_deep_extend('force', thumb_existing_config, geometry)
+ vim.api.nvim_win_set_config(self.thumb_win, thumb_config)
+ end
+
+ self:redraw_if_needed()
+end
+
+function scrollbar_win:show_gutter(geometry)
+ if not self.enable_gutter then return end
+
+ -- create window if it doesn't exist
+ if self.gutter_win == nil or not vim.api.nvim_win_is_valid(self.gutter_win) then
+ self.gutter_win = self:_make_win(geometry, 'BlinkCmpScrollBarGutter')
+ else
+ -- update with the geometry
+ local gutter_existing_config = vim.api.nvim_win_get_config(self.gutter_win)
+ local gutter_config = vim.tbl_deep_extend('force', gutter_existing_config, geometry)
+ vim.api.nvim_win_set_config(self.gutter_win, gutter_config)
+ end
+
+ self:redraw_if_needed()
+end
+
+function scrollbar_win:hide_thumb()
+ if self.thumb_win and vim.api.nvim_win_is_valid(self.thumb_win) then
+ vim.api.nvim_win_close(self.thumb_win, true)
+ self.thumb_win = nil
+ self:redraw_if_needed()
+ end
+end
+
+function scrollbar_win:hide_gutter()
+ if self.gutter_win and vim.api.nvim_win_is_valid(self.gutter_win) then
+ vim.api.nvim_win_close(self.gutter_win, true)
+ self.gutter_win = nil
+ self:redraw_if_needed()
+ end
+end
+
+function scrollbar_win:hide()
+ self:hide_thumb()
+ self:hide_gutter()
+end
+
+function scrollbar_win:_make_win(geometry, hl_group)
+ if self.buf == nil or not vim.api.nvim_buf_is_valid(self.buf) then self.buf = vim.api.nvim_create_buf(false, true) end
+
+ local win_config = vim.tbl_deep_extend('force', geometry, {
+ style = 'minimal',
+ focusable = false,
+ noautocmd = true,
+ })
+ local win = vim.api.nvim_open_win(self.buf, false, win_config)
+ vim.api.nvim_set_option_value('winhighlight', 'Normal:' .. hl_group .. ',EndOfBuffer:' .. hl_group, { win = win })
+ return win
+end
+
+local redraw_queued = false
+function scrollbar_win:redraw_if_needed()
+ if redraw_queued or vim.api.nvim_get_mode().mode ~= 'c' then return end
+
+ redraw_queued = true
+ vim.schedule(function()
+ redraw_queued = false
+ if self.gutter_win ~= nil and vim.api.nvim_win_is_valid(self.gutter_win) then
+ vim.api.nvim__redraw({ win = self.gutter_win, flush = true })
+ end
+ if self.thumb_win ~= nil and vim.api.nvim_win_is_valid(self.thumb_win) then
+ vim.api.nvim__redraw({ win = self.thumb_win, flush = true })
+ end
+ end)
+end
+
+return scrollbar_win