Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cmp-nvim-lsp triggers completion when it should not #6

Open
abeldekat opened this issue Jan 6, 2025 · 32 comments · Fixed by #7 or #8
Open

cmp-nvim-lsp triggers completion when it should not #6

abeldekat opened this issue Jan 6, 2025 · 32 comments · Fixed by #7 or #8

Comments

@abeldekat
Copy link
Owner

abeldekat commented Jan 6, 2025

mini_snippets_cmp.mp4
repro.lua
--[[
Use:
  mkdir ~/.config/repro
  cd ~/.config/repro

  touch init.lua
  add the contents of this file to init.lua
  issue command: NVIM_APPNAME=repro nvim
  restart after installation
  inside nvim, issue command :e init.lua

Remove:
  rm -rf ~/.local/share/repro ~/.local/state/repro ~/.local/cache/repro
  rm -rf ~/.config/repro
--]]

local function clone(path_to_site)
  local mini_path = path_to_site .. "pack/deps/start/mini.nvim"
  if not vim.uv.fs_stat(mini_path) then
    vim.cmd('echo "Installing `mini.nvim`" | redraw')
    local clone_cmd = { "git", "clone", "--filter=blob:none", "https://github.com/echasnovski/mini.nvim", mini_path }
    vim.fn.system(clone_cmd)
    vim.cmd("packadd mini.nvim | helptags ALL")
    vim.cmd('echo "Installed `mini.nvim`" | redraw')
  end
end

local path_to_site = vim.fn.stdpath("data") .. "/site/"
clone(path_to_site)
local MiniDeps = require("mini.deps")
MiniDeps.setup({ path = { package = path_to_site } })
local add, now = MiniDeps.add, MiniDeps.now
-- local later = MiniDeps.later()

local use_cmp_nvim_lsp = true

--[[
Reproduce with the following snippet:
{ prefix = "abc", body = "T1=${1:9} T2=${2:<$1>}", desc = "test test" },

--]]

-- OK scenario 1
-- The source for cmp-nvim-lsp is **not** used.
-- Type "abc" and accept snippet(c-y)

-- OK scenario 2
-- The source for cmp-nvim-lsp **is** used.
-- Change use_cmp_nvim_lsp to true and restart
-- Type "abc" and expand directy with mini.snippets, <c-j>

-- Issue scenario!
-- The source for cmp-nvim-lsp **is** used.
-- Type "abc" and accept snippet(c-y)

--[[
End reproduce
--]]

vim.g.mapleader = " "
now(function()
  require("mini.basics").setup()
  vim.cmd.colorscheme("randomhue")

  add({
    source = "echasnovski/mini.snippets",
    -- depends = { "rafamadriz/friendly-snippets" },
  })
  local snippets = require("mini.snippets")
  snippets.setup({
    snippets = {
      { prefix = "abc", body = "T1=${1:9} T2=${2:<$1>}", desc = "test test" },
      -- snippets.gen_loader.from_lang()
    },
  })

  local cmp_depends = { "hrsh7th/cmp-nvim-lsp", "hrsh7th/cmp-buffer", "abeldekat/cmp-mini-snippets" }
  local cmp_sources = { { name = "mini_snippets" }, { name = "buffer" } }
  if use_cmp_nvim_lsp then table.insert(cmp_sources, 1, { name = "nvim_lsp" }) end
  add({
    source = "hrsh7th/nvim-cmp",
    depends = cmp_depends,
  })
  local cmp = require("cmp")
  require("cmp").setup({
    snippet = {
      expand = function(args) -- mini.snippets expands snippets from lsp...
        ---@diagnostic disable-next-line: undefined-global
        local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
        insert({ body = args.body }) -- Insert at cursor
      end,
    },
    sources = cmp.config.sources(cmp_sources),
    mapping = cmp.mapping.preset.insert(),
    completion = { completeopt = "menu,menuone,noinsert" },
    experimental = { ghost_text = { hl_group = "CmpGhostText" } },
  })

  add("williamboman/mason.nvim")
  add("williamboman/mason-lspconfig.nvim")
  add("neovim/nvim-lspconfig")
  require("mason").setup()
  require("mason-lspconfig").setup({ ensure_installed = { "lua_ls" } })
  require("lspconfig").lua_ls.setup({})
end)

The snippet:

{ prefix = "abc", body = "T1=${1:9} T2=${2:<$1>}", desc = "test test" },

This does not happen with LuaSnip and cmp_luasnip. Perhaps it's related to the insertmode mini.snippets maintains.

@abeldekat
Copy link
Owner Author

@echasnovski,

I can fix this by changing source:execute into:

---Executed after the item was selected.
---@param completion_item lsp.CompletionItem
---@param callback fun(completion_item: lsp.CompletionItem|nil)
function source:execute(completion_item, callback)
  callback(completion_item)
  vim.schedule(function ()
    insert_snippet(completion_item.data.snip, completion_item.word)
  end)
