diff options
| author | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
|---|---|---|
| committer | Mike Vink <mike@pionative.com> | 2025-01-19 13:52:52 +0100 |
| commit | b77413ff8f59f380612074f0c9bd49093d8db695 (patch) | |
| tree | 32c39a811ba96ed4ab0a1c81cce9f8d518ed7e31 /lua/blink/cmp/sources/lib | |
Squashed 'mut/neovim/pack/plugins/start/blink.cmp/' content from commit 1cc3b1a
git-subtree-dir: mut/neovim/pack/plugins/start/blink.cmp
git-subtree-split: 1cc3b1a908fbcfd15451c4772759549724f38524
Diffstat (limited to 'lua/blink/cmp/sources/lib')
| -rw-r--r-- | lua/blink/cmp/sources/lib/init.lua | 304 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/provider/config.lua | 46 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/provider/init.lua | 179 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/provider/list.lua | 128 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/provider/override.lua | 17 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/queue.lua | 68 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/tree.lua | 168 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/types.lua | 31 | ||||
| -rw-r--r-- | lua/blink/cmp/sources/lib/utils.lua | 15 |
9 files changed, 956 insertions, 0 deletions
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 |
