diff options
Diffstat (limited to 'lua/blink/cmp/sources')
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 |