end

The idea: Insert the snippet later, when source:execute is done.
Do you think this is a valid approach?

@echasnovski
Copy link

Do you think this is a valid approach?

Calling MiniSnippets.default_insert() should be fine inside vim.schedule() (as it just insert snippet at cursor). Not sure about removing the matched word as it is done after callback.

A more compact way of achieving this would be to use local insert_snippet = vim.schedule_wrap(function(snip, word) ... end), but that is a bit off style.

But I think this is an indicator of some issue in 'cmp-nvim-lsp' or 'nvim-cmp' itself which might be hard to find. But in general vim.schedule()-ing something that is meant to be used interactively should usually be fine.

@abeldekat abeldekat linked a pull request Jan 6, 2025 that will close this issue
abeldekat added a commit that referenced this issue Jan 6, 2025
fix: cmp-nvim-lsp triggers completion when it should not
solves #6
@abeldekat
Copy link
Owner Author

Thanks!

But I think this is an indicator of some issue in 'cmp-nvim-lsp' or 'nvim-cmp' itself which might be hard to find.

Yes. I have not seen this behaviour in the mini.snippets for blink.

@abeldekat
Copy link
Owner Author

abeldekat commented Jan 8, 2025

In hindsight, I think it's best to solve the issue outside of this plugin.
The schedule makes it harder to reason about the execute method.

Reopening...

The primary reason this happens: T1=: The = is a trigger character for cmp-nvim-lsp, provided by lua_ls

@abeldekat abeldekat reopened this Jan 8, 2025
abeldekat added a commit that referenced this issue Jan 8, 2025
revert using vim.schedule. Makes the code harder to reason about. See #6
@abeldekat abeldekat reopened this Jan 8, 2025
@echasnovski
Copy link

Do you have any particular examples of why using vim.schedule is "bad" per se? What scenarios lead to this?

@abeldekat
Copy link
Owner Author

1736361107

I see more scenario's where the completion window appears. Also when mini.snippets is configured to only expand lsp snippets.
Except: When the completion engine is mini.completion.

Especially when the user has opted-in to ghosttext, the expanded snippet become hard to read.

Example: mini.snippets standalone with nvim-cmp. Expand snippet "T1=fu$1"

repro.lua
local function clone(path_to_site)
  local mini_path = path_to_site .. "pack/deps/start/mini.nvim"
  if not vim.uv.fs_stat(mini_path) then
    vim.cmd('echo "Installing `mini.nvim`" | redraw')
    local clone_cmd = { "git", "clone", "--filter=blob:none", "https://github.com/echasnovski/mini.nvim", mini_path }
    vim.fn.system(clone_cmd)
    vim.cmd("packadd mini.nvim | helptags ALL")
    vim.cmd('echo "Installed `mini.nvim`" | redraw')
  end
end

local path_to_site = vim.fn.stdpath("data") .. "/site/"
clone(path_to_site)
local MiniDeps = require("mini.deps")
MiniDeps.setup({ path = { package = path_to_site } })
local add, now = MiniDeps.add, MiniDeps.now

vim.g.mapleader = " "
now(function()
  require("mini.basics").setup()
  vim.cmd.colorscheme("randomhue")

  add({
    source = "echasnovski/mini.snippets",
  })
  local snippets = require("mini.snippets")

  local use_cmp_snippet_source = false
  snippets.setup({
    snippets = {
      -- completion on direct expand, no completion via cmp-mini-snippets:
      { prefix = "a1", body = "T1=fu$1", desc = "fu before $1" },
      -- completion on direct expand, no completion via cmp-mini-snippets:
      { prefix = "a2", body = "T1=fu$1 $0", desc = "fu before $1 and space after" },
    },
  })

  local cmp_depends = { "hrsh7th/cmp-nvim-lsp", "hrsh7th/cmp-buffer", "abeldekat/cmp-mini-snippets" }
  local cmp_sources = { { name = "nvim_lsp" }, { name = "buffer" } }
  if use_cmp_snippet_source then table.insert(cmp_sources, 2, { name = "mini_snippets" }) end
  add({
    source = "hrsh7th/nvim-cmp",
    depends = cmp_depends,
  })
  local cmp = require("cmp")
  require("cmp").setup({
    snippet = {
      expand = function(args) -- mini.snippets expands snippets from lsp...
        ---@diagnostic disable-next-line: undefined-global
        local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
        insert({ body = args.body }) -- Insert at cursor
      end,
    },
    sources = cmp.config.sources(cmp_sources),
    mapping = cmp.mapping.preset.insert(),
    completion = { completeopt = "menu,menuone,noinsert" },
    experimental = { ghost_text = { hl_group = "CmpGhostText" } },
  })
  vim.api.nvim_set_hl(0, "CmpGhostText", { link = "Comment", default = true })

  add("williamboman/mason.nvim")
  add("williamboman/mason-lspconfig.nvim")
  add("neovim/nvim-lspconfig")
  require("mason").setup()
  require("mason-lspconfig").setup({ ensure_installed = { "lua_ls" } })
  require("lspconfig").lua_ls.setup({})
end)

