summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/sources
diff options
context:
space:
mode:
Diffstat (limited to 'lua/blink/cmp/sources')
-rw-r--r--lua/blink/cmp/sources/buffer.lua118
-rw-r--r--lua/blink/cmp/sources/cmdline/constants.lua40
-rw-r--r--lua/blink/cmp/sources/cmdline/help.lua53
-rw-r--r--lua/blink/cmp/sources/cmdline/init.lua107
-rw-r--r--lua/blink/cmp/sources/lib/init.lua304
-rw-r--r--lua/blink/cmp/sources/lib/provider/config.lua46
-rw-r--r--lua/blink/cmp/sources/lib/provider/init.lua179
-rw-r--r--lua/blink/cmp/sources/lib/provider/list.lua128
-rw-r--r--lua/blink/cmp/sources/lib/provider/override.lua17
-rw-r--r--lua/blink/cmp/sources/lib/queue.lua68
-rw-r--r--lua/blink/cmp/sources/lib/tree.lua168
-rw-r--r--lua/blink/cmp/sources/lib/types.lua31
-rw-r--r--lua/blink/cmp/sources/lib/utils.lua15
-rw-r--r--lua/blink/cmp/sources/lsp/completion.lua74
-rw-r--r--lua/blink/cmp/sources/lsp/init.lua131
-rw-r--r--lua/blink/cmp/sources/path/fs.lua70
-rw-r--r--lua/blink/cmp/sources/path/init.lua89
-rw-r--r--lua/blink/cmp/sources/path/lib.lua125
-rw-r--r--lua/blink/cmp/sources/path/regex.lua10
-rw-r--r--lua/blink/cmp/sources/snippets/default/builtin.lua188
-rw-r--r--lua/blink/cmp/sources/snippets/default/init.lua65
-rw-r--r--lua/blink/cmp/sources/snippets/default/registry.lua144
-rw-r--r--lua/blink/cmp/sources/snippets/default/scan.lua94
-rw-r--r--lua/blink/cmp/sources/snippets/init.lua9
-rw-r--r--lua/blink/cmp/sources/snippets/luasnip.lua168
-rw-r--r--lua/blink/cmp/sources/snippets/mini_snippets.lua143
-rw-r--r--lua/blink/cmp/sources/snippets/utils.lua89
27 files changed, 2673 insertions, 0 deletions
diff --git a/lua/blink/cmp/sources/buffer.lua b/lua/blink/cmp/sources/buffer.lua
new file mode 100644
index 0000000..bb679f2
--- /dev/null
+++ b/lua/blink/cmp/sources/buffer.lua
@@ -0,0 +1,118 @@
+-- todo: nvim-cmp only updates the lines that got changed which is better
+-- but this is *speeeeeed* and simple. should add the better way
+-- but ensure it doesn't add too much complexity
+
+local uv = vim.uv
+
+--- @param bufnr integer
+--- @return string
+local function get_buf_text(bufnr)
+ local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
+
+ if bufnr ~= vim.api.nvim_get_current_buf() then return table.concat(lines, '\n') end
+
+ -- exclude word under the cursor for the current buffer
+ local line_number = vim.api.nvim_win_get_cursor(0)[1]
+ local column = vim.api.nvim_win_get_cursor(0)[2]
+ local line = lines[line_number]
+ local start_col = column
+ while start_col > 1 do
+ local char = line:sub(start_col, start_col)
+ if char:match('[%w_\\-]') == nil then break end
+ start_col = start_col - 1
+ end
+ local end_col = column
+ while end_col < #line do
+ local char = line:sub(end_col + 1, end_col + 1)
+ if char:match('[%w_\\-]') == nil then break end
+ end_col = end_col + 1
+ end
+ lines[line_number] = line:sub(1, start_col) .. ' ' .. line:sub(end_col + 1)
+
+ return table.concat(lines, '\n')
+end
+
+local function words_to_items(words)
+ local items = {}
+ for _, word in ipairs(words) do
+ table.insert(items, {
+ label = word,
+ kind = require('blink.cmp.types').CompletionItemKind.Text,
+ insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText,
+ insertText = word,
+ })
+ end
+ return items
+end
+
+--- @param buf_text string
+--- @param callback fun(items: blink.cmp.CompletionItem[])
+local function run_sync(buf_text, callback) callback(words_to_items(require('blink.cmp.fuzzy').get_words(buf_text))) end
+
+local function run_async(buf_text, callback)
+ local worker = uv.new_work(
+ -- must use ffi directly since the normal one requires the config which isnt present
+ function(items, cpath)
+ package.cpath = cpath
+ return table.concat(require('blink.cmp.fuzzy.rust').get_words(items), '\n')
+ end,
+ function(words)
+ local items = words_to_items(vim.split(words, '\n'))
+ vim.schedule(function() callback(items) end)
+ end
+ )
+ worker:queue(buf_text, package.cpath)
+end
+
+--- @class blink.cmp.BufferOpts
+--- @field get_bufnrs fun(): integer[]
+
+--- Public API
+
+local buffer = {}
+
+function buffer.new(opts)
+ --- @cast opts blink.cmp.BufferOpts
+
+ local self = setmetatable({}, { __index = buffer })
+ self.get_bufnrs = opts.get_bufnrs
+ or function()
+ return vim
+ .iter(vim.api.nvim_list_wins())
+ :map(function(win) return vim.api.nvim_win_get_buf(win) end)
+ :filter(function(buf) return vim.bo[buf].buftype ~= 'nofile' end)
+ :totable()
+ end
+ return self
+end
+
+function buffer:get_completions(_, callback)
+ local transformed_callback = function(items)
+ callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = items })
+ end
+
+ vim.schedule(function()
+ local bufnrs = require('blink.cmp.lib.utils').deduplicate(self.get_bufnrs())
+ local buf_texts = {}
+ for _, buf in ipairs(bufnrs) do
+ table.insert(buf_texts, get_buf_text(buf))
+ end
+ local buf_text = table.concat(buf_texts, '\n')
+
+ -- should take less than 2ms
+ if #buf_text < 20000 then
+ run_sync(buf_text, transformed_callback)
+ -- should take less than 10ms
+ elseif #buf_text < 500000 then
+ run_async(buf_text, transformed_callback)
+ -- too big so ignore
+ else
+ transformed_callback({})
+ end
+ end)
+
+ -- TODO: cancel run_async
+ return function() end
+end
+
+return buffer
diff --git a/lua/blink/cmp/sources/cmdline/constants.lua b/lua/blink/cmp/sources/cmdline/constants.lua
new file mode 100644
index 0000000..fd2ee85
--- /dev/null
+++ b/lua/blink/cmp/sources/cmdline/constants.lua
@@ -0,0 +1,40 @@
+return {
+ help_commands = {
+ 'help',
+ 'hel',
+ 'he',
+ 'h',
+ },
+ file_commands = {
+ 'edit',
+ 'e',
+ 'read',
+ 'r',
+ 'write',
+ 'w',
+ 'saveas',
+ 'sav',
+ 'split',
+ 'sp',
+ 'vsplit',
+ 'vs',
+ 'tabedit',
+ 'tabe',
+ 'badd',
+ 'bad',
+ 'next',
+ 'n',
+ 'previous',
+ 'prev',
+ 'args',
+ 'source',
+ 'so',
+ 'find',
+ 'fin',
+ 'diffsplit',
+ 'diffs',
+ 'diffpatch',
+ 'diffp',
+ 'make',
+ },
+}
diff --git a/lua/blink/cmp/sources/cmdline/help.lua b/lua/blink/cmp/sources/cmdline/help.lua
new file mode 100644
index 0000000..bae4988
--- /dev/null
+++ b/lua/blink/cmp/sources/cmdline/help.lua
@@ -0,0 +1,53 @@
+local async = require('blink.cmp.lib.async')
+
+local help = {}
+
+--- Processes a help file and returns a list of tags asynchronously
+--- @param file string
+--- @return blink.cmp.Task
+--- TODO: rewrite using async lib, shared as a library in lib/fs.lua
+local function read_tags_from_file(file)
+ return async.task.new(function(resolve)
+ vim.uv.fs_open(file, 'r', 438, function(err, fd)
+ if err or fd == nil then return resolve({}) end
+
+ -- Read file content
+ vim.uv.fs_fstat(fd, function(stat_err, stat)
+ if stat_err or stat == nil then
+ vim.uv.fs_close(fd)
+ return resolve({})
+ end
+
+ vim.uv.fs_read(fd, stat.size, 0, function(read_err, data)
+ vim.uv.fs_close(fd)
+
+ if read_err or data == nil then return resolve({}) end
+
+ -- Process the file content
+ local tags = {}
+ for line in data:gmatch('[^\r\n]+') do
+ local tag = line:match('^([^\t]+)')
+ if tag then table.insert(tags, tag) end
+ end
+
+ resolve(tags)
+ end)
+ end)
+ end)
+ end)
+end
+
+--- @param arg_prefix string
+function help.get_completions(arg_prefix)
+ local help_files = vim.api.nvim_get_runtime_file('doc/tags', true)
+
+ return async.task
+ .await_all(vim.tbl_map(read_tags_from_file, help_files))
+ :map(function(tags_arrs) return require('blink.cmp.lib.utils').flatten(tags_arrs) end)
+ :map(function(tags)
+ -- TODO: remove after adding support for fuzzy matching on custom range
+ return vim.tbl_filter(function(tag) return vim.startswith(tag, arg_prefix) end, tags)
+ end)
+end
+
+return help
diff --git a/lua/blink/cmp/sources/cmdline/init.lua b/lua/blink/cmp/sources/cmdline/init.lua
new file mode 100644
index 0000000..5ff5e11
--- /dev/null
+++ b/lua/blink/cmp/sources/cmdline/init.lua
@@ -0,0 +1,107 @@
+-- Credit goes to @hrsh7th for the code that this was based on
+-- https://github.com/hrsh7th/cmp-cmdline
+-- License: MIT
+
+local async = require('blink.cmp.lib.async')
+local constants = require('blink.cmp.sources.cmdline.constants')
+
+--- @class blink.cmp.Source
+local cmdline = {}
+
+function cmdline.new()
+ local self = setmetatable({}, { __index = cmdline })
+ self.before_line = ''
+ self.offset = -1
+ self.ctype = ''
+ self.items = {}
+ return self
+end
+
+function cmdline:get_trigger_characters() return { ' ', '.', '#', '-', '=', '/', ':' } end
+
+function cmdline:get_completions(context, callback)
+ 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_argument = 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_bounds(keyword_config.range)
+ local current_arg_prefix = current_arg:sub(1, keyword.start_col - #text_before_argument - 1)
+
+ local task = async.task
+ .empty()
+ :map(function()
+ -- Special case for help where we read all the tags ourselves
+ if vim.tbl_contains(constants.help_commands, arguments[1] or '') then
+ return require('blink.cmp.sources.cmdline.help').get_completions(current_arg_prefix)
+ end
+
+ local completions = {}
+ local completion_type = vim.fn.getcmdcompltype()
+ -- Handle custom completions explicitly, since otherwise they won't work in input() mode (getcmdtype() == '@')
+ if vim.startswith(completion_type, 'custom,') or vim.startswith(completion_type, 'customlist,') then
+ local fun = completion_type:gsub('custom,', ''):gsub('customlist,', '')
+ completions = vim.fn.call(fun, { current_arg_prefix, vim.fn.getcmdline(), vim.fn.getcmdpos() })
+ -- `custom,` type returns a string, delimited by newlines
+ if type(completions) == 'string' then completions = vim.split(completions, '\n') end
+ else
+ local query = (text_before_argument .. current_arg_prefix):gsub([[\\]], [[\\\\]])
+ completions = vim.fn.getcompletion(query, 'cmdline')
+ end
+
+ -- Special case for files, escape special characters
+ if vim.tbl_contains(constants.file_commands, arguments[1] or '') then
+ completions = vim.tbl_map(function(completion) return vim.fn.fnameescape(completion) end, completions)
+ end
+
+ return completions
+ end)
+ :map(function(completions)
+ local items = {}
+ for _, completion in ipairs(completions) do
+ local has_prefix = string.find(completion, current_arg_prefix, 1, true) == 1
+
+ -- remove prefix from the filter text
+ local filter_text = completion
+ if has_prefix then filter_text = completion:sub(#current_arg_prefix + 1) end
+
+ -- for lua, use the filter text as the label since it doesn't include the prefix
+ local label = arguments[1] == 'lua' and filter_text or completion
+
+ -- add prefix to the newText
+ local new_text = completion
+ if not has_prefix then new_text = current_arg_prefix .. completion end
+
+ table.insert(items, {
+ label = label,
+ filterText = filter_text,
+ -- move items starting with special characters to the end of the list
+ sortText = label:lower():gsub('^([!-@\\[-`])', '~%1'),
+ textEdit = {
+ newText = new_text,
+ range = {
+ start = { line = 0, character = #text_before_argument },
+ ['end'] = { line = 0, character = #text_before_argument + #current_arg },
+ },
+ },
+ kind = require('blink.cmp.types').CompletionItemKind.Property,
+ })
+ end
+
+ callback({
+ is_incomplete_backward = true,
+ is_incomplete_forward = false,
+ items = items,
+ })
+ end)
+ :catch(function(err)
+ vim.notify('Error while fetching completions: ' .. err, vim.log.levels.ERROR, { title = 'blink.cmp' })
+ callback({ is_incomplete_backward = false, is_incomplete_forward = false, items = {} })
+ end)
+
+ return function() task:cancel() end
+end
+
+return cmdline
diff --git a/lua/blink/cmp/sources/lib/init.lua b/lua/blink/cmp/sources/lib/init.lua
new file mode 100644
index 0000000..9b56cf5
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/init.lua
@@ -0,0 +1,304 @@
+local async = require('blink.cmp.lib.async')
+local config = require('blink.cmp.config')
+
+--- @class blink.cmp.Sources
+--- @field completions_queue blink.cmp.SourcesQueue | nil
+--- @field current_signature_help blink.cmp.Task | nil
+--- @field sources_registered boolean
+--- @field providers table<string, blink.cmp.SourceProvider>
+--- @field completions_emitter blink.cmp.EventEmitter<blink.cmp.SourceCompletionsEvent>
+---
+--- @field get_all_providers fun(): blink.cmp.SourceProvider[]
+--- @field get_enabled_provider_ids fun(mode: blink.cmp.Mode): string[]
+--- @field get_enabled_providers fun(mode: blink.cmp.Mode): table<string, blink.cmp.SourceProvider>
+--- @field get_provider_by_id fun(id: string): blink.cmp.SourceProvider
+--- @field get_trigger_characters fun(mode: blink.cmp.Mode): string[]
+---
+--- @field emit_completions fun(context: blink.cmp.Context, responses: table<string, blink.cmp.CompletionResponse>)
+--- @field request_completions fun(context: blink.cmp.Context)
+--- @field cancel_completions fun()
+--- @field apply_max_items_for_completions fun(context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[]
+--- @field listen_on_completions fun(callback: fun(context: blink.cmp.Context, items: blink.cmp.CompletionItem[]))
+--- @field resolve fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem): blink.cmp.Task
+--- @field execute fun(context: blink.cmp.Context, item: blink.cmp.CompletionItem): blink.cmp.Task
+---
+--- @field get_signature_help_trigger_characters fun(mode: blink.cmp.Mode): { trigger_characters: string[], retrigger_characters: string[] }
+--- @field get_signature_help fun(context: blink.cmp.SignatureHelpContext, callback: fun(signature_help: lsp.SignatureHelp | nil))
+--- @field cancel_signature_help fun()
+---
+--- @field reload fun(provider?: string)
+--- @field get_lsp_capabilities fun(override?: lsp.ClientCapabilities, include_nvim_defaults?: boolean): lsp.ClientCapabilities
+
+--- @class blink.cmp.SourceCompletionsEvent
+--- @field context blink.cmp.Context
+--- @field items table<string, blink.cmp.CompletionItem[]>
+
+--- @type blink.cmp.Sources
+--- @diagnostic disable-next-line: missing-fields
+local sources = {
+ completions_queue = nil,
+ providers = {},
+ completions_emitter = require('blink.cmp.lib.event_emitter').new('source_completions', 'BlinkCmpSourceCompletions'),
+}
+
+function sources.get_all_providers()
+ local providers = {}
+ for provider_id, _ in pairs(config.sources.providers) do
+ providers[provider_id] = sources.get_provider_by_id(provider_id)
+ end
+ return providers
+end
+
+function sources.get_enabled_provider_ids(mode)
+ local enabled_providers = mode ~= 'default' and config.sources[mode]
+ or config.sources.per_filetype[vim.bo.filetype]
+ or config.sources.default
+ if type(enabled_providers) == 'function' then return enabled_providers() end
+ --- @cast enabled_providers string[]
+ return enabled_providers
+end
+
+function sources.get_enabled_providers(mode)
+ local mode_providers = sources.get_enabled_provider_ids(mode)
+
+ --- @type table<string, blink.cmp.SourceProvider>
+ local providers = {}
+ for _, provider_id in ipairs(mode_providers) do
+ local provider = sources.get_provider_by_id(provider_id)
+ if provider:enabled() then providers[provider_id] = sources.get_provider_by_id(provider_id) end
+ end
+ return providers
+end
+
+function sources.get_provider_by_id(provider_id)
+ -- TODO: remove in v1.0
+ if not sources.providers[provider_id] and provider_id == 'luasnip' then
+ error(
+ "Luasnip has been moved to the `snippets` source, alongside a new preset system (`snippets.preset = 'luasnip'`). See the documentation for more information."
+ )
+ end
+
+ assert(
+ sources.providers[provider_id] ~= nil or config.sources.providers[provider_id] ~= nil,
+ 'Requested provider "'
+ .. provider_id
+ .. '" has not been configured. Available providers: '
+ .. vim.fn.join(vim.tbl_keys(sources.providers), ', ')
+ )
+
+ -- initialize the provider if it hasn't been initialized yet
+ if not sources.providers[provider_id] then
+ local provider_config = config.sources.providers[provider_id]
+ sources.providers[provider_id] = require('blink.cmp.sources.lib.provider').new(provider_id, provider_config)
+ end
+
+ return sources.providers[provider_id]
+end
+
+--- Completion ---
+
+function sources.get_trigger_characters(mode)
+ local providers = sources.get_enabled_providers(mode)
+ local trigger_characters = {}
+ for _, provider in pairs(providers) do
+ vim.list_extend(trigger_characters, provider:get_trigger_characters())
+ end
+ return trigger_characters
+end
+
+function sources.emit_completions(context, _items_by_provider)
+ local items_by_provider = {}
+ for id, items in pairs(_items_by_provider) do
+ if sources.providers[id]:should_show_items(context, items) then items_by_provider[id] = items end
+ end
+ sources.completions_emitter:emit({ context = context, items = items_by_provider })
+end
+
+function sources.request_completions(context)
+ -- create a new context if the id changed or if we haven't created one yet
+ if sources.completions_queue == nil or context.id ~= sources.completions_queue.id then
+ if sources.completions_queue ~= nil then sources.completions_queue:destroy() end
+ sources.completions_queue =
+ require('blink.cmp.sources.lib.queue').new(context, sources.get_all_providers(), sources.emit_completions)
+ -- send cached completions if they exist to immediately trigger updates
+ elseif sources.completions_queue:get_cached_completions() ~= nil then
+ sources.emit_completions(
+ context,
+ --- @diagnostic disable-next-line: param-type-mismatch
+ sources.completions_queue:get_cached_completions()
+ )
+ end
+
+ sources.completions_queue:get_completions(context)
+end
+
+function sources.cancel_completions()
+ if sources.completions_queue ~= nil then
+ sources.completions_queue:destroy()
+ sources.completions_queue = nil
+ end
+end
+
+--- Limits the number of items per source as configured
+function sources.apply_max_items_for_completions(context, items)
+ -- get the configured max items for each source
+ local total_items_for_sources = {}
+ local max_items_for_sources = {}
+ for id, source in pairs(sources.providers) do
+ max_items_for_sources[id] = source.config.max_items(context, items)
+ total_items_for_sources[id] = 0
+ end
+
+ -- no max items configured, return as-is
+ if #vim.tbl_keys(max_items_for_sources) == 0 then return items end
+
+ -- apply max items
+ local filtered_items = {}
+ for _, item in ipairs(items) do
+ local max_items = max_items_for_sources[item.source_id]
+ total_items_for_sources[item.source_id] = total_items_for_sources[item.source_id] + 1
+ if max_items == nil or total_items_for_sources[item.source_id] <= max_items then
+ table.insert(filtered_items, item)
+ end
+ end
+ return filtered_items
+end
+
+--- Resolve ---
+
+function sources.resolve(context, item)
+ --- @type blink.cmp.SourceProvider?
+ local item_source = nil
+ for _, source in pairs(sources.providers) do
+ if source.id == item.source_id then
+ item_source = source
+ break
+ end
+ end
+ if item_source == nil then
+ return async.task.new(function(resolve) resolve(item) end)
+ end
+
+ return item_source
+ :resolve(context, item)
+ :catch(function(err) vim.print('failed to resolve item with error: ' .. err) end)
+end
+
+--- Execute ---
+
+function sources.execute(context, item)
+ local item_source = nil
+ for _, source in pairs(sources.providers) do
+ if source.id == item.source_id then
+ item_source = source
+ break
+ end
+ end
+ if item_source == nil then
+ return async.task.new(function(resolve) resolve() end)
+ end
+
+ return item_source
+ :execute(context, item)
+ :catch(function(err) vim.print('failed to execute item with error: ' .. err) end)
+end
+
+--- Signature help ---
+
+function sources.get_signature_help_trigger_characters(mode)
+ local trigger_characters = {}
+ local retrigger_characters = {}
+
+ -- todo: should this be all sources? or should it follow fallbacks?
+ for _, source in pairs(sources.get_enabled_providers(mode)) do
+ local res = source:get_signature_help_trigger_characters()
+ vim.list_extend(trigger_characters, res.trigger_characters)
+ vim.list_extend(retrigger_characters, res.retrigger_characters)
+ end
+ return { trigger_characters = trigger_characters, retrigger_characters = retrigger_characters }
+end
+
+function sources.get_signature_help(context, callback)
+ local tasks = {}
+ for _, source in pairs(sources.providers) do
+ table.insert(tasks, source:get_signature_help(context))
+ end
+
+ sources.current_signature_help = async.task.await_all(tasks):map(function(signature_helps)
+ signature_helps = vim.tbl_filter(function(signature_help) return signature_help ~= nil end, signature_helps)
+ callback(signature_helps[1])
+ end)
+end
+
+function sources.cancel_signature_help()
+ if sources.current_signature_help ~= nil then
+ sources.current_signature_help:cancel()
+ sources.current_signature_help = nil
+ end
+end
+
+--- Misc ---
+
+--- For external integrations to force reloading the source
+function sources.reload(provider)
+ -- Reload specific provider
+ if provider ~= nil then
+ assert(type(provider) == 'string', 'Expected string for provider')
+ assert(
+ sources.providers[provider] ~= nil or config.sources.providers[provider] ~= nil,
+ 'Source ' .. provider .. ' does not exist'
+ )
+ if sources.providers[provider] ~= nil then sources.providers[provider]:reload() end
+ return
+ end
+
+ -- Reload all providers
+ for _, source in pairs(sources.providers) do
+ source:reload()
+ end
+end
+
+function sources.get_lsp_capabilities(override, include_nvim_defaults)
+ return vim.tbl_deep_extend('force', include_nvim_defaults and vim.lsp.protocol.make_client_capabilities() or {}, {
+ textDocument = {
+ completion = {
+ completionItem = {
+ snippetSupport = true,
+ commitCharactersSupport = false, -- todo:
+ documentationFormat = { 'markdown', 'plaintext' },
+ deprecatedSupport = true,
+ preselectSupport = false, -- todo:
+ tagSupport = { valueSet = { 1 } }, -- deprecated
+ insertReplaceSupport = true, -- todo:
+ resolveSupport = {
+ properties = {
+ 'documentation',
+ 'detail',
+ 'additionalTextEdits',
+ -- todo: support more properties? should test if it improves latency
+ },
+ },
+ insertTextModeSupport = {
+ -- todo: support adjustIndentation
+ valueSet = { 1 }, -- asIs
+ },
+ labelDetailsSupport = true,
+ },
+ completionList = {
+ itemDefaults = {
+ 'commitCharacters',
+ 'editRange',
+ 'insertTextFormat',
+ 'insertTextMode',
+ 'data',
+ },
+ },
+
+ contextSupport = true,
+ insertTextMode = 1, -- asIs
+ },
+ },
+ }, override or {})
+end
+
+return sources
diff --git a/lua/blink/cmp/sources/lib/provider/config.lua b/lua/blink/cmp/sources/lib/provider/config.lua
new file mode 100644
index 0000000..cda6a52
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/provider/config.lua
@@ -0,0 +1,46 @@
+--- @class blink.cmp.SourceProviderConfigWrapper
+--- @field new fun(config: blink.cmp.SourceProviderConfig): blink.cmp.SourceProviderConfigWrapper
+---
+--- @field name string
+--- @field module string
+--- @field enabled fun(): boolean
+--- @field async fun(ctx: blink.cmp.Context): boolean
+--- @field timeout_ms fun(ctx: blink.cmp.Context): number
+--- @field transform_items fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[]
+--- @field should_show_items fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean
+--- @field max_items? fun(ctx: blink.cmp.Context, items: blink.cmp.CompletionItem[]): number
+--- @field min_keyword_length fun(ctx: blink.cmp.Context): number
+--- @field fallbacks fun(ctx: blink.cmp.Context): string[]
+--- @field score_offset fun(ctx: blink.cmp.Context): number
+
+--- @class blink.cmp.SourceProviderConfigWrapper
+--- @diagnostic disable-next-line: missing-fields
+local wrapper = {}
+
+function wrapper.new(config)
+ local function call_or_get(fn_or_val, default)
+ if fn_or_val == nil then
+ return function() return default end
+ end
+ return function(...)
+ if type(fn_or_val) == 'function' then return fn_or_val(...) end
+ return fn_or_val
+ end
+ end
+
+ local self = setmetatable({}, { __index = config })
+ self.name = config.name
+ self.module = config.module
+ self.enabled = call_or_get(config.enabled, true)
+ self.async = call_or_get(config.async, false)
+ self.timeout_ms = call_or_get(config.timeout_ms, 2000)
+ self.transform_items = config.transform_items or function(_, items) return items end
+ self.should_show_items = call_or_get(config.should_show_items, true)
+ self.max_items = call_or_get(config.max_items, nil)
+ self.min_keyword_length = call_or_get(config.min_keyword_length, 0)
+ self.fallbacks = call_or_get(config.fallbacks, {})
+ self.score_offset = call_or_get(config.score_offset, 0)
+ return self
+end
+
+return wrapper
diff --git a/lua/blink/cmp/sources/lib/provider/init.lua b/lua/blink/cmp/sources/lib/provider/init.lua
new file mode 100644
index 0000000..0494dcc
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/provider/init.lua
@@ -0,0 +1,179 @@
+--- Wraps the sources to respect the configuration options and provide a unified interface
+--- @class blink.cmp.SourceProvider
+--- @field id string
+--- @field name string
+--- @field config blink.cmp.SourceProviderConfigWrapper
+--- @field module blink.cmp.Source
+--- @field list blink.cmp.SourceProviderList | nil
+--- @field resolve_tasks table<blink.cmp.CompletionItem, blink.cmp.Task>
+---
+--- @field new fun(id: string, config: blink.cmp.SourceProviderConfig): blink.cmp.SourceProvider
+--- @field enabled fun(self: blink.cmp.SourceProvider): boolean
+--- @field get_trigger_characters fun(self: blink.cmp.SourceProvider): string[]
+--- @field get_completions fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, on_items: fun(items: blink.cmp.CompletionItem[], is_cached: boolean))
+--- @field should_show_items fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): boolean
+--- @field transform_items fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, items: blink.cmp.CompletionItem[]): blink.cmp.CompletionItem[]
+--- @field resolve fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, item: blink.cmp.CompletionItem): blink.cmp.Task
+--- @field execute fun(self: blink.cmp.SourceProvider, context: blink.cmp.Context, item: blink.cmp.CompletionItem, callback: fun()): blink.cmp.Task
+--- @field get_signature_help_trigger_characters fun(self: blink.cmp.SourceProvider): { trigger_characters: string[], retrigger_characters: string[] }
+--- @field get_signature_help fun(self: blink.cmp.SourceProvider, context: blink.cmp.SignatureHelpContext): blink.cmp.Task
+--- @field reload (fun(self: blink.cmp.SourceProvider): nil) | nil
+
+--- @type blink.cmp.SourceProvider
+--- @diagnostic disable-next-line: missing-fields
+local source = {}
+
+local async = require('blink.cmp.lib.async')
+
+function source.new(id, config)
+ assert(type(config.name) == 'string', 'Each source in config.sources.providers must have a "name" of type string')
+ assert(type(config.module) == 'string', 'Each source in config.sources.providers must have a "module" of type string')
+
+ local self = setmetatable({}, { __index = source })
+ self.id = id
+ self.name = config.name
+ self.module = require('blink.cmp.sources.lib.provider.override').new(
+ require(config.module).new(config.opts or {}, config),
+ config.override
+ )
+ self.config = require('blink.cmp.sources.lib.provider.config').new(config)
+ self.list = nil
+ self.resolve_tasks = {}
+
+ return self
+end
+
+function source:enabled()
+ -- user defined
+ if not self.config.enabled() then return false end
+
+ -- source defined
+ if self.module.enabled == nil then return true end
+ return self.module:enabled()
+end
+
+--- Completion ---
+
+function source:get_trigger_characters()
+ if self.module.get_trigger_characters == nil then return {} end
+ return self.module:get_trigger_characters()
+end
+
+function source:get_completions(context, on_items)
+ -- return the previous successful completions if the context is the same
+ -- and the data doesn't need to be updated
+ -- or if the list is async, since we don't want to cause a flash of no items
+ if self.list ~= nil and self.list:is_valid_for_context(context) then
+ self.list:set_on_items(on_items)
+ self.list:emit(true)
+ return
+ end
+
+ -- the source indicates we should refetch when this character is typed
+ local trigger_character = context.trigger.character
+ and vim.tbl_contains(self:get_trigger_characters(), context.trigger.character)
+
+ -- The TriggerForIncompleteCompletions kind is handled by the source provider itself
+ local source_context = require('blink.cmp.lib.utils').shallow_copy(context)
+ source_context.trigger = trigger_character
+ and { kind = vim.lsp.protocol.CompletionTriggerKind.TriggerCharacter, character = context.trigger.character }
+ or { kind = vim.lsp.protocol.CompletionTriggerKind.Invoked }
+
+ local async_initial_items = self.list ~= nil and self.list.context.id == context.id and self.list.items or {}
+ if self.list ~= nil then self.list:destroy() end
+
+ self.list = require('blink.cmp.sources.lib.provider.list').new(
+ self,
+ context,
+ on_items,
+ -- HACK: if the source is async, we're not reusing the previous list and the response was marked as incomplete,
+ -- the user will see a flash of no items from the provider, since the list emits immediately. So we hack around
+ -- this for now
+ { async_initial_items = async_initial_items }
+ )
+end
+
+function source:should_show_items(context, items)
+ -- if keyword length is configured, check if the context is long enough
+ local provider_min_keyword_length = self.config.min_keyword_length(context)
+
+ -- for manual trigger, we ignore the min_keyword_length set globally, but still respect per-provider
+ local global_min_keyword_length = 0
+ if context.trigger.initial_kind ~= 'manual' then
+ local global_min_keyword_length_func_or_num = require('blink.cmp.config').sources.min_keyword_length
+ if type(global_min_keyword_length_func_or_num) == 'function' then
+ global_min_keyword_length = global_min_keyword_length_func_or_num(context)
+ else
+ global_min_keyword_length = global_min_keyword_length_func_or_num
+ end
+ end
+
+ local min_keyword_length = math.max(provider_min_keyword_length, global_min_keyword_length)
+ local current_keyword_length = context.bounds.length
+ if current_keyword_length < min_keyword_length then return false end
+
+ if self.config.should_show_items == nil then return true end
+ return self.config.should_show_items(context, items)
+end
+
+function source:transform_items(context, items)
+ if self.config.transform_items ~= nil then items = self.config.transform_items(context, items) end
+ items = require('blink.cmp.config').sources.transform_items(context, items)
+ return items
+end
+
+--- Resolve ---
+
+function source:resolve(context, item)
+ local tasks = self.resolve_tasks
+ if tasks[item] == nil or tasks[item].status == async.STATUS.CANCELLED then
+ tasks[item] = async.task.new(function(resolve)
+ if self.module.resolve == nil then return resolve(item) end
+
+ return self.module:resolve(item, function(resolved_item)
+ -- HACK: it's out of spec to update keys not in resolveSupport.properties but some LSPs do it anyway
+ local merged_item = vim.tbl_deep_extend('force', item, resolved_item or {})
+ local transformed_item = self:transform_items(context, { merged_item })[1] or merged_item
+ vim.schedule(function() resolve(transformed_item) end)
+ end)
+ end)
+ end
+ return tasks[item]
+end
+
+--- Execute ---
+
+function source:execute(context, item)
+ if self.module.execute == nil then
+ return async.task.new(function(resolve) resolve() end)
+ end
+ return async.task.new(function(resolve) self.module:execute(context, item, resolve) end)
+end
+
+--- Signature help ---
+
+function source:get_signature_help_trigger_characters()
+ if self.module.get_signature_help_trigger_characters == nil then
+ return { trigger_characters = {}, retrigger_characters = {} }
+ end
+ return self.module:get_signature_help_trigger_characters()
+end
+
+function source:get_signature_help(context)
+ return async.task.new(function(resolve)
+ if self.module.get_signature_help == nil then return resolve(nil) end
+ return self.module:get_signature_help(context, function(signature_help)
+ vim.schedule(function() resolve(signature_help) end)
+ end)
+ end)
+end
+
+--- Misc ---
+
+--- For external integrations to force reloading the source
+function source:reload()
+ if self.module.reload == nil then return end
+ self.module:reload()
+end
+
+return source
diff --git a/lua/blink/cmp/sources/lib/provider/list.lua b/lua/blink/cmp/sources/lib/provider/list.lua
new file mode 100644
index 0000000..17d4bdf
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/provider/list.lua
@@ -0,0 +1,128 @@
+--- @class blink.cmp.SourceProviderList
+--- @field provider blink.cmp.SourceProvider
+--- @field context blink.cmp.Context
+--- @field items blink.cmp.CompletionItem[]
+--- @field on_items fun(items: blink.cmp.CompletionItem[], is_cached: boolean)
+--- @field has_completed boolean
+--- @field is_incomplete_backward boolean
+--- @field is_incomplete_forward boolean
+--- @field cancel_completions? fun(): nil
+---
+--- @field new fun(provider: blink.cmp.SourceProvider,context: blink.cmp.Context, on_items: fun(items: blink.cmp.CompletionItem[], is_cached: boolean), opts: blink.cmp.SourceProviderListOpts): blink.cmp.SourceProviderList
+--- @field append fun(self: blink.cmp.SourceProviderList, response: blink.cmp.CompletionResponse)
+--- @field emit fun(self: blink.cmp.SourceProviderList, is_cached?: boolean)
+--- @field destroy fun(self: blink.cmp.SourceProviderList): nil
+--- @field set_on_items fun(self: blink.cmp.SourceProviderList, on_items: fun(items: blink.cmp.CompletionItem[], is_cached: boolean))
+--- @field is_valid_for_context fun(self: blink.cmp.SourceProviderList, context: blink.cmp.Context): boolean
+---
+--- @class blink.cmp.SourceProviderListOpts
+--- @field async_initial_items blink.cmp.CompletionItem[]
+
+--- @type blink.cmp.SourceProviderList
+--- @diagnostic disable-next-line: missing-fields
+local list = {}
+
+function list.new(provider, context, on_items, opts)
+ --- @type blink.cmp.SourceProviderList
+ local self = setmetatable({
+ provider = provider,
+ context = context,
+ items = opts.async_initial_items,
+ on_items = on_items,
+
+ has_completed = false,
+ is_incomplete_backward = true,
+ is_incomplete_forward = true,
+ }, { __index = list })
+
+ -- Immediately fetch completions
+ local default_response = {
+ is_incomplete_forward = true,
+ is_incomplete_backward = true,
+ items = {},
+ }
+ if self.provider.module.get_completions == nil then
+ self:append(default_response)
+ else
+ self.cancel_completions = self.provider.module:get_completions(
+ self.context,
+ function(response) self:append(response or default_response) end
+ )
+ end
+
+ -- if async, immediately send the default response/initial items
+ local is_async = self.provider.config.async(self.context)
+ if is_async and not self.has_completed then self:emit() end
+
+ -- if not async and timeout is set, send the default response after the timeout
+ local timeout_ms = self.provider.config.timeout_ms(self.context)
+ if not is_async and timeout_ms > 0 then
+ vim.defer_fn(function()
+ if not self.has_completed then self:append(default_response) end
+ end, timeout_ms)
+ end
+
+ return self
+end
+
+function list:append(response)
+ if self.has_completed and #response.items == 0 then return end
+
+ if not self.has_completed then
+ self.has_completed = true
+ self.is_incomplete_backward = response.is_incomplete_backward
+ self.is_incomplete_forward = response.is_incomplete_forward
+ self.items = {}
+ end
+
+ -- add metadata and default kind
+ local source_score_offset = self.provider.config.score_offset(self.context) or 0
+ for _, item in ipairs(response.items) do
+ item.score_offset = (item.score_offset or 0) + source_score_offset
+ item.cursor_column = self.context.cursor[2]
+ item.source_id = self.provider.id
+ item.source_name = self.provider.name
+ item.kind = item.kind or require('blink.cmp.types').CompletionItemKind.Property
+ end
+
+ -- combine with existing items
+ local new_items = {}
+ vim.list_extend(new_items, self.items)
+ vim.list_extend(new_items, response.items)
+ self.items = new_items
+
+ -- run provider-local and global transform_items functions
+ self.items = self.provider:transform_items(self.context, self.items)
+
+ self:emit()
+end
+
+function list:emit(is_cached)
+ if is_cached == nil then is_cached = false end
+ self.on_items(self.items, is_cached)
+end
+
+function list:destroy()
+ if self.cancel_completions ~= nil then self.cancel_completions() end
+ self.on_items = function() end
+end
+
+function list:set_on_items(on_items) self.on_items = on_items end
+
+function list:is_valid_for_context(new_context)
+ if self.context.id ~= new_context.id then return false end
+
+ -- get the text for the current and queued context
+ local old_context_query = self.context.line:sub(self.context.bounds.start_col, self.context.cursor[2])
+ local new_context_query = new_context.line:sub(new_context.bounds.start_col, new_context.cursor[2])
+
+ -- check if the texts are overlapping
+ local is_before = vim.startswith(old_context_query, new_context_query)
+ local is_after = vim.startswith(new_context_query, old_context_query)
+
+ return (is_before and not self.is_incomplete_backward)
+ or (is_after and not self.is_incomplete_forward)
+ or (is_after == is_before and not (self.is_incomplete_backward or self.is_incomplete_forward))
+end
+
+return list
diff --git a/lua/blink/cmp/sources/lib/provider/override.lua b/lua/blink/cmp/sources/lib/provider/override.lua
new file mode 100644
index 0000000..96e8edd
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/provider/override.lua
@@ -0,0 +1,17 @@
+--- @class blink.cmp.Override : blink.cmp.Source
+--- @field new fun(module: blink.cmp.Source, override_config: blink.cmp.SourceOverride): blink.cmp.Override
+
+local override = {}
+
+function override.new(module, override_config)
+ override_config = override_config or {}
+
+ return setmetatable({}, {
+ __index = function(_, key)
+ if override_config[key] ~= nil then return function(_, ...) return override_config[key](module, ...) end end
+ return module[key]
+ end,
+ })
+end
+
+return override
diff --git a/lua/blink/cmp/sources/lib/queue.lua b/lua/blink/cmp/sources/lib/queue.lua
new file mode 100644
index 0000000..3947902
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/queue.lua
@@ -0,0 +1,68 @@
+local async = require('blink.cmp.lib.async')
+
+--- @class blink.cmp.SourcesQueue
+--- @field id number
+--- @field providers table<string, blink.cmp.SourceProvider>
+--- @field request blink.cmp.Task | nil
+--- @field queued_request_context blink.cmp.Context | nil
+--- @field cached_items_by_provider table<string, blink.cmp.CompletionResponse> | nil
+--- @field on_completions_callback fun(context: blink.cmp.Context, responses: table<string, blink.cmp.CompletionResponse>)
+---
+--- @field new fun(context: blink.cmp.Context, providers: table<string, blink.cmp.SourceProvider>, on_completions_callback: fun(context: blink.cmp.Context, responses: table<string, blink.cmp.CompletionResponse>)): blink.cmp.SourcesQueue
+--- @field get_cached_completions fun(self: blink.cmp.SourcesQueue): table<string, blink.cmp.CompletionResponse> | nil
+--- @field get_completions fun(self: blink.cmp.SourcesQueue, context: blink.cmp.Context)
+--- @field destroy fun(self: blink.cmp.SourcesQueue)
+
+--- @type blink.cmp.SourcesQueue
+--- @diagnostic disable-next-line: missing-fields
+local queue = {}
+
+function queue.new(context, providers, on_completions_callback)
+ local self = setmetatable({}, { __index = queue })
+ self.id = context.id
+ self.providers = providers
+
+ self.request = nil
+ self.queued_request_context = nil
+ self.on_completions_callback = on_completions_callback
+
+ return self
+end
+
+function queue:get_cached_completions() return self.cached_items_by_provider end
+
+function queue:get_completions(context)
+ assert(context.id == self.id, 'Requested completions on a sources context with a different context ID')
+
+ if self.request ~= nil then
+ if self.request.status == async.STATUS.RUNNING then
+ self.queued_request_context = context
+ return
+ else
+ self.request:cancel()
+ end
+ end
+
+ -- Create a task to get the completions, send responses upstream
+ -- and run the queued request, if it exists
+ local tree = require('blink.cmp.sources.lib.tree').new(context, vim.tbl_values(self.providers))
+ self.request = tree:get_completions(context, function(items_by_provider)
+ self.cached_items_by_provider = items_by_provider
+ self.on_completions_callback(context, items_by_provider)
+
+ -- run the queued request, if it exists
+ local queued_context = self.queued_request_context
+ if queued_context ~= nil then
+ self.queued_request_context = nil
+ self.request:cancel()
+ self:get_completions(queued_context)
+ end
+ end)
+end
+
+function queue:destroy()
+ self.on_completions_callback = function() end
+ if self.request ~= nil then self.request:cancel() end
+end
+
+return queue
diff --git a/lua/blink/cmp/sources/lib/tree.lua b/lua/blink/cmp/sources/lib/tree.lua
new file mode 100644
index 0000000..c89d04d
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/tree.lua
@@ -0,0 +1,168 @@
+--- @class blink.cmp.SourceTreeNode
+--- @field id string
+--- @field source blink.cmp.SourceProvider
+--- @field dependencies blink.cmp.SourceTreeNode[]
+--- @field dependents blink.cmp.SourceTreeNode[]
+
+--- @class blink.cmp.SourceTree
+--- @field nodes blink.cmp.SourceTreeNode[]
+--- @field new fun(context: blink.cmp.Context, all_sources: blink.cmp.SourceProvider[]): blink.cmp.SourceTree
+--- @field get_completions fun(self: blink.cmp.SourceTree, context: blink.cmp.Context, on_items_by_provider: fun(items_by_provider: table<string, blink.cmp.CompletionItem[]>)): blink.cmp.Task
+--- @field emit_completions fun(self: blink.cmp.SourceTree, items_by_provider: table<string, blink.cmp.CompletionItem[]>, on_items_by_provider: fun(items_by_provider: table<string, blink.cmp.CompletionItem[]>)): nil
+--- @field get_top_level_nodes fun(self: blink.cmp.SourceTree): blink.cmp.SourceTreeNode[]
+--- @field detect_cycle fun(node: blink.cmp.SourceTreeNode, visited?: table<string, boolean>, path?: table<string, boolean>): boolean
+
+local utils = require('blink.cmp.lib.utils')
+local async = require('blink.cmp.lib.async')
+
+--- @type blink.cmp.SourceTree
+--- @diagnostic disable-next-line: missing-fields
+local tree = {}
+
+--- @param context blink.cmp.Context
+--- @param all_sources blink.cmp.SourceProvider[]
+function tree.new(context, all_sources)
+ -- only include enabled sources for the given context
+ local sources = vim.tbl_filter(
+ function(source) return vim.tbl_contains(context.providers, source.id) and source:enabled(context) end,
+ all_sources
+ )
+ local source_ids = vim.tbl_map(function(source) return source.id end, sources)
+
+ -- create a node for each source
+ local nodes = vim.tbl_map(
+ function(source) return { id = source.id, source = source, dependencies = {}, dependents = {} } end,
+ sources
+ )
+
+ -- build the tree
+ for idx, source in ipairs(sources) do
+ local node = nodes[idx]
+ for _, fallback_source_id in ipairs(source.config.fallbacks(context, source_ids)) do
+ local fallback_node = nodes[utils.index_of(source_ids, fallback_source_id)]
+ if fallback_node ~= nil then
+ table.insert(node.dependents, fallback_node)
+ table.insert(fallback_node.dependencies, node)
+ end
+ end
+ end
+
+ -- circular dependency check
+ for _, node in ipairs(nodes) do
+ tree.detect_cycle(node)
+ end
+
+ return setmetatable({ nodes = nodes }, { __index = tree })
+end
+
+function tree:get_completions(context, on_items_by_provider)
+ local should_push_upstream = false
+ local items_by_provider = {}
+ local is_all_cached = true
+ local nodes_falling_back = {}
+
+ --- @param node blink.cmp.SourceTreeNode
+ local function get_completions_for_node(node)
+ -- check that all the dependencies have been triggered, and are falling back
+ for _, dependency in ipairs(node.dependencies) do
+ if not nodes_falling_back[dependency.id] then return async.task.empty() end
+ end
+
+ return async.task.new(function(resolve, reject)
+ return node.source:get_completions(context, function(items, is_cached)
+ items_by_provider[node.id] = items
+ is_all_cached = is_all_cached and is_cached
+
+ if should_push_upstream then self:emit_completions(items_by_provider, on_items_by_provider) end
+ if #items ~= 0 then return resolve() end
+
+ -- run dependents if the source returned 0 items
+ nodes_falling_back[node.id] = true
+ local tasks = vim.tbl_map(function(dependent) return get_completions_for_node(dependent) end, node.dependents)
+ async.task.await_all(tasks):map(resolve):catch(reject)
+ end)
+ end)
+ end
+
+ -- run the top level nodes and let them fall back to their dependents if needed
+ local tasks = vim.tbl_map(function(node) return get_completions_for_node(node) end, self:get_top_level_nodes())
+ return async.task
+ .await_all(tasks)
+ :map(function()
+ should_push_upstream = true
+
+ -- if atleast one of the results wasn't cached, emit the results
+ if not is_all_cached then self:emit_completions(items_by_provider, on_items_by_provider) end
+ end)
+ :catch(function(err) vim.print('failed to get completions with error: ' .. err) end)
+end
+
+function tree:emit_completions(items_by_provider, on_items_by_provider)
+ local nodes_falling_back = {}
+ local final_items_by_provider = {}
+
+ local add_node_items
+ add_node_items = function(node)
+ for _, dependency in ipairs(node.dependencies) do
+ if not nodes_falling_back[dependency.id] then return end
+ end
+ local items = items_by_provider[node.id]
+ if items ~= nil and #items > 0 then
+ final_items_by_provider[node.id] = items
+ else
+ nodes_falling_back[node.id] = true
+ for _, dependent in ipairs(node.dependents) do
+ add_node_items(dependent)
+ end
+ end
+ end
+
+ for _, node in ipairs(self:get_top_level_nodes()) do
+ add_node_items(node)
+ end
+
+ on_items_by_provider(final_items_by_provider)
+end
+
+--- Internal ---
+
+function tree:get_top_level_nodes()
+ local top_level_nodes = {}
+ for _, node in ipairs(self.nodes) do
+ if #node.dependencies == 0 then table.insert(top_level_nodes, node) end
+ end
+ return top_level_nodes
+end
+
+--- Helper function to detect cycles using DFS
+--- @param node blink.cmp.SourceTreeNode
+--- @param visited? table<string, boolean>
+--- @param path? table<string, boolean>
+--- @return boolean
+function tree.detect_cycle(node, visited, path)
+ visited = visited or {}
+ path = path or {}
+
+ if path[node.id] then
+ -- Found a cycle - construct the cycle path for error message
+ local cycle = { node.id }
+ for id, _ in pairs(path) do
+ table.insert(cycle, id)
+ end
+ error('Circular dependency detected: ' .. table.concat(cycle, ' -> '))
+ end
+
+ if visited[node.id] then return false end
+
+ visited[node.id] = true
+ path[node.id] = true
+
+ for _, dependent in ipairs(node.dependents) do
+ if tree.detect_cycle(dependent, visited, path) then return true end
+ end
+
+ path[node.id] = nil
+ return false
+end
+
+return tree
diff --git a/lua/blink/cmp/sources/lib/types.lua b/lua/blink/cmp/sources/lib/types.lua
new file mode 100644
index 0000000..b2bd708
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/types.lua
@@ -0,0 +1,31 @@
+--- @class blink.cmp.CompletionTriggerContext
+--- @field kind number
+--- @field character string | nil
+
+--- @class blink.cmp.CompletionResponse
+--- @field is_incomplete_forward boolean
+--- @field is_incomplete_backward boolean
+--- @field items blink.cmp.CompletionItem[]
+
+--- @class blink.cmp.Source
+--- @field new fun(opts: table, config: blink.cmp.SourceProviderConfig): blink.cmp.Source
+--- @field enabled? fun(self: blink.cmp.Source): boolean
+--- @field get_trigger_characters? fun(self: blink.cmp.Source): string[]
+--- @field get_completions? fun(self: blink.cmp.Source, context: blink.cmp.Context, callback: fun(response?: blink.cmp.CompletionResponse)): (fun(): nil) | nil
+--- @field should_show_completions? fun(self: blink.cmp.Source, context: blink.cmp.Context, response: blink.cmp.CompletionResponse): boolean
+--- @field resolve? fun(self: blink.cmp.Source, item: blink.cmp.CompletionItem, callback: fun(resolved_item?: lsp.CompletionItem)): ((fun(): nil) | nil)
+--- @field execute? fun(self: blink.cmp.Source, context: blink.cmp.Context, item: blink.cmp.CompletionItem, callback: fun()): (fun(): nil) | nil
+--- @field get_signature_help_trigger_characters? fun(self: blink.cmp.Source): string[]
+--- @field get_signature_help? fun(self: blink.cmp.Source, context: blink.cmp.SignatureHelpContext, callback: fun(signature_help: lsp.SignatureHelp | nil)): (fun(): nil) | nil
+--- @field reload? fun(self: blink.cmp.Source): nil
+
+--- @class blink.cmp.SourceOverride
+--- @field enabled? fun(self: blink.cmp.Source): boolean
+--- @field get_trigger_characters? fun(self: blink.cmp.Source): string[]
+--- @field get_completions? fun(self: blink.cmp.Source, context: blink.cmp.Context, callback: fun(response: blink.cmp.CompletionResponse | nil)): (fun(): nil) | nil
+--- @field should_show_completions? fun(self: blink.cmp.Source, context: blink.cmp.Context, response: blink.cmp.CompletionResponse): boolean
+--- @field resolve? fun(self: blink.cmp.Source, item: blink.cmp.CompletionItem, callback: fun(resolved_item: lsp.CompletionItem | nil)): ((fun(): nil) | nil)
+--- @field execute? fun(self: blink.cmp.Source, context: blink.cmp.Context, item: blink.cmp.CompletionItem, callback: fun()): (fun(): nil) | nil
+--- @field get_signature_help_trigger_characters? fun(self: blink.cmp.Source): string[]
+--- @field get_signature_help? fun(self: blink.cmp.Source, context: blink.cmp.SignatureHelpContext, callback: fun(signature_help: lsp.SignatureHelp | nil)): (fun(): nil) | nil
+--- @field reload? fun(self: blink.cmp.Source): nil
diff --git a/lua/blink/cmp/sources/lib/utils.lua b/lua/blink/cmp/sources/lib/utils.lua
new file mode 100644
index 0000000..97025bb
--- /dev/null
+++ b/lua/blink/cmp/sources/lib/utils.lua
@@ -0,0 +1,15 @@
+local utils = {}
+
+--- @param item blink.cmp.CompletionItem
+--- @return lsp.CompletionItem
+function utils.blink_item_to_lsp_item(item)
+ local lsp_item = vim.deepcopy(item)
+ lsp_item.score_offset = nil
+ lsp_item.source_id = nil
+ lsp_item.source_name = nil
+ lsp_item.cursor_column = nil
+ lsp_item.client_id = nil
+ return lsp_item
+end
+
+return utils
diff --git a/lua/blink/cmp/sources/lsp/completion.lua b/lua/blink/cmp/sources/lsp/completion.lua
new file mode 100644
index 0000000..5d54280
--- /dev/null
+++ b/lua/blink/cmp/sources/lsp/completion.lua
@@ -0,0 +1,74 @@
+local async = require('blink.cmp.lib.async')
+local known_defaults = {
+ 'commitCharacters',
+ 'insertTextFormat',
+ 'insertTextMode',
+ 'data',
+}
+local CompletionTriggerKind = vim.lsp.protocol.CompletionTriggerKind
+
+local completion = {}
+
+--- @param context blink.cmp.Context
+--- @param client vim.lsp.Client
+--- @return blink.cmp.Task
+function completion.get_completion_for_client(context, client)
+ return async.task.new(function(resolve)
+ local params = vim.lsp.util.make_position_params(0, client.offset_encoding)
+ params.context = {
+ triggerKind = context.trigger.kind == 'trigger_character' and CompletionTriggerKind.TriggerCharacter
+ or CompletionTriggerKind.Invoked,
+ }
+ if context.trigger.kind == 'trigger_character' then params.context.triggerCharacter = context.trigger.character end
+
+ local _, request_id = client.request('textDocument/completion', params, function(err, result)
+ if err or result == nil then
+ resolve({ is_incomplete_forward = true, is_incomplete_backward = true, items = {} })
+ return
+ end
+
+ local items = result.items or result
+ local default_edit_range = result.itemDefaults and result.itemDefaults.editRange
+ for _, item in ipairs(items) do
+ item.client_id = client.id
+
+ -- score offset for deprecated items
+ -- todo: make configurable
+ if item.deprecated or (item.tags and vim.tbl_contains(item.tags, 1)) then item.score_offset = -2 end
+
+ -- set defaults
+ for key, value in pairs(result.itemDefaults or {}) do
+ if vim.tbl_contains(known_defaults, key) then item[key] = item[key] or value end
+ end
+ if default_edit_range and item.textEdit == nil then
+ local new_text = item.textEditText or item.insertText or item.label
+ if default_edit_range.replace ~= nil then
+ item.textEdit = {
+ replace = default_edit_range.replace,
+ insert = default_edit_range.insert,
+ newText = new_text,
+ }
+ else
+ item.textEdit = {
+ range = result.itemDefaults.editRange,
+ newText = new_text,
+ }
+ end
+ end
+ end
+
+ resolve({
+ is_incomplete_forward = result.isIncomplete or false,
+ is_incomplete_backward = true,
+ items = items,
+ })
+ end)
+
+ -- cancellation function
+ return function()
+ if request_id ~= nil then client.cancel_request(request_id) end
+ end
+ end)
+end
+
+return completion
diff --git a/lua/blink/cmp/sources/lsp/init.lua b/lua/blink/cmp/sources/lsp/init.lua
new file mode 100644
index 0000000..4dbfd8f
--- /dev/null
+++ b/lua/blink/cmp/sources/lsp/init.lua
@@ -0,0 +1,131 @@
+local async = require('blink.cmp.lib.async')
+
+--- @type blink.cmp.Source
+--- @diagnostic disable-next-line: missing-fields
+local lsp = {}
+
+function lsp.new() return setmetatable({}, { __index = lsp }) end
+
+--- Completion ---
+
+function lsp:get_trigger_characters()
+ local clients = vim.lsp.get_clients({ bufnr = 0 })
+ local trigger_characters = {}
+
+ for _, client in pairs(clients) do
+ local completion_provider = client.server_capabilities.completionProvider
+ if completion_provider and completion_provider.triggerCharacters then
+ for _, trigger_character in pairs(completion_provider.triggerCharacters) do
+ table.insert(trigger_characters, trigger_character)
+ end
+ end
+ end
+
+ return trigger_characters
+end
+
+function lsp:get_completions(context, callback)
+ local completion_lib = require('blink.cmp.sources.lsp.completion')
+ local clients = vim.tbl_filter(
+ function(client) return client.server_capabilities and client.server_capabilities.completionProvider end,
+ vim.lsp.get_clients({ bufnr = 0, method = 'textDocument/completion' })
+ )
+
+ -- TODO: implement a timeout before returning the menu as-is. In the future, it would be neat
+ -- to detect slow LSPs and consistently run them async
+ local task = async.task
+ .await_all(vim.tbl_map(function(client) return completion_lib.get_completion_for_client(context, client) end, clients))
+ :map(function(responses)
+ local final = { is_incomplete_forward = false, is_incomplete_backward = false, items = {} }
+ for _, response in ipairs(responses) do
+ final.is_incomplete_forward = final.is_incomplete_forward or response.is_incomplete_forward
+ final.is_incomplete_backward = final.is_incomplete_backward or response.is_incomplete_backward
+ vim.list_extend(final.items, response.items)
+ end
+ callback(final)
+ end)
+ return function() task:cancel() end
+end
+
+--- Resolve ---
+
+function lsp:resolve(item, callback)
+ local client = vim.lsp.get_client_by_id(item.client_id)
+ if client == nil or not client.server_capabilities.completionProvider.resolveProvider then
+ callback(item)
+ return
+ end
+
+ -- strip blink specific fields to avoid decoding errors on some LSPs
+ item = require('blink.cmp.sources.lib.utils').blink_item_to_lsp_item(item)
+
+ local success, request_id = client.request('completionItem/resolve', item, function(error, resolved_item)
+ if error or resolved_item == nil then callback(item) end
+ callback(resolved_item)
+ end)
+ if not success then callback(item) end
+ if request_id ~= nil then
+ return function() client.cancel_request(request_id) end
+ end
+end
+
+--- Signature help ---
+
+function lsp:get_signature_help_trigger_characters()
+ local clients = vim.lsp.get_clients({ bufnr = 0 })
+ local trigger_characters = {}
+ local retrigger_characters = {}
+
+ for _, client in pairs(clients) do
+ local signature_help_provider = client.server_capabilities.signatureHelpProvider
+ if signature_help_provider and signature_help_provider.triggerCharacters then
+ for _, trigger_character in pairs(signature_help_provider.triggerCharacters) do
+ table.insert(trigger_characters, trigger_character)
+ end
+ end
+ if signature_help_provider and signature_help_provider.retriggerCharacters then
+ for _, retrigger_character in pairs(signature_help_provider.retriggerCharacters) do
+ table.insert(retrigger_characters, retrigger_character)
+ end
+ end
+ end
+
+ return { trigger_characters = trigger_characters, retrigger_characters = retrigger_characters }
+end
+
+function lsp:get_signature_help(context, callback)
+ -- no providers with signature help support
+ if #vim.lsp.get_clients({ bufnr = 0, method = 'textDocument/signatureHelp' }) == 0 then
+ callback(nil)
+ return function() end
+ end
+
+ -- TODO: offset encoding is global but should be per-client
+ local first_client = vim.lsp.get_clients({ bufnr = 0 })[1]
+ local offset_encoding = first_client and first_client.offset_encoding or 'utf-16'
+
+ local params = vim.lsp.util.make_position_params(nil, offset_encoding)
+ params.context = {
+ triggerKind = context.trigger.kind,
+ triggerCharacter = context.trigger.character,
+ isRetrigger = context.is_retrigger,
+ activeSignatureHelp = context.active_signature_help,
+ }
+
+ -- otherwise, we call all clients
+ -- TODO: some LSPs never response (typescript-tools.nvim)
+ return vim.lsp.buf_request_all(0, 'textDocument/signatureHelp', params, function(result)
+ local signature_helps = {}
+ for client_id, res in pairs(result) do
+ local signature_help = res.result
+ if signature_help ~= nil then
+ signature_help.client_id = client_id
+ table.insert(signature_helps, signature_help)
+ end
+ end
+ -- todo: pick intelligently
+ callback(signature_helps[1])
+ end)
+end
+
+return lsp
diff --git a/lua/blink/cmp/sources/path/fs.lua b/lua/blink/cmp/sources/path/fs.lua
new file mode 100644
index 0000000..4ac79f0
--- /dev/null
+++ b/lua/blink/cmp/sources/path/fs.lua
@@ -0,0 +1,70 @@
+local async = require('blink.cmp.lib.async')
+local uv = vim.uv
+local fs = {}
+
+--- Scans a directory asynchronously in a loop until
+--- it finds all entries
+--- @param path string
+--- @return blink.cmp.Task
+function fs.scan_dir_async(path)
+ local max_entries = 200
+ return async.task.new(function(resolve, reject)
+ uv.fs_opendir(path, function(err, handle)
+ if err ~= nil or handle == nil then return reject(err) end
+
+ local all_entries = {}
+
+ local function read_dir()
+ uv.fs_readdir(handle, function(err, entries)
+ if err ~= nil or entries == nil then return reject(err) end
+
+ vim.list_extend(all_entries, entries)
+ if #entries == max_entries then
+ read_dir()
+ else
+ resolve(all_entries)
+ end
+ end)
+ end
+ read_dir()
+ end, max_entries)
+ end)
+end
+
+--- @param entries { name: string, type: string }[]
+--- @return blink.cmp.Task
+function fs.fs_stat_all(cwd, entries)
+ local tasks = {}
+ for _, entry in ipairs(entries) do
+ table.insert(
+ tasks,
+ async.task.new(function(resolve)
+ uv.fs_stat(cwd .. '/' .. entry.name, function(err, stat)
+ if err then return resolve(nil) end
+ resolve({ name = entry.name, type = entry.type, stat = stat })
+ end)
+ end)
+ )
+ end
+ return async.task.await_all(tasks):map(function(entries)
+ return vim.tbl_filter(function(entry) return entry ~= nil end, entries)
+ end)
+end
+
+--- @param path string
+--- @param byte_limit number
+--- @return blink.cmp.Task
+function fs.read_file(path, byte_limit)
+ return async.task.new(function(resolve, reject)
+ uv.fs_open(path, 'r', 438, function(open_err, fd)
+ if open_err or fd == nil then return reject(open_err) end
+ uv.fs_read(fd, byte_limit, 0, function(read_err, data)
+ uv.fs_close(fd, function() end)
+ if read_err or data == nil then return reject(read_err) end
+ resolve(data)
+ end)
+ end)
+ end)
+end
+
+return fs
diff --git a/lua/blink/cmp/sources/path/init.lua b/lua/blink/cmp/sources/path/init.lua
new file mode 100644
index 0000000..bb5f508
--- /dev/null
+++ b/lua/blink/cmp/sources/path/init.lua
@@ -0,0 +1,89 @@
+-- credit to https://github.com/hrsh7th/cmp-path for the original implementation
+-- and https://codeberg.org/FelipeLema/cmp-async-path for the async implementation
+
+--- @class blink.cmp.PathOpts
+--- @field trailing_slash boolean
+--- @field label_trailing_slash boolean
+--- @field get_cwd fun(context: blink.cmp.Context): string
+--- @field show_hidden_files_by_default boolean
+
+--- @class blink.cmp.Source
+--- @field opts blink.cmp.PathOpts
+local path = {}
+
+function path.new(opts)
+ local self = setmetatable({}, { __index = path })
+
+ --- @type blink.cmp.PathOpts
+ opts = vim.tbl_deep_extend('keep', opts, {
+ trailing_slash = true,
+ label_trailing_slash = true,
+ get_cwd = function(context) return vim.fn.expand(('#%d:p:h'):format(context.bufnr)) end,
+ show_hidden_files_by_default = false,
+ })
+ require('blink.cmp.config.utils').validate('sources.providers.path', {
+ trailing_slash = { opts.trailing_slash, 'boolean' },
+ label_trailing_slash = { opts.label_trailing_slash, 'boolean' },
+ get_cwd = { opts.get_cwd, 'function' },
+ show_hidden_files_by_default = { opts.show_hidden_files_by_default, 'boolean' },
+ }, opts)
+
+ self.opts = opts
+ return self
+end
+
+function path:get_trigger_characters() return { '/', '.' } end
+
+function path:get_completions(context, callback)
+ -- we use libuv, but the rest of the library expects to be synchronous
+ callback = vim.schedule_wrap(callback)
+
+ local lib = require('blink.cmp.sources.path.lib')
+
+ local dirname = lib.dirname(self.opts.get_cwd, context)
+ if not dirname then return callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = {} }) end
+
+ local include_hidden = self.opts.show_hidden_files_by_default
+ or (string.sub(context.line, context.bounds.start_col, context.bounds.start_col) == '.' and context.bounds.length == 0)
+ or (
+ string.sub(context.line, context.bounds.start_col - 1, context.bounds.start_col - 1) == '.'
+ and context.bounds.length > 0
+ )
+ lib
+ .candidates(context, dirname, include_hidden, self.opts)
+ :map(
+ function(candidates)
+ callback({ is_incomplete_forward = false, is_incomplete_backward = false, items = candidates })
+ end
+ )
+ :catch(function() callback() end)
+end
+
+function path:resolve(item, callback)
+ require('blink.cmp.sources.path.fs')
+ .read_file(item.data.full_path, 1024)
+ :map(function(content)
+ local is_binary = content:find('\0')
+
+ -- binary file
+ if is_binary then
+ item.documentation = {
+ kind = 'plaintext',
+ value = 'Binary file',
+ }
+ -- highlight with markdown
+ else
+ local ext = vim.fn.fnamemodify(item.data.path, ':e')
+ item.documentation = {
+ kind = 'markdown',
+ value = '```' .. ext .. '\n' .. content .. '```',
+ }
+ end
+
+ return item
+ end)
+ :map(function(resolved_item) callback(resolved_item) end)
+ :catch(function() callback(item) end)
+end
+
+return path
diff --git a/lua/blink/cmp/sources/path/lib.lua b/lua/blink/cmp/sources/path/lib.lua
new file mode 100644
index 0000000..53fd970
--- /dev/null
+++ b/lua/blink/cmp/sources/path/lib.lua
@@ -0,0 +1,125 @@
+local regex = require('blink.cmp.sources.path.regex')
+local lib = {}
+
+--- @param get_cwd fun(context: blink.cmp.Context): string
+--- @param context blink.cmp.Context
+function lib.dirname(get_cwd, context)
+ -- HACK: move this :sub logic into the context?
+ -- it's not obvious that you need to avoid going back a char if the start_col == end_col
+ local line_before_cursor = context.line:sub(1, context.bounds.start_col - (context.bounds.length == 0 and 1 or 0))
+ local s = regex.PATH:match_str(line_before_cursor)
+ if not s then return nil end
+
+ local dirname = string.gsub(string.sub(line_before_cursor, s + 2), regex.NAME .. '*$', '') -- exclude '/'
+ local prefix = string.sub(line_before_cursor, 1, s + 1) -- include '/'
+
+ local buf_dirname = get_cwd(context)
+ if vim.api.nvim_get_mode().mode == 'c' then buf_dirname = vim.fn.getcwd() end
+ if prefix:match('%.%./$') then return vim.fn.resolve(buf_dirname .. '/../' .. dirname) end
+ if prefix:match('%./$') or prefix:match('"$') or prefix:match("'$") then
+ return vim.fn.resolve(buf_dirname .. '/' .. dirname)
+ end
+ if prefix:match('~/$') then return vim.fn.resolve(vim.fn.expand('~') .. '/' .. dirname) end
+ local env_var_name = prefix:match('%$([%a_]+)/$')
+ if env_var_name then
+ local env_var_value = vim.fn.getenv(env_var_name)
+ if env_var_value ~= vim.NIL then return vim.fn.resolve(env_var_value .. '/' .. dirname) end
+ end
+ if prefix:match('/$') then
+ local accept = true
+ -- Ignore URL components
+ accept = accept and not prefix:match('%a/$')
+ -- Ignore URL scheme
+ accept = accept and not prefix:match('%a+:/$') and not prefix:match('%a+://$')
+ -- Ignore HTML closing tags
+ accept = accept and not prefix:match('</$')
+ -- Ignore math calculation
+ accept = accept and not prefix:match('[%d%)]%s*/$')
+ -- Ignore / comment
+ accept = accept and (not prefix:match('^[%s/]*$') or not lib.is_slash_comment())
+ if accept then return vim.fn.resolve('/' .. dirname) end
+ end
+ -- Windows drive letter (C:/)
+ if prefix:match('(%a:)[/\\]$') then return vim.fn.resolve(prefix:match('(%a:)[/\\]$') .. '/' .. dirname) end
+ return nil
+end
+
+--- @param context blink.cmp.Context
+--- @param dirname string
+--- @param include_hidden boolean
+--- @param opts table
+function lib.candidates(context, dirname, include_hidden, opts)
+ local fs = require('blink.cmp.sources.path.fs')
+ local ranges = lib.get_text_edit_ranges(context)
+ return fs.scan_dir_async(dirname)
+ :map(function(entries) return fs.fs_stat_all(dirname, entries) end)
+ :map(function(entries)
+ return vim.tbl_filter(function(entry) return include_hidden or entry.name:sub(1, 1) ~= '.' end, entries)
+ end)
+ :map(function(entries)
+ return vim.tbl_map(
+ function(entry)
+ return lib.entry_to_completion_item(
+ entry,
+ dirname,
+ entry.type == 'directory' and ranges.directory or ranges.file,
+ opts
+ )
+ end,
+ entries
+ )
+ end)
+end
+
+function lib.is_slash_comment()
+ local commentstring = vim.bo.commentstring or ''
+ local no_filetype = vim.bo.filetype == ''
+ local is_slash_comment = false
+ is_slash_comment = is_slash_comment or commentstring:match('/%*')
+ is_slash_comment = is_slash_comment or commentstring:match('//')
+ return is_slash_comment and not no_filetype
+end
+
+--- @param entry { name: string, type: string, stat: table }
+--- @param dirname string
+--- @param range lsp.Range
+--- @param opts table
+--- @return blink.cmp.CompletionItem[]
+function lib.entry_to_completion_item(entry, dirname, range, opts)
+ local is_dir = entry.type == 'directory'
+ local CompletionItemKind = require('blink.cmp.types').CompletionItemKind
+ local insert_text = is_dir and opts.trailing_slash and entry.name .. '/' or entry.name
+ return {
+ label = (opts.label_trailing_slash and is_dir) and entry.name .. '/' or entry.name,
+ kind = is_dir and CompletionItemKind.Folder or CompletionItemKind.File,
+ insertText = insert_text,
+ textEdit = { newText = insert_text, range = range },
+ sortText = (is_dir and '1' or '2') .. entry.name:lower(), -- Sort directories before files
+ data = { path = entry.name, full_path = dirname .. '/' .. entry.name, type = entry.type, stat = entry.stat },
+ }
+end
+
+--- @param context blink.cmp.Context
+--- @return { file: lsp.Range, directory: lsp.Range }
+function lib.get_text_edit_ranges(context)
+ local line_before_cursor = context.line:sub(1, context.cursor[2])
+ local next_letter_is_slash = context.line:sub(context.cursor[2] + 1, context.cursor[2] + 1) == '/'
+
+ local parts = vim.split(line_before_cursor, '/')
+ local last_part = parts[#parts]
+
+ -- TODO: return the insert and replace ranges, instead of only the insert range
+ return {
+ file = {
+ start = { line = context.cursor[1] - 1, character = context.cursor[2] - #last_part },
+ ['end'] = { line = context.cursor[1] - 1, character = context.cursor[2] },
+ },
+ directory = {
+ start = { line = context.cursor[1] - 1, character = context.cursor[2] - #last_part },
+ -- replace the slash after the cursor, if it exists
+ ['end'] = { line = context.cursor[1] - 1, character = context.cursor[2] + (next_letter_is_slash and 1 or 0) },
+ },
+ }
+end
+
+return lib
diff --git a/lua/blink/cmp/sources/path/regex.lua b/lua/blink/cmp/sources/path/regex.lua
new file mode 100644
index 0000000..af27d25
--- /dev/null
+++ b/lua/blink/cmp/sources/path/regex.lua
@@ -0,0 +1,10 @@
+local NAME_REGEX = '\\%([^/\\\\:\\*?<>\'"`\\|]\\)'
+local PATH_REGEX =
+ assert(vim.regex(([[\%(\%(/PAT*[^/\\\\:\\*?<>\'"`\\| .~]\)\|\%(/\.\.\)\)*/\zePAT*$]]):gsub('PAT', NAME_REGEX)))
+
+return {
+ --- Lua pattern for matching file names
+ NAME = '[^/\\:*?<>\'"`|]',
+ --- Vim regex for matching file paths
+ PATH = PATH_REGEX,
+}
diff --git a/lua/blink/cmp/sources/snippets/default/builtin.lua b/lua/blink/cmp/sources/snippets/default/builtin.lua
new file mode 100644
index 0000000..b66ca1c
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/default/builtin.lua
@@ -0,0 +1,188 @@
+-- credit to https://github.com/L3MON4D3 for these variables
+-- see: https://github.com/L3MON4D3/LuaSnip/blob/master/lua/luasnip/util/_builtin_vars.lua
+-- and credit to https://github.com/garymjr for his changes
+-- see: https://github.com/garymjr/nvim-snippets/blob/main/lua/snippets/utils/builtin.lua
+
+local builtin = {
+ lazy = {},
+}
+
+function builtin.lazy.TM_FILENAME() return vim.fn.expand('%:t') end
+
+function builtin.lazy.TM_FILENAME_BASE() return vim.fn.expand('%:t:s?\\.[^\\.]\\+$??') end
+
+function builtin.lazy.TM_DIRECTORY() return vim.fn.expand('%:p:h') end
+
+function builtin.lazy.TM_FILEPATH() return vim.fn.expand('%:p') end
+
+function builtin.lazy.CLIPBOARD(opts) return vim.fn.getreg(opts.clipboard_register or vim.v.register, true) end
+
+local function buf_to_ws_part()
+ local LSP_WORSKPACE_PARTS = 'LSP_WORSKPACE_PARTS'
+ local ok, ws_parts = pcall(vim.api.nvim_buf_get_var, 0, LSP_WORSKPACE_PARTS)
+ if not ok then
+ local file_path = vim.fn.expand('%:p')
+
+ for _, ws in pairs(vim.lsp.buf.list_workspace_folders()) do
+ if file_path:find(ws, 1, true) == 1 then
+ ws_parts = { ws, file_path:sub(#ws + 2, -1) }
+ break
+ end
+ end
+ -- If it can't be extracted from lsp, then we use the file path
+ if not ok and not ws_parts then ws_parts = { vim.fn.expand('%:p:h'), vim.fn.expand('%:p:t') } end
+ vim.api.nvim_buf_set_var(0, LSP_WORSKPACE_PARTS, ws_parts)
+ end
+ return ws_parts
+end
+
+function builtin.lazy.RELATIVE_FILEPATH() -- The relative (to the opened workspace or folder) file path of the current document
+ return buf_to_ws_part()[2]
+end
+
+function builtin.lazy.WORKSPACE_FOLDER() -- The path of the opened workspace or folder
+ return buf_to_ws_part()[1]
+end
+
+function builtin.lazy.WORKSPACE_NAME() -- The name of the opened workspace or folder
+ local parts = vim.split(buf_to_ws_part()[1] or '', '[\\/]')
+ return parts[#parts]
+end
+
+function builtin.lazy.CURRENT_YEAR() return os.date('%Y') end
+
+function builtin.lazy.CURRENT_YEAR_SHORT() return os.date('%y') end
+
+function builtin.lazy.CURRENT_MONTH() return os.date('%m') end
+
+function builtin.lazy.CURRENT_MONTH_NAME() return os.date('%B') end
+
+function builtin.lazy.CURRENT_MONTH_NAME_SHORT() return os.date('%b') end
+
+function builtin.lazy.CURRENT_DATE() return os.date('%d') end
+
+function builtin.lazy.CURRENT_DAY_NAME() return os.date('%A') end
+
+function builtin.lazy.CURRENT_DAY_NAME_SHORT() return os.date('%a') end
+
+function builtin.lazy.CURRENT_HOUR() return os.date('%H') end
+
+function builtin.lazy.CURRENT_MINUTE() return os.date('%M') end
+
+function builtin.lazy.CURRENT_SECOND() return os.date('%S') end
+
+function builtin.lazy.CURRENT_SECONDS_UNIX() return tostring(os.time()) end
+
+local function get_timezone_offset(ts)
+ local utcdate = os.date('!*t', ts)
+ local localdate = os.date('*t', ts)
+ localdate.isdst = false -- this is the trick
+ local diff = os.difftime(os.time(localdate), os.time(utcdate))
+ local h, m = math.modf(diff / 3600)
+ return string.format('%+.4d', 100 * h + 60 * m)
+end
+
+function builtin.lazy.CURRENT_TIMEZONE_OFFSET()
+ return get_timezone_offset(os.time()):gsub('([+-])(%d%d)(%d%d)$', '%1%2:%3')
+end
+
+math.randomseed(os.time())
+
+function builtin.lazy.RANDOM() return string.format('%06d', math.random(999999)) end
+
+function builtin.lazy.RANDOM_HEX()
+ return string.format('%06x', math.random(16777216)) --16^6
+end
+
+function builtin.lazy.UUID()
+ local random = math.random
+ local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
+ local out
+ local function subs(c)
+ local v = (((c == 'x') and random(0, 15)) or random(8, 11))
+ return string.format('%x', v)
+ end
+
+ out = template:gsub('[xy]', subs)
+ return out
+end
+
+local _comments_cache = {}
+local function buffer_comment_chars()
+ local commentstring = vim.bo.commentstring
+ if _comments_cache[commentstring] then return _comments_cache[commentstring] end
+ local comments = { '//', '/*', '*/' }
+ local placeholder = '%s'
+ local index_placeholder = commentstring:find(vim.pesc(placeholder))
+ if index_placeholder then
+ index_placeholder = index_placeholder - 1
+ if index_placeholder + #placeholder == #commentstring then
+ comments[1] = vim.trim(commentstring:sub(1, -#placeholder - 1))
+ else
+ comments[2] = vim.trim(commentstring:sub(1, index_placeholder))
+ comments[3] = vim.trim(commentstring:sub(index_placeholder + #placeholder + 1, -1))
+ end
+ end
+ _comments_cache[commentstring] = comments
+ return comments
+end
+
+function builtin.lazy.LINE_COMMENT() return buffer_comment_chars()[1] end
+
+function builtin.lazy.BLOCK_COMMENT_START() return buffer_comment_chars()[2] end
+
+function builtin.lazy.BLOCK_COMMENT_END() return buffer_comment_chars()[3] end
+
+local function get_cursor()
+ local c = vim.api.nvim_win_get_cursor(0)
+ c[1] = c[1] - 1
+ return c
+end
+
+local function get_current_line()
+ local pos = get_cursor()
+ return vim.api.nvim_buf_get_lines(0, pos[1], pos[1] + 1, false)[1]
+end
+
+local function word_under_cursor(cur, line)
+ if line == nil then return end
+
+ local ind_start = 1
+ local ind_end = #line
+
+ while true do
+ local tmp = string.find(line, '%W%w', ind_start)
+ if not tmp then break end
+ if tmp > cur[2] + 1 then break end
+ ind_start = tmp + 1
+ end
+
+ local tmp = string.find(line, '%w%W', cur[2] + 1)
+ if tmp then ind_end = tmp end
+
+ return string.sub(line, ind_start, ind_end)
+end
+
+local function get_selected_text()
+ if vim.fn.visualmode() == 'V' then return vim.fn.trim(vim.fn.getreg(vim.v.register, true), '\n', 2) end
+ return ''
+end
+
+vim.api.nvim_create_autocmd('InsertEnter', {
+ group = vim.api.nvim_create_augroup('BlinkSnippetsEagerEnter', { clear = true }),
+ callback = function()
+ builtin.eager = {}
+ builtin.eager.TM_CURRENT_LINE = get_current_line()
+ builtin.eager.TM_CURRENT_WORD = word_under_cursor(get_cursor(), builtin.eager.TM_CURRENT_LINE)
+ builtin.eager.TM_LINE_INDEX = tostring(get_cursor()[1])
+ builtin.eager.TM_LINE_NUMBER = tostring(get_cursor()[1] + 1)
+ builtin.eager.TM_SELECTED_TEXT = get_selected_text()
+ end,
+})
+
+vim.api.nvim_create_autocmd('InsertLeave', {
+ group = vim.api.nvim_create_augroup('BlinkSnippetsEagerLeave', { clear = true }),
+ callback = function() builtin.eager = nil end,
+})
+
+return builtin
diff --git a/lua/blink/cmp/sources/snippets/default/init.lua b/lua/blink/cmp/sources/snippets/default/init.lua
new file mode 100644
index 0000000..db7fece
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/default/init.lua
@@ -0,0 +1,65 @@
+--- @class blink.cmp.SnippetsOpts
+--- @field friendly_snippets? boolean
+--- @field search_paths? string[]
+--- @field global_snippets? string[]
+--- @field extended_filetypes? table<string, string[]>
+--- @field ignored_filetypes? string[]
+--- @field get_filetype? fun(context: blink.cmp.Context): string
+--- @field clipboard_register? string
+
+local snippets = {}
+
+function snippets.new(opts)
+ --- @cast opts blink.cmp.SnippetsOpts
+
+ local self = setmetatable({}, { __index = snippets })
+ --- @type table<string, blink.cmp.CompletionItem[]>
+ self.cache = {}
+ self.registry = require('blink.cmp.sources.snippets.default.registry').new(opts)
+ self.get_filetype = opts.get_filetype or function() return vim.bo.filetype end
+ return self
+end
+
+function snippets:get_completions(context, callback)
+ local filetype = self.get_filetype(context)
+ if vim.tbl_contains(self.registry.config.ignored_filetypes, filetype) then return callback() end
+
+ if not self.cache[filetype] then
+ local global_snippets = self.registry:get_global_snippets()
+ local extended_snippets = self.registry:get_extended_snippets(filetype)
+ local ft_snippets = self.registry:get_snippets_for_ft(filetype)
+ local snips = vim.list_extend({}, global_snippets)
+ vim.list_extend(snips, extended_snippets)
+ vim.list_extend(snips, ft_snippets)
+
+ self.cache[filetype] = snips
+ end
+
+ local items = vim.tbl_map(
+ function(item) return self.registry:snippet_to_completion_item(item) end,
+ self.cache[filetype]
+ )
+ callback({
+ is_incomplete_forward = false,
+ is_incomplete_backward = false,
+ items = items,
+ })
+end
+
+function snippets:resolve(item, callback)
+ local parsed_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(item.insertText)
+ local snippet = parsed_snippet and tostring(parsed_snippet) or item.insertText
+
+ local resolved_item = vim.deepcopy(item)
+ resolved_item.detail = snippet
+ resolved_item.documentation = {
+ kind = 'markdown',
+ value = item.description,
+ }
+ callback(resolved_item)
+end
+
+--- For external integrations to force reloading the snippets
+function snippets:reload() self.cache = {} end
+
+return snippets
diff --git a/lua/blink/cmp/sources/snippets/default/registry.lua b/lua/blink/cmp/sources/snippets/default/registry.lua
new file mode 100644
index 0000000..5be225c
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/default/registry.lua
@@ -0,0 +1,144 @@
+--- Credit to https://github.com/garymjr/nvim-snippets/blob/main/lua/snippets/utils/init.lua
+--- for the original implementation
+--- Original License: MIT
+
+--- @class blink.cmp.Snippet
+--- @field prefix string
+--- @field body string[] | string
+--- @field description? string
+
+local registry = {
+ builtin_vars = require('blink.cmp.sources.snippets.default.builtin'),
+}
+
+local utils = require('blink.cmp.sources.snippets.utils')
+local default_config = {
+ friendly_snippets = true,
+ search_paths = { vim.fn.stdpath('config') .. '/snippets' },
+ global_snippets = { 'all' },
+ extended_filetypes = {},
+ ignored_filetypes = {},
+ --- @type string?
+ clipboard_register = nil,
+}
+
+--- @param config blink.cmp.SnippetsOpts
+function registry.new(config)
+ local self = setmetatable({}, { __index = registry })
+ self.config = vim.tbl_deep_extend('force', default_config, config)
+ self.config.search_paths = vim.tbl_map(function(path) return vim.fs.normalize(path) end, self.config.search_paths)
+
+ if self.config.friendly_snippets then
+ for _, path in ipairs(vim.api.nvim_list_runtime_paths()) do
+ if string.match(path, 'friendly.snippets') then table.insert(self.config.search_paths, path) end
+ end
+ end
+ self.registry = require('blink.cmp.sources.snippets.default.scan').register_snippets(self.config.search_paths)
+
+ return self
+end
+
+--- @param filetype string
+--- @return blink.cmp.Snippet[]
+function registry:get_snippets_for_ft(filetype)
+ local loaded_snippets = {}
+ local files = self.registry[filetype]
+ if not files then return loaded_snippets end
+
+ files = type(files) == 'table' and files or { files }
+
+ for _, f in ipairs(files) do
+ local contents = utils.read_file(f)
+ if contents then
+ local snippets = utils.parse_json_with_error_msg(f, contents)
+ for _, key in ipairs(vim.tbl_keys(snippets)) do
+ local snippet = utils.read_snippet(snippets[key], key)
+ for _, snippet_def in pairs(snippet) do
+ table.insert(loaded_snippets, snippet_def)
+ end
+ end
+ end
+ end
+
+ return loaded_snippets
+end
+
+--- @param filetype string
+--- @return blink.cmp.Snippet[]
+function registry:get_extended_snippets(filetype)
+ local loaded_snippets = {}
+ if not filetype then return loaded_snippets end
+
+ local extended_snippets = self.config.extended_filetypes[filetype] or {}
+ for _, ft in ipairs(extended_snippets) do
+ if vim.tbl_contains(self.config.extended_filetypes, filetype) then
+ vim.list_extend(loaded_snippets, self:get_extended_snippets(ft))
+ else
+ vim.list_extend(loaded_snippets, self:get_snippets_for_ft(ft))
+ end
+ end
+ return loaded_snippets
+end
+
+--- @return blink.cmp.Snippet[]
+function registry:get_global_snippets()
+ local loaded_snippets = {}
+ local global_snippets = self.config.global_snippets
+ for _, ft in ipairs(global_snippets) do
+ if vim.tbl_contains(self.config.extended_filetypes, ft) then
+ vim.list_extend(loaded_snippets, self:get_extended_snippets(ft))
+ else
+ vim.list_extend(loaded_snippets, self:get_snippets_for_ft(ft))
+ end
+ end
+ return loaded_snippets
+end
+
+--- @param snippet blink.cmp.Snippet
+--- @return blink.cmp.CompletionItem
+function registry:snippet_to_completion_item(snippet)
+ local body = type(snippet.body) == 'string' and snippet.body or table.concat(snippet.body, '\n')
+ return {
+ kind = require('blink.cmp.types').CompletionItemKind.Snippet,
+ label = snippet.prefix,
+ insertTextFormat = vim.lsp.protocol.InsertTextFormat.Snippet,
+ insertText = self:expand_vars(body),
+ description = snippet.description,
+ }
+end
+
+--- @param snippet string
+--- @return string
+function registry:parse_body(snippet)
+ local parse = utils.safe_parse(self:expand_vars(snippet))
+ return parse and tostring(parse) or snippet
+end
+
+--- @param snippet string
+--- @return string
+function registry:expand_vars(snippet)
+ local lazy_vars = self.builtin_vars.lazy
+ local eager_vars = self.builtin_vars.eager or {}
+
+ local resolved_snippet = snippet
+ local parsed_snippet = utils.safe_parse(snippet)
+ if not parsed_snippet then return snippet end
+
+ for _, child in ipairs(parsed_snippet.data.children) do
+ local type, data = child.type, child.data
+ if type == vim.lsp._snippet_grammar.NodeType.Variable then
+ if eager_vars[data.name] then
+ resolved_snippet = resolved_snippet:gsub('%$[{]?(' .. data.name .. ')[}]?', eager_vars[data.name])
+ elseif lazy_vars[data.name] then
+ resolved_snippet = resolved_snippet:gsub(
+ '%$[{]?(' .. data.name .. ')[}]?',
+ lazy_vars[data.name]({ clipboard_register = self.config.clipboard_register })
+ )
+ end
+ end
+ end
+
+ return resolved_snippet
+end
+
+return registry
diff --git a/lua/blink/cmp/sources/snippets/default/scan.lua b/lua/blink/cmp/sources/snippets/default/scan.lua
new file mode 100644
index 0000000..6971691
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/default/scan.lua
@@ -0,0 +1,94 @@
+local utils = require('blink.cmp.sources.snippets.utils')
+local scan = {}
+
+function scan.register_snippets(search_paths)
+ local registry = {}
+
+ for _, path in ipairs(search_paths) do
+ local files = scan.load_package_json(path) or scan.scan_for_snippets(path)
+ for ft, file in pairs(files) do
+ local key
+ if type(ft) == 'number' then
+ key = vim.fn.fnamemodify(files[ft], ':t:r')
+ else
+ key = ft
+ end
+
+ if not key then return end
+
+ registry[key] = registry[key] or {}
+ if type(file) == 'table' then
+ vim.list_extend(registry[key], file)
+ else
+ table.insert(registry[key], file)
+ end
+ end
+ end
+
+ return registry
+end
+
+---@type fun(self: utils, dir: string, result?: string[]): string[]
+---@return string[]
+function scan.scan_for_snippets(dir, result)
+ result = result or {}
+
+ local stat = vim.uv.fs_stat(dir)
+ if not stat then return result end
+
+ if stat.type == 'directory' then
+ local req = vim.uv.fs_scandir(dir)
+ if not req then return result end
+
+ local function iter() return vim.uv.fs_scandir_next(req) end
+
+ for name, ftype in iter do
+ local path = string.format('%s/%s', dir, name)
+
+ if ftype == 'directory' then
+ result[name] = scan.scan_for_snippets(path, result[name] or {})
+ else
+ scan.scan_for_snippets(path, result)
+ end
+ end
+ elseif stat.type == 'file' then
+ local name = vim.fn.fnamemodify(dir, ':t')
+
+ if name:match('%.json$') then table.insert(result, dir) end
+ elseif stat.type == 'link' then
+ local target = vim.uv.fs_readlink(dir)
+
+ if target then scan.scan_for_snippets(target, result) end
+ end
+
+ return result
+end
+
+--- This will try to load the snippets from the package.json file
+---@param path string
+function scan.load_package_json(path)
+ local file = path .. '/package.json'
+ -- todo: ideally this is async, although it takes 0.5ms on my system so it might not matter
+ local data = utils.read_file(file)
+ if not data then return end
+
+ local pkg = require('blink.cmp.sources.snippets.utils').parse_json_with_error_msg(file, data)
+
+ ---@type {path: string, language: string|string[]}[]
+ local snippets = vim.tbl_get(pkg, 'contributes', 'snippets')
+ if not snippets then return end
+
+ local ret = {} ---@type table<string, string[]>
+ for _, s in ipairs(snippets) do
+ local langs = s.language or {}
+ langs = type(langs) == 'string' and { langs } or langs
+ ---@cast langs string[]
+ for _, lang in ipairs(langs) do
+ ret[lang] = ret[lang] or {}
+ table.insert(ret[lang], vim.fs.normalize(vim.fs.joinpath(path, s.path)))
+ end
+ end
+ return ret
+end
+
+return scan
diff --git a/lua/blink/cmp/sources/snippets/init.lua b/lua/blink/cmp/sources/snippets/init.lua
new file mode 100644
index 0000000..2a4b0ba
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/init.lua
@@ -0,0 +1,9 @@
+local source = {}
+
+function source.new(opts)
+ local preset = opts.preset or require('blink.cmp.config').snippets.preset
+ local module = 'blink.cmp.sources.snippets.' .. preset
+ return require(module).new(opts)
+end
+
+return source
diff --git a/lua/blink/cmp/sources/snippets/luasnip.lua b/lua/blink/cmp/sources/snippets/luasnip.lua
new file mode 100644
index 0000000..91716d2
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/luasnip.lua
@@ -0,0 +1,168 @@
+--- @class blink.cmp.LuasnipSourceOptions
+--- @field use_show_condition? boolean Whether to use show_condition for filtering snippets
+--- @field show_autosnippets? boolean Whether to show autosnippets in the completion list
+
+--- @class blink.cmp.LuasnipSource : blink.cmp.Source
+--- @field config blink.cmp.LuasnipSourceOptions
+--- @field items_cache table<string, blink.cmp.CompletionItem[]>
+
+--- @type blink.cmp.LuasnipSource
+--- @diagnostic disable-next-line: missing-fields
+local source = {}
+
+local defaults_config = {
+ use_show_condition = true,
+ show_autosnippets = true,
+}
+
+function source.new(opts)
+ local config = vim.tbl_deep_extend('keep', opts, defaults_config)
+ require('blink.cmp.config.utils').validate('sources.providers.luasnip', {
+ use_show_condition = { config.use_show_condition, 'boolean' },
+ show_autosnippets = { config.show_autosnippets, 'boolean' },
+ }, config)
+
+ local self = setmetatable({}, { __index = source })
+ self.config = config
+ self.items_cache = {}
+
+ local luasnip_ag = vim.api.nvim_create_augroup('BlinkCmpLuaSnipReload', { clear = true })
+ vim.api.nvim_create_autocmd('User', {
+ pattern = 'LuasnipSnippetsAdded',
+ callback = function() self:reload() end,
+ group = luasnip_ag,
+ desc = 'Reset internal cache of luasnip source of blink.cmp when new snippets are added',
+ })
+ vim.api.nvim_create_autocmd('User', {
+ pattern = 'LuasnipCleanup',
+ callback = function() self:reload() end,
+ group = luasnip_ag,
+ desc = 'Reload luasnip source of blink.cmp when snippets are cleared',
+ })
+
+ return self
+end
+
+function source:enabled()
+ local ok, _ = pcall(require, 'luasnip')
+ return ok
+end
+
+function source:get_completions(ctx, callback)
+ --- @type blink.cmp.CompletionItem[]
+ local items = {}
+
+ -- gather snippets from relevant filetypes, including extensions
+ for _, ft in ipairs(require('luasnip.util.util').get_snippet_filetypes()) do
+ if self.items_cache[ft] then
+ vim.list_extend(items, self.items_cache[ft])
+ goto continue
+ end
+
+ -- cache not yet available for this filetype
+ self.items_cache[ft] = {}
+ -- Gather filetype snippets and, optionally, autosnippets
+ local snippets = require('luasnip').get_snippets(ft, { type = 'snippets' })
+ if self.config.show_autosnippets then
+ local autosnippets = require('luasnip').get_snippets(ft, { type = 'autosnippets' })
+ snippets = require('blink.cmp.lib.utils').shallow_copy(snippets)
+ vim.list_extend(snippets, autosnippets)
+ end
+ snippets = vim.tbl_filter(function(snip) return not snip.hidden end, snippets)
+
+ -- Get the max priority for use with sortText
+ local max_priority = 0
+ for _, snip in ipairs(snippets) do
+ max_priority = math.max(max_priority, snip.effective_priority or 0)
+ end
+
+ for _, snip in ipairs(snippets) do
+ -- Convert priority of 1000 (with max of 8000) to string like "00007000|||asd" for sorting
+ -- This will put high priority snippets at the top of the list, and break ties based on the trigger
+ local inversed_priority = max_priority - (snip.effective_priority or 0)
+ local sort_text = ('0'):rep(8 - #tostring(inversed_priority), '') .. inversed_priority .. '|||' .. snip.trigger
+
+ --- @type lsp.CompletionItem
+ local item = {
+ kind = require('blink.cmp.types').CompletionItemKind.Snippet,
+ label = snip.trigger,
+ insertText = snip.trigger,
+ insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText,
+ sortText = sort_text,
+ data = { snip_id = snip.id, show_condition = snip.show_condition },
+ }
+ -- populate snippet cache for this filetype
+ table.insert(self.items_cache[ft], item)
+ -- while we're at it, also populate completion items for this request
+ table.insert(items, item)
+ end
+
+ ::continue::
+ end
+
+ -- Filter items based on show_condition, if configured
+ if self.config.use_show_condition then
+ local line_to_cursor = ctx.line:sub(0, ctx.cursor[2] - 1)
+ items = vim.tbl_filter(function(item) return item.data.show_condition(line_to_cursor) end, items)
+ end
+
+ callback({
+ is_incomplete_forward = false,
+ is_incomplete_backward = false,
+ items = items,
+ context = ctx,
+ })
+end
+
+function source:resolve(item, callback)
+ local snip = require('luasnip').get_id_snippet(item.data.snip_id)
+
+ local resolved_item = vim.deepcopy(item)
+
+ local detail = snip:get_docstring()
+ if type(detail) == 'table' then detail = table.concat(detail, '\n') end
+ resolved_item.detail = detail
+
+ if snip.dscr then
+ resolved_item.documentation = {
+ kind = 'markdown',
+ value = table.concat(vim.lsp.util.convert_input_to_markdown_lines(snip.dscr), '\n'),
+ }
+ end
+
+ callback(resolved_item)
+end
+
+function source:execute(_, item)
+ local luasnip = require('luasnip')
+ local snip = luasnip.get_id_snippet(item.data.snip_id)
+
+ -- if trigger is a pattern, expand "pattern" instead of actual snippet
+ if snip.regTrig then snip = snip:get_pattern_expand_helper() end
+
+ -- get (0, 0) indexed cursor position
+ -- the completion has been accepted by this point, so ctx.cursor is out of date
+ local cursor = vim.api.nvim_win_get_cursor(0)
+ cursor[1] = cursor[1] - 1
+
+ local expand_params = snip:matches(require('luasnip.util.util').get_current_line_to_cursor())
+
+ local clear_region = {
+ from = { cursor[1], cursor[2] - #item.insertText },
+ to = cursor,
+ }
+ if expand_params ~= nil and expand_params.clear_region ~= nil then
+ clear_region = expand_params.clear_region
+ elseif expand_params ~= nil and expand_params.trigger ~= nil then
+ clear_region = {
+ from = { cursor[1], cursor[2] - #expand_params.trigger },
+ to = cursor,
+ }
+ end
+
+ luasnip.snip_expand(snip, { expand_params = expand_params, clear_region = clear_region })
+end
+
+function source:reload() self.items_cache = {} end
+
+return source
diff --git a/lua/blink/cmp/sources/snippets/mini_snippets.lua b/lua/blink/cmp/sources/snippets/mini_snippets.lua
new file mode 100644
index 0000000..3923f20
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/mini_snippets.lua
@@ -0,0 +1,143 @@
+--- @module 'mini.snippets'
+
+--- @class blink.cmp.MiniSnippetsSourceOptions
+--- @field use_items_cache? boolean completion items are cached using default mini.snippets context
+
+--- @class blink.cmp.MiniSnippetsSource : blink.cmp.Source
+--- @field config blink.cmp.MiniSnippetsSourceOptions
+--- @field items_cache table<string, blink.cmp.CompletionItem[]>
+
+--- @class blink.cmp.MiniSnippetsSnippet
+--- @field prefix string string snippet identifier.
+--- @field body string string snippet content with appropriate syntax.
+--- @field desc string string snippet description in human readable form.
+
+--- @type blink.cmp.MiniSnippetsSource
+--- @diagnostic disable-next-line: missing-fields
+local source = {}
+
+local defaults_config = {
+ --- Whether to use a cache for completion items
+ use_items_cache = true,
+}
+
+function source.new(opts)
+ local config = vim.tbl_deep_extend('keep', opts, defaults_config)
+ vim.validate({
+ use_items_cache = { config.use_items_cache, 'boolean' },
+ })
+
+ local self = setmetatable({}, { __index = source })
+ self.config = config
+ self.items_cache = {}
+ return self
+end
+
+function source:enabled()
+ ---@diagnostic disable-next-line: undefined-field
+ return _G.MiniSnippets ~= nil -- ensure that user has explicitly setup mini.snippets
+end
+
+local function to_completion_items(snippets)
+ local result = {}
+
+ for _, snip in ipairs(snippets) do
+ --- @type lsp.CompletionItem
+ local item = {
+ kind = require('blink.cmp.types').CompletionItemKind.Snippet,
+ label = snip.prefix,
+ insertText = snip.prefix,
+ insertTextFormat = vim.lsp.protocol.InsertTextFormat.PlainText,
+ data = { snip = snip },
+ }
+ table.insert(result, item)
+ end
+ return result
+end
+
+-- NOTE: Completion items are cached by default using the default 'mini.snippets' context
+--
+-- vim.b.minisnippets_config can contain buffer-local snippets.
+-- a buffer can contain code in multiple languages
+--
+-- See :h MiniSnippets.default_prepare
+--
+-- Return completion items produced from snippets either directly or from cache
+local function get_completion_items(cache)
+ if not cache then return to_completion_items(MiniSnippets.expand({ match = false, insert = false })) end
+
+ -- Compute cache id
+ local _, context = MiniSnippets.default_prepare({})
+ local id = 'buf=' .. context.buf_id .. ',lang=' .. context.lang
+
+ -- Return the completion items for this context from cache
+ if cache[id] then return cache[id] end
+
+ -- Retrieve all raw snippets in context and transform into completion items
+ local snippets = MiniSnippets.expand({ match = false, insert = false })
+ --- @cast snippets table
+ local items = to_completion_items(vim.deepcopy(snippets))
+ cache[id] = items
+
+ return items
+end
+
+function source:get_completions(ctx, callback)
+ local cache = self.config.use_items_cache and self.items_cache or nil
+
+ --- @type blink.cmp.CompletionItem[]
+ local items = get_completion_items(cache)
+ callback({
+ is_incomplete_forward = false,
+ is_incomplete_backward = false,
+ items = items,
+ context = ctx,
+ ---@diagnostic disable-next-line: missing-return
+ })
+end
+
+function source:resolve(item, callback)
+ --- @type blink.cmp.MiniSnippetsSnippet
+ local snip = item.data.snip
+
+ local desc = snip.desc
+ if desc and not item.documentation then
+ item.documentation = {
+ kind = 'markdown',
+ value = table.concat(vim.lsp.util.convert_input_to_markdown_lines(desc), '\n'),
+ }
+ end
+
+ local detail = snip.body
+ if not item.detail then
+ if type(detail) == 'table' then detail = table.concat(detail, '\n') end
+ item.detail = detail
+ end
+
+ callback(item)
+end
+
+function source:execute(_, item)
+ -- Remove the word inserted by blink and insert snippet
+ -- It's safe to assume that mode is insert during completion
+
+ --- @type blink.cmp.MiniSnippetsSnippet
+ local snip = item.data.snip
+
+ local cursor = vim.api.nvim_win_get_cursor(0)
+ cursor[1] = cursor[1] - 1 -- nvim_buf_set_text: line is zero based
+ local start_col = cursor[2] - #item.insertText
+ vim.api.nvim_buf_set_text(0, cursor[1], start_col, cursor[1], cursor[2], {})
+
+ local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
+ ---@diagnostic disable-next-line: missing-return
+ insert({ body = snip.body }) -- insert at cursor
+end
+
+-- For external integrations to force reloading the snippets
+function source:reload()
+ MiniSnippets.setup(MiniSnippets.config)
+ self.items_cache = {}
+end
+
+return source
diff --git a/lua/blink/cmp/sources/snippets/utils.lua b/lua/blink/cmp/sources/snippets/utils.lua
new file mode 100644
index 0000000..f0903b7
--- /dev/null
+++ b/lua/blink/cmp/sources/snippets/utils.lua
@@ -0,0 +1,89 @@
+local utils = {
+ parse_cache = {},
+}
+
+--- Parses the json file and notifies the user if there's an error
+---@param path string
+---@param json string
+function utils.parse_json_with_error_msg(path, json)
+ local ok, parsed = pcall(vim.json.decode, json)
+ if not ok then
+ vim.notify(
+ 'Failed to parse json file "' .. path .. '" for blink.cmp snippets. Error: ' .. parsed,
+ vim.log.levels.ERROR,
+ { title = 'blink.cmp' }
+ )
+ return {}
+ end
+ return parsed
+end
+
+---@type fun(path: string): string|nil
+function utils.read_file(path)
+ local file = io.open(path, 'r')
+ if not file then return nil end
+ local content = file:read('*a')
+ file:close()
+ return content
+end
+
+---@type fun(input: string): vim.snippet.Node<vim.snippet.SnippetData>|nil
+function utils.safe_parse(input)
+ if utils.parse_cache[input] then return utils.parse_cache[input] end
+
+ local safe, parsed = pcall(vim.lsp._snippet_grammar.parse, input)
+ if not safe then return nil end
+
+ utils.parse_cache[input] = parsed
+ return parsed
+end
+
+---@type fun(snippet: blink.cmp.Snippet, fallback: string): table
+function utils.read_snippet(snippet, fallback)
+ local snippets = {}
+ local prefix = snippet.prefix or fallback
+ local description = snippet.description or fallback
+ local body = snippet.body
+
+ if type(description) == 'table' then description = vim.fn.join(description, '') end
+
+ if type(prefix) == 'table' then
+ for _, p in ipairs(prefix) do
+ snippets[p] = {
+ prefix = p,
+ body = body,
+ description = description,
+ }
+ end
+ else
+ snippets[prefix] = {
+ prefix = prefix,
+ body = body,
+ description = description,
+ }
+ end
+ return snippets
+end
+
+function utils.get_tab_stops(snippet)
+ local expanded_snippet = require('blink.cmp.sources.snippets.utils').safe_parse(snippet)
+ if not expanded_snippet then return end
+
+ local tabstops = {}
+ local grammar = require('vim.lsp._snippet_grammar')
+ local line = 1
+ local character = 1
+ for _, child in ipairs(expanded_snippet.data.children) do
+ local lines = tostring(child) == '' and {} or vim.split(tostring(child), '\n')
+ line = line + math.max(#lines - 1, 0)
+ character = #lines == 0 and character or #lines > 1 and #lines[#lines] or (character + #lines[#lines])
+ if child.type == grammar.NodeType.Placeholder or child.type == grammar.NodeType.Tabstop then
+ table.insert(tabstops, { index = child.data.tabstop, line = line, character = character })
+ end
+ end
+
+ table.sort(tabstops, function(a, b) return a.index < b.index end)
+ return tabstops
+end
+
+return utils