diff --git a/docs/configuration/general.md b/docs/configuration/general.md index 300dcf24..5dbe2d79 100644 --- a/docs/configuration/general.md +++ b/docs/configuration/general.md @@ -57,6 +57,9 @@ For more common configurations, see the [recipes](../recipes.md). cmdline = {}, }, + -- Use a preset for snippets, check the snippets documentation for more information + snippets = { preset = 'default' | 'luasnip' | 'mini_snippets' }, + -- Experimental signature help support signature = { enabled = true } } diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 3faa8607..cc916aff 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -435,6 +435,7 @@ sources.providers = { score_offset = 0, -- Boost/penalize the score of the items override = nil, -- Override the source's functions }, + path = { name = 'Path', module = 'blink.cmp.sources.path', @@ -447,9 +448,12 @@ sources.providers = { show_hidden_files_by_default = false, } }, + snippets = { name = 'Snippets', module = 'blink.cmp.sources.snippets', + + -- For `snippets.preset == 'default'` opts = { friendly_snippets = true, search_paths = { vim.fn.stdpath('config') .. '/snippets' }, @@ -462,17 +466,22 @@ sources.providers = { -- Set to '+' to use the system clipboard, or '"' to use the unnamed register clipboard_register = nil, } - }, - luasnip = { - name = 'Luasnip', - module = 'blink.cmp.sources.luasnip', + + -- For `snippets.preset == 'luasnip'` opts = { -- Whether to use show_condition for filtering snippets use_show_condition = true, -- Whether to show autosnippets in the completion list show_autosnippets = true, } + + -- For `snippets.preset == 'mini_snippets'` + opts = { + -- Whether to use a cache for completion items + use_items_cache = true, + } }, + buffer = { name = 'Buffer', module = 'blink.cmp.sources.buffer', diff --git a/docs/configuration/snippets.md b/docs/configuration/snippets.md index 54d849fe..1d639a91 100644 --- a/docs/configuration/snippets.md +++ b/docs/configuration/snippets.md @@ -43,18 +43,26 @@ By default, the `snippets` source will check `~/.config/nvim/snippets` for your -- `main` does not work at the moment dependencies = { 'L3MON4D3/LuaSnip', version = 'v2.*' }, opts = { - snippets = { - expand = function(snippet) require('luasnip').lsp_expand(snippet) end, - active = function(filter) - if filter and filter.direction then - return require('luasnip').jumpable(filter.direction) - end - return require('luasnip').in_snippet() - end, - jump = function(direction) require('luasnip').jump(direction) end, + snippets = { preset = 'luasnip' }, + -- ensure you have the `snippets` source (enabled by default) + sources = { + default = { 'lsp', 'path', 'snippets', 'buffer' }, }, + } +} +``` + +## `mini.snippets` + +```lua +{ + 'saghen/blink.cmp', + dependencies = 'echasnovski/mini.snippets', + opts = { + snippets = { preset = 'mini_snippets' }, + -- ensure you have the `snippets` source (enabled by default) sources = { - default = { 'lsp', 'path', 'luasnip', 'buffer' }, + default = { 'lsp', 'path', 'snippets', 'buffer' }, }, } } diff --git a/lua/blink/cmp/config/snippets.lua b/lua/blink/cmp/config/snippets.lua index 82d32295..cd804e98 100644 --- a/lua/blink/cmp/config/snippets.lua +++ b/lua/blink/cmp/config/snippets.lua @@ -1,22 +1,61 @@ --- @class (exact) blink.cmp.SnippetsConfig +--- @field preset 'default' | 'luasnip' | 'mini_snippets' --- @field expand fun(snippet: string) Function to use when expanding LSP provided snippets --- @field active fun(filter?: { direction?: number }): boolean Function to use when checking if a snippet is active --- @field jump fun(direction: number) Function to use when jumping between tab stops in a snippet, where direction can be negative or positive +--- @param handlers table<'default' | 'luasnip' | 'mini_snippets', fun(...): any> +local function by_preset(handlers) + return function(...) + local preset = require('blink.cmp.config').snippets.preset + return handlers[preset](...) + end +end + local validate = require('blink.cmp.config.utils').validate local snippets = { --- @type blink.cmp.SnippetsConfig default = { - -- NOTE: we wrap these in functions to reduce startup by 1-2ms - -- when using lazy.nvim - expand = function(snippet) vim.snippet.expand(snippet) end, - active = function(filter) return vim.snippet.active(filter) end, - jump = function(direction) vim.snippet.jump(direction) end, + preset = 'default', + -- NOTE: we wrap `vim.snippet` calls to reduce startup by 1-2ms + expand = by_preset({ + default = function(snippet) vim.snippet.expand(snippet) end, + luasnip = function(snippet) require('luasnip').lsp_expand(snippet) end, + mini_snippets = function(snippet) + if not _G.MiniSnippets then error('mini.snippets has not been setup') end + local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert + insert(snippet) + end, + }), + active = by_preset({ + default = function(filter) return vim.snippet.active(filter) end, + luasnip = function(filter) + if filter and filter.direction then return require('luasnip').jumpable(filter.direction) end + return require('luasnip').in_snippet() + end, + mini_snippets = function() + if not _G.MiniSnippets then error('mini.snippets has not been setup') end + return MiniSnippets.session.get(false) ~= nil + end, + }), + jump = by_preset({ + default = function(direction) vim.snippet.jump(direction) end, + luasnip = function(direction) require('luasnip').jump(direction) end, + mini_snippets = function(direction) + if not _G.MiniSnippets then error('mini.snippets has not been setup') end + MiniSnippets.session.jump(direction == -1 and 'prev' or 'next') + end, + }), }, } function snippets.validate(config) validate('snippets', { + preset = { + config.preset, + function(preset) return vim.tbl_contains({ 'default', 'luasnip', 'mini_snippets' }, preset) end, + 'one of: "default", "luasnip", "mini_snippets"', + }, expand = { config.expand, 'function' }, active = { config.active, 'function' }, jump = { config.jump, 'function' }, diff --git a/lua/blink/cmp/config/sources.lua b/lua/blink/cmp/config/sources.lua index 613a098d..ab06b5f1 100644 --- a/lua/blink/cmp/config/sources.lua +++ b/lua/blink/cmp/config/sources.lua @@ -88,11 +88,6 @@ local sources = { module = 'blink.cmp.sources.snippets', score_offset = -3, }, - luasnip = { - name = 'Luasnip', - module = 'blink.cmp.sources.luasnip', - score_offset = -3, - }, buffer = { name = 'Buffer', module = 'blink.cmp.sources.buffer', diff --git a/lua/blink/cmp/sources/buffer.lua b/lua/blink/cmp/sources/buffer.lua index 293aace9..bb679f26 100644 --- a/lua/blink/cmp/sources/buffer.lua +++ b/lua/blink/cmp/sources/buffer.lua @@ -72,7 +72,8 @@ end local buffer = {} function buffer.new(opts) - opts = opts or {} ---@type blink.cmp.BufferOpts + --- @cast opts blink.cmp.BufferOpts + local self = setmetatable({}, { __index = buffer }) self.get_bufnrs = opts.get_bufnrs or function() diff --git a/lua/blink/cmp/sources/lib/init.lua b/lua/blink/cmp/sources/lib/init.lua index 826ae829..9b56cf5e 100644 --- a/lua/blink/cmp/sources/lib/init.lua +++ b/lua/blink/cmp/sources/lib/init.lua @@ -71,6 +71,13 @@ function sources.get_enabled_providers(mode) 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 "' diff --git a/lua/blink/cmp/sources/lib/provider/init.lua b/lua/blink/cmp/sources/lib/provider/init.lua index c0b1f67c..0494dcc3 100644 --- a/lua/blink/cmp/sources/lib/provider/init.lua +++ b/lua/blink/cmp/sources/lib/provider/init.lua @@ -33,7 +33,7 @@ function source.new(id, config) self.id = id self.name = config.name self.module = require('blink.cmp.sources.lib.provider.override').new( - require(config.module).new(config.opts, config), + require(config.module).new(config.opts or {}, config), config.override ) self.config = require('blink.cmp.sources.lib.provider.config').new(config) diff --git a/lua/blink/cmp/sources/path/init.lua b/lua/blink/cmp/sources/path/init.lua index d2dd3b36..bb5f5088 100644 --- a/lua/blink/cmp/sources/path/init.lua +++ b/lua/blink/cmp/sources/path/init.lua @@ -15,7 +15,7 @@ function path.new(opts) local self = setmetatable({}, { __index = path }) --- @type blink.cmp.PathOpts - opts = vim.tbl_deep_extend('keep', opts or {}, { + 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, diff --git a/lua/blink/cmp/sources/snippets/builtin.lua b/lua/blink/cmp/sources/snippets/default/builtin.lua similarity index 100% rename from lua/blink/cmp/sources/snippets/builtin.lua rename to lua/blink/cmp/sources/snippets/default/builtin.lua 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 00000000..db7fece2 --- /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 +--- @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 + 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/registry.lua b/lua/blink/cmp/sources/snippets/default/registry.lua similarity index 96% rename from lua/blink/cmp/sources/snippets/registry.lua rename to lua/blink/cmp/sources/snippets/default/registry.lua index b163ad67..5be225c1 100644 --- a/lua/blink/cmp/sources/snippets/registry.lua +++ b/lua/blink/cmp/sources/snippets/default/registry.lua @@ -8,7 +8,7 @@ --- @field description? string local registry = { - builtin_vars = require('blink.cmp.sources.snippets.builtin'), + builtin_vars = require('blink.cmp.sources.snippets.default.builtin'), } local utils = require('blink.cmp.sources.snippets.utils') @@ -33,7 +33,7 @@ function registry.new(config) if string.match(path, 'friendly.snippets') then table.insert(self.config.search_paths, path) end end end - self.registry = require('blink.cmp.sources.snippets.scan').register_snippets(self.config.search_paths) + self.registry = require('blink.cmp.sources.snippets.default.scan').register_snippets(self.config.search_paths) return self end diff --git a/lua/blink/cmp/sources/snippets/scan.lua b/lua/blink/cmp/sources/snippets/default/scan.lua similarity index 100% rename from lua/blink/cmp/sources/snippets/scan.lua rename to lua/blink/cmp/sources/snippets/default/scan.lua diff --git a/lua/blink/cmp/sources/snippets/init.lua b/lua/blink/cmp/sources/snippets/init.lua index b670f770..2a4b0baa 100644 --- a/lua/blink/cmp/sources/snippets/init.lua +++ b/lua/blink/cmp/sources/snippets/init.lua @@ -1,65 +1,9 @@ ---- @class blink.cmp.SnippetsOpts ---- @field friendly_snippets? boolean ---- @field search_paths? string[] ---- @field global_snippets? string[] ---- @field extended_filetypes? table ---- @field ignored_filetypes? string[] ---- @field get_filetype? fun(context: blink.cmp.Context): string ---- @field clipboard_register? string +local source = {} -local snippets = {} - -function snippets.new(opts) - --- @type blink.cmp.SnippetsOpts - opts = opts or {} - local self = setmetatable({}, { __index = snippets }) - --- @type table - self.cache = {} - self.registry = require('blink.cmp.sources.snippets.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, - }) +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 -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 +return source diff --git a/lua/blink/cmp/sources/luasnip.lua b/lua/blink/cmp/sources/snippets/luasnip.lua similarity index 98% rename from lua/blink/cmp/sources/luasnip.lua rename to lua/blink/cmp/sources/snippets/luasnip.lua index 5085ef8c..91716d25 100644 --- a/lua/blink/cmp/sources/luasnip.lua +++ b/lua/blink/cmp/sources/snippets/luasnip.lua @@ -16,7 +16,7 @@ local defaults_config = { } function source.new(opts) - local config = vim.tbl_deep_extend('keep', opts or {}, defaults_config) + 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' }, 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 00000000..3923f202 --- /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 + +--- @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