@echasnovski
Copy link

Isn't the screenshot just the result of LSP autocompletion? As it appears after normally typing text outside of snippet session?

@abeldekat
Copy link
Owner Author

Unfortunately not. Using repro.lua:

  1. Type a1
  2. Press <c-j>

@abeldekat
Copy link
Owner Author

When I type:

local T1=fu, completion kicks in, but the tabstop is positioned differently

@abeldekat
Copy link
Owner Author

abeldekat commented Jan 9, 2025

Just to gather my own thoughts...)

I put together two minimal versions of "init.lua" to more easily compare luasnip and mini.snippets:

  1. minisnippets_using_deps_and_cmp
  2. luasnip_using_deps_and_cmp

Both setups can be used with or without the corresponding completion source.
Completion uses ghost_text.

Test setup

Installation

Step 1:

  mkdir ~/.config/repro
  cd ~/.config/repro
  touch init.lua

Step 2:

Copy the contents of the included init.lua into the newly created init.lua

Step 3:

NVIM_APPNAME=repro nvim

Step 4:

Close nvim after all plugins are installed

Step 5:

NVIM_APPNAME=repro nvim init.lua

To remove:

rm -rf ~/.local/share/repro ~/.local/state/repro ~/.local/cache/repro
rm -rf ~/.config/repro
luasnip_using_deps_and_cmp
local function clone(path_to_site)
  local mini_path = path_to_site .. "pack/deps/start/mini.nvim"
  if not vim.uv.fs_stat(mini_path) then
    vim.cmd('echo "Installing `mini.nvim`" | redraw')
    local clone_cmd = { "git", "clone", "--filter=blob:none", "https://github.com/echasnovski/mini.nvim", mini_path }
    vim.fn.system(clone_cmd)
    vim.cmd("packadd mini.nvim | helptags ALL")
    vim.cmd('echo "Installed `mini.nvim`" | redraw')
  end
end
local path_to_site = vim.fn.stdpath("data") .. "/site/"
clone(path_to_site)
local MiniDeps = require("mini.deps")
MiniDeps.setup({ path = { package = path_to_site } })
local add, now = MiniDeps.add, MiniDeps.now

vim.g.mapleader = " "
local use_cmp_snippet_source = false -- use cmp_luasnip
now(function()
  require("mini.basics").setup()
  vim.cmd.colorscheme("randomhue")

  add({ source = "L3MON4D3/LuaSnip" }) -- jsregexp is optional
  local ls = require("luasnip")
  ls.setup({})
  ls.add_snippets(nil, {
    all = {
      ls.parser.parse_snippet("a1", "T1=fu$1"),
      ls.parser.parse_snippet("a2", "T1=fu$1 $0"),
    },
  }, {})

  -- taken from luasnip readme:
  vim.keymap.set({ "i" }, "<c-j>", function() ls.expand() end, { silent = true })
  vim.keymap.set({ "i", "s" }, "<c-l>", function() ls.jump(1) end, { silent = true })
  vim.keymap.set({ "i", "s" }, "<c-h>", function() ls.jump(-1) end, { silent = true })

  local cmp_depends = { "hrsh7th/cmp-nvim-lsp", "saadparwaiz1/cmp_luasnip", "hrsh7th/cmp-buffer" }
  local cmp_sources = { { name = "nvim_lsp" }, { name = "buffer" } }
  if use_cmp_snippet_source then table.insert(cmp_sources, 2, { name = "luasnip" }) end

  add({ source = "hrsh7th/nvim-cmp", depends = cmp_depends })
  local cmp = require("cmp")
  require("cmp").setup({
    snippet = {
      expand = function(args) require("luasnip").lsp_expand(args.body) end,
    },
    sources = cmp.config.sources(cmp_sources),
    mapping = cmp.mapping.preset.insert(),
    completion = { completeopt = "menu,menuone,noinsert" },
    experimental = { ghost_text = { hl_group = "CmpGhostText" } },
  })
  vim.api.nvim_set_hl(0, "CmpGhostText", { link = "Comment", default = true })

  add("williamboman/mason.nvim")
  add("williamboman/mason-lspconfig.nvim")
  add("neovim/nvim-lspconfig")
  require("mason").setup()
  require("mason-lspconfig").setup({ ensure_installed = { "lua_ls" } })
  require("lspconfig").lua_ls.setup({})
  require("mini.statusline").setup({})
end)
minisnippets_using_deps_and_cmp
local function clone(path_to_site)
  local mini_path = path_to_site .. "pack/deps/start/mini.nvim"
  if not vim.uv.fs_stat(mini_path) then
    vim.cmd('echo "Installing `mini.nvim`" | redraw')
    local clone_cmd = { "git", "clone", "--filter=blob:none", "https://github.com/echasnovski/mini.nvim", mini_path }
    vim.fn.system(clone_cmd)
    vim.cmd("packadd mini.nvim | helptags ALL")
    vim.cmd('echo "Installed `mini.nvim`" | redraw')
  end
end
local path_to_site = vim.fn.stdpath("data") .. "/site/"
clone(path_to_site)
local MiniDeps = require("mini.deps")
MiniDeps.setup({ path = { package = path_to_site } })
local add, now = MiniDeps.add, MiniDeps.now

vim.g.mapleader = " "
local use_cmp_snippet_source = false -- use cmp-mini-snippets
now(function()
  require("mini.basics").setup()
  vim.cmd.colorscheme("randomhue")

  add({ source = "echasnovski/mini.snippets" })
  local snippets = require("mini.snippets")
  snippets.setup({
    snippets = {
      -- completion on direct expand, no completion via cmp-mini-snippets:
      { prefix = "a1", body = "T1=fu$1", desc = "fu before $1" },
      -- completion on direct expand, no completion via cmp-mini-snippets:
      { prefix = "a2", body = "T1=fu$1 $0", desc = "fu before $1 and space after" },
    },
  })

  local cmp_depends = { "hrsh7th/cmp-nvim-lsp", "hrsh7th/cmp-buffer", "abeldekat/cmp-mini-snippets" }
  local cmp_sources = { { name = "nvim_lsp" }, { name = "buffer" } }
  if use_cmp_snippet_source then table.insert(cmp_sources, 2, { name = "mini_snippets" }) end

  add({ source = "hrsh7th/nvim-cmp", depends = cmp_depends })
  local cmp = require("cmp")
  require("cmp").setup({
    snippet = {
      expand = function(args) -- mini.snippets expands snippets from lsp...
        ---@diagnostic disable-next-line: undefined-global
        local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
        insert({ body = args.body }) -- Insert at cursor
      end,
    },
    sources = cmp.config.sources(cmp_sources),
    mapping = cmp.mapping.preset.insert(),
    completion = { completeopt = "menu,menuone,noinsert" },
    experimental = { ghost_text = { hl_group = "CmpGhostText" } },
  })
  vim.api.nvim_set_hl(0, "CmpGhostText", { link = "Comment", default = true })

  add("williamboman/mason.nvim")
  add("williamboman/mason-lspconfig.nvim")
  add("neovim/nvim-lspconfig")
  require("mason").setup()
  require("mason-lspconfig").setup({ ensure_installed = { "lua_ls" } })
  require("lspconfig").lua_ls.setup({})
  require("mini.statusline").setup({})
end)

Reproduce

Without the completion source(use_cmp_snippet_source=false):

  1. Open init.lua
  2. line 16
  3. Type o
  4. Type a1
  5. Type <c-j>

Luasnip result

1736410187

Mini.snippets result

1736410476

Thoughts

In this testcase, both luasnip and mini.snippets show the same behavior.
After snippet expansion, the completion engine kicks in, offering the function snippet from the lsp.

With mini.snippets, the combination of ghost_text and the display of tabstops is a bit confusing imo.

I intend to investigate a way to always prevent nvim-cmp from starting after the snippet is inserted. Especially with larger snippets, it might be important for the user to initially have a clear view on the snippet itself.

@abeldekat
Copy link
Owner Author

@echasnovski, @xzbdmw,

I did some testing regarding the following comments:
-1-
-2-

The conclusion by @xzbdmw resulted in this nvim-cmp issue:

vim.snippet.expand works correctly because it stays in select mode, does not trigger completion at all, only after user typing fun lua_ls will begin to send correct TextEdits, anyway, by that time placeholder is already gone.
To my understanding, the fix would be suspending completion request to stop step 2 from firing when first expanding.

I think I found a way to avoid completion directly after snippet expand:

    expand = {
      insert = function(snippet)
        -- Prevent any completion directly after snippet insert:
        require("cmp.config").set_onetime({ sources = {} })
        MiniSnippets.default_insert(snippet)
      end,
    },

Test setup

Installation

Step 1:

  mkdir ~/.config/repro
  cd ~/.config/repro
  touch init.lua

Step 2:

Copy the contents of the included init.lua into the newly created init.lua

Step 3:

NVIM_APPNAME=repro nvim

Step 4:

Close nvim after all plugins are installed

Step 5:

NVIM_APPNAME=repro nvim init.lua

To remove:

rm -rf ~/.local/share/repro ~/.local/state/repro ~/.local/cache/repro
rm -rf ~/.config/repro
Luasnip-nvim-cmp-standalone
local function clone(path_to_site)
  local mini_path = path_to_site .. "pack/deps/start/mini.nvim"
  if not vim.uv.fs_stat(mini_path) then
    vim.cmd('echo "Installing `mini.nvim`" | redraw')
    local clone_cmd = { "git", "clone", "--filter=blob:none", "https://github.com/echasnovski/mini.nvim", mini_path }
    vim.fn.system(clone_cmd)
    vim.cmd("packadd mini.nvim | helptags ALL")
    vim.cmd('echo "Installed `mini.nvim`" | redraw')
  end
end
local path_to_site = vim.fn.stdpath("data") .. "/site/"
clone(path_to_site)
local MiniDeps = require("mini.deps")
MiniDeps.setup({ path = { package = path_to_site } })
local add, now = MiniDeps.add, MiniDeps.now

vim.g.mapleader = " "
local use_cmp_snippet_source = false -- toggle use cmp_luasnip
local use_signature = false -- toggle use lsp-signature
now(function()
  require("mini.basics").setup()
  vim.cmd.colorscheme("randomhue")

  add("L3MON4D3/LuaSnip")
  local ls = require("luasnip")
  ls.setup({
    history = true,
    delete_check_events = "TextChanged",
  })
  ls.add_snippets(nil, {
    -- just one dummy custom snippet:
    all = {
      ls.parser.parse_snippet("aaa1", "T1=fu$1"),
    },
  }, {})

  -- taken from luasnip readme:
  vim.keymap.set({ "i" }, "<c-j>", function() ls.expand() end, { silent = true })
  vim.keymap.set({ "i", "s" }, "<c-l>", function() ls.jump(1) end, { silent = true })
  vim.keymap.set({ "i", "s" }, "<c-h>", function() ls.jump(-1) end, { silent = true })

  local cmp_depends =
    { "hrsh7th/cmp-nvim-lsp", "saadparwaiz1/cmp_luasnip", "hrsh7th/cmp-buffer", "ray-x/lsp_signature.nvim" }
  local cmp_sources = { { name = "nvim_lsp" }, { name = "buffer" } }
  if use_cmp_snippet_source then table.insert(cmp_sources, 2, { name = "luasnip" }) end

  add({ source = "hrsh7th/nvim-cmp", depends = cmp_depends })
  local cmp = require("cmp")
  require("cmp").setup({
    snippet = {
      expand = function(args) require("luasnip").lsp_expand(args.body) end,
    },
    sources = cmp.config.sources(cmp_sources),
    mapping = cmp.mapping.preset.insert(),
    completion = { completeopt = "menu,menuone,noinsert" },
    -- experimental = { ghost_text = { hl_group = "CmpGhostText" } },
  })
  -- vim.api.nvim_set_hl(0, "CmpGhostText", { link = "Comment", default = true })
  if use_signature then require("lsp_signature").setup() end

  add("williamboman/mason.nvim")
  add("williamboman/mason-lspconfig.nvim")
  add("neovim/nvim-lspconfig")
  add("j-hui/fidget.nvim")
  require("mason").setup()
  require("mason-lspconfig").setup({ ensure_installed = { "lua_ls" } })
  require("lspconfig").lua_ls.setup({
    settings = {
      Lua = {
        runtime = { version = "LuaJIT" },
        completion = { callSnippet = "Replace" },
        workspace = {
          checkThirdParty = false,
          library = {
            vim.env.VIMRUNTIME,
            "${3rd}/luv/library",
            "${3rd}/busted/library",
          },
        },
      },
    },
  })
  require("fidget").setup({})

  require("mini.statusline").setup({})
end)
Minisnippets-nvim-cmp-standalone
local function clone(path_to_site)
  local mini_path = path_to_site .. "pack/deps/start/mini.nvim"
  if not vim.uv.fs_stat(mini_path) then
    vim.cmd('echo "Installing `mini.nvim`" | redraw')
    local clone_cmd = { "git", "clone", "--filter=blob:none", "https://github.com/echasnovski/mini.nvim", mini_path }
    vim.fn.system(clone_cmd)
    vim.cmd("packadd mini.nvim | helptags ALL")
    vim.cmd('echo "Installed `mini.nvim`" | redraw')
  end
end
local path_to_site = vim.fn.stdpath("data") .. "/site/"
clone(path_to_site)
local MiniDeps = require("mini.deps")
MiniDeps.setup({ path = { package = path_to_site } })
local add, now = MiniDeps.add, MiniDeps.now

vim.g.mapleader = " "
local use_cmp_snippet_source = false -- toggle use cmp-mini-snippets
local use_signature = false -- toggle use lsp-signature
now(function()
  require("mini.basics").setup()
  vim.cmd.colorscheme("randomhue")

  local mini_snippets = require("mini.snippets")
  mini_snippets.setup({
    snippets = {
      -- just one dummy custom snippet:
      { prefix = "aaa1", body = "T1=fu$1", desc = "fu before $1" },
    },
    expand = {
      insert = function(snippet)
        -- Prevent any completion directly after snippet insert:
        require("cmp.config").set_onetime({ sources = {} })
        MiniSnippets.default_insert(snippet)
      end,
    },
  })

  local cmp_depends =
    { "hrsh7th/cmp-nvim-lsp", "hrsh7th/cmp-buffer", "abeldekat/cmp-mini-snippets", "ray-x/lsp_signature.nvim" }
  local cmp_sources = { { name = "nvim_lsp" }, { name = "buffer" } }
  if use_cmp_snippet_source then table.insert(cmp_sources, 2, { name = "mini_snippets" }) end

  add({ source = "hrsh7th/nvim-cmp", depends = cmp_depends })
  local cmp = require("cmp")
  require("cmp").setup({
    snippet = {
      expand = function(args) -- mini.snippets expands snippets from lsp...
        ---@diagnostic disable-next-line: undefined-global
        local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
        insert({ body = args.body }) -- Insert at cursor
      end,
    },
    sources = cmp.config.sources(cmp_sources),
    mapping = cmp.mapping.preset.insert(),
    completion = { completeopt = "menu,menuone,noinsert" },
    -- experimental = { ghost_text = { hl_group = "CmpGhostText" } },
  })
  -- vim.api.nvim_set_hl(0, "CmpGhostText", { link = "Comment", default = true })
  if use_signature then require("lsp_signature").setup() end

  add("williamboman/mason.nvim")
  add("williamboman/mason-lspconfig.nvim")
  add("neovim/nvim-lspconfig")
  add("j-hui/fidget.nvim")
  require("mason").setup()
  require("mason-lspconfig").setup({ ensure_installed = { "lua_ls" } })
  require("lspconfig").lua_ls.setup({
    settings = {
      Lua = {
        runtime = { version = "LuaJIT" },
        completion = { callSnippet = "Replace" },
        workspace = {
          checkThirdParty = false,
          library = {
            vim.env.VIMRUNTIME,
            "${3rd}/luv/library",
            "${3rd}/busted/library",
          },
        },
      },
    },
  })
  require("fidget").setup({})

  require("mini.statusline").setup({})
end)

The lsp is configured with callSnippets and ghost_text is disabled. I don't include friendly snippets, only concentrating on two snippets provided by lsp:

  • Kind "function", the snippet that causes the issue.
{
  body = "function ()\n\t$0\nend"
}
  • Kind "snippet", this one is expanded correctly in all cases.
{
  body = "function ($1)\n\t$0\nend"
}

Reproduce

  1. Open init.lua
  2. Wait for lsp to load
  3. Type: vim.sch<c-y>
  4. Notice that both luasnip and mini.snippets "look" the same, except that mode = select with luasnip. Both don't show any completions
  5. Type fu
  6. select the entry with kind "function"
  7. type <c-y>.
  8. The result is according to comments: With mini.snippets, the final ) is removed incorrectly.

Repeat, but in step 6, select the entry with kind = "snippet". All is well.

Thoughts

The nvim-cmp issue is most probably valid.

When LuaSnip changes mode from "select" into "insert", this code in nvim-cmp triggers.

I can think of 2 reasons why mini.snippets itself might play a part in the issue:

  1. I don't understand why the function with kind "snippet" does expand correctly.
  2. By preventing initial completion after expand, the scenario's seem to be the same for both engines starting from step 5.

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

require("cmp.config").set_onetime({ sources = {} })

That’s interesting, might be the best workaround. (I guess vscode does something similar)

I don't understand why the function with kind "snippet" does expand correctly

Because the one provided by lsp has additional TextEdits, it aims to remove the placeholder (fun) when accepting, while mini.snippets already removed it. I only see this problem in lua_ls, other lsps are smart enough to not to offer compltion after snippets expansion.

@abeldekat
Copy link
Owner Author

Because the one provided by lsp has additional TextEdits, it aims to remove the placeholder (fun) when accepting, while mini.snippets already removed it. I only see this problem in lua_ls, other lsps are smart enough to not to offer compltion after snippets expansion.

It's a tough subject...) I'm afraid I don't completely understand your explanation.

  1. In this test setup, no completion is offered after snippets expansion.
  2. In this test setup, both snippets(function/snippet kind) are offered by the lsp. I printed the incoming snippets for expand.insert. The only difference: The snippet expanding correctly has an extra $1.

... while mini.snippets already removed it

If that's the issue, perhaps mini.snippets should not try to remove the placeholder when the snippet is received from lsp....

@echasnovski
Copy link

If that's the issue, perhaps mini.snippets should not try to remove the placeholder when the snippet is received from lsp....

'mini.snippets' removes matched region in default_insert only if supplied table contains region field. This is not the case here, this removes matched region. As it should be because expand.insert step might be overridden by the user and it only needs to be able to insert snippet at cursor. Requiring user overrides to also remove matched region seems to be too much, especially as it is handled in MiniSnippets.expand() (which is the main use case for expand.insert).

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

The two snippets body are the same, but their completionitem may not, you amy want to print debug a bit :)

Oh I see the onetime trick does not prevent right pair missing? I’ll investigate this case (I’m familiar with the code base).

@abeldekat
Copy link
Owner Author

abeldekat commented Jan 10, 2025

'mini.snippets' removes matched region in default_insert only if supplied table contains region field. This is not the case here, this removes matched region

The test setup does not use cmp-mini-snippets or cmp_luasnip. Only lsp-snippets are handled.
As said by Echasnovski, I don't think mini.snippets removes the placeholder when expanding lsp:

-- nvim-cmp
    snippet = {
      expand = function(args) -- mini.snippets expands snippets from lsp...
        ---@diagnostic disable-next-line: undefined-global
        local insert = MiniSnippets.config.expand.insert or MiniSnippets.default_insert
        insert({ body = args.body }) -- Insert at cursor
      end,

Given this observation:

By preventing initial completion after expand, the scenario's seem to be the same for both engines starting from step 5.

Is there a difference in "handling" between luasnip and mini.snippets in the "failure" case? I also intend to debug some more later on...)

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

Oh I see the onetime trick does not prevent right pair missing? I’ll investigate this case (I’m familiar with the code base).

I think I find the problem, mini.snippets removes the "fun" placeholder after cmp-nvim-lsp sends the request to lua_ls (by perf, time difference is 3ms, in other words, type 'f' -> both mini.snippets and cmp detects TextChangedI -> cmp-nvim-lsp sends request -> mini.snippets clears the placeholder), so in the perspective of lua_ls, there still exist "fun" needed to be cleared by TextEdits, even though it is cleared by mini.snippets, the problem is cmp does not know the completion item is outdated.

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

By apply this patch, that is, defer lsp request sending if mini.snippest has not clear the placeholder, all good. (combine the set_onetime trick)

cmp-nvim-lsp git:(main) ✗ git --no-pager diff  HEAD                                                   130 [02:11:03]
diff --git a/lua/cmp_nvim_lsp/source.lua b/lua/cmp_nvim_lsp/source.lua
index 43ccac1..4c61b0f 100644
--- a/lua/cmp_nvim_lsp/source.lua
+++ b/lua/cmp_nvim_lsp/source.lua
@@ -35,7 +35,7 @@ source.is_available = function(self)
   if not self:_get(self.client.server_capabilities, { 'completionProvider' }) then
     return false
   end
-  return true;
+  return true
 end
 
 ---Get LSP's PositionEncodingKind.
@@ -68,6 +68,26 @@ source.complete = function(self, params, callback)
   lsp_params.context = {}
   lsp_params.context.triggerKind = params.completion_context.triggerKind
   lsp_params.context.triggerCharacter = params.completion_context.triggerCharacter
+
+  local mini_snippets_active = function()
+    local cursor = vim.api.nvim_win_get_cursor(0)
+    local extmarks = vim.api.nvim_buf_get_extmarks(0, vim.api.nvim_create_namespace('MiniSnippetsNodes'), 0, -1, { 
details = true })
+    for _, mark in ipairs(extmarks) do
+      local detail = mark[4]
+      if detail.hl_group == 'MiniSnippetsCurrentReplace' and mark[3] + 1 == cursor[2] and mark[2] + 1 == cursor[1] 
then
+        return true
+      end
+    end
+    return false
+  end
+  if mini_snippets_active() then
+    vim.defer_fn(function()
+      self:_request('textDocument/completion', lsp_params, function(_, response)
+        callback(response)
+      end)
+    end, 3)
+    return
+  end
   self:_request('textDocument/completion', lsp_params, function(_, response)
     callback(response)
   end)

@echasnovski
Copy link

By apply this patch, that is, defer lsp request sending if mini.snippest has not clear the placeholder, all good. (combine the set_onetime trick)

Please, don't rely on 'mini.snippets' internals to fix a behavior which looks like an issue outside of 'mini.snippets'.
If anything, checking whether current tabstop has already replaced its placeholder should be done by traversing session's nodes until the first node with current tabstop (cur_tabstop). If that node contains placeholder field, then it still has placeholder and any proper text edit will replace it.

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

Yeah I understand, that's a proof of concept, the real fix would not look like that.

@abeldekat
Copy link
Owner Author

abeldekat commented Jan 10, 2025

Interesting...

Could it be a that the difference of handling multiple snippets is into play?
Mini.snippets has nested snippets. The help mentions that luasnip "merges" the snippets.

In the scenario, we are talking about two snippets, offered by lua_ls:

  1. Kind "function", the snippet that causes the issue.
{
  body = "function ()\n\t$0\nend"
}
  1. Kind "snippet", this one is expanded correctly in all cases.
{
  body = "function ($1)\n\t$0\nend"
}

They are different. The second snippet contains tabstop $1

When I remove nesting, snippet 1 also expands correctly!

Reproduce:
Type: vim.schedule()<esc>i. We are now inside the parenthesis in insert mode. I did not expand vim.schedule, thus there is no snippet yet.
Type: fu and accept the snippet indicated with "kind = function". Snippet expanded succesfully...

Given the reproduce in this comment, with snippet 1, I only ever see one final tabstop, alltough I did expand a second time inside the parenthesis of vim.schedule.
Snippet 2 is clearly nested: When done, the final tabstop of vim.schedule reappears.

I also notice that snippet 1 is only offered inside vim.schedule. In for example schedule_fallback, which also takes a function, the lsp only offers snippet 2.

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

Type: vim.schedule()i. We are now inside the parenthesis in insert mode. I did not expand vim.schedule, thus there is no snippet yet.
Type: fu and accept the snippet indicated with "kind = function". Snippet expanded succesfully...

Yes because it does not contain placeholders when you don't expand schedule. This does not relate to "merge", but rather lua_ls think it is you typed the placeholder and give you suggestions based on that.

@abeldekat
Copy link
Owner Author

Also note: Using blink, it happens as well...

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

Right 😄, stay in insert mode is superior unless many of completion plugins assumes you to stay in select mode.

@abeldekat
Copy link
Owner Author

Right 😄, stay in insert mode is superior unless many of completion plugins assumes you to stay in select mode.

Or, this is a really odd usecase...) I can't reproduce the scenario in any other way, especially since snippet 1 is only offered from inside vim.schedule

@xzbdmw
Copy link

xzbdmw commented Jan 10, 2025

Me too. But vim.schedule is important one, and you will very likely hit in this case.

@abeldekat
Copy link
Owner Author

I have some new input and decided to create a separate issue #9

Regarding this issue, "cmp-nvim-lsp triggers completion when it should not", I think there are two possible outcomes:

  1. Document oneshot in the README of this repro
  2. Or, create an issue in nvim-cmp. In order to do that, I would like to improve my understanding of what causes the behaviour.

I found that nvim-cmp "mostly" operates correctly. But, for example, when a function has a callback parameter annotated with fun(), and this parameter is the first parameter, then completion kicks in.

@xzbdmw
Copy link

xzbdmw commented Jan 12, 2025

I'll try to explain more detail:
mini.snippets: insert mode -> type 'f' -> both mini.snippets and cmp detects TextChangedI -> cmp-nvim-lsp sends request -> lua_ls receives the buffer content, which is vim.schedule(ffn) -> mini.snippets clears the placeholder fn, it becomes vim.schedule(f) -> "fun" completion_item contains TextEdits that want to clear the fn , now what in fn's original place is now the right pair -> right pair gone, before snippets expanding. Neither mini.snippets or cmp is to blame here IMO, they all do what they supposed to do.

luasnip: select mode -> type 'f' -> nvim clears the placeholder, by the definition of select mode -> cmp-nvim-lsp sends request -> lua_ls receives the buffer content, which is vim.schedule(f) -> all good.

@abeldekat
Copy link
Owner Author

Thanks! I will refer to your comment in issue #9.
For now, it's probably best to focus this issue on the "triggered completion"

@xzbdmw
Copy link

xzbdmw commented Jan 12, 2025

That's not easy, somehow we need to lock the state in the span that you type f and mini.snippets clear the fn placeholder, just like what select mode does, but I don't think there are ways to do so.

If nvim-cmp and blink want to add special detection rule for mini.snippets, then that's possible.

@echasnovski
Copy link

If nvim-cmp and blink want to add special detection rule for mini.snippets, then that's possible.

If this is what happens, then 'nvim-cmp' delaying sending request (like wrapping it in vim.schedule) should fix it. 'mini.snippets' replaces placeholders (or more generally - synchronizes text for current tabstop) "immediately" and if request is postponed a little, it should be able to get the most up to date buffer state (i.e. after placeholder is removed).

@xzbdmw
Copy link

xzbdmw commented Jan 12, 2025

Or it is an lua_ls bug, if you type vim.schedule(ffn) literally, there is no such completion item!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